diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml
new file mode 100644
index 0000000000..dcb62ccfca
--- /dev/null
+++ b/.github/workflows/build-app.yml
@@ -0,0 +1,41 @@
+name: Build app
+
+# Compiles a debug APK on every push so broken builds fail before release.
+on:
+ push:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: build-app-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ if: github.actor != 'dependabot[bot]'
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '21'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+
+ - name: Build debug APK
+ run: gradle :app:assembleDebug -Ppixelplay.enableAbiSplits=true
+
+ - name: Upload debug APKs
+ uses: actions/upload-artifact@v7
+ with:
+ name: PixelPlay-debug-${{ github.sha }}
+ path: app/build/outputs/apk/debug/*.apk
+ if-no-files-found: error
+ compression-level: 0
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index b2793cfd88..ece51db7f3 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -1,10 +1,7 @@
name: "CodeQL Advanced"
+# Weekly + manual only — avoid a full Gradle rebuild on every master push.
on:
- push:
- branches: [ master ]
- pull_request:
- branches: [ master ]
workflow_dispatch:
schedule:
- cron: '25 15 * * 0'
@@ -49,7 +46,7 @@ jobs:
build-mode: manual
- name: Build with Gradle
- run: ${{ github.workspace }}/gradlew :app:assembleDebug :wear:assembleDebug --no-daemon
+ run: ${{ github.workspace }}/gradlew :app:assembleDebug --no-daemon
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml
new file mode 100644
index 0000000000..4aad782b7d
--- /dev/null
+++ b/.github/workflows/commitlint.yml
@@ -0,0 +1,68 @@
+name: Commit Lint (Semantic)
+
+# Keep this — cheap and useful. Runs on push/PR; not an APK build.
+on:
+ push:
+ branches: [master, 'feat/**', 'fix/**', 'chore/**', 'ci/**']
+ 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).
+ # Force-pushes rewrite `before` so that SHA may no longer exist — fall back
+ # to linting only HEAD instead of failing with "Invalid revision range".
+ BEFORE="${{ github.event.before }}"
+ ZERO="0000000000000000000000000000000000000000"
+ if [ -z "$BEFORE" ] || [ "$BEFORE" = "$ZERO" ]; then
+ echo "No previous SHA (new branch); linting HEAD only."
+ npx commitlint --last --verbose
+ elif ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
+ echo "Previous SHA $BEFORE is unreachable (likely force-push); linting HEAD only."
+ npx commitlint --last --verbose
+ else
+ npx commitlint --from "$BEFORE" --to "${{ github.sha }}" --verbose
+ 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/nightly-apk.yml b/.github/workflows/nightly-apk.yml
index 2c6f7728f5..fd318847b6 100644
--- a/.github/workflows/nightly-apk.yml
+++ b/.github/workflows/nightly-apk.yml
@@ -1,9 +1,7 @@
name: Build Nightly APK
+# Manual only — continuous Release APK on master replaces scheduled nightlies.
on:
- schedule:
- # Runs at 02:17 America/Buenos_Aires.
- - cron: '17 5 * * *'
workflow_dispatch:
permissions:
diff --git a/.github/workflows/phone-debug.yml b/.github/workflows/phone-debug.yml
index 3f7d70fb2e..5b97931414 100644
--- a/.github/workflows/phone-debug.yml
+++ b/.github/workflows/phone-debug.yml
@@ -1,8 +1,7 @@
name: Build Phone APK (Debug)
+# PR / manual only — master publishes via Release APK instead.
on:
- push:
- branches: [ master ]
pull_request:
types: [opened, synchronize, reopened]
branches: [ master ]
diff --git a/.github/workflows/phone-release.yml b/.github/workflows/phone-release.yml
index 1c33965a3e..b538388ff7 100644
--- a/.github/workflows/phone-release.yml
+++ b/.github/workflows/phone-release.yml
@@ -1,8 +1,7 @@
name: Build Phone APK (Release)
+# PR / manual only — master publishes via Release APK instead.
on:
- push:
- branches: [ master ]
pull_request:
types: [opened, synchronize, reopened]
branches: [ master ]
diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml
new file mode 100644
index 0000000000..a29254d0d4
--- /dev/null
+++ b/.github/workflows/release-apk.yml
@@ -0,0 +1,259 @@
+name: Release APK
+
+# Builds a signed (CI keystore) release APK and publishes a GitHub Release with
+# conventional-commit notes + downloadable APKs.
+#
+# Triggers:
+# - Every push to master → continuous pre-release (auto tag + changelog)
+# - Version tags (v*) → release for that tag
+# - Manual workflow_dispatch → named version (e.g. 0.8.1-soundcloud)
+on:
+ push:
+ branches: [ master ]
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ 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.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+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: |
+ SHORT_SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ VERSION="${{ inputs.version }}"
+ VERSION="${VERSION#v}"
+ TAG="v${VERSION}"
+ PRERELEASE="${{ inputs.prerelease }}"
+ TITLE="PixelPlayer ${VERSION}"
+ elif [ "${{ github.ref_type }}" = "tag" ]; then
+ TAG="${GITHUB_REF_NAME}"
+ VERSION="${TAG#v}"
+ # Tag releases with a dash (e.g. 0.8.1-soundcloud) stay pre-release.
+ if [[ "${VERSION}" == *-* ]]; then
+ PRERELEASE="true"
+ else
+ PRERELEASE="false"
+ fi
+ TITLE="PixelPlayer ${VERSION}"
+ else
+ # Continuous build on every master push — browseable on the Releases page.
+ VERSION="continuous-$(date -u +%Y%m%d)-${SHORT_SHA}"
+ TAG="v${VERSION}"
+ # Keep as a full release so /releases/latest always points here.
+ PRERELEASE="false"
+ TITLE="PixelPlayer continuous (${SHORT_SHA})"
+ fi
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
+ echo "prerelease=${PRERELEASE}" >> "$GITHUB_OUTPUT"
+ echo "title=${TITLE}" >> "$GITHUB_OUTPUT"
+ # Must fit in signed 32-bit Int (max 2147483647). yyMMddHHmm overflows.
+ VERSION_CODE="$(( $(date -u +%s) / 60 ))"
+ echo "version_code=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
+ echo "Resolved release ${TAG} (prerelease=${PRERELEASE}, versionCode=${VERSION_CODE})"
+
+ - 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
+ -PAPP_VERSION_NAME=${{ steps.meta.outputs.version }}
+ -PAPP_VERSION_CODE=${{ steps.meta.outputs.version_code }}
+
+ - 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 = `## ${process.env.RELEASE_TITLE || `PixelPlayer ${version}`}\n\n`;
+ if (version.startsWith('continuous-')) {
+ body += '_Automatic build from `master`. Use a manual / tagged release for stable version numbers._\n\n';
+ }
+ const typed =
+ section('Features', buckets.feat) +
+ section('Fixes', buckets.fix) +
+ section('Performance', buckets.perf) +
+ section('Refactors', buckets.refactor) +
+ section('Docs', buckets.docs) +
+ section('Chores', buckets.chore) +
+ section('Other', buckets.other);
+ body += typed || '_No conventional commits in this range._\n\n';
+ const repo = process.env.GITHUB_REPOSITORY;
+ const arm64Name = `PixelPlayer-${version}-arm64-v8a.apk`;
+ const armName = `PixelPlayer-${version}-armeabi-v7a.apk`;
+ const arm64Url = `https://github.com/${repo}/releases/download/${tag}/${arm64Name}`;
+ const armUrl = `https://github.com/${repo}/releases/download/${tag}/${armName}`;
+ body += [
+ '### Downloads',
+ '',
+ `- [${arm64Name}](${arm64Url}) — modern 64-bit devices (recommended)`,
+ `- [${armName}](${armUrl}) — older 32-bit devices`,
+ '',
+ '> CI builds use the repository CI keystore (not the store/Play signing key).',
+ '',
+ 'You can also expand **Assets** below on this release page.',
+ '',
+ ].join('\n');
+
+ core.setOutput('body', body);
+ await core.summary.addHeading(`Release ${tag}`).addRaw(body).write();
+ env:
+ RELEASE_TITLE: ${{ steps.meta.outputs.title }}
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.meta.outputs.tag }}
+ name: ${{ steps.meta.outputs.title }}
+ body: ${{ steps.notes.outputs.body }}
+ target_commitish: ${{ github.sha }}
+ draft: false
+ prerelease: ${{ steps.meta.outputs.prerelease == 'true' }}
+ make_latest: ${{ steps.meta.outputs.prerelease != 'true' }}
+ files: |
+ dist/PixelPlayer-${{ steps.meta.outputs.version }}-arm64-v8a.apk
+ dist/PixelPlayer-${{ steps.meta.outputs.version }}-armeabi-v7a.apk
+ 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/.github/workflows/wearos-apk.yml b/.github/workflows/wearos-apk.yml
deleted file mode 100644
index 6e7b53b6da..0000000000
--- a/.github/workflows/wearos-apk.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-name: Build Wear OS APK (Debug)
-
-on:
- push:
- branches: [ master ]
- pull_request:
- types: [opened, synchronize, reopened]
- branches: [ master ]
- workflow_dispatch:
-
-permissions:
- contents: read
-
-jobs:
- build-wear:
- if: github.actor != 'dependabot[bot]'
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Set up JDK 21
- uses: actions/setup-java@v5
- with:
- distribution: temurin
- java-version: '21'
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v6
-
- - name: Build Wear OS debug APK
- run: gradle :wear:assembleDebug
-
- - name: Upload Wear OS APK artifact
- uses: actions/upload-artifact@v7.0.1
- with:
- name: PixelPlay-wear-only-debug
- path: wear/build/outputs/apk/debug/*.apk
diff --git a/.gitignore b/.gitignore
index 986f70d62c..f8a3ed31ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,7 @@ misc.xml
CLAUDE.md
markdown.xml
.vscode/
+.cursor/
.agents/
# Node/tooling dependencies and local env files
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0fe99c2b68..26602a86b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.9.0-youtube] - 2026-09-28
+
+### Added
+- YouTube Music sign-in, personalized Feed, Liked Music, library playlists, and radio.
+- Skip silence playback setting.
+- Multi-select songs and add them to a YouTube Music playlist, or create a new one.
+- Infinite scroll on Liked Music, newest likes first.
+
+### Changed
+- About lists KibetMasi as the maintainer.
+- Changelog sheet stays half-screen until you drag or scroll it open.
+- Dropped unused local-library and Bluetooth runtime permissions.
+
+### Fixed
+- Launch no longer crashes on Pixel Fold after permission cleanup (Cast/Wi-Fi init).
+- Authenticated InnerTube session (visitorData + DATASYNC_ID) so likes and home are yours.
+- Tapping a playlist such as Recap opens it through YouTube Music browse, not NewPipe.
+- Liking a song now calls YouTube Music like/like and like/removelike.
+- Tapping the current song pauses or resumes instead of restarting.
+- First tap starts playback; a second tap is not required.
+
+## [0.8.1-soundcloud] - 2026-09-23
+
+### Added
+- **SoundCloud:** Personalized feed shelves for likes, recent plays, mixes, curated tracks, playlists, and followed creators.
+- **SoundCloud:** Automatic queue playback so the next playable track starts when the current track ends.
+- **SoundCloud:** End-of-list loading that requests more results while scrolling.
+- **SoundCloud:** Like and unlike controls on track tiles and list rows.
+
+### Changed
+- **SoundCloud:** Reused the Library accent header, pill navigation, action row, and tiles/list controls across every SoundCloud page.
+- **SoundCloud:** Compact screens use feed category pills while foldables and wide screens show the full multi-section feed.
+
+### Fixed
+- **SoundCloud:** Playlist Back now restores the previous SoundCloud tab and feed state.
+- **SoundCloud:** Downloaded tracks retain artwork, with metadata backfill for existing downloads.
+- **SoundCloud:** Tile labels are no longer clipped by the card’s rounded bottom corners.
+
+## [0.8.0-soundcloud] - 2026-09-23
+
+### Added
+- **SoundCloud:** New main-tab SoundCloud experience with session sign-in, feed, discover, likes, tracks, playlists, and downloads.
+- **SoundCloud:** Always-on search, tiles/list view (wide defaults to tiles), shuffle play, pull-to-refresh, and playlist back navigation.
+- **SoundCloud:** Downloads shown as playable Library-style songs (MediaStore) with tile/list layouts.
+- **CI:** Conventional-commit lint (`commitlint`) for pushes and PR titles.
+- **CI:** `Release APK` workflow that builds phone release APKs and publishes a GitHub Release with feature notes from conventional commits.
+
+### Changed
+- **CI:** Phone-only focus — removed Wear OS APK workflow; CodeQL builds `:app` only.
+
+### Fixed
+- **SoundCloud:** Hilt/KSP crash from ViewModel default parameters (`Unexpected owner function: null`).
+- **SoundCloud:** Release compile OptIn for experimental Material3 loading indicators.
+
## [0.7.5-beta] - 2026-06-13
### Added
diff --git a/README.md b/README.md
index 10fddba929..c816d14a82 100644
--- a/README.md
+++ b/README.md
@@ -1,215 +1,98 @@
-# PixelPlayer 🎵
+# PixelPlayer
-
-
-
-
+
+
- A beautiful, feature-rich music player for Android
- Built with Jetpack Compose and Material Design 3
+ A YouTube Music player for Android
+ Feed, radio, playlists, and likes — with background playback
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
---
-## ‼️ DISCLAIMER
-- No fork of this project will recieve support, if you use a fork, ask the forker to support you.
-
----
-
-## ✨ Features
-
-### 🎨 Modern UI/UX
-- **Material You** - Dynamic color theming that adapts to your wallpaper
-- **Smooth Animations** - Fluid transitions and micro-interactions
-- **Customizable UI** - Adjustable corner radius and navigation bar settings
-- **Dark/Light Theme** - Automatic or manual theme switching
-- **Album Art Colors** - Dynamic color extraction from album artwork
-
-### 🎵 Powerful Playback
-- **Media3 ExoPlayer** - Industry-leading audio engine with FFmpeg support
-- **Background Playback** - Full media session integration
-- **Queue Management** - Drag-and-drop reordering
-- **Shuffle & Repeat** - All playback modes supported
-- **Gapless Playback** - Seamless transitions between tracks
-- **Custom Transitions** - Configure crossfades between songs
-
-### 📚 Library Management
-- **Multi-format Support** - MP3, FLAC, AAC, OGG, WAV, and more
-- **Browse By** - Songs, Albums, Artists, Genres, Folders
-- **Smart Artist Parsing** - Configurable delimiters for multi-artist tracks
-- **Album Artist Grouping** - Proper album organization
-- **Folder Filtering** - Choose which directories to scan
-
-### 🔍 Discovery & Organization
-- **Full-text Search** - Search across your entire library
-- **Daily Mix** - AI-powered personalized playlist based on listening habits
-- **Playlists** - Create and manage custom playlists
-- **Statistics** - Track your listening history and habits
-
-### 🎤 Lyrics
-- **Synchronized Lyrics** - LRC format via LRCLIB API
-- **Lyrics Editing** - Modify or add lyrics to your tracks
-- **Scrolling Display** - Follow along as you listen
-
-### 🖼️ Artist Artwork
-- **Deezer Integration** - Automatic artist images from Deezer API
-- **Smart Caching** - Memory (LRU) + database caching for offline access
-- **Fallback Icons** - Beautiful placeholders when images unavailable
-
-### 📲 Connectivity
-- **Chromecast** - Stream to your TV or smart speakers
-- **Android Auto** - Full Android Auto support for in-car playback (Soon)
-- **Widgets** - Home screen control with Glance widgets
-
-### ⚙️ Advanced Features
-- **Tag Editor** - Edit metadata with TagLib (MP3, FLAC, M4A support)
-- **AI Playlists** - Generate playlists with AI (Supports Gemini, Deepseek, OpenAI, etc.)
+## Download & install
----
+1. Open **[Latest Release](https://github.com/kibetmasi/PixelPlayer/releases/latest)**.
+2. Under **Assets**, download the APK that matches your phone:
+ - **`…-arm64-v8a.apk`** — most phones from ~2017 onward (**recommended**)
+ - **`…-armeabi-v7a.apk`** — older 32-bit devices
+3. On your phone, open the downloaded file and install.
+ - If Android blocks it, allow **Install unknown apps** for your browser / Files app.
+4. Open PixelPlayer. Playback continues in the background with the notification controls.
-## 🛠️ Tech Stack
-
-| Category | Technology |
-|----------|------------|
-| **Language** | [Kotlin](https://kotlinlang.org/) 100% |
-| **UI Framework** | [Jetpack Compose](https://developer.android.com/jetpack/compose) |
-| **Design System** | [Material Design 3](https://m3.material.io/) |
-| **Audio Engine** | [Media3 ExoPlayer](https://developer.android.com/guide/topics/media/media3) + FFmpeg |
-| **Architecture** | MVVM with StateFlow/SharedFlow |
-| **DI** | [Hilt](https://dagger.dev/hilt/) |
-| **Database** | [Room](https://developer.android.com/training/data-storage/room) |
-| **Networking** | [Retrofit](https://square.github.io/retrofit/) + OkHttp |
-| **Image Loading** | [Coil](https://coil-kt.github.io/coil/) |
-| **Async** | Kotlin Coroutines & Flow |
-| **Background Tasks** | WorkManager |
-| **Metadata** | [TagLib](https://github.com/nicholaus/taglib-android) |
-| **Widgets** | [Glance](https://developer.android.com/jetpack/compose/glance) |
+> Tip: each new release can update the app **in place** (same signing key). You usually don’t need to uninstall first — that keeps login and settings.
---
-## 📱 Requirements
-
-- **Android 11** (API 30) or higher
-- **6GB RAM** recommended for smooth performance
-
----
+## YouTube Music
-## 🚀 Getting Started
+PixelPlayer plays YouTube audio through the existing player. It does not scan a local music library.
-### Prerequisites
+| Tab | What it does |
+|-----|----------------|
+| **Feed** | Your Mix, artwork collage, trending songs, radio starters, and playlists |
+| **Radio** | Starts a song, then keeps queuing similar tracks |
+| **Search** | Songs, albums, and playlists |
+| **Playlists** | Today's hits, workout, focus, and party |
+| **Liked** | Songs you like on device, plus liked videos from your Google account after sign-in |
-- Android Studio Ladybug | 2024.2.1 or newer
-- Android SDK 29+
-- JDK 11+
+### Sign in with Google
-### Installation
+Liked videos and the “channels you follow” shelf need the Google account you use on YouTube.
-1. **Clone the repository**
- ```sh
- git clone https://github.com/theovilardo/PixelPlayer.git
- ```
+1. On **Feed**, tap the **account** icon.
+2. Tap **Sign in with Google** and sign in on the page that opens.
+3. Tap **Done** when YouTube finishes signing you in.
+4. Open **Liked** to load that account’s liked videos.
-2. **Open in Android Studio**
- - Open Android Studio
- - Select "Open an Existing Project"
- - Navigate to the cloned directory
+**Sign out** is on the same Account screen. The heart on a song still saves a like on the device if you are signed out.
-3. **Sync and Build**
- - Wait for Gradle to sync dependencies
- - Build the project (Build → Make Project)
-
-4. **Run**
- - Connect a device or start an emulator
- - Click Run (▶️)
+The header also opens the **changelog** and app **settings**.
---
-## ⬇️ Download
-
-
-
-
-
-
+## SoundCloud
-
-
-
-
-
+SoundCloud is not a bottom-bar tab in this build. The previous web `client_id` and sign-in flow is described in [docs/soundcloud-implementation.md](docs/soundcloud-implementation.md).
---
-## 📂 Project Structure
-
-```
-app/src/main/java/com/theveloper/pixelplay/
-├── data/
-│ ├── database/ # Room entities, DAOs, migrations
-│ ├── model/ # Domain models (Song, Album, Artist, etc.)
-│ ├── network/ # API services (LRCLIB, Deezer)
-│ ├── preferences/ # DataStore preferences
-│ ├── repository/ # Data repositories
-│ ├── service/ # MusicService, HTTP server
-│ └── worker/ # WorkManager sync workers
-├── di/ # Hilt dependency injection modules
-├── presentation/
-│ ├── components/ # Reusable Compose components
-│ ├── navigation/ # Navigation graph
-│ ├── screens/ # Screen composables
-│ └── viewmodel/ # ViewModels
-├── ui/
-│ ├── glancewidget/ # Home screen widgets
-│ └── theme/ # Colors, typography, theming
-└── utils/ # Extensions and utilities
-```
+## Everyday use (quick)
----
+- **Feed** shuffle plays Your Mix and keeps playing in the background.
+- **Radio** is a continuous mix based on the song you start, not a live broadcast.
+- **Liked** and the heart icon keep songs you like. Sign in to also load YouTube liked videos.
+- **Playlists** opens a collection; tap a track to play it.
-## 🤝 Contributing
+---
-Contributions are welcome! Please feel free to submit a Pull Request.
+## Requirements
-1. Fork the Project
-2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
-3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
-4. Push to the Branch (`git push origin feature/AmazingFeature`)
-5. Open a Pull Request
+- Android **11** or newer
+- Internet for YouTube Music
+- Notification permission so background playback can show controls
---
-## 📄 License
+## Support & credits
-This project is licensed under a Proprietary License - see the [LICENSE](LICENSE) file for details.
+This build is maintained at **[kibetmasi/PixelPlayer](https://github.com/kibetmasi/PixelPlayer)**. A push to `master` publishes a signed release APK on the Releases page.
-Portions contributed before 2026-05-12 remain available under the MIT License; see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
-
----
-
-
- Made with ❤️ by theovilardo
-
-
- Logo designed by Aureal.
-
+Upstream project by [theovilardo](https://github.com/theovilardo/PixelPlayer). Logo by [Aureal](https://github.com/NPSummers).
+See [LICENSE](LICENSE) and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9f2583065f..4b40905cd6 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 {
@@ -248,6 +253,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.lifecycleprocess)
implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.window)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
@@ -287,6 +293,7 @@ dependencies {
// Media & Files
implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.exoplayer.ffmpeg)
@@ -322,6 +329,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..f8d4f917e2 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -46,7 +46,8 @@
# [NUEVO] Regla general para mantener metadatos de Kotlin, puede ayudar a R8
-keep class kotlin.Metadata { *; }
-# ExoPlayer FFmpeg extension
+# ExoPlayer extensions loaded via reflection (DefaultMediaSourceFactory)
+-keep class androidx.media3.exoplayer.hls.** { *; }
-keep class androidx.media3.decoder.ffmpeg.** { *; }
-keep class androidx.media3.exoplayer.ffmpeg.** { *; }
@@ -213,3 +214,24 @@
public static int d(...);
public static int i(...);
}
+
+# NewPipe Extractor reads protobuf fields by name (browseId_). R8 must not rename them.
+-keep class org.schabi.newpipe.extractor.** { *; }
+-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; }
+-keep class org.mozilla.javascript.** { *; }
+-keep class org.mozilla.javascript.XMLLib.** { *; }
+-keep class com.google.protobuf.** { *; }
+-dontwarn org.mozilla.javascript.XMLLib
+-dontwarn org.schabi.newpipe.extractor.**
+
+# SoundCloud client_id is injected reflectively into NewPipe.
+-keepclassmembers class org.schabi.newpipe.extractor.services.soundcloud.SoundcloudParsingHelper {
+ static java.lang.String clientId;
+}
+
+# Rhino / NewPipe optional JDK APIs not on Android
+-dontwarn org.mozilla.javascript.**
+-dontwarn javax.script.**
+-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..c179cf813a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,35 +5,17 @@
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
+ android:name="android.permission.NEARBY_WIFI_DEVICES"
+ android:usesPermissionFlags="neverForLocation"
+ tools:targetApi="33" />
+
+
+
+
= Build.VERSION_CODES.TIRAMISU) {
- listOf(Manifest.permission.READ_MEDIA_AUDIO, Manifest.permission.POST_NOTIFICATIONS)
+ listOf(Manifest.permission.POST_NOTIFICATIONS)
} else {
- listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+ emptyList()
}
@OptIn(ExperimentalPermissionsApi::class)
val permissionState = rememberMultiplePermissionsState(permissions = permissions)
// Determine if we need to show Setup based on completion OR missing permissions
- val permissionsValid = permissionState.allPermissionsGranted
+ val permissionsValid = permissions.isEmpty() || permissionState.allPermissionsGranted
val showSetupScreen = remember(isSetupComplete, permissionsValid, isBenchmarkMode) {
when {
isBenchmarkMode -> false
@@ -275,8 +287,7 @@ class MainActivity : ComponentActivity() {
// Sync Trigger: When we are NOT showing setup (meaning permissions are good and setup is done)
LaunchedEffect(showSetupScreen) {
if (showSetupScreen == false) {
- LogUtils.i(this, "Setup complete/skipped and permissions valid. Starting sync.")
- mainViewModel.startSync()
+ LogUtils.i(this, "Setup complete. Local library sync is disabled.")
}
}
@@ -478,6 +489,56 @@ class MainActivity : ComponentActivity() {
}
}
+ private fun openReleasePage(url: String) {
+ if (!GitHubReleaseUpdateService.isAllowedReleasePage(url)) {
+ LogUtils.w(this, "Refusing to open release URL: $url")
+ return
+ }
+ try {
+ startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
+ } catch (_: ActivityNotFoundException) {
+ LogUtils.w(this, "No activity available to open URL: $url")
+ }
+ }
+
+ private fun canRequestPackageInstallsSafely(): Boolean {
+ return PackageInstallAccess.canRequestInstalls {
+ packageManager.canRequestPackageInstalls()
+ }
+ }
+
+ private fun openUnknownSourcesSettings(): Boolean {
+ val intent = Intent(
+ Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
+ "package:$packageName".toUri(),
+ )
+ return runCatching {
+ startActivity(intent)
+ true
+ }.getOrDefault(false)
+ }
+
+ private fun installDownloadedApk(file: File) {
+ val uri = FileProvider.getUriForFile(this, "$packageName.provider", file)
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/vnd.android.package-archive")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ try {
+ startActivity(intent)
+ } catch (_: ActivityNotFoundException) {
+ LogUtils.w(this, "No activity available to install update")
+ }
+ }
+
+ private fun rememberDismissedUpdate(version: String) {
+ getSharedPreferences(UPDATE_PREFS, MODE_PRIVATE)
+ .edit()
+ .putString(UPDATE_DISMISSED_VERSION, version)
+ .apply()
+ }
+
private fun PlayStoreAnnouncementRemoteConfig.toUiModel(context: Context): PlayStoreAnnouncementUiModel {
val fallback = PlayStoreAnnouncementDefaults.localizedTemplate(context)
return fallback.copy(
@@ -610,9 +671,11 @@ class MainActivity : ComponentActivity() {
val commonNavItems = remember {
persistentListOf(
- BottomNavItem("Home", R.string.nav_bar_home, R.drawable.rounded_home_24, R.drawable.home_24_rounded_filled, Screen.Home),
+ BottomNavItem("Feed", R.string.nav_bar_feed, R.drawable.rounded_home_24, R.drawable.home_24_rounded_filled, Screen.Home),
+ BottomNavItem("Radio", R.string.nav_bar_radio, R.drawable.rounded_instant_mix_24, null, Screen.Radio),
BottomNavItem("Search", R.string.nav_bar_search, R.drawable.rounded_search_24, R.drawable.rounded_search_24, Screen.Search),
- BottomNavItem("Library", R.string.nav_bar_library, R.drawable.rounded_library_music_24, R.drawable.round_library_music_24, Screen.Library)
+ BottomNavItem("Playlists", R.string.nav_bar_playlists, R.drawable.rounded_playlist_play_24, null, Screen.Playlists),
+ BottomNavItem("Liked", R.string.nav_bar_liked, R.drawable.rounded_favorite_24, R.drawable.round_favorite_24, Screen.Liked),
)
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
@@ -636,6 +699,8 @@ class MainActivity : ComponentActivity() {
Screen.Stats.route,
Screen.EditTransition.route,
Screen.Experimental.route,
+ Screen.SoundCloudSettings.route,
+ Screen.YoutubeSettings.route,
Screen.ArtistSettings.route,
Screen.Equalizer.route,
Screen.SettingsCategory.route,
@@ -742,11 +807,19 @@ class MainActivity : ComponentActivity() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val announcementService = remember { GitHubAnnouncementPropertiesService() }
+ val releaseUpdateService = remember { GitHubReleaseUpdateService() }
val context = LocalContext.current
var playStoreAnnouncement by remember {
mutableStateOf(PlayStoreAnnouncementDefaults.localizedTemplate(context))
}
var showPlayStoreAnnouncement by remember { mutableStateOf(false) }
+ var pendingUpdateVersion by remember { mutableStateOf(null) }
+ var updateAnnouncement by remember {
+ mutableStateOf(PlayStoreAnnouncementDefaults.localizedTemplate(context))
+ }
+ var showUpdateAnnouncement by remember { mutableStateOf(false) }
+ var updateDownloadBusy by remember { mutableStateOf(false) }
+ var updateDownloadJob by remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
if (PlayStoreAnnouncementDefaults.LOCAL_PREVIEW_ENABLED) {
@@ -769,6 +842,65 @@ class MainActivity : ComponentActivity() {
}
}
+ var lastUpdateCheckElapsed by remember { mutableLongStateOf(0L) }
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ suspend fun refreshAvailableUpdate(force: Boolean = false) {
+ val installed = BuildConfig.VERSION_NAME.trim().removePrefix("v")
+ if (!force && (installed.isEmpty() || installed == "1.0.0")) return
+ if (showUpdateAnnouncement || updateDownloadBusy) return
+ val now = SystemClock.elapsedRealtime()
+ if (!force &&
+ lastUpdateCheckElapsed != 0L &&
+ now - lastUpdateCheckElapsed < UPDATE_CHECK_INTERVAL_MS
+ ) {
+ return
+ }
+ releaseUpdateService.fetchLatestRelease()
+ .onSuccess { latest ->
+ lastUpdateCheckElapsed = SystemClock.elapsedRealtime()
+ if (latest.version == installed) {
+ if (force) {
+ Toast.makeText(
+ this@MainActivity,
+ getString(R.string.changelog_up_to_date),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ return@onSuccess
+ }
+ val dismissed = getSharedPreferences(UPDATE_PREFS, MODE_PRIVATE)
+ .getString(UPDATE_DISMISSED_VERSION, null)
+ if (!force && dismissed == latest.version) return@onSuccess
+ pendingUpdateVersion = latest.version
+ updateAnnouncement = PlayStoreAnnouncementUiModel(
+ enabled = true,
+ title = getString(R.string.update_dialog_title),
+ body = getString(R.string.update_dialog_body, latest.version),
+ playStoreUrl = latest.apkUrl.ifBlank { latest.pageUrl },
+ primaryActionLabel = getString(R.string.update_dialog_action),
+ dismissActionLabel = getString(R.string.update_dialog_later),
+ linkPendingMessage = "",
+ )
+ showUpdateAnnouncement = true
+ }
+ .onFailure { throwable ->
+ LogUtils.w(
+ this@MainActivity,
+ "Release check unavailable. ${throwable.message ?: ""}",
+ )
+ }
+ }
+
+ LaunchedEffect(lifecycleOwner) {
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ while (isActive) {
+ refreshAvailableUpdate()
+ delay(UPDATE_CHECK_INTERVAL_MS)
+ }
+ }
+ }
+
LaunchedEffect(userPreferencesRepository) {
userPreferencesRepository.clearDeprecatedPlayerSheetPreference()
}
@@ -965,6 +1097,11 @@ class MainActivity : ComponentActivity() {
}
}
) {
+ CompositionLocalProvider(
+ LocalRequestAppUpdateCheck provides {
+ scope.launch { refreshAvailableUpdate(force = true) }
+ },
+ ) {
AppNavigation(
playerViewModel = playerViewModel,
navController = navController,
@@ -973,6 +1110,7 @@ class MainActivity : ComponentActivity() {
onSearchBarActiveChange = { isSearchBarActive = it },
onOpenSidebar = { scope.launch { drawerState.open() } }
)
+ }
}
val isExpandedOrExpanding by remember {
@@ -1059,6 +1197,50 @@ class MainActivity : ComponentActivity() {
}
)
}
+ if (showUpdateAnnouncement) {
+ PlayStoreAnnouncementDialog(
+ announcement = updateAnnouncement,
+ primaryBusy = updateDownloadBusy,
+ onDismiss = {
+ updateDownloadJob?.cancel()
+ updateDownloadBusy = false
+ pendingUpdateVersion?.let(::rememberDismissedUpdate)
+ showUpdateAnnouncement = false
+ },
+ onOpenPlayStore = { url ->
+ if (GitHubReleaseUpdateService.isAllowedApkDownload(url)) {
+ if (!canRequestPackageInstallsSafely()) {
+ updateAnnouncement = updateAnnouncement.copy(
+ body = getString(R.string.update_dialog_install_permission),
+ )
+ if (!openUnknownSourcesSettings()) {
+ openReleasePage(url)
+ }
+ return@PlayStoreAnnouncementDialog
+ }
+ if (updateDownloadBusy) return@PlayStoreAnnouncementDialog
+ updateDownloadBusy = true
+ updateDownloadJob = scope.launch {
+ val file = File(cacheDir, "updates/PixelPlayer-update.apk")
+ releaseUpdateService.downloadApk(url, file)
+ .onSuccess { apk ->
+ updateDownloadBusy = false
+ showUpdateAnnouncement = false
+ installDownloadedApk(apk)
+ }
+ .onFailure {
+ updateDownloadBusy = false
+ updateAnnouncement = updateAnnouncement.copy(
+ body = getString(R.string.update_dialog_failed),
+ )
+ }
+ }
+ } else {
+ openReleasePage(url)
+ }
+ },
+ )
+ }
}
}
}
@@ -1145,7 +1327,11 @@ class MainActivity : ComponentActivity() {
super.onResume()
}
-
+ private companion object {
+ const val UPDATE_PREFS = "pixelplay_updates"
+ const val UPDATE_DISMISSED_VERSION = "dismissed_release_version"
+ const val UPDATE_CHECK_INTERVAL_MS = 48L * 24 * 60 * 60 * 1000
+ }
}
/**
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt
index d9da7840b3..f7f62c4e95 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt
@@ -166,7 +166,7 @@ class AiHandler @Inject constructor(
val effectiveTemperature = if (params.temperature == 0.7f) {
if (temperature == 0.7f) {
when (type) {
- AiSystemPromptType.METADATA -> 0.1f
+ AiSystemPromptType.METADATA, AiSystemPromptType.STATS_CREDITS -> 0.1f
AiSystemPromptType.MOOD_ANALYSIS -> 0.2f
AiSystemPromptType.TAGGING -> 0.4f
AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt
index 9edf9e4042..29feaf00d2 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt
@@ -11,6 +11,7 @@ enum class AiSystemPromptType {
MOOD_ANALYSIS,
PERSONA,
DAILY_MIX,
+ STATS_CREDITS,
GENERAL
}
@@ -182,6 +183,21 @@ class AiSystemPromptEngine @Inject constructor() {
$dailyMixPersonaPrompt
""".trimIndent()
+ AiSystemPromptType.STATS_CREDITS -> """
+ Music credits editor. You name the artist and album for songs that are missing them.
+
+ - Each song gives an id, title, and any artist or album already known.
+ - Fill only the blank side. Keep a name that is already present.
+ - Use the well-known recording when the title identifies it.
+ - When the recording is not clear, use an empty string.
+ - Never answer Unknown Artist, Unknown Album, or YouTube Music.
+
+
+ Return ONLY a raw JSON array. One object per song, in the same order:
+ [{"id":"...","artist":"...","album":"..."}]
+
+ """.trimIndent()
+
AiSystemPromptType.GENERAL -> """
PixelPlayer Assistant — a knowledgeable music companion.
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt
index fd0fb1c1d8..18a64f98e7 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt
@@ -96,7 +96,7 @@ class GenericOpenAiClient(
.addHeader("Content-Type", "application/json")
if (providerName.equals("OpenRouter", ignoreCase = true)) {
- requestBuilder.addHeader("HTTP-Referer", "https://github.com/theovilardo/PixelPlayer")
+ requestBuilder.addHeader("HTTP-Referer", "https://github.com/kibetmasi/PixelPlayer")
requestBuilder.addHeader("X-Title", "PixelPlayer")
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/AlbumEntity.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/AlbumEntity.kt
index a540d99bad..d6702bafcc 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/database/AlbumEntity.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/database/AlbumEntity.kt
@@ -44,6 +44,7 @@ fun AlbumEntity.toAlbum(): Album {
LocalArtworkUri.looksLikeVolatileArtworkUri(this.albumArtUriString) ->
LocalArtworkUri.parseSongIdFromVolatileArtworkUri(this.albumArtUriString)
?.let { LocalArtworkUri.buildSongUri(it) }
+ ?: this.albumArtUriString
else -> this.albumArtUriString
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubAnnouncementPropertiesService.kt b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubAnnouncementPropertiesService.kt
index d540ad1cae..fd3719481e 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubAnnouncementPropertiesService.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubAnnouncementPropertiesService.kt
@@ -36,8 +36,8 @@ class GitHubAnnouncementPropertiesService @Inject constructor() {
* - play_store_link_pending_message
*/
suspend fun fetchPlayStoreAnnouncement(
- owner: String = "theovilardo",
- repo: String = "PixelPlay",
+ owner: String = "kibetmasi",
+ repo: String = "PixelPlayer",
branch: String = "master",
configPath: String = "remote-config/app-announcements.properties",
): Result {
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt
index 7f963aed68..4d84e78667 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubContributorService.kt
@@ -25,8 +25,8 @@ class GitHubContributorService @Inject constructor() {
* Fetches contributors from the GitHub repository
*/
suspend fun fetchContributors(
- owner: String = "theovilardo",
- repo: String = "PixelPlay"
+ owner: String = "kibetmasi",
+ repo: String = "PixelPlayer"
): Result> {
return withContext(Dispatchers.IO) {
try {
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateService.kt b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateService.kt
new file mode 100644
index 0000000000..786b6073bb
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateService.kt
@@ -0,0 +1,176 @@
+package com.theveloper.pixelplay.data.github
+
+import android.os.Build
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import kotlin.coroutines.cancellation.CancellationException
+
+data class GitHubLatestRelease(
+ val version: String,
+ val pageUrl: String,
+ val apkUrl: String,
+)
+
+@Singleton
+class GitHubReleaseUpdateService @Inject constructor() {
+
+ /**
+ * Latest GitHub Release for this fork. A push to master publishes that
+ * release from CI, so installed builds can notice a newer tag.
+ */
+ suspend fun fetchLatestRelease(
+ owner: String = OWNER,
+ repo: String = REPO,
+ ): Result = withContext(Dispatchers.IO) {
+ var connection: HttpURLConnection? = null
+ try {
+ val endpoint = "https://api.github.com/repos/$owner/$repo/releases/latest"
+ connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
+ requestMethod = "GET"
+ connectTimeout = 10_000
+ readTimeout = 10_000
+ setRequestProperty("Accept", "application/vnd.github+json")
+ setRequestProperty("User-Agent", "PixelPlayer")
+ }
+ when (val code = connection.responseCode) {
+ HttpURLConnection.HTTP_OK -> {
+ val body = connection.inputStream.bufferedReader().use { it.readText() }
+ val json = JSONObject(body)
+ val version = json.optString("tag_name").trim().removePrefix("v")
+ val pageUrl = json.optString("html_url").trim()
+ val apkUrl = selectApkUrl(json)
+ if (version.isEmpty() || !isAllowedReleasePage(pageUrl)) {
+ Result.failure(IllegalStateException("Latest release is missing a usable tag or page"))
+ } else {
+ Result.success(
+ GitHubLatestRelease(version = version, pageUrl = pageUrl, apkUrl = apkUrl),
+ )
+ }
+ }
+ else -> Result.failure(
+ IllegalStateException("Latest release request failed: $code"),
+ )
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ } finally {
+ connection?.disconnect()
+ }
+ }
+
+ suspend fun downloadApk(url: String, destination: File): Result = withContext(Dispatchers.IO) {
+ if (!isAllowedApkDownload(url)) {
+ return@withContext Result.failure(IllegalStateException("Refusing APK URL"))
+ }
+ var current = URL(url)
+ var connection: HttpURLConnection? = null
+ try {
+ repeat(MAX_REDIRECTS) {
+ connection?.disconnect()
+ connection = (current.openConnection() as HttpURLConnection).apply {
+ instanceFollowRedirects = false
+ connectTimeout = 15_000
+ readTimeout = 60_000
+ setRequestProperty("User-Agent", "PixelPlayer")
+ setRequestProperty("Accept", "application/octet-stream, */*")
+ }
+ val code = connection!!.responseCode
+ if (code in 300..399) {
+ val location = connection!!.getHeaderField("Location")
+ ?: return@withContext Result.failure(IllegalStateException("Redirect missing location"))
+ val next = URL(current, location)
+ if (!isAllowedDownloadHop(next)) {
+ return@withContext Result.failure(IllegalStateException("Refusing redirect host"))
+ }
+ current = next
+ return@repeat
+ }
+ if (code != HttpURLConnection.HTTP_OK) {
+ return@withContext Result.failure(IllegalStateException("APK download failed: $code"))
+ }
+ destination.parentFile?.mkdirs()
+ if (destination.exists()) destination.delete()
+ connection!!.inputStream.use { input ->
+ destination.outputStream().use { output -> input.copyTo(output) }
+ }
+ if (destination.length() < MIN_APK_BYTES) {
+ destination.delete()
+ return@withContext Result.failure(IllegalStateException("Downloaded file is too small"))
+ }
+ return@withContext Result.success(destination)
+ }
+ Result.failure(IllegalStateException("Too many redirects"))
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ destination.delete()
+ Result.failure(e)
+ } finally {
+ connection?.disconnect()
+ }
+ }
+
+ private fun selectApkUrl(json: JSONObject): String {
+ val assets = json.optJSONArray("assets") ?: return ""
+ val abi = preferredAbi()
+ var matched = ""
+ var fallback = ""
+ for (i in 0 until assets.length()) {
+ val asset = assets.optJSONObject(i) ?: continue
+ val name = asset.optString("name")
+ val url = asset.optString("browser_download_url")
+ if (!name.endsWith(".apk", ignoreCase = true) || !isAllowedApkDownload(url)) continue
+ if (!name.contains("arm64-v8a") && !name.contains("armeabi-v7a")) continue
+ if (fallback.isEmpty() && name.contains("armeabi-v7a")) fallback = url
+ if (name.contains(abi)) matched = url
+ }
+ return matched.ifEmpty { fallback }
+ }
+
+ companion object {
+ private const val MAX_REDIRECTS = 5
+ private const val MIN_APK_BYTES = 1024L
+ const val OWNER = "kibetmasi"
+ const val REPO = "PixelPlayer"
+
+ fun isAllowedReleasePage(url: String): Boolean {
+ val parsed = runCatching { URL(url) }.getOrNull() ?: return false
+ if (!parsed.protocol.equals("https", ignoreCase = true)) return false
+ if (!parsed.host.equals("github.com", ignoreCase = true)) return false
+ val path = parsed.path.orEmpty()
+ return path == "/$OWNER/$REPO/releases" ||
+ path.startsWith("/$OWNER/$REPO/releases/")
+ }
+
+ fun isAllowedApkDownload(url: String): Boolean {
+ val parsed = runCatching { URL(url) }.getOrNull() ?: return false
+ if (!parsed.protocol.equals("https", ignoreCase = true)) return false
+ if (!parsed.host.equals("github.com", ignoreCase = true)) return false
+ val path = parsed.path.orEmpty()
+ return path.startsWith("/$OWNER/$REPO/releases/download/") &&
+ path.endsWith(".apk", ignoreCase = true)
+ }
+
+ private fun isAllowedDownloadHop(url: URL): Boolean {
+ if (!url.protocol.equals("https", ignoreCase = true)) return false
+ val host = url.host.orEmpty().lowercase()
+ if (host == "github.com") return isAllowedApkDownload(url.toString())
+ return host == "release-assets.githubusercontent.com" ||
+ host == "objects.githubusercontent.com" ||
+ host == "github-releases.githubusercontent.com"
+ }
+
+ private fun preferredAbi(): String {
+ val abis = Build.SUPPORTED_ABIS
+ return when {
+ abis.any { it == "arm64-v8a" } -> "arm64-v8a"
+ else -> "armeabi-v7a"
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt b/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt
index 9906d3ce4f..1500c5c2d9 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/model/PlaybackQueueSnapshot.kt
@@ -11,6 +11,8 @@ data class PlaybackQueueItemSnapshot(
val albumTitle: String? = null,
val artworkUri: String? = null,
val durationMs: Long? = null,
+ val soundCloudPermalink: String? = null,
+ val youtubeWatchUrl: String? = null,
)
@Serializable
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt
index 4598f1a86f..f21fbde8da 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt
@@ -140,6 +140,7 @@ class UserPreferencesRepository @Inject constructor(
val KEEP_PLAYING_IN_BACKGROUND = booleanPreferencesKey("keep_playing_in_background")
val IS_CROSSFADE_ENABLED = booleanPreferencesKey("is_crossfade_enabled")
val HI_FI_MODE_ENABLED = booleanPreferencesKey("hi_fi_mode_enabled")
+ val SKIP_SILENCE_ENABLED = booleanPreferencesKey("skip_silence_enabled")
val CROSSFADE_DURATION = intPreferencesKey("crossfade_duration")
val CUSTOM_GENRES = stringSetPreferencesKey("custom_genres")
val CUSTOM_GENRE_ICONS = stringPreferencesKey("custom_genre_icons")
@@ -349,6 +350,13 @@ class UserPreferencesRepository @Inject constructor(
dataStore.edit { it[PreferencesKeys.HI_FI_MODE_ENABLED] = enabled }
}
+ val skipSilenceEnabledFlow: Flow =
+ pref { it[PreferencesKeys.SKIP_SILENCE_ENABLED] ?: false }
+
+ suspend fun setSkipSilenceEnabled(enabled: Boolean) {
+ dataStore.edit { it[PreferencesKeys.SKIP_SILENCE_ENABLED] = enabled }
+ }
+
val keepPlayingInBackgroundFlow: Flow =
pref { it[PreferencesKeys.KEEP_PLAYING_IN_BACKGROUND] ?: true }
@@ -986,7 +994,7 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) {
}
val carouselStyleFlow: Flow =
- pref { it[PreferencesKeys.CAROUSEL_STYLE] ?: CarouselStyle.NO_PEEK }
+ pref { it[PreferencesKeys.CAROUSEL_STYLE] ?: CarouselStyle.ONE_PEEK }
suspend fun setCarouselStyle(style: String) {
dataStore.edit { it[PreferencesKeys.CAROUSEL_STYLE] = style }
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt
index adab461ca8..c5400ace15 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt
@@ -52,11 +52,15 @@ import com.theveloper.pixelplay.data.preferences.EqualizerPreferencesRepository
import com.theveloper.pixelplay.data.preferences.ThemePreferencesRepository
import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository
import com.theveloper.pixelplay.data.repository.MusicRepository
+import com.theveloper.pixelplay.soundcloud.SoundCloudClient
import com.theveloper.pixelplay.data.service.player.DualPlayerEngine
import com.theveloper.pixelplay.data.service.player.TransitionController
import com.theveloper.pixelplay.ui.glancewidget.PlayerActions
import com.theveloper.pixelplay.utils.AlbumArtUtils
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -168,6 +172,12 @@ class MusicService : MediaLibraryService() {
@Inject
lateinit var listeningStatsTracker: ListeningStatsTracker
@Inject
+ lateinit var soundCloudClient: SoundCloudClient
+ @Inject
+ lateinit var youtubeClient: com.theveloper.pixelplay.youtube.YoutubeClient
+ @Inject
+ lateinit var youtubeMusicStore: com.theveloper.pixelplay.youtube.YoutubeMusicStore
+ @Inject
@AppScope
lateinit var appScope: CoroutineScope
@@ -198,6 +208,10 @@ class MusicService : MediaLibraryService() {
private var countedPlayCount = 0
private var countedOriginalId: String? = null
private var countedPlayListener: Player.Listener? = null
+ private var consecutivePlaybackErrorSkips = 0
+ private val soundCloudRefreshAttempted = mutableSetOf()
+ private val youtubeRefreshAttempts = mutableMapOf()
+ private val youtubePrepareStarted = mutableSetOf()
private val alarmManager by lazy {
getSystemService(Context.ALARM_SERVICE) as AlarmManager
}
@@ -267,6 +281,8 @@ class MusicService : MediaLibraryService() {
private const val PLAYBACK_SNAPSHOT_DEBOUNCE_MS = 1500L
private const val MEDIA_SESSION_BUTTON_DEBOUNCE_MS = 250L
private const val DEFERRED_SERVICE_STARTUP_WORK_DELAY_MS = 1_000L
+ private const val MAX_PLAYBACK_ERROR_SKIPS = 8
+ private const val MAX_YOUTUBE_REFRESH_ATTEMPTS = 2
private const val PAUSED_RESTORE_PREPARE_QUEUE_LIMIT = 50
private val pendingMediaButtonForegroundStarts = AtomicInteger(0)
@@ -370,6 +386,7 @@ class MusicService : MediaLibraryService() {
return
}
+ rememberYoutubePlayback(mediaItem)
val positionMs = player.currentPosition.coerceAtLeast(0L)
val durationMs = player.duration
val fallbackDurationMs = mediaItem.mediaMetadata.extras
@@ -395,6 +412,50 @@ class MusicService : MediaLibraryService() {
}
}
+ private fun rememberYoutubePlayback(mediaItem: MediaItem?) {
+ val item = mediaItem ?: return
+ val songId = item.mediaId ?: return
+ if (!songId.startsWith("yt_")) return
+ val metadata = item.mediaMetadata
+ val title = metadata.title?.toString()?.trim().orEmpty()
+ if (title.isBlank()) return
+ val artist = metadata.artist?.toString().orEmpty()
+ val album = metadata.albumTitle?.toString().orEmpty()
+ val artworkUrl = metadata.artworkUri?.toString()
+ val videoId = metadata.extras
+ ?.getString(com.theveloper.pixelplay.youtube.YoutubeClient.EXTRA_WATCH_URL)
+ ?.let(com.theveloper.pixelplay.youtube.YoutubeClient::videoIdFromUrl)
+ .orEmpty()
+ youtubeMusicStore.rememberPlayback(
+ songId = songId,
+ title = title,
+ artist = artist,
+ album = album,
+ artworkUrl = artworkUrl,
+ videoId = videoId,
+ )
+ val missingCredit = artist.isBlank() || album.isBlank() ||
+ artist.equals("YouTube Music", ignoreCase = true) ||
+ album.equals("YouTube Music", ignoreCase = true)
+ if (missingCredit && videoId.length == 11 && youtubeMusicStore.claimCreditLookup(videoId)) {
+ appScope.launch(Dispatchers.IO) {
+ val credits = runCatching { youtubeClient.creditsFor(videoId) }.getOrNull()
+ if (credits == null) {
+ youtubeMusicStore.releaseCreditLookup(videoId)
+ return@launch
+ }
+ youtubeMusicStore.rememberPlayback(
+ songId = songId,
+ title = title,
+ artist = credits.artist.ifBlank { artist },
+ album = credits.album.ifBlank { album },
+ artworkUrl = artworkUrl,
+ videoId = videoId,
+ )
+ }
+ }
+ }
+
override fun onCreate() {
// Media3's Cast SDK callback path (MediaSessionImpl$$ExternalSyntheticLambda →
// Util.postOrRun → MediaNotificationManager.updateNotificationInternal) calls
@@ -405,9 +466,10 @@ class MusicService : MediaLibraryService() {
val existingHandler = Thread.currentThread().uncaughtExceptionHandler
previousMainThreadExceptionHandler = existingHandler
Thread.currentThread().setUncaughtExceptionHandler { thread, throwable ->
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
- throwable is ForegroundServiceStartNotAllowedException
- ) {
+ val isFgsDenied = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+ generateSequence(throwable) { it.cause }
+ .any { it is ForegroundServiceStartNotAllowedException }
+ if (isFgsDenied) {
Timber.tag(TAG).w(throwable, "Suppressed ForegroundServiceStartNotAllowedException from Media3/Cast internal path")
} else {
existingHandler?.uncaughtException(thread, throwable)
@@ -507,6 +569,12 @@ class MusicService : MediaLibraryService() {
}
}
+ serviceScope.launch {
+ userPreferencesRepository.skipSilenceEnabledFlow.collect { enabled ->
+ engine.setSkipSilence(enabled)
+ }
+ }
+
serviceScope.launch {
userPreferencesRepository.resumeOnHeadsetReconnectFlow.collect { enabled ->
resumeOnHeadsetReconnectEnabled = enabled
@@ -1055,6 +1123,7 @@ class MusicService : MediaLibraryService() {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} catch (e: Exception) {
+ // Includes ForegroundServiceStartNotAllowedException on API 31+.
Timber.tag(TAG).w(e, "Failed to promote service to foreground for external command")
}
}
@@ -1175,10 +1244,31 @@ class MusicService : MediaLibraryService() {
}
}
}
- val startCommandResult = super.onStartCommand(intent, flags, startId)
+ val startCommandResult = try {
+ super.onStartCommand(intent, flags, startId)
+ } catch (e: Exception) {
+ // Android 12+: Media3 may call startForeground() from onStartCommand while the app
+ // is cached/backgrounded (widget/media-button/system restart). That throws
+ // ForegroundServiceStartNotAllowedException and kills the process if uncaught.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+ e is ForegroundServiceStartNotAllowedException
+ ) {
+ Timber.tag(TAG).w(
+ e,
+ "super.onStartCommand startForeground denied; continuing without FGS promote"
+ )
+ START_STICKY
+ } else {
+ throw e
+ }
+ }
if (needsTemporaryForeground || startedTemporaryForegroundInOnCreate) {
if (mediaSession?.player?.hasForegroundPlaybackIntent() != true) {
- stopForeground(STOP_FOREGROUND_REMOVE)
+ try {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } catch (e: Exception) {
+ Timber.tag(TAG).w(e, "stopForeground after temporary FGS failed")
+ }
if (needsTemporaryForeground) {
stopSelfResult(startId)
}
@@ -1313,6 +1403,14 @@ class MusicService : MediaLibraryService() {
override fun onPlaybackStateChanged(playbackState: Int) {
Timber.tag(TAG).d("Playback state changed: $playbackState")
+ if (playbackState == Player.STATE_READY) {
+ consecutivePlaybackErrorSkips = 0
+ (mediaSession?.player ?: engine.masterPlayer).currentMediaItem?.mediaId
+ ?.let {
+ soundCloudRefreshAttempted.remove(it)
+ youtubeRefreshAttempts.remove(it)
+ }
+ }
if (playbackState == Player.STATE_ENDED) {
listeningStatsTracker.finalizeCurrentSession()
val mediaItem = (mediaSession?.player ?: engine.masterPlayer).currentMediaItem
@@ -1341,6 +1439,7 @@ class MusicService : MediaLibraryService() {
if (nextIndex != androidx.media3.common.C.INDEX_UNSET) {
runCatching { replayGainProcessor.prefetch(player.getMediaItemAt(nextIndex)) }
}
+ prepareUpcomingYoutubeItem(player)
}
override fun onPositionDiscontinuity(
@@ -1422,6 +1521,7 @@ class MusicService : MediaLibraryService() {
if (nextIndex != androidx.media3.common.C.INDEX_UNSET) {
runCatching { replayGainProcessor.prefetch(player.getMediaItemAt(nextIndex)) }
}
+ prepareUpcomingYoutubeItem(player)
// Optimization: Don't force-update widgets on every rapid skip.
// Let the debounced updater handle it to prevent UI freezes.
widgetUpdateManager.requestFullUpdate(false)
@@ -1455,16 +1555,50 @@ class MusicService : MediaLibraryService() {
}
override fun onPlayerError(error: PlaybackException) {
- Timber.tag(TAG).e(error, "Error en el reproductor: ")
- serviceScope.launch {
- val currentMediaItem = mediaSession?.player?.currentMediaItem
- val trackTitle = currentMediaItem?.mediaMetadata?.title?.toString()
- ?: currentMediaItem?.mediaId
- ?: getString(R.string.common_unknown_track)
- val errorMessage = error.localizedMessage ?: error.message ?: "Unknown error"
- val toastMessage = getString(R.string.player_playback_error, "$trackTitle ($errorMessage)")
- android.widget.Toast.makeText(this@MusicService, toastMessage, android.widget.Toast.LENGTH_LONG).show()
+ val player = mediaSession?.player ?: engine.masterPlayer
+ val currentItem = player.currentMediaItem
+ val permalink = soundCloudPermalink(currentItem)
+ val mediaId = currentItem?.mediaId.orEmpty()
+ if (currentItem != null && !permalink.isNullOrBlank() && soundCloudRefreshAttempted.add(mediaId)) {
+ serviceScope.launch {
+ val refreshed = refreshSoundCloudItem(currentItem, permalink)
+ withContext(Dispatchers.Main.immediate) {
+ val sameTrack = player.currentMediaItem?.mediaId == mediaId
+ val oldUri = currentItem.localConfiguration?.uri
+ val newUri = refreshed.localConfiguration?.uri
+ if (sameTrack && newUri != null && newUri != oldUri) {
+ player.replaceMediaItem(player.currentMediaItemIndex, refreshed)
+ player.prepare()
+ player.play()
+ } else {
+ skipUnplayableItem(player, error)
+ }
+ }
+ }
+ return
+ }
+ val watchUrl = youtubeWatchUrl(currentItem)
+ val attempts = youtubeRefreshAttempts[mediaId] ?: 0
+ if (currentItem != null && !watchUrl.isNullOrBlank() && attempts < MAX_YOUTUBE_REFRESH_ATTEMPTS) {
+ youtubeRefreshAttempts[mediaId] = attempts + 1
+ serviceScope.launch {
+ val refreshed = refreshYoutubeItem(currentItem, watchUrl)
+ withContext(Dispatchers.Main.immediate) {
+ val sameTrack = player.currentMediaItem?.mediaId == mediaId
+ val oldUri = currentItem.localConfiguration?.uri
+ val newUri = refreshed.localConfiguration?.uri
+ if (sameTrack && newUri != null && newUri != oldUri) {
+ player.replaceMediaItem(player.currentMediaItemIndex, refreshed)
+ player.prepare()
+ player.play()
+ } else {
+ skipUnplayableItem(player, error)
+ }
+ }
+ }
+ return
}
+ skipUnplayableItem(player, error)
}
}
@@ -1689,6 +1823,10 @@ class MusicService : MediaLibraryService() {
albumTitle = metadata.albumTitle?.toString(),
artworkUri = resolveStoredArtworkUriString(metadata),
durationMs = durationMs,
+ soundCloudPermalink = metadata.extras?.getString(SoundCloudClient.EXTRA_PERMALINK)
+ ?: SoundCloudClient.permalinkForSongId(mediaItem.mediaId),
+ youtubeWatchUrl = metadata.extras?.getString(com.theveloper.pixelplay.youtube.YoutubeClient.EXTRA_WATCH_URL)
+ ?: com.theveloper.pixelplay.youtube.YoutubeClient.watchUrlForSongId(mediaItem.mediaId),
)
)
}
@@ -1760,7 +1898,7 @@ class MusicService : MediaLibraryService() {
else -> 0
}
- val preparedItems = restoredItems.toMutableList()
+ val preparedItems = refreshYoutubeQueue(refreshSoundCloudQueue(restoredItems)).toMutableList()
preparedItems.getOrNull(resolvedIndex)?.let { currentItem ->
val resolvedCurrentItem = runCatching { engine.resolveMediaItem(currentItem) }.getOrNull()
if (resolvedCurrentItem != null && resolvedCurrentItem != currentItem) {
@@ -1840,6 +1978,14 @@ class MusicService : MediaLibraryService() {
snapshotItem.durationMs?.takeIf { it > 0L }?.let {
putLong(MediaItemBuilder.EXTERNAL_EXTRA_DURATION, it)
}
+ snapshotItem.soundCloudPermalink?.takeIf { it.isNotBlank() }?.let { permalink ->
+ putString(SoundCloudClient.EXTRA_PERMALINK, permalink)
+ SoundCloudClient.rememberPermalink(snapshotItem.mediaId, permalink)
+ }
+ snapshotItem.youtubeWatchUrl?.takeIf { it.isNotBlank() }?.let { watchUrl ->
+ putString(com.theveloper.pixelplay.youtube.YoutubeClient.EXTRA_WATCH_URL, watchUrl)
+ com.theveloper.pixelplay.youtube.YoutubeClient.rememberWatchUrl(snapshotItem.mediaId, watchUrl)
+ }
}
metadataBuilder.setExtras(extras)
@@ -1848,6 +1994,115 @@ class MusicService : MediaLibraryService() {
.setUri(MediaItemBuilder.playbackUri(snapshotItem.uri))
.setMediaMetadata(metadataBuilder.build())
.build()
+ .let(MediaItemBuilder::withSoundCloudPermalink)
+ .let(MediaItemBuilder::withYoutubeWatchUrl)
+ }
+
+ private fun soundCloudPermalink(item: MediaItem?): String? {
+ if (item == null) return null
+ if (item.mediaId.startsWith("sc_dl_")) return null
+ return item.mediaMetadata.extras?.getString(SoundCloudClient.EXTRA_PERMALINK)
+ ?: SoundCloudClient.permalinkForSongId(item.mediaId)
+ }
+
+ private fun youtubeWatchUrl(item: MediaItem?): String? {
+ if (item == null) return null
+ return item.mediaMetadata.extras?.getString(com.theveloper.pixelplay.youtube.YoutubeClient.EXTRA_WATCH_URL)
+ ?: com.theveloper.pixelplay.youtube.YoutubeClient.watchUrlForSongId(item.mediaId)
+ }
+
+ private suspend fun refreshYoutubeQueue(items: List): List = coroutineScope {
+ items.map { item ->
+ async(Dispatchers.IO) {
+ val watchUrl = youtubeWatchUrl(item) ?: return@async item
+ refreshYoutubeItem(item, watchUrl)
+ }
+ }.awaitAll()
+ }
+
+ private fun prepareUpcomingYoutubeItem(player: Player) {
+ val nextIndex = player.nextMediaItemIndex
+ if (nextIndex == C.INDEX_UNSET || nextIndex == player.currentMediaItemIndex) return
+ val next = runCatching { player.getMediaItemAt(nextIndex) }.getOrNull() ?: return
+ val watchUrl = youtubeWatchUrl(next) ?: return
+ val currentUri = next.localConfiguration?.uri?.toString().orEmpty()
+ if (isYoutubeStreamUrl(currentUri)) return
+ val mediaId = next.mediaId
+ if (!youtubePrepareStarted.add(mediaId)) return
+ serviceScope.launch {
+ val refreshed = refreshYoutubeItem(next, watchUrl)
+ withContext(Dispatchers.Main.immediate) {
+ val index = (0 until player.mediaItemCount).firstOrNull { itemIndex ->
+ runCatching { player.getMediaItemAt(itemIndex).mediaId }.getOrNull() == mediaId
+ } ?: return@withContext
+ if (index == player.currentMediaItemIndex) return@withContext
+ val existing = player.getMediaItemAt(index).localConfiguration?.uri
+ val updated = refreshed.localConfiguration?.uri
+ if (updated != null && updated != existing) {
+ player.replaceMediaItem(index, refreshed)
+ }
+ }
+ }
+ }
+
+ private fun isYoutubeStreamUrl(uri: String): Boolean {
+ return uri.contains("googlevideo.com") || uri.contains("videoplayback")
+ }
+
+ private suspend fun refreshYoutubeItem(item: MediaItem, watchUrl: String): MediaItem =
+ withContext(Dispatchers.IO) {
+ val currentUri = item.localConfiguration?.uri?.toString()
+ val streamUrl = runCatching {
+ youtubeClient.resolveTrack(watchUrl, avoidStreamUrl = currentUri).streamUrl
+ }.getOrNull()
+ if (streamUrl.isNullOrBlank() || streamUrl == currentUri) return@withContext item
+ val refreshed = item.buildUpon().setUri(streamUrl).build()
+ MediaItemBuilder.withYoutubeWatchUrl(refreshed)
+ }
+
+ private suspend fun refreshSoundCloudQueue(items: List): List = coroutineScope {
+ items.map { item ->
+ async(Dispatchers.IO) {
+ val permalink = soundCloudPermalink(item) ?: return@async item
+ refreshSoundCloudItem(item, permalink)
+ }
+ }.awaitAll()
+ }
+
+ private suspend fun refreshSoundCloudItem(item: MediaItem, permalink: String): MediaItem =
+ withContext(Dispatchers.IO) {
+ val streamUrl = runCatching { soundCloudClient.resolveTrack(permalink).streamUrl }.getOrNull()
+ if (streamUrl.isNullOrBlank()) return@withContext item
+ val refreshed = item.buildUpon().setUri(streamUrl).build()
+ MediaItemBuilder.withSoundCloudPermalink(refreshed)
+ }
+
+ private fun skipUnplayableItem(player: Player, error: PlaybackException) {
+ val nextIndex = player.nextMediaItemIndex
+ val canSkipToAnother = nextIndex != C.INDEX_UNSET && nextIndex != player.currentMediaItemIndex
+ if (canSkipToAnother && consecutivePlaybackErrorSkips < MAX_PLAYBACK_ERROR_SKIPS) {
+ consecutivePlaybackErrorSkips++
+ Timber.tag(TAG).w(
+ error,
+ "Skipping unplayable item (%d)",
+ consecutivePlaybackErrorSkips,
+ )
+ player.seekToNextMediaItem()
+ player.prepare()
+ player.play()
+ return
+ }
+ consecutivePlaybackErrorSkips = 0
+ Timber.tag(TAG).e(error, "Error en el reproductor: ")
+ serviceScope.launch {
+ val currentMediaItem = mediaSession?.player?.currentMediaItem
+ val trackTitle = currentMediaItem?.mediaMetadata?.title?.toString()
+ ?: currentMediaItem?.mediaId
+ ?: getString(R.string.common_unknown_track)
+ val errorMessage = error.localizedMessage ?: error.message ?: "Unknown error"
+ val toastMessage = getString(R.string.player_playback_error, "$trackTitle ($errorMessage)")
+ android.widget.Toast.makeText(this@MusicService, toastMessage, android.widget.Toast.LENGTH_LONG).show()
+ }
}
private fun getOpenAppPendingIntent(): PendingIntent {
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastOptionsProvider.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastOptionsProvider.kt
index 794a94c686..21639490d9 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastOptionsProvider.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastOptionsProvider.kt
@@ -27,6 +27,8 @@ class CastOptionsProvider : OptionsProvider {
return CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
.setCastMediaOptions(mediaOptions)
+ .setEnableReconnectionService(false)
+ .setResumeSavedSession(false)
.build()
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt
index c8f05dc0a2..8d7b7f005b 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt
@@ -63,6 +63,7 @@ import com.theveloper.pixelplay.data.navidrome.NavidromeStreamProxy
import com.theveloper.pixelplay.data.qqmusic.QqMusicStreamProxy
import androidx.core.net.toUri
import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics
+import com.theveloper.pixelplay.youtube.YoutubeDownloader
data class ActiveDecoderInfo(
val name: String,
@@ -249,6 +250,9 @@ class DualPlayerEngine @Inject constructor(
private var scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
var hiFiModeEnabled: Boolean = false
private set
+ /** Trims silent passages inside a track (ExoPlayer's SilenceSkippingAudioProcessor). */
+ var skipSilenceEnabled: Boolean = false
+ private set
private var audioOffloadEnabled = !shouldDisableAudioOffloadByDefault()
private var transitionJob: Job? = null
private var bufferingFallbackJob: Job? = null
@@ -987,16 +991,16 @@ class DualPlayerEngine @Inject constructor(
}
/**
- * Returns a [DefaultLoadControl] tuned to the device's RAM tier.
+ * Returns a [DefaultLoadControl] tuned to the device's RAM tier, using the single
+ * source of truth in [loadControlBufferProfileFor].
*
* Low-RAM devices ([ActivityManager.isLowRamDevice]) receive halved buffer ceilings
* to prevent memory pressure when both players co-exist during a crossfade.
- * [bufferForPlaybackMs] is set to ExoPlayer's documented default of 2 500 ms on both
- * tiers — the previous value of 5 000 ms doubled first-audio latency with no benefit.
*/
private fun buildAdaptiveLoadControl(): DefaultLoadControl {
val isLowRam = (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager)
.isLowRamDevice
+ val profile = loadControlBufferProfileFor(isLowRam)
// setPrioritizeTimeOverSizeThresholds(true): instructs ExoPlayer to use buffered
// *duration* (not buffered *bytes*) as the criterion for deciding when to start
// playback and when to stop buffering. This is required for correct behaviour with
@@ -1005,27 +1009,15 @@ class DualPlayerEngine @Inject constructor(
// Without this flag ExoPlayer falls back to a default byte threshold that was
// designed for typical compressed audio (~128–320 kbps) and will underperform on
// files with bitrates above ~1 Mbps.
- return if (isLowRam) {
- DefaultLoadControl.Builder()
- .setBufferDurationsMs(
- /* minBufferMs */ 15_000,
- /* maxBufferMs */ 30_000,
- /* bufferForPlaybackMs */ 2_500,
- /* bufferForPlaybackAfterRebufferMs */ 5_000
- )
- .setPrioritizeTimeOverSizeThresholds(true)
- .build()
- } else {
- DefaultLoadControl.Builder()
- .setBufferDurationsMs(
- /* minBufferMs */ 30_000,
- /* maxBufferMs */ 60_000,
- /* bufferForPlaybackMs */ 2_500,
- /* bufferForPlaybackAfterRebufferMs */ 5_000
- )
- .setPrioritizeTimeOverSizeThresholds(true)
- .build()
- }
+ return DefaultLoadControl.Builder()
+ .setBufferDurationsMs(
+ profile.minBufferMs,
+ profile.maxBufferMs,
+ profile.bufferForPlaybackMs,
+ profile.bufferForPlaybackAfterRebufferMs
+ )
+ .setPrioritizeTimeOverSizeThresholds(true)
+ .build()
}
private fun buildPlayer(): ExoPlayer {
@@ -1100,15 +1092,16 @@ class DualPlayerEngine @Inject constructor(
override fun resolveDataSpec(dataSpec: DataSpec): DataSpec {
val uri = dataSpec.uri
val scheme = uri.scheme
+ val withAgent = youtubePlaybackSpec(dataSpec)
if (scheme in CLOUD_PROXY_SCHEMES) {
val originalUri = uri.toString()
val resolved = resolvedUriCache.get(originalUri)
if (resolved != null) {
- return dataSpec.buildUpon().setUri(resolved).build()
+ return withAgent.buildUpon().setUri(resolved).build()
}
Timber.tag("DualPlayerEngine").d("resolveDataSpec: Cache MISS for %s — using original URI", scheme)
}
- return dataSpec
+ return withAgent
}
}
@@ -1145,6 +1138,7 @@ class DualPlayerEngine @Inject constructor(
.build()
setHandleAudioBecomingNoisy(true)
setWakeMode(C.WAKE_MODE_LOCAL)
+ skipSilenceEnabled = this@DualPlayerEngine.skipSilenceEnabled
playWhenReady = false
}
}
@@ -1193,6 +1187,14 @@ class DualPlayerEngine @Inject constructor(
rebuildPlayersPreservingMasterState("Hi-Fi mode set to $enabled")
}
+ fun setSkipSilence(enabled: Boolean) {
+ if (skipSilenceEnabled == enabled) return
+ skipSilenceEnabled = enabled
+ if (::playerA.isInitialized) playerA.skipSilenceEnabled = enabled
+ playerB?.skipSilenceEnabled = enabled
+ Timber.tag("DualPlayerEngine").d("Skip silence set to %b", enabled)
+ }
+
suspend fun resolveCloudUri(uri: Uri): Uri = withContext(Dispatchers.IO) {
val uriString = uri.toString()
resolvedUriCache.get(uriString)?.let { return@withContext it }
@@ -1269,6 +1271,15 @@ class DualPlayerEngine @Inject constructor(
gdriveStreamProxy.resolveGDriveUri(uriString)?.toUri()
}
+ private fun youtubePlaybackSpec(dataSpec: DataSpec): DataSpec {
+ val host = dataSpec.uri.host.orEmpty()
+ val youtube = host.contains("googlevideo.com") || host.endsWith("youtube.com")
+ if (!youtube) return dataSpec
+ val headers = HashMap(dataSpec.httpRequestHeaders)
+ headers["User-Agent"] = YoutubeDownloader.USER_AGENT
+ return dataSpec.buildUpon().setHttpRequestHeaders(headers).build()
+ }
+
suspend fun resolveMediaItem(mediaItem: MediaItem): MediaItem {
val uri = mediaItem.localConfiguration?.uri ?: return mediaItem
val scheme = uri.scheme
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt
index bdd5cd4a70..d8a5dd3610 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt
@@ -70,7 +70,8 @@ class PlaybackStatsRepository @Inject constructor(
val artist: String,
val albumArtUri: String?,
val totalDurationMs: Long,
- val playCount: Int
+ val playCount: Int,
+ val origin: PlaybackOrigin? = null,
)
data class ArtistPlaybackSummary(
@@ -92,9 +93,41 @@ class PlaybackStatsRepository @Inject constructor(
val albumArtUri: String?,
val totalDurationMs: Long,
val playCount: Int,
- val uniqueSongs: Int
+ val uniqueSongs: Int,
+ val origin: PlaybackOrigin? = null,
)
+ enum class PlaybackOrigin {
+ SOUNDCLOUD,
+ TELEGRAM,
+ NETEASE,
+ QQ_MUSIC,
+ NAVIDROME,
+ JELLYFIN,
+ GOOGLE_DRIVE,
+ }
+
+ private fun Song.statsOrigin(): PlaybackOrigin? {
+ val uri = contentUriString.lowercase()
+ val pathLower = path.lowercase()
+ val art = albumArtUriString.orEmpty().lowercase()
+ return when {
+ telegramChatId != null || telegramFileId != null || uri.startsWith("telegram://") ->
+ PlaybackOrigin.TELEGRAM
+ neteaseId != null || uri.startsWith("netease://") -> PlaybackOrigin.NETEASE
+ !qqMusicMid.isNullOrBlank() || uri.startsWith("qqmusic://") -> PlaybackOrigin.QQ_MUSIC
+ !navidromeId.isNullOrBlank() || uri.startsWith("navidrome://") -> PlaybackOrigin.NAVIDROME
+ !jellyfinId.isNullOrBlank() || uri.startsWith("jellyfin://") -> PlaybackOrigin.JELLYFIN
+ !gdriveFileId.isNullOrBlank() || uri.startsWith("gdrive://") -> PlaybackOrigin.GOOGLE_DRIVE
+ id.startsWith("sc_") ||
+ genre.equals("SoundCloud", ignoreCase = true) ||
+ pathLower.contains("pixelplayer/soundcloud") ||
+ uri.contains("soundcloud") ||
+ art.contains("sndcdn") -> PlaybackOrigin.SOUNDCLOUD
+ else -> null
+ }
+ }
+
data class TimelineEntry(
val label: String,
val totalDurationMs: Long,
@@ -270,7 +303,8 @@ class PlaybackStatsRepository @Inject constructor(
artist = artist,
albumArtUri = song.albumArtUriString,
totalDurationMs = segmentsForSong.sumOf { it.durationMs },
- playCount = segmentsForSong.size
+ playCount = segmentsForSong.size,
+ origin = song.statsOrigin(),
)
}
.sortedWith(
@@ -395,12 +429,17 @@ class PlaybackStatsRepository @Inject constructor(
.asSequence()
.mapNotNull { songMap[it.key] }
.firstOrNull()
+ val origin = groupedSongs
+ .map { songMap[it.key]?.statsOrigin() }
+ .distinct()
+ .singleOrNull()
AlbumPlaybackSummary(
album = album,
albumArtUri = firstSong?.albumArtUriString,
totalDurationMs = flattened.sumOf { it.durationMs },
playCount = flattened.size,
- uniqueSongs = uniqueSongCount
+ uniqueSongs = uniqueSongCount,
+ origin = origin,
)
}
.sortedWith(
@@ -1110,3 +1149,11 @@ enum class StatsTimeRange(val displayName: String) {
YEAR("Year to Date"),
ALL("All Time")
}
+
+/** Library songs win when an id exists in both lists. */
+internal fun mergeStatsSongs(library: List, streamed: List): List {
+ if (streamed.isEmpty()) return library
+ val known = HashSet(library.size)
+ library.forEach { known += it.id }
+ return library + streamed.filter { it.id !in known && it.title.isNotBlank() }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt
index 8166aa3262..ed17048312 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumCarouselSelection.kt
@@ -47,7 +47,7 @@ fun AlbumCarouselSection(
onSongSelected: (Song, Int) -> Unit,
onAlbumClick: (Song) -> Unit = {},
modifier: Modifier = Modifier,
- carouselStyle: String = CarouselStyle.NO_PEEK,
+ carouselStyle: String = CarouselStyle.ONE_PEEK,
itemSpacing: Dp = 8.dp,
albumArtQuality: AlbumArtQuality = AlbumArtQuality.MEDIUM
) {
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt
index 0370ac54df..e7794a1d84 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/BetaInfoBottomSheet.kt
@@ -71,8 +71,8 @@ import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape
@Composable
fun BetaInfoBottomSheet(modifier: Modifier = Modifier) {
val context = LocalContext.current
- val issuesUrl = "https://github.com/theovilardo/PixelPlayer/issues"
- val reportUrl = "https://github.com/theovilardo/PixelPlayer/issues/new/choose"
+ val issuesUrl = "https://github.com/kibetmasi/PixelPlayer/issues"
+ val reportUrl = "https://github.com/kibetmasi/PixelPlayer/issues/new/choose"
val fabCornerRadius = 18.dp
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt
index 5d8a3860d1..ee4bb8d59a 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt
@@ -1,10 +1,8 @@
-package com.theveloper.pixelplay.presentation.components
+package com.theveloper.pixelplay.presentation.components
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
-import androidx.annotation.ArrayRes
-import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -12,6 +10,8 @@ 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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -20,27 +20,37 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.MediumExtendedFloatingActionButton
+import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MediumExtendedFloatingActionButton
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetValue
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -48,111 +58,79 @@ import com.theveloper.pixelplay.R
import com.theveloper.pixelplay.presentation.components.subcomps.SineWaveLine
import com.theveloper.pixelplay.ui.theme.ExpTitleTypography
import com.theveloper.pixelplay.ui.theme.GoogleSansRounded
+import java.net.HttpURLConnection
+import java.net.URL
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape
+val LocalRequestAppUpdateCheck = staticCompositionLocalOf<() -> Unit> { {} }
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class)
+@Composable
+fun ChangelogModalSheet(onDismiss: () -> Unit) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ ) {
+ val fill = if (sheetState.targetValue == SheetValue.Expanded) 1f else 0.55f
+ ChangelogBottomSheet(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(fill),
+ onDismiss = onDismiss,
+ )
+ }
+}
+
+private const val CHANGELOG_RAW_URL =
+ "https://raw.githubusercontent.com/kibetmasi/PixelPlayer/master/CHANGELOG.md"
+private const val CHANGELOG_PAGE_URL =
+ "https://github.com/kibetmasi/PixelPlayer/blob/master/CHANGELOG.md"
+
data class ChangelogSection(
- @StringRes val titleRes: Int,
- @ArrayRes val itemsRes: Int
+ val title: String,
+ val items: List,
)
data class ChangelogVersion(
val version: String,
val date: String,
- val sections: List
-)
-
-@Composable
-private fun changelogVersions(): List = listOf(
- ChangelogVersion(
- version = "0.7.5-beta",
- date = "2026-06-13",
- sections = listOf(
- ChangelogSection(R.string.changelog_sec_whats_new, R.array.changelog_075_whats_new),
- ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_075_improvements),
- ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_075_fixes)
- )
- ),
- ChangelogVersion(
- version = "0.7.0-beta",
- date = "2026-05-25",
- sections = listOf(
- ChangelogSection(R.string.changelog_sec_whats_new, R.array.changelog_070_whats_new),
- ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_070_improvements),
- ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_070_fixes),
- ChangelogSection(R.string.changelog_sec_added, R.array.changelog_070_added)
- )
- ),
- ChangelogVersion(
- version = "0.6.0-beta",
- date = "2026-03-05",
- sections = listOf(
- ChangelogSection(R.string.changelog_sec_whats_new, R.array.changelog_060_whats_new),
- ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_060_improvements),
- ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_060_fixes)
- )
- ),
- ChangelogVersion(
- version = "0.5.0-beta",
- date = "2026-01-14",
- sections = listOf(
- ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_050_improvements),
- ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_050_fixes)
- )
- ),
- ChangelogVersion(
- version = "0.4.0-beta",
- date = "2025-12-15",
- sections = listOf(
- ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_040_improvements)
- )
- ),
- ChangelogVersion(
- version = "0.3.0-beta",
- date = "2025-10-28",
- sections = listOf(
- ChangelogSection(R.string.changelog_sec_whats_new, R.array.changelog_030_whats_new),
- ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_030_improvements),
- ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_030_fixes)
- )
- ),
- ChangelogVersion(
- version = "0.2.0-beta",
- date = "2024-09-15",
- sections = listOf(
- ChangelogSection(R.string.changelog_sec_added, R.array.changelog_020_added),
- ChangelogSection(R.string.changelog_sec_improvements, R.array.changelog_020_improvements),
- ChangelogSection(R.string.changelog_sec_fixes, R.array.changelog_020_fixes)
- )
- )
+ val sections: List,
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChangelogBottomSheet(
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ onDismiss: () -> Unit = {},
) {
val context = LocalContext.current
- val changelogUrl = "https://github.com/theovilardo/PixelPlayer/blob/master/CHANGELOG.md"
- val changelog = changelogVersions()
-
+ val requestUpdateCheck = LocalRequestAppUpdateCheck.current
+ var changelog by remember { mutableStateOf>(emptyList()) }
+ var loading by remember { mutableStateOf(true) }
val fabCornerRadius = 16.dp
- Box(modifier = modifier.fillMaxWidth()) {
+ LaunchedEffect(Unit) {
+ changelog = withContext(Dispatchers.IO) { fetchRepoChangelog() }
+ loading = false
+ }
+
+ Box(modifier = modifier.fillMaxWidth().fillMaxHeight()) {
Column(
modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 0.dp),
- horizontalAlignment = Alignment.CenterHorizontally
+ .fillMaxSize()
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.changelog_title),
fontFamily = GoogleSansRounded,
style = ExpTitleTypography.displaySmall,
- color = MaterialTheme.colorScheme.onSurface
+ color = MaterialTheme.colorScheme.onSurface,
)
-
Spacer(modifier = Modifier.height(16.dp))
-
SineWaveLine(
modifier = Modifier
.fillMaxWidth()
@@ -166,24 +144,56 @@ fun ChangelogBottomSheet(
strokeWidth = 4.dp,
amplitude = 4.dp,
waves = 7.6f,
- phase = 0f
+ phase = 0f,
)
-
LazyColumn(
modifier = Modifier
.fillMaxWidth()
+ .weight(1f, fill = true)
.padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
- contentPadding = PaddingValues(bottom = 120.dp)
+ contentPadding = PaddingValues(bottom = 120.dp),
) {
- items(changelog, key = { it.version }) { version ->
- ChangelogVersionItem(version = version)
+ item(key = "check_for_updates") {
+ FilledTonalButton(
+ onClick = {
+ onDismiss()
+ requestUpdateCheck()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.changelog_check_updates))
+ }
+ }
+ if (loading) {
+ item(key = "loading") {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ } else if (changelog.isEmpty()) {
+ item(key = "empty") {
+ Text(
+ text = stringResource(R.string.changelog_unavailable),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ } else {
+ items(changelog, key = { it.version + it.date }) { version ->
+ ChangelogVersionItem(version = version)
+ }
}
}
}
MediumExtendedFloatingActionButton(
- onClick = { openUrl(context, changelogUrl) },
+ onClick = { openUrl(context, CHANGELOG_PAGE_URL) },
shape = AbsoluteSmoothCornerShape(
cornerRadiusBR = fabCornerRadius,
smoothnessAsPercentBR = 60,
@@ -192,20 +202,20 @@ fun ChangelogBottomSheet(
cornerRadiusTR = fabCornerRadius,
smoothnessAsPercentTR = 60,
cornerRadiusTL = fabCornerRadius,
- smoothnessAsPercentTL = 60
+ smoothnessAsPercentTL = 60,
),
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
icon = {
Icon(
painter = painterResource(id = R.drawable.github),
- contentDescription = null
+ contentDescription = null,
)
},
text = { Text(text = stringResource(R.string.changelog_view_github)) },
modifier = Modifier
.align(Alignment.BottomEnd)
- .padding(horizontal = 24.dp, vertical = 24.dp)
+ .padding(horizontal = 24.dp, vertical = 24.dp),
)
Box(
@@ -217,13 +227,11 @@ fun ChangelogBottomSheet(
brush = Brush.verticalGradient(
listOf(
Color.Transparent,
- MaterialTheme.colorScheme.surfaceContainerLow
- )
- )
- )
- ) {
-
- }
+ MaterialTheme.colorScheme.surfaceContainerLow,
+ ),
+ ),
+ ),
+ )
}
}
@@ -231,7 +239,7 @@ fun ChangelogBottomSheet(
fun ChangelogVersionItem(version: ChangelogVersion) {
Column(
modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(16.dp)
+ verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
VersionBadge(versionNumber = version.version)
@@ -239,10 +247,9 @@ fun ChangelogVersionItem(version: ChangelogVersion) {
Text(
text = version.date,
style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
-
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
version.sections.forEach { section ->
ChangelogCategory(section = section)
@@ -253,35 +260,33 @@ fun ChangelogVersionItem(version: ChangelogVersion) {
@Composable
fun ChangelogCategory(section: ChangelogSection) {
- val items = stringArrayResource(section.itemsRes).toList()
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(22.dp),
- tonalElevation = 6.dp
+ tonalElevation = 6.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 18.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
+ verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
- text = stringResource(section.titleRes),
+ text = section.title,
style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.primary
+ color = MaterialTheme.colorScheme.primary,
)
-
- items.forEachIndexed { index, item ->
+ section.items.forEachIndexed { index, item ->
Row(
verticalAlignment = Alignment.Top,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.padding(top = 6.dp)
.size(8.dp)
- .background(MaterialTheme.colorScheme.primary, CircleShape)
+ .background(MaterialTheme.colorScheme.primary, CircleShape),
)
val linkColor = MaterialTheme.colorScheme.primary
val annotatedText = buildAnnotatedString {
@@ -293,30 +298,25 @@ fun ChangelogCategory(section: ChangelogSection) {
withLink(
LinkAnnotation.Url(
url = "https://github.com/$username",
- styles = TextLinkStyles(
- style = SpanStyle(color = linkColor)
- )
- )
+ styles = TextLinkStyles(style = SpanStyle(color = linkColor)),
+ ),
) {
append(match.value)
}
lastIndex = match.range.last + 1
}
- if (lastIndex < item.length) {
- append(item.substring(lastIndex))
- }
+ if (lastIndex < item.length) append(item.substring(lastIndex))
}
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface
+ color = MaterialTheme.colorScheme.onSurface,
)
}
-
- if (index != items.lastIndex) {
+ if (index != section.items.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 10.dp),
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f),
)
}
}
@@ -325,30 +325,94 @@ fun ChangelogCategory(section: ChangelogSection) {
}
@Composable
-fun VersionBadge(
- versionNumber: String
-) {
+fun VersionBadge(versionNumber: String) {
Box(
- modifier = Modifier
- .background(
- color = MaterialTheme.colorScheme.primaryContainer,
- shape = CircleShape
- )
+ modifier = Modifier.background(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = CircleShape,
+ ),
) {
Text(
modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
text = versionNumber,
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.Bold
+ fontWeight = FontWeight.Bold,
)
}
}
+private fun fetchRepoChangelog(): List {
+ var connection: HttpURLConnection? = null
+ return try {
+ connection = (URL(CHANGELOG_RAW_URL).openConnection() as HttpURLConnection).apply {
+ requestMethod = "GET"
+ connectTimeout = 10_000
+ readTimeout = 10_000
+ setRequestProperty("User-Agent", "PixelPlayer")
+ setRequestProperty("Accept", "text/plain")
+ }
+ if (connection.responseCode != HttpURLConnection.HTTP_OK) return emptyList()
+ connection.inputStream.bufferedReader().use { parseChangelog(it.readText()) }
+ } catch (_: Exception) {
+ emptyList()
+ } finally {
+ connection?.disconnect()
+ }
+}
+
+internal fun parseChangelog(markdown: String): List {
+ val versions = mutableListOf()
+ var version: String? = null
+ var date = ""
+ val sections = mutableListOf()
+ var sectionTitle: String? = null
+ val items = mutableListOf()
+ val heading = Regex("""^## \[(.+)](?:\s*-\s*(.*))?$""")
+
+ fun flushSection() {
+ val title = sectionTitle ?: return
+ if (items.isNotEmpty()) sections += ChangelogSection(title, items.toList())
+ items.clear()
+ sectionTitle = null
+ }
+
+ fun flushVersion() {
+ flushSection()
+ val name = version ?: return
+ if (sections.isNotEmpty()) versions += ChangelogVersion(name, date, sections.toList())
+ sections.clear()
+ version = null
+ date = ""
+ }
+
+ markdown.lineSequence().forEach { raw ->
+ val line = raw.trim()
+ val match = heading.matchEntire(line)
+ when {
+ match != null -> {
+ flushVersion()
+ val name = match.groupValues[1].trim()
+ if (!name.equals("Unreleased", ignoreCase = true)) {
+ version = name
+ date = match.groupValues.getOrNull(2)?.trim().orEmpty()
+ }
+ }
+ version != null && line.startsWith("### ") -> {
+ flushSection()
+ sectionTitle = line.removePrefix("### ").trim()
+ }
+ version != null && sectionTitle != null && line.startsWith("- ") -> {
+ items += line.removePrefix("- ").trim()
+ }
+ }
+ }
+ flushVersion()
+ return versions
+}
+
private fun openUrl(context: Context, url: String) {
- val uri = try { url.toUri() } catch (_: Throwable) { url.toUri() }
- val intent = Intent(Intent.ACTION_VIEW, uri)
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ val intent = Intent(Intent.ACTION_VIEW, url.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt
index 9787826e8f..6f795d02f7 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt
@@ -119,6 +119,7 @@ import com.theveloper.pixelplay.presentation.components.subcomps.LyricsMoreBotto
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
+import androidx.core.content.ContextCompat
import androidx.compose.ui.platform.LocalView
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -482,8 +483,15 @@ fun LyricsSheet(
}
}
}
- context.registerReceiver(receiver, IntentFilter(Intent.ACTION_SCREEN_OFF))
- onDispose { context.unregisterReceiver(receiver) }
+ ContextCompat.registerReceiver(
+ context,
+ receiver,
+ IntentFilter(Intent.ACTION_SCREEN_OFF),
+ ContextCompat.RECEIVER_EXPORTED,
+ )
+ onDispose {
+ runCatching { context.unregisterReceiver(receiver) }
+ }
}
// Auto-hide controls logic
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt
index c2fa601beb..97f956fe16 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt
@@ -106,7 +106,9 @@ fun MultiSelectionBottomSheet(
onToggleLikeAll: (shouldLike: Boolean) -> Unit,
onShareAll: () -> Unit,
onDeleteAll: (activity: Activity, onResult: (Boolean) -> Unit) -> Unit,
- onBatchEdit: () -> Unit
+ onBatchEdit: () -> Unit,
+ showBatchEdit: Boolean = true,
+ showDelete: Boolean = true,
) {
val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -216,23 +218,24 @@ fun MultiSelectionBottomSheet(
Spacer(Modifier.width(10.dp))
- //Batch edit button
- FilledTonalIconButton(
- modifier = Modifier
- .height(74.dp),
- colors = IconButtonDefaults.filledTonalIconButtonColors(
- containerColor = MaterialTheme.colorScheme.surfaceBright,
- contentColor = MaterialTheme.colorScheme.onSurface
- ),
- onClick = {
- onBatchEdit()
- },
- ) {
- Icon(
- modifier = Modifier.padding(horizontal = 8.dp),
- imageVector = Icons.Rounded.Edit,
- contentDescription = stringResource(R.string.song_info_cd_edit_metadata)
- )
+ if (showBatchEdit) {
+ FilledTonalIconButton(
+ modifier = Modifier
+ .height(74.dp),
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceBright,
+ contentColor = MaterialTheme.colorScheme.onSurface
+ ),
+ onClick = {
+ onBatchEdit()
+ },
+ ) {
+ Icon(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ imageVector = Icons.Rounded.Edit,
+ contentDescription = stringResource(R.string.song_info_cd_edit_metadata)
+ )
+ }
}
}
@@ -274,7 +277,13 @@ fun MultiSelectionBottomSheet(
)
Spacer(Modifier.width(6.dp))
TightWrapText(
- text = stringResource(R.string.song_info_action_play_all),
+ text = stringResource(
+ if (selectedSongs.size <= 1) {
+ R.string.song_info_action_play
+ } else {
+ R.string.song_info_action_play_all
+ }
+ ),
modifier = Modifier.padding(end = 4.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
@@ -414,7 +423,7 @@ fun MultiSelectionBottomSheet(
) {
FilledTonalButton(
modifier = Modifier
- .weight(0.5f)
+ .weight(if (showDelete) 0.5f else 1f)
.heightIn(min = 66.dp),
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
@@ -440,38 +449,40 @@ fun MultiSelectionBottomSheet(
lineHeight = 20.sp
)
}
-
- FilledTonalButton(
- modifier = Modifier
- .weight(0.5f)
- .heightIn(min = 66.dp),
- colors = ButtonDefaults.filledTonalButtonColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
- contentColor = MaterialTheme.colorScheme.onErrorContainer
- ),
- contentPadding = PaddingValues(horizontal = 10.dp),
- shape = CircleShape,
- onClick = {
- val activity = (context as? Activity)
- if (activity != null) {
- onDeleteAll(activity) { success ->
- if (success) onDismiss()
+
+ if (showDelete) {
+ FilledTonalButton(
+ modifier = Modifier
+ .weight(0.5f)
+ .heightIn(min = 66.dp),
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer
+ ),
+ contentPadding = PaddingValues(horizontal = 10.dp),
+ shape = CircleShape,
+ onClick = {
+ val activity = (context as? Activity)
+ if (activity != null) {
+ onDeleteAll(activity) { success ->
+ if (success) onDismiss()
+ }
}
}
+ ) {
+ Icon(
+ Icons.Rounded.Delete,
+ contentDescription = stringResource(R.string.song_info_action_delete_all)
+ )
+ Spacer(Modifier.width(6.dp))
+ TightWrapText(
+ text = stringResource(R.string.song_info_action_delete_all),
+ modifier = Modifier.padding(end = 4.dp),
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 2,
+ lineHeight = 20.sp
+ )
}
- ) {
- Icon(
- Icons.Rounded.Delete,
- contentDescription = stringResource(R.string.song_info_action_delete_all)
- )
- Spacer(Modifier.width(6.dp))
- TightWrapText(
- text = stringResource(R.string.song_info_action_delete_all),
- modifier = Modifier.padding(end = 4.dp),
- overflow = TextOverflow.Ellipsis,
- maxLines = 2,
- lineHeight = 20.sp
- )
}
}
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayStoreAnnouncementDialog.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayStoreAnnouncementDialog.kt
index bc079fedae..83b5e81a82 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayStoreAnnouncementDialog.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlayStoreAnnouncementDialog.kt
@@ -14,6 +14,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
@@ -67,6 +68,7 @@ fun PlayStoreAnnouncementDialog(
announcement: PlayStoreAnnouncementUiModel,
onDismiss: () -> Unit,
onOpenPlayStore: (String) -> Unit,
+ primaryBusy: Boolean = false,
) {
val cardShape = AbsoluteSmoothCornerShape(
cornerRadiusTL = 30.dp,
@@ -180,27 +182,36 @@ fun PlayStoreAnnouncementDialog(
Button(
onClick = {
- announcement.playStoreUrl?.let(onOpenPlayStore)
+ if (!primaryBusy) {
+ announcement.playStoreUrl?.let(onOpenPlayStore)
+ }
},
- enabled = hasPlayStoreLink,
+ enabled = hasPlayStoreLink && !primaryBusy,
shape = actionShape,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
- //modifier = Modifier.weight(1f),
) {
- Icon(
- painter = painterResource(id = R.drawable.rounded_arrow_forward_24),
- contentDescription = null,
- modifier = Modifier.size(18.dp),
- )
+ if (primaryBusy) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_arrow_forward_24),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ }
Spacer(modifier = Modifier.width(6.dp))
Text(
- text = if (hasPlayStoreLink) {
- announcement.primaryActionLabel
- } else {
- stringResource(R.string.playstore_dialog_coming_soon)
+ text = when {
+ primaryBusy -> stringResource(R.string.update_dialog_downloading)
+ hasPlayStoreLink -> announcement.primaryActionLabel
+ else -> stringResource(R.string.playstore_dialog_coming_soon)
},
textAlign = TextAlign.Center,
)
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt
index a3f564732a..cea0f44d7e 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt
@@ -288,7 +288,8 @@ fun QueueBottomSheet(
// Read show queue history preference
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
- val showQueueHistory = settingsState.showQueueHistory
+ val playerUiState by viewModel.playerUiState.collectAsStateWithLifecycle()
+ val showQueueHistory = settingsState.showQueueHistory || playerUiState.keepPlayedSongsInQueue
// Offset to convert display indices to queue indices when history is hidden.
val queueIndexOffset = if (showQueueHistory || currentSongIndex < 0) 0 else currentSongIndex
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt
index a75eeefa9c..34b6c021b5 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt
@@ -49,6 +49,9 @@ fun StreamingProviderSheet(
onNavigateToNavidromeDashboard: () -> Unit = {},
isJellyfinLoggedIn: Boolean = false,
onNavigateToJellyfinDashboard: () -> Unit = {},
+ isSoundCloudSignedIn: Boolean = false,
+ isSoundCloudConfigured: Boolean = false,
+ onOpenSoundCloudSettings: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
@@ -193,6 +196,23 @@ fun StreamingProviderSheet(
onDismissRequest()
}
)
+
+ ProviderRow(
+ iconPainter = painterResource(R.drawable.ic_soundcloud_24),
+ iconTint = Color(0xFFFF5500),
+ title = stringResource(R.string.soundcloud_section),
+ subtitle = when {
+ isSoundCloudSignedIn -> stringResource(R.string.soundcloud_provider_subtitle_signed_in)
+ isSoundCloudConfigured -> stringResource(R.string.soundcloud_provider_subtitle_ready)
+ else -> stringResource(R.string.soundcloud_provider_subtitle_setup)
+ },
+ shape = providerSegmentItemShape,
+ isConnected = isSoundCloudSignedIn || isSoundCloudConfigured,
+ onClick = {
+ onOpenSoundCloudSettings()
+ onDismissRequest()
+ }
+ )
}
}
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt
index 2aed352222..1865960e37 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt
@@ -16,6 +16,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -29,6 +30,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import com.theveloper.pixelplay.data.model.Song
@@ -39,6 +41,10 @@ import com.theveloper.pixelplay.presentation.components.scoped.rememberFullPlaye
import com.theveloper.pixelplay.presentation.viewmodel.PlayerSheetState
import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel
import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState
+import com.theveloper.pixelplay.soundcloud.SoundCloudPrefsViewModel
+import android.widget.Toast
+import androidx.compose.ui.platform.LocalContext
+import kotlinx.coroutines.launch
@OptIn(UnstableApi::class)
@Composable
@@ -118,6 +124,13 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers(
val isMiniPlayerVisible by remember {
derivedStateOf { playerContentExpansionFraction.value < 0.01f }
}
+ val soundCloudPrefs = hiltViewModel()
+ val likedPermalinks by soundCloudPrefs.likedPermalinks.collectAsStateWithLifecycle()
+ val soundCloudPermalink = soundCloudPrefs.permalinkFor(currentSongNonNull.id)
+ val youtubeLiked by playerViewModel.isCurrentSongFavorite.collectAsStateWithLifecycle()
+ val isYoutubeSong = currentSongNonNull.id.startsWith("yt_")
+ val context = LocalContext.current
+ val likeScope = rememberCoroutineScope()
MiniPlayerContentInternal(
song = currentSongNonNull,
isPlaying = infrequentPlayerState.isPlaying,
@@ -126,6 +139,25 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers(
onPlayPause = { playerViewModel.playPause() },
onPrevious = { playerViewModel.previousSong() },
onNext = { playerViewModel.nextSong() },
+ soundCloudLiked = if (isYoutubeSong) {
+ youtubeLiked
+ } else {
+ soundCloudPermalink != null && soundCloudPermalink in likedPermalinks
+ },
+ onSoundCloudLike = when {
+ isYoutubeSong -> ({ playerViewModel.toggleFavorite() })
+ soundCloudPermalink == null -> null
+ else -> {
+ {
+ likeScope.launch {
+ val error = soundCloudPrefs.toggleSoundCloudLike(currentSongNonNull.id)
+ if (!error.isNullOrBlank()) {
+ Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+ },
canScroll = isMiniPlayerVisible && infrequentPlayerState.isPlaying,
modifier = Modifier.fillMaxSize()
)
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt
index 59ae7c316d..43eb0c7376 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt
@@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Favorite
+import androidx.compose.material.icons.rounded.FavoriteBorder
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.SkipNext
@@ -40,11 +42,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.size.Size
+import com.theveloper.pixelplay.R
import com.theveloper.pixelplay.data.model.Song
import com.theveloper.pixelplay.ui.theme.GoogleSansRounded
@@ -69,6 +73,8 @@ internal fun MiniPlayerContentInternal(
onPlayPause: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
+ soundCloudLiked: Boolean = false,
+ onSoundCloudLike: (() -> Unit)? = null,
modifier: Modifier = Modifier,
canScroll: Boolean = true
) {
@@ -149,6 +155,43 @@ internal fun MiniPlayerContentInternal(
}
Spacer(modifier = Modifier.width(8.dp))
+ if (onSoundCloudLike != null) {
+ val likeInteraction = remember(song.id) { MutableInteractionSource() }
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .clickable(
+ interactionSource = likeInteraction,
+ indication = miniPlayerIndication,
+ enabled = controlsEnabled,
+ ) {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+ onSoundCloudLike()
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = if (soundCloudLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder,
+ contentDescription = stringResource(
+ when {
+ song.id.startsWith("yt_") && soundCloudLiked -> R.string.youtube_unlike
+ song.id.startsWith("yt_") -> R.string.youtube_like
+ soundCloudLiked -> R.string.soundcloud_unlike
+ else -> R.string.soundcloud_like
+ },
+ ),
+ tint = if (soundCloudLiked) {
+ LocalMaterialTheme.current.error
+ } else {
+ LocalMaterialTheme.current.onPrimaryContainer
+ },
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ Spacer(modifier = Modifier.width(4.dp))
+ }
+
Box(
modifier = Modifier
.size(36.dp)
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayout.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayout.kt
new file mode 100644
index 0000000000..f92a238925
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayout.kt
@@ -0,0 +1,93 @@
+package com.theveloper.pixelplay.presentation.components.player
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.window.layout.FoldingFeature
+import androidx.window.layout.WindowInfoTracker
+import androidx.window.layout.WindowLayoutInfo
+
+/**
+ * How the expanded player should sit on the current window.
+ * [Phone] is every window that does not cross a hinge: a normal phone in
+ * either orientation, and the Pixel Fold cover display.
+ */
+internal enum class FoldPlayerLayout {
+ Phone,
+ Tabletop,
+ Book,
+ FlatWide,
+}
+
+internal data class FoldSignal(
+ val halfOpened: Boolean,
+ val flat: Boolean,
+ val horizontal: Boolean,
+ val vertical: Boolean,
+)
+
+internal data class FoldObservation(
+ val layout: FoldPlayerLayout,
+ val hingeThickness: Dp,
+)
+
+internal fun foldPlayerLayout(signal: FoldSignal?, isLandscape: Boolean): FoldPlayerLayout {
+ if (signal == null) return FoldPlayerLayout.Phone
+ return when {
+ signal.halfOpened && signal.horizontal -> FoldPlayerLayout.Tabletop
+ signal.halfOpened && signal.vertical -> FoldPlayerLayout.Book
+ signal.flat && isLandscape -> FoldPlayerLayout.FlatWide
+ else -> FoldPlayerLayout.Phone
+ }
+}
+
+@Composable
+internal fun rememberFoldObservation(isLandscape: Boolean): FoldObservation {
+ val context = LocalContext.current
+ val activity = remember(context) { context.findActivity() }
+ if (activity == null) {
+ return FoldObservation(FoldPlayerLayout.Phone, 0.dp)
+ }
+ val layoutInfo = WindowInfoTracker.getOrCreate(activity)
+ .windowLayoutInfo(activity)
+ .collectAsStateWithLifecycle(initialValue = WindowLayoutInfo(emptyList()))
+ .value
+ val fold = layoutInfo.displayFeatures.filterIsInstance().firstOrNull()
+ val signal = fold?.let {
+ FoldSignal(
+ halfOpened = it.state == FoldingFeature.State.HALF_OPENED,
+ flat = it.state == FoldingFeature.State.FLAT,
+ horizontal = it.orientation == FoldingFeature.Orientation.HORIZONTAL,
+ vertical = it.orientation == FoldingFeature.Orientation.VERTICAL,
+ )
+ }
+ val thickness = foldThickness(fold)
+ return FoldObservation(foldPlayerLayout(signal, isLandscape), thickness)
+}
+
+@Composable
+private fun foldThickness(fold: FoldingFeature?): Dp {
+ if (fold == null) return 0.dp
+ val px = if (fold.orientation == FoldingFeature.Orientation.HORIZONTAL) {
+ fold.bounds.height()
+ } else {
+ fold.bounds.width()
+ }
+ return with(LocalDensity.current) { px.toDp() }.coerceAtLeast(0.dp)
+}
+
+private fun Context.findActivity(): Activity? {
+ var current: Context = this
+ while (current is ContextWrapper) {
+ if (current is Activity) return current
+ current = current.baseContext
+ }
+ return null
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerQueue.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerQueue.kt
new file mode 100644
index 0000000000..e1e247ca07
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerQueue.kt
@@ -0,0 +1,73 @@
+package com.theveloper.pixelplay.presentation.components.player
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.theveloper.pixelplay.R
+import com.theveloper.pixelplay.data.model.Song
+import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem
+import com.theveloper.pixelplay.ui.theme.GoogleSansRounded
+import kotlinx.collections.immutable.ImmutableList
+
+@Composable
+internal fun FoldPlayerQueue(
+ queue: ImmutableList,
+ currentSongId: String?,
+ queueName: String,
+ onSongClick: (Song, Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val listState = rememberLazyListState()
+ val currentIndex = queue.indexOfFirst { it.id == currentSongId }
+ LaunchedEffect(currentSongId, queue.size) {
+ if (currentIndex >= 0) listState.animateScrollToItem(currentIndex)
+ }
+ Column(modifier) {
+ Text(
+ text = queueName.ifBlank { stringResource(R.string.queue_source_fallback_label) },
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (queue.isEmpty()) {
+ Text(
+ text = stringResource(R.string.queue_empty_label),
+ modifier = Modifier.padding(16.dp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = GoogleSansRounded,
+ )
+ } else {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ itemsIndexed(queue, key = { index, song -> "${song.id}-$index" }) { index, song ->
+ val current = song.id == currentSongId
+ EnhancedSongListItem(
+ song = song,
+ isPlaying = current,
+ isCurrentSong = current,
+ showMoreOptionsButton = false,
+ onMoreOptionsClick = {},
+ onClick = { onSongClick(song, index) },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt
index 8a30f6f41d..b697464eea 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt
@@ -326,6 +326,7 @@ fun FullPlayerContent(
val isLandscape =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
+ val fold = rememberFoldObservation(isLandscape)
// Lógica para el botón de Lyrics en el reproductor expandido
@@ -497,7 +498,7 @@ fun FullPlayerContent(
Unit
}
- val albumCoverSection: @Composable (Modifier) -> Unit = { modifier ->
+ val albumCoverSection: @Composable (Modifier, Dp) -> Unit = { modifier, cap ->
FullPlayerAlbumCoverSection(
song = song,
currentPlaybackQueue = currentPlaybackQueue,
@@ -517,6 +518,7 @@ fun FullPlayerContent(
onAlbumClick = { albumSong ->
playerViewModel.triggerAlbumNavigationFromPlayer(albumSong.albumId)
},
+ maxCarouselSide = cap,
modifier = modifier
)
}
@@ -899,8 +901,8 @@ fun FullPlayerContent(
}
) { paddingValues ->
// MD3: 方向变化时先 alpha=0 再淡入新布局,避免双布局同时测量导致错位
- var contentVisible by remember(isLandscape) { mutableStateOf(false) }
- LaunchedEffect(isLandscape) { contentVisible = true }
+ var contentVisible by remember(isLandscape, fold.layout) { mutableStateOf(false) }
+ LaunchedEffect(isLandscape, fold.layout) { contentVisible = true }
val contentAlpha by animateFloatAsState(
targetValue = if (contentVisible) 1f else 0f,
animationSpec = tween(durationMillis = 380, easing = FastOutSlowInEasing),
@@ -911,22 +913,62 @@ fun FullPlayerContent(
.fillMaxSize()
.graphicsLayer { alpha = contentAlpha }
) {
- if (isLandscape) {
- FullPlayerLandscapeContent(
+ when (fold.layout) {
+ FoldPlayerLayout.Tabletop -> FullPlayerTabletopContent(
paddingValues = paddingValues,
+ hingeThickness = fold.hingeThickness,
albumCoverSection = albumCoverSection,
- songMetadataSection = landscapeSongMetadataSection,
+ songMetadataSection = portraitSongMetadataSection,
playerProgressSection = playerProgressSection,
- controlsSection = controlsSection
+ controlsSection = controlsSection,
+ queue = {
+ FoldPlayerQueue(
+ queue = currentPlaybackQueue,
+ currentSongId = song.id,
+ queueName = currentQueueSourceName,
+ onSongClick = onAlbumSongSelected,
+ modifier = it,
+ )
+ },
)
- } else {
- FullPlayerPortraitContent(
+ FoldPlayerLayout.Book, FoldPlayerLayout.FlatWide -> FullPlayerBookContent(
paddingValues = paddingValues,
+ hingeThickness = if (fold.layout == FoldPlayerLayout.FlatWide) {
+ fold.hingeThickness.coerceAtMost(8.dp)
+ } else {
+ fold.hingeThickness.coerceAtLeast(12.dp)
+ },
albumCoverSection = albumCoverSection,
- songMetadataSection = portraitSongMetadataSection,
+ songMetadataSection = landscapeSongMetadataSection,
playerProgressSection = playerProgressSection,
- controlsSection = controlsSection
+ controlsSection = controlsSection,
+ queue = {
+ FoldPlayerQueue(
+ queue = currentPlaybackQueue,
+ currentSongId = song.id,
+ queueName = currentQueueSourceName,
+ onSongClick = onAlbumSongSelected,
+ modifier = it,
+ )
+ },
)
+ FoldPlayerLayout.Phone -> if (isLandscape) {
+ FullPlayerLandscapeContent(
+ paddingValues = paddingValues,
+ albumCoverSection = albumCoverSection,
+ songMetadataSection = landscapeSongMetadataSection,
+ playerProgressSection = playerProgressSection,
+ controlsSection = controlsSection,
+ )
+ } else {
+ FullPlayerPortraitContent(
+ paddingValues = paddingValues,
+ albumCoverSection = albumCoverSection,
+ songMetadataSection = portraitSongMetadataSection,
+ playerProgressSection = playerProgressSection,
+ controlsSection = controlsSection,
+ )
+ }
}
}
}
@@ -1018,6 +1060,7 @@ private fun FullPlayerAlbumCoverSection(
requestedScrollIndex: Int?,
onSongSelected: (Song, Int) -> Unit,
onAlbumClick: (Song) -> Unit,
+ maxCarouselSide: Dp = Dp.Unspecified,
modifier: Modifier = Modifier
) {
val shouldDelay = loadingTweaks.delayAll || loadingTweaks.delayAlbumCarousel
@@ -1038,12 +1081,17 @@ private fun FullPlayerAlbumCoverSection(
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
- val carouselHeight = when (carouselStyle) {
+ val preferredHeight = when (carouselStyle) {
CarouselStyle.NO_PEEK -> maxWidth
CarouselStyle.ONE_PEEK -> maxWidth * 0.8f
CarouselStyle.TWO_PEEK -> maxWidth * 0.6f
else -> maxWidth * 0.8f
}
+ val carouselHeight = if (maxCarouselSide != Dp.Unspecified) {
+ preferredHeight.coerceAtMost(maxCarouselSide)
+ } else {
+ preferredHeight
+ }
DelayedContent(
shouldDelay = shouldDelay,
@@ -1373,7 +1421,7 @@ private fun FullPlayerSongMetadataSection(
@Composable
private fun FullPlayerPortraitContent(
paddingValues: PaddingValues,
- albumCoverSection: @Composable (Modifier) -> Unit,
+ albumCoverSection: @Composable (Modifier, Dp) -> Unit,
songMetadataSection: @Composable () -> Unit,
playerProgressSection: @Composable () -> Unit,
controlsSection: @Composable () -> Unit
@@ -1389,7 +1437,7 @@ private fun FullPlayerPortraitContent(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceAround
) {
- albumCoverSection(Modifier)
+ albumCoverSection(Modifier, Dp.Unspecified)
Column(
modifier = Modifier.fillMaxWidth(),
@@ -1408,7 +1456,7 @@ private fun FullPlayerPortraitContent(
@Composable
private fun FullPlayerLandscapeContent(
paddingValues: PaddingValues,
- albumCoverSection: @Composable (Modifier) -> Unit,
+ albumCoverSection: @Composable (Modifier, Dp) -> Unit,
songMetadataSection: @Composable () -> Unit,
playerProgressSection: @Composable () -> Unit,
controlsSection: @Composable () -> Unit
@@ -1426,7 +1474,8 @@ private fun FullPlayerLandscapeContent(
albumCoverSection(
Modifier
.fillMaxHeight()
- .weight(1f)
+ .weight(1f),
+ Dp.Unspecified,
)
Spacer(Modifier.width(9.dp))
Column(
@@ -1447,6 +1496,101 @@ private fun FullPlayerLandscapeContent(
}
}
+@SuppressLint("UnusedBoxWithConstraintsScope")
+@Composable
+private fun FullPlayerTabletopContent(
+ paddingValues: PaddingValues,
+ hingeThickness: Dp,
+ albumCoverSection: @Composable (Modifier, Dp) -> Unit,
+ songMetadataSection: @Composable () -> Unit,
+ playerProgressSection: @Composable () -> Unit,
+ controlsSection: @Composable () -> Unit,
+ queue: @Composable (Modifier) -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ BoxWithConstraints(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ albumCoverSection(Modifier, maxHeight.coerceAtMost(maxWidth))
+ }
+ songMetadataSection()
+ }
+ Spacer(Modifier.height(hingeThickness.coerceAtLeast(12.dp)))
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp),
+ ) {
+ queue(Modifier.weight(1f))
+ playerProgressSection()
+ controlsSection()
+ }
+ }
+}
+
+@SuppressLint("UnusedBoxWithConstraintsScope")
+@Composable
+private fun FullPlayerBookContent(
+ paddingValues: PaddingValues,
+ hingeThickness: Dp,
+ albumCoverSection: @Composable (Modifier, Dp) -> Unit,
+ songMetadataSection: @Composable () -> Unit,
+ playerProgressSection: @Composable () -> Unit,
+ controlsSection: @Composable () -> Unit,
+ queue: @Composable (Modifier) -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .padding(start = 24.dp, end = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ BoxWithConstraints(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ albumCoverSection(Modifier, minOf(maxWidth, maxHeight))
+ }
+ songMetadataSection()
+ playerProgressSection()
+ controlsSection()
+ }
+ Spacer(Modifier.width(hingeThickness))
+ queue(
+ Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .padding(end = 12.dp),
+ )
+ }
+}
+
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt
index 30abec5879..c2ef9297a0 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt
@@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -77,6 +78,7 @@ private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
* @param customShape Optional custom shape for the surface
* @param isSelected Whether this item is selected in multi-selection mode
* @param isSelectionMode Whether multi-selection mode is active
+ * @param handleGestures When false, the caller owns click / long-press on [modifier]
* @param onLongPress Callback for long press gesture (activates selection)
* @param onMoreOptionsClick Callback for more options button
* @param onClick Callback for tap gesture
@@ -97,11 +99,14 @@ fun EnhancedSongListItem(
selectionIndex: Int? = null,
isSelectionMode: Boolean = false,
showMoreOptionsButton: Boolean = true,
+ handleGestures: Boolean = true,
onLongPress: () -> Unit = {},
onMoreOptionsClick: (Song) -> Unit,
onClick: () -> Unit
) {
val albumArtTargetSizePx = with(LocalDensity.current) { albumArtSize.roundToPx() }
+ val currentOnClick = rememberUpdatedState(onClick)
+ val currentOnLongPress = rememberUpdatedState(onLongPress)
val isHighlighted = isCurrentSong && !isLoading
val transition = updateTransition(
targetState = EnhancedSongAnimationTarget(
@@ -254,22 +259,24 @@ fun EnhancedSongListItem(
Modifier
}
)
- .pointerInput(isSelectionMode) {
- detectTapGestures(
- onTap = {
- if (isSelectionMode) {
- // In selection mode, tap toggles selection
- onLongPress()
- } else {
- onClick()
- }
- },
- onLongPress = {
- // Long press always activates/toggles selection
- onLongPress()
+ .then(
+ if (handleGestures) {
+ Modifier.pointerInput(isSelectionMode) {
+ detectTapGestures(
+ onTap = {
+ if (isSelectionMode) {
+ currentOnLongPress.value()
+ } else {
+ currentOnClick.value()
+ }
+ },
+ onLongPress = { currentOnLongPress.value() },
+ )
}
- )
- },
+ } else {
+ Modifier
+ }
+ ),
shape = surfaceShape,
color = containerColor,
) {
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt
index fb04d8a3d8..fc6ddce463 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt
@@ -19,6 +19,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -85,6 +86,7 @@ val genHeight = 42.dp
@Composable
fun LibraryActionRow(
onMainActionClick: () -> Unit,
+ mainActionEnabled: Boolean = true,
iconRotation: Float,
onSortClick: () -> Unit,
onLocateClick: () -> Unit = {},
@@ -102,6 +104,7 @@ fun LibraryActionRow(
onFolderClick: (String) -> Unit,
onNavigateBack: () -> Unit,
isShuffleEnabled: Boolean = false,
+ trailingContent: @Composable RowScope.() -> Unit = {},
// Storage Filter
showStorageFilterButton: Boolean = false,
currentStorageFilter: com.theveloper.pixelplay.data.model.StorageFilter = com.theveloper.pixelplay.data.model.StorageFilter.ALL,
@@ -158,6 +161,7 @@ fun LibraryActionRow(
FilledTonalButton(
onClick = onMainActionClick,
+ enabled = mainActionEnabled,
shape = RoundedCornerShape(
topStart = 26.dp, bottomStart = 26.dp,
topEnd = newButtonEndCorner, bottomEnd = newButtonEndCorner
@@ -271,6 +275,8 @@ fun LibraryActionRow(
Spacer(modifier = Modifier.width(8.dp))
+ trailingContent()
+
if (showSortButton) {
val outerCorner = 26.dp
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt
index 7e55cceb8d..8ca293488a 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt
@@ -26,6 +26,7 @@ import com.theveloper.pixelplay.R
import androidx.compose.ui.unit.IntOffset
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
+import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -41,9 +42,10 @@ import com.theveloper.pixelplay.presentation.screens.DailyMixScreen
import com.theveloper.pixelplay.presentation.screens.EditTransitionScreen
import com.theveloper.pixelplay.presentation.screens.EasterEggScreen
import com.theveloper.pixelplay.presentation.screens.ExperimentalSettingsScreen
+import com.theveloper.pixelplay.presentation.screens.SoundCloudSettingsScreen
import com.theveloper.pixelplay.presentation.screens.GenreDetailScreen
-import com.theveloper.pixelplay.presentation.screens.HomeScreen
-import com.theveloper.pixelplay.presentation.screens.LibraryScreen
+import com.theveloper.pixelplay.presentation.screens.YoutubeScreen
+import com.theveloper.pixelplay.presentation.screens.YoutubeSettingsScreen
import com.theveloper.pixelplay.presentation.screens.MashupScreen
import com.theveloper.pixelplay.presentation.screens.NavBarCornerRadiusScreen
import com.theveloper.pixelplay.presentation.screens.PaletteStyleSettingsScreen
@@ -52,7 +54,7 @@ import com.theveloper.pixelplay.presentation.screens.RecentlyPlayedScreen
import com.theveloper.pixelplay.presentation.screens.AboutScreen
import com.theveloper.pixelplay.presentation.screens.OpenSourceLicensesScreen
-import com.theveloper.pixelplay.presentation.screens.SearchScreen
+import com.theveloper.pixelplay.youtube.YoutubeSection
import com.theveloper.pixelplay.presentation.screens.StatsScreen
import com.theveloper.pixelplay.presentation.screens.SettingsScreen
import com.theveloper.pixelplay.presentation.screens.SettingsCategoryScreen
@@ -122,11 +124,10 @@ fun AppNavigation(
},
) {
ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) {
- HomeScreen(
- navController = navController,
- paddingValuesParent = paddingValues,
+ YoutubeScreen(
+ section = YoutubeSection.HOME,
playerViewModel = playerViewModel,
- onOpenSidebar = onOpenSidebar
+ navController = navController,
)
}
}
@@ -162,16 +163,17 @@ fun AppNavigation(
},
) {
ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) {
- SearchScreen(
- paddingValues = paddingValues,
+ YoutubeScreen(
+ section = YoutubeSection.SEARCH,
playerViewModel = playerViewModel,
navController = navController,
- onSearchBarActiveChange = onSearchBarActiveChange
)
}
}
+ youtubeTab(Screen.Radio.route, YoutubeSection.RADIO, navController, playerViewModel)
+ youtubeTab(Screen.Playlists.route, YoutubeSection.PLAYLISTS, navController, playerViewModel)
composable(
- Screen.Library.route,
+ Screen.Liked.route,
enterTransition = {
mainRootEnterTransition(
fromRoute = initialState.destination.route,
@@ -202,7 +204,50 @@ fun AppNavigation(
},
) {
ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) {
- LibraryScreen(navController = navController, playerViewModel = playerViewModel)
+ YoutubeScreen(
+ section = YoutubeSection.LIKED,
+ playerViewModel = playerViewModel,
+ navController = navController,
+ )
+ }
+ }
+ composable(
+ Screen.SoundCloud.route,
+ enterTransition = {
+ mainRootEnterTransition(
+ fromRoute = initialState.destination.route,
+ toRoute = targetState.destination.route,
+ fallback = enterTransition()
+ )
+ },
+ exitTransition = {
+ mainRootExitTransition(
+ fromRoute = initialState.destination.route,
+ toRoute = targetState.destination.route,
+ fallback = exitTransition()
+ )
+ },
+ popEnterTransition = {
+ mainRootEnterTransition(
+ fromRoute = initialState.destination.route,
+ toRoute = targetState.destination.route,
+ fallback = popEnterTransition()
+ )
+ },
+ popExitTransition = {
+ mainRootExitTransition(
+ fromRoute = initialState.destination.route,
+ toRoute = targetState.destination.route,
+ fallback = popExitTransition()
+ )
+ },
+ ) {
+ ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) {
+ YoutubeScreen(
+ section = YoutubeSection.HOME,
+ playerViewModel = playerViewModel,
+ navController = navController,
+ )
}
}
composable(
@@ -265,6 +310,13 @@ fun AppNavigation(
)
}
}
+ composable(
+ Screen.SoundCloudSettings.route,
+ ) {
+ ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) {
+ SoundCloudSettingsScreen(navController = navController)
+ }
+ }
composable(
Screen.Experimental.route,
) {
@@ -495,6 +547,11 @@ fun AppNavigation(
)
}
}
+ composable(Screen.YoutubeSettings.route) {
+ ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) {
+ YoutubeSettingsScreen(navController = navController)
+ }
+ }
composable(
Screen.JellyfinDashboard.route,
) {
@@ -508,9 +565,51 @@ fun AppNavigation(
}
}
+private fun NavGraphBuilder.youtubeTab(
+ route: String,
+ section: com.theveloper.pixelplay.youtube.YoutubeSection,
+ navController: NavHostController,
+ playerViewModel: PlayerViewModel,
+) {
+ composable(
+ route,
+ enterTransition = {
+ mainRootEnterTransition(
+ fromRoute = initialState.destination.route,
+ toRoute = targetState.destination.route,
+ fallback = enterTransition(),
+ )
+ },
+ exitTransition = {
+ mainRootExitTransition(
+ fromRoute = initialState.destination.route,
+ toRoute = targetState.destination.route,
+ fallback = exitTransition(),
+ )
+ },
+ popEnterTransition = {
+ mainRootEnterTransition(
+ fromRoute = initialState.destination.route,
+ toRoute = targetState.destination.route,
+ fallback = popEnterTransition(),
+ )
+ },
+ popExitTransition = {
+ mainRootExitTransition(
+ fromRoute = initialState.destination.route,
+ toRoute = targetState.destination.route,
+ fallback = popExitTransition(),
+ )
+ },
+ ) {
+ ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) {
+ YoutubeScreen(section = section, playerViewModel = playerViewModel, navController = navController)
+ }
+ }
+}
+
private fun String.toRoute(): String = when (this) {
LaunchTab.SEARCH -> Screen.Search.route
- LaunchTab.LIBRARY -> Screen.Library.route
else -> Screen.Home.route
}
@@ -520,7 +619,7 @@ private enum class MainRootDirection {
}
// Base duration for bottom-nav switches at 1x — at 0.5x system scale = ~190 ms.
-private const val BOTTOM_NAV_TRANSITION_DURATION = 380
+private const val BOTTOM_NAV_TRANSITION_DURATION = 160
// MD3 Expressive easing for bottom-nav switches
private val BottomNavEasing = CubicBezierEasing(0.2f, 0f, 0f, 1f)
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/MainRootRoutes.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/MainRootRoutes.kt
index 3bfd7dc47c..a8921cd3bf 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/MainRootRoutes.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/MainRootRoutes.kt
@@ -2,14 +2,18 @@ package com.theveloper.pixelplay.presentation.navigation
internal fun isMainRootRoute(route: String?): Boolean = when (route) {
Screen.Home.route,
+ Screen.Radio.route,
Screen.Search.route,
- Screen.Library.route -> true
+ Screen.Playlists.route,
+ Screen.Liked.route -> true
else -> false
}
internal fun mainRootRouteIndex(route: String?): Int? = when (route) {
Screen.Home.route -> 0
- Screen.Search.route -> 1
- Screen.Library.route -> 2
+ Screen.Radio.route -> 1
+ Screen.Search.route -> 2
+ Screen.Playlists.route -> 3
+ Screen.Liked.route -> 4
else -> null
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt
index e079695893..c5ab0e7292 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt
@@ -7,7 +7,13 @@ import androidx.compose.runtime.Immutable
sealed class Screen(val route: String) {
object Home : Screen("home")
object Search : Screen("search")
+ object Liked : Screen("liked")
+ object Radio : Screen("radio")
+ object Playlists : Screen("playlists")
+ object YoutubeSettings : Screen("youtube_settings")
object Library : Screen("library")
+ object SoundCloud : Screen("soundcloud")
+ object SoundCloudSettings : Screen("soundcloud_settings")
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/AboutScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt
index f1050c4b3d..9165079cd2 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AboutScreen.kt
@@ -40,7 +40,6 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
@@ -102,7 +101,6 @@ import coil.compose.AsyncImagePainter
import coil.request.ImageRequest
import coil.size.Size
import com.theveloper.pixelplay.R
-import com.theveloper.pixelplay.data.github.GitHubContributorService
import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar
import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight
import com.theveloper.pixelplay.presentation.components.SmartImage
@@ -111,7 +109,6 @@ import com.theveloper.pixelplay.presentation.navigation.navigateSafely
import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel
import kotlinx.coroutines.launch
import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape
-import timber.log.Timber
import kotlin.math.roundToInt
private data class Contributor(
@@ -128,53 +125,15 @@ private data class Contributor(
)
private val CoreMaintainer = Contributor(
- id = "theovilardo",
- displayName = "Theo Vilardo",
- role = "Creator and maintainer",
- detail = "Building PixelPlayer with direct community feedback.",
- avatarUrl = "https://avatars.githubusercontent.com/u/26845343?v=4",
+ id = "kibetmasi",
+ displayName = "KibetMasi",
+ role = "Maintainer",
+ detail = "Building PixelPlayer.",
+ avatarUrl = "https://avatars.githubusercontent.com/kibetmasi",
iconRes = R.drawable.round_developer_board_24,
- githubUrl = "https://github.com/theovilardo",
+ githubUrl = "https://github.com/kibetmasi",
)
-private val PinnedCommunityMembers = listOf(
- Contributor(
- id = "lostf1sh",
- displayName = "@lostf1sh",
- role = "Most active contributor",
- detail = "Has contributed enormously across core features, architecture and reliability.",
- badge = "Top Impact",
- iconRes = R.drawable.rounded_celebration_24,
- githubUrl = "https://github.com/lostf1sh",
- ),
- Contributor(
- id = "cromaguy",
- displayName = "@cromaguy",
- role = "Rhythm developer",
- detail = "Developer of Rhythm (another music app) and key community supporter.",
- badge = "Community Ally",
- iconRes = R.drawable.round_developer_board_24,
- githubUrl = "https://github.com/cromaguy",
- ),
- Contributor(
- id = "colbycabrera",
- displayName = "@ColbyCabrera",
- role = "Early contributor",
- detail = "Helped shape PixelPlayer in the first stages of the app.",
- badge = "Early Support",
- iconRes = R.drawable.round_newspaper_24,
- githubUrl = "https://github.com/ColbyCabrera",
- ),
-)
-
-private val PinnedAliases = mapOf(
- "cromaguy" to setOf("chroma"),
-)
-
-private fun normalizeHandle(handle: String): String {
- return handle.trim().removePrefix("@").lowercase()
-}
-
// AboutTopBar removed, replaced by CollapsibleCommonTopBar
@androidx.annotation.OptIn(UnstableApi::class)
@@ -193,74 +152,6 @@ fun AboutScreen(
"N/A"
}
- var contributors by remember { mutableStateOf>(emptyList()) }
- var isLoadingContributors by remember { mutableStateOf(true) }
- val githubService = remember { GitHubContributorService() }
-
- LaunchedEffect(Unit) {
- try {
- val result = githubService.fetchContributors()
- result.onSuccess { githubContributors ->
- contributors = githubContributors
- .filter { normalizeHandle(it.login) != CoreMaintainer.id }
- .map { github ->
- Contributor(
- id = normalizeHandle(github.login),
- displayName = "@${github.login}",
- role = "Community contributor",
- avatarUrl = github.avatar_url,
- iconRes = R.drawable.rounded_person_24,
- githubUrl = github.html_url,
- contributions = github.contributions,
- )
- }
- }
- result.onFailure { exception ->
- Timber.e(exception, "Failed to fetch contributors from GitHub")
- contributors = emptyList()
- }
- } finally {
- isLoadingContributors = false
- }
- }
-
- val contributorsById = remember(contributors) {
- contributors.associateBy { it.id }
- }
-
- val spotlightContributors = remember(contributorsById) {
- PinnedCommunityMembers.map { pinned ->
- val primaryMatch = contributorsById[pinned.id]
- val aliasMatch = PinnedAliases[pinned.id]
- ?.firstNotNullOfOrNull { alias -> contributorsById[alias] }
- val match = primaryMatch ?: aliasMatch
-
- if (match == null) {
- pinned
- } else {
- pinned.copy(
- avatarUrl = match.avatarUrl ?: pinned.avatarUrl,
- contributions = match.contributions ?: pinned.contributions,
- githubUrl = match.githubUrl ?: pinned.githubUrl,
- )
- }
- }
- }
-
- val excludedIds = remember(spotlightContributors) {
- buildSet {
- add(CoreMaintainer.id)
- spotlightContributors.forEach { spotlight ->
- add(spotlight.id)
- addAll(PinnedAliases[spotlight.id].orEmpty())
- }
- }
- }
-
- val communityContributors = remember(contributors, excludedIds) {
- contributors.filterNot { it.id in excludedIds }
- }
-
val transitionState = remember { MutableTransitionState(false) }
LaunchedEffect(Unit) {
transitionState.targetState = true
@@ -398,30 +289,6 @@ fun AboutScreen(
)
}
- item(key = "spotlight_title") {
- AboutSectionHeader(
- title = stringResource(R.string.about_spotlight_title),
- subtitle = stringResource(R.string.about_spotlight_subtitle),
- modifier = Modifier.padding(top = 24.dp),
- )
- }
-
- itemsIndexed(
- items = spotlightContributors,
- key = { _, contributor -> "spotlight_${contributor.id}" },
- ) { index, contributor ->
- ContributorCard(
- contributor = contributor,
- shape = expressiveListShape(index = index, count = spotlightContributors.size),
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp)
- .padding(top = if (index == 0) 0.dp else 3.dp),
- showContributionCount = true,
- onCardClick = contributor.githubUrl?.let { url -> { openUrl(context, url) } },
- )
- }
-
item(key = "licenses_title") {
AboutSectionHeader(
title = stringResource(R.string.about_licenses_title),
@@ -441,61 +308,6 @@ fun AboutScreen(
)
}
- item(key = "contributors_title") {
- AboutSectionHeader(
- title = stringResource(R.string.about_contributors_section_title),
- subtitle = stringResource(R.string.about_contributors_section_subtitle),
- modifier = Modifier.padding(top = 24.dp),
- )
- }
-
- if (isLoadingContributors) {
- item(key = "contributors_loading") {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 28.dp),
- contentAlignment = Alignment.Center,
- ) {
- CircularProgressIndicator()
- }
- }
- } else if (communityContributors.isEmpty()) {
- item(key = "contributors_empty") {
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- shape = expressiveListShape(index = 0, count = 1),
- color = MaterialTheme.colorScheme.surfaceContainerLow,
- tonalElevation = 1.dp,
- ) {
- Text(
- text = stringResource(R.string.about_no_contributors),
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- } else {
- itemsIndexed(
- items = communityContributors,
- key = { _, contributor -> "contributor_${contributor.id}" },
- ) { index, contributor ->
- ContributorCard(
- contributor = contributor,
- shape = expressiveListShape(index = index, count = communityContributors.size),
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp)
- .padding(top = if (index == 0) 0.dp else 3.dp),
- showContributionCount = true,
- onCardClick = contributor.githubUrl?.let { url -> { openUrl(context, url) } },
- )
- }
- }
-
item(key = "bottom_spacer") {
Spacer(modifier = Modifier.height(24.dp))
}
@@ -663,7 +475,7 @@ private fun AboutHeroCard(
subtitle = stringResource(R.string.about_github_subtitle),
iconRes = R.drawable.github,
contentDescription = stringResource(R.string.about_cd_open_github_repo),
- onClick = { openUrl(context, "https://github.com/theovilardo/PixelPlayer") },
+ onClick = { openUrl(context, "https://github.com/kibetmasi/PixelPlayer") },
modifier = Modifier.weight(1f),
)
SocialChip(
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt
index ba622628f7..f6bacb15b4 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt
@@ -87,7 +87,7 @@ import com.theveloper.pixelplay.data.preferences.CollagePattern
import com.theveloper.pixelplay.presentation.components.AlbumArtCollage
import com.theveloper.pixelplay.presentation.components.BetaInfoBottomSheet
import com.theveloper.pixelplay.presentation.components.Beta05CleanInstallDisclaimerDialog
-import com.theveloper.pixelplay.presentation.components.ChangelogBottomSheet
+import com.theveloper.pixelplay.presentation.components.ChangelogModalSheet
import com.theveloper.pixelplay.presentation.netease.dashboard.NeteaseDashboardViewModel
import com.theveloper.pixelplay.presentation.jellyfin.dashboard.JellyfinDashboardViewModel
import com.theveloper.pixelplay.presentation.navidrome.dashboard.NavidromeDashboardViewModel
@@ -108,6 +108,7 @@ import com.theveloper.pixelplay.presentation.navigation.Screen
import com.theveloper.pixelplay.presentation.components.StreamingProviderSheet
import com.theveloper.pixelplay.presentation.telegram.auth.TelegramLoginActivity
import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel
+import com.theveloper.pixelplay.soundcloud.SoundCloudPrefsViewModel
import com.theveloper.pixelplay.presentation.viewmodel.SettingsViewModel
import com.theveloper.pixelplay.presentation.viewmodel.StatsViewModel
import com.theveloper.pixelplay.ui.theme.ExpTitleTypography
@@ -135,6 +136,7 @@ fun HomeScreen(
qqMusicViewModel: QqMusicDashboardViewModel = hiltViewModel(),
navidromeViewModel: NavidromeDashboardViewModel = hiltViewModel(),
jellyfinViewModel: JellyfinDashboardViewModel = hiltViewModel(),
+ soundCloudPrefsViewModel: SoundCloudPrefsViewModel = hiltViewModel(),
onOpenSidebar: () -> Unit
) {
val context = LocalContext.current
@@ -532,12 +534,7 @@ fun HomeScreen(
}
}
if (showChangelogBottomSheet) {
- ModalBottomSheet(
- onDismissRequest = { showChangelogBottomSheet = false },
- sheetState = sheetState
- ) {
- ChangelogBottomSheet()
- }
+ ChangelogModalSheet(onDismiss = { showChangelogBottomSheet = false })
}
if (showBetaInfoBottomSheet) {
ModalBottomSheet(
@@ -553,6 +550,8 @@ fun HomeScreen(
val isQqMusicLoggedIn by qqMusicViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isNavidromeLoggedIn by navidromeViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isJellyfinLoggedIn by jellyfinViewModel.isLoggedIn.collectAsStateWithLifecycle()
+ val soundCloudSession by soundCloudPrefsViewModel.session.collectAsStateWithLifecycle()
+ val soundCloudClientId by soundCloudPrefsViewModel.clientId.collectAsStateWithLifecycle()
StreamingProviderSheet(
onDismissRequest = { showStreamingProviderSheet = false },
isNeteaseLoggedIn = isNeteaseLoggedIn,
@@ -570,7 +569,12 @@ fun HomeScreen(
isJellyfinLoggedIn = isJellyfinLoggedIn,
onNavigateToJellyfinDashboard = {
navController.navigateSafely(Screen.JellyfinDashboard.route)
- }
+ },
+ isSoundCloudSignedIn = soundCloudSession.isSignedIn,
+ isSoundCloudConfigured = soundCloudClientId.isNotBlank(),
+ onOpenSoundCloudSettings = {
+ navController.navigateSafely(Screen.SoundCloudSettings.route)
+ },
)
}
if (shouldShowCleanInstallDisclaimer) {
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt
index 5d37d029e9..548479c02e 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt
@@ -2175,7 +2175,7 @@ fun LibraryScreen(
}
@Composable
-private fun CompactLibraryPagerIndicator(
+internal fun CompactLibraryPagerIndicator(
currentIndex: Int,
pageCount: Int,
modifier: Modifier = Modifier
@@ -2820,10 +2820,10 @@ internal fun resolveFolderNavigationDirection(initialPath: String?, targetPath:
}
private fun isDescendantFolderPath(ancestorPath: String, candidatePath: String): Boolean {
- val normalizedAncestor = ancestorPath.trimEnd(File.separatorChar)
- val normalizedCandidate = candidatePath.trimEnd(File.separatorChar)
- if (normalizedAncestor == normalizedCandidate) return false
- return normalizedCandidate.startsWith("$normalizedAncestor${File.separatorChar}")
+ val ancestor = ancestorPath.trimEnd('/', '\\')
+ val candidate = candidatePath.trimEnd('/', '\\')
+ if (ancestor == candidate) return false
+ return candidate.startsWith("$ancestor/") || candidate.startsWith("$ancestor\\")
}
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class)
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt
index dd1672dbc6..22bea4d1bd 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt
@@ -856,6 +856,13 @@ fun SettingsCategoryScreen(
enabled = uiState.hiFiModeDeviceSupported,
leadingIcon = { Icon(painterResource(R.drawable.outline_high_quality_24), null, tint = MaterialTheme.colorScheme.secondary) }
)
+ SwitchSettingItem(
+ title = stringResource(R.string.settings_skip_silence_title),
+ subtitle = stringResource(R.string.settings_skip_silence_subtitle),
+ checked = uiState.skipSilenceEnabled,
+ onCheckedChange = { settingsViewModel.setSkipSilenceEnabled(it) },
+ leadingIcon = { Icon(painterResource(R.drawable.rounded_align_justify_space_even_24), null, tint = MaterialTheme.colorScheme.secondary) }
+ )
SwitchSettingItem(
title = stringResource(R.string.settings_persistent_shuffle_title),
subtitle = stringResource(R.string.settings_persistent_shuffle_subtitle),
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt
index fa40ecb265..b5842979dd 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt
@@ -36,7 +36,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
@@ -218,7 +217,7 @@ fun SettingsScreen(
it != SettingsCategory.DEVICE_CAPABILITIES
}
- val totalItems = mainCategories.size + 3 // Device + Accounts + About
+ val totalItems = mainCategories.size + 2 // Device + About
fun shapeFor(index: Int) =
when {
totalItems == 1 -> RoundedCornerShape(24.dp)
@@ -261,19 +260,6 @@ fun SettingsScreen(
}
itemIndex++
- ExpressiveNavigationItem(
- title = stringResource(R.string.settings_category_accounts_title),
- subtitle = stringResource(R.string.settings_category_accounts_subtitle),
- icon = Icons.Rounded.AccountCircle,
- colors = getAccountsColors(isDark),
- onClick = { navController.navigateSafely(Screen.Accounts.route) },
- shape = shapeFor(itemIndex)
- )
- if (itemIndex < totalItems - 1) {
- Spacer(modifier = Modifier.height(2.dp))
- }
- itemIndex++
-
ExpressiveCategoryItem(
category = SettingsCategory.ABOUT,
customColors = getCategoryColors(SettingsCategory.ABOUT, isDark),
@@ -455,14 +441,6 @@ fun ExpressiveCategoryItem(
}
}
-private fun getAccountsColors(isDark: Boolean): Pair {
- return if (isDark) {
- Color(0xFF37474F) to Color(0xFFBBD9E8)
- } else {
- Color(0xFFD6EAF5) to Color(0xFF103548)
- }
-}
-
@Composable
fun ExpressiveSettingsGroup(content: @Composable () -> Unit) {
Column(
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt
index 5100106317..9f6ac9a499 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SetupScreen.kt
@@ -553,7 +553,6 @@ sealed class SetupPage {
private fun buildSetupPages(sdkInt: Int): List {
val pages = mutableListOf(
SetupPage.Welcome,
- SetupPage.MediaPermission
)
if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
@@ -561,15 +560,9 @@ private fun buildSetupPages(sdkInt: Int): List {
}
pages += SetupPage.BackupRestore
- pages += SetupPage.DirectorySelection
pages += SetupPage.ThemeSelection
pages += SetupPage.LibraryLayout
pages += SetupPage.NavBarLayout
-
- if (sdkInt >= Build.VERSION_CODES.S) {
- pages += SetupPage.AlarmsPermission
- }
-
pages += SetupPage.BatteryOptimization
pages += SetupPage.Finish
return pages
@@ -610,14 +603,13 @@ private fun isPermissionGateSatisfied(
}
private fun allRequiredPermissionsGrantedNow(context: Context): Boolean {
- val mediaGranted = hasMediaPermissionNow(context)
val notificationsGranted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
- return mediaGranted && notificationsGranted
+ return notificationsGranted
}
private fun hasMediaPermissionNow(context: Context): Boolean {
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt
new file mode 100644
index 0000000000..8ef362f570
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudScreen.kt
@@ -0,0 +1,1916 @@
+@file:OptIn(
+ androidx.compose.material3.ExperimentalMaterial3Api::class,
+ androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class,
+)
+
+package com.theveloper.pixelplay.presentation.screens
+
+import android.content.Intent
+
+import androidx.activity.compose.BackHandler
+import com.theveloper.pixelplay.presentation.viewmodel.PlaylistViewModel
+import com.theveloper.pixelplay.presentation.components.subcomps.SelectionCountPill
+import com.theveloper.pixelplay.presentation.components.subcomps.SelectionActionRow
+import com.theveloper.pixelplay.presentation.components.resolveNavBarOccupiedHeight
+import com.theveloper.pixelplay.presentation.components.PlaylistBottomSheet
+import com.theveloper.pixelplay.presentation.components.MultiSelectionBottomSheet
+import com.theveloper.pixelplay.presentation.components.EditMultipleSongsSheet
+import androidx.compose.ui.zIndex
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.animation.togetherWith
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+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.LazyRow
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+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.Favorite
+import androidx.compose.material.icons.rounded.FavoriteBorder
+import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material.icons.rounded.PlaylistPlay
+import androidx.compose.material.icons.rounded.Search
+import androidx.compose.material.icons.rounded.Share
+import androidx.compose.material.icons.rounded.Shuffle
+import androidx.compose.material.icons.rounded.ViewModule
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilledIconButton
+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.DockedSearchBar
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.material3.PrimaryScrollableTabRow
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+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.components.subcomps.LibraryActionRow
+import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon
+import com.theveloper.pixelplay.presentation.navigation.Screen
+import com.theveloper.pixelplay.presentation.navigation.navigateSafely
+import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel
+import com.theveloper.pixelplay.soundcloud.SoundCloudClient
+import com.theveloper.pixelplay.soundcloud.SoundCloudSearchHit
+import com.theveloper.pixelplay.soundcloud.SoundCloudFeedShelf
+import com.theveloper.pixelplay.soundcloud.SoundCloudSection
+import com.theveloper.pixelplay.soundcloud.SoundCloudUiState
+import com.theveloper.pixelplay.soundcloud.SoundCloudViewModel
+import com.theveloper.pixelplay.ui.theme.GoogleSansRounded
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape
+import com.theveloper.pixelplay.data.preferences.LibraryNavigationMode
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.lazy.grid.itemsIndexed
+
+/** Material medium width — foldables / tablets / landscape. */
+private const val WIDE_SCREEN_DP = 600
+
+@Composable
+fun SoundCloudScreen(
+ playerViewModel: PlayerViewModel,
+ navController: NavController,
+ viewModel: SoundCloudViewModel = hiltViewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val stablePlayer by playerViewModel.stablePlayerState.collectAsStateWithLifecycle()
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ val haptic = LocalHapticFeedback.current
+ val playlistViewModel: PlaylistViewModel = hiltViewModel()
+ val multiSelectionState = playerViewModel.multiSelectionStateHolder
+ val selectedSongs by multiSelectionState.selectedSongs.collectAsStateWithLifecycle()
+ val isSelectionMode by multiSelectionState.isSelectionMode.collectAsStateWithLifecycle()
+ val selectedSongIds by multiSelectionState.selectedSongIds.collectAsStateWithLifecycle()
+ val favoriteSongIds by playerViewModel.favoriteSongIds.collectAsStateWithLifecycle()
+ var showMultiSelectionSheet by remember { mutableStateOf(false) }
+ var showPlaylistBottomSheet by remember { mutableStateOf(false) }
+ var showBatchEditSheet by remember { mutableStateOf(false) }
+ var playlistSheetSongs by remember { mutableStateOf>(emptyList()) }
+ val downloadsSelectionActive =
+ isSelectionMode && uiState.section == SoundCloudSection.DOWNLOADS
+ val systemNavBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ val navBarCompactMode by playerViewModel.navBarCompactMode.collectAsStateWithLifecycle()
+ val bottomBarHeightDp = resolveNavBarOccupiedHeight(systemNavBarInset, navBarCompactMode)
+ val onDownloadLongPress: (Song) -> Unit = remember(multiSelectionState, haptic) {
+ { song ->
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ multiSelectionState.toggleSelection(song)
+ }
+ }
+ val onDownloadSelectionToggle: (Song) -> Unit = remember(multiSelectionState) {
+ { song -> multiSelectionState.toggleSelection(song) }
+ }
+ val sections = remember {
+ SoundCloudSection.entries.filter { it != SoundCloudSection.SEARCH }
+ }
+ val selectedIndex = sections.indexOf(uiState.section).let { if (it < 0) -1 else it }
+ val libraryNavigationMode by playerViewModel.libraryNavigationMode.collectAsStateWithLifecycle()
+ val isCompactNavigation = libraryNavigationMode == LibraryNavigationMode.COMPACT_PILL
+ var showSectionSwitcherSheet by remember { mutableStateOf(false) }
+ val currentSection = if (selectedIndex >= 0) sections[selectedIndex] else SoundCloudSection.FEED
+ val configuration = LocalConfiguration.current
+ val isWideScreen = configuration.screenWidthDp >= WIDE_SCREEN_DP
+ val useTiles = uiState.tilesOverride ?: isWideScreen
+ 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 = bottomBarHeightDp + MiniPlayerHeight + ListExtraBottomGap,
+ )
+ val currentSong = stablePlayer.currentSong
+ val currentSongId = currentSong?.id
+ val isPlaying = stablePlayer.isPlaying
+
+ fun hitIsCurrent(hit: SoundCloudSearchHit): Boolean =
+ isSoundCloudHitCurrent(hit, currentSong)
+
+ val sectionPagerState = rememberPagerState(
+ initialPage = selectedIndex.coerceAtLeast(0),
+ ) { sections.size }
+ LaunchedEffect(selectedIndex) {
+ if (selectedIndex < 0) return@LaunchedEffect
+ if (sectionPagerState.currentPage != selectedIndex && !sectionPagerState.isScrollInProgress) {
+ sectionPagerState.animateScrollToPage(selectedIndex)
+ }
+ }
+ LaunchedEffect(sectionPagerState, sections) {
+ snapshotFlow { sectionPagerState.settledPage }
+ .distinctUntilChanged()
+ .collect { page ->
+ val section = sections.getOrNull(page) ?: return@collect
+ val current = viewModel.uiState.value.section
+ if (current != section && current != SoundCloudSection.SEARCH) {
+ viewModel.selectSection(section)
+ }
+ }
+ }
+
+ val onHitPlay: (SoundCloudSearchHit, List) -> Unit =
+ remember(viewModel, playerViewModel, scope) {
+ { hit, queueHits ->
+ if (hit.kind == SoundCloudSearchHit.Kind.PLAYLIST) {
+ viewModel.openPlaylist(hit)
+ } else {
+ scope.launch {
+ try {
+ // Resolve only the tapped track — don't wait on the whole list.
+ val (startSong, startHit) = viewModel.resolvePlayableStart(hit, queueHits)
+ playerViewModel.playSongs(
+ songsToPlay = listOf(startSong),
+ startSong = startSong,
+ queueName = "SoundCloud",
+ )
+ viewModel.setIdle()
+ viewModel.prefetchQueueAfter(startHit, queueHits) { song ->
+ playerViewModel.addSongToQueue(song)
+ }
+ } 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 (startSong, remaining) = viewModel.resolveShuffleStart()
+ val queueName = uiState.browsingPlaylistTitle
+ ?.let { "SoundCloud · $it" }
+ ?: "SoundCloud · ${uiState.section.name.lowercase().replaceFirstChar { it.titlecase() }}"
+ playerViewModel.playSongsShuffled(
+ songsToPlay = listOf(startSong),
+ queueName = queueName,
+ startAtZero = true,
+ )
+ viewModel.setIdle()
+ viewModel.prefetchHits(remaining) { song ->
+ playerViewModel.addSongToQueue(song)
+ }
+ }
+ } catch (t: Throwable) {
+ viewModel.setIdle(error = t.message ?: t::class.java.simpleName)
+ }
+ }
+ }
+
+ // Keep Back inside SoundCloud when browsing a playlist (don't jump to Library).
+ BackHandler(
+ enabled = downloadsSelectionActive,
+ ) {
+ multiSelectionState.clearSelection()
+ showMultiSelectionSheet = false
+ }
+ BackHandler(
+ enabled = !downloadsSelectionActive &&
+ (uiState.canNavigateBack || uiState.browsingPlaylistUrl != null),
+ ) {
+ viewModel.navigateBack()
+ }
+
+ LaunchedEffect(uiState.section) {
+ if (uiState.section != SoundCloudSection.DOWNLOADS) {
+ multiSelectionState.clearSelection()
+ showMultiSelectionSheet = false
+ showPlaylistBottomSheet = false
+ showBatchEditSheet = false
+ }
+ }
+ androidx.compose.runtime.DisposableEffect(Unit) {
+ onDispose {
+ multiSelectionState.clearSelection()
+ }
+ }
+
+ val headerContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
+ Scaffold(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(headerContainerColor),
+ topBar = {
+ Column(modifier = Modifier.background(headerContainerColor)) {
+ TopAppBar(
+ title = {
+ if (isCompactNavigation) {
+ LibraryNavigationPill(
+ modifier = Modifier,
+ title = stringResource(currentSection.labelRes),
+ isExpanded = showSectionSwitcherSheet,
+ showIcon = true,
+ iconRes = currentSection.iconRes,
+ pageIndex = selectedIndex.coerceAtLeast(0),
+ compressForWatchTransfer = false,
+ onClick = { showSectionSwitcherSheet = true },
+ onArrowClick = { showSectionSwitcherSheet = true },
+ )
+ } else {
+ Text(
+ modifier = Modifier.padding(start = 8.dp),
+ text = stringResource(R.string.soundcloud_tab_title),
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.ExtraBold,
+ color = MaterialTheme.colorScheme.primary,
+ fontSize = 40.sp,
+ letterSpacing = 1.sp,
+ )
+ }
+ },
+ actions = {
+ FilledIconButton(
+ modifier = Modifier.padding(end = 14.dp),
+ onClick = { navController.navigateSafely(Screen.SoundCloudSettings.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,
+ ),
+ )
+
+ if (!isCompactNavigation) {
+ PrimaryScrollableTabRow(
+ selectedTabIndex = selectedIndex.coerceAtLeast(0),
+ containerColor = Color.Transparent,
+ edgePadding = 12.dp,
+ indicator = {},
+ divider = {},
+ ) {
+ sections.forEachIndexed { index, section ->
+ TabAnimation(
+ index = index,
+ title = section.name,
+ selectedIndex = if (selectedIndex < 0) -1 else selectedIndex,
+ onClick = {
+ viewModel.selectSection(section)
+ scope.launch { sectionPagerState.animateScrollToPage(index) }
+ },
+ ) {
+ Text(
+ text = stringResource(section.labelRes).uppercase(),
+ style = MaterialTheme.typography.labelLarge,
+ fontFamily = GoogleSansRounded,
+ fontWeight = if (selectedIndex == index) FontWeight.Bold else FontWeight.Medium,
+ )
+ }
+ }
+ }
+ } else {
+ CompactLibraryPagerIndicator(
+ currentIndex = selectedIndex.coerceAtLeast(0),
+ pageCount = sections.size,
+ modifier = Modifier.padding(top = 8.dp, bottom = 10.dp),
+ )
+ }
+ }
+ },
+ ) { scaffoldPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = scaffoldPadding.calculateTopPadding())
+ .background(headerContainerColor),
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.surface,
+ shape = AbsoluteSmoothCornerShape(
+ cornerRadiusTL = 34.dp,
+ cornerRadiusTR = 34.dp,
+ cornerRadiusBL = 0.dp,
+ cornerRadiusBR = 0.dp,
+ smoothnessAsPercentTL = 60,
+ smoothnessAsPercentTR = 60,
+ smoothnessAsPercentBL = 60,
+ smoothnessAsPercentBR = 60,
+ ),
+ ) {
+ Column(modifier = Modifier.fillMaxSize()) {
+
+ AnimatedContent(
+ targetState = downloadsSelectionActive,
+ label = "SoundCloudActionRowMode",
+ transitionSpec = {
+ (slideInHorizontally { -it } + fadeIn()) togetherWith
+ (slideOutHorizontally { it } + fadeOut())
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 8.dp, top = 10.dp, end = 12.dp)
+ .heightIn(min = 56.dp),
+ ) { inSelectionMode ->
+ if (inSelectionMode) {
+ SelectionActionRow(
+ selectedCount = selectedSongs.size,
+ onSelectAll = {
+ multiSelectionState.selectAll(uiState.downloads)
+ },
+ onDeselect = { multiSelectionState.clearSelection() },
+ onOptionsClick = { showMultiSelectionSheet = true },
+ )
+ } else {
+ LibraryActionRow(
+ modifier = Modifier.fillMaxWidth(),
+ onMainActionClick = onShuffle,
+ mainActionEnabled = canShuffle,
+ iconRotation = 0f,
+ onSortClick = {},
+ showSortButton = false,
+ isPlaylistTab = false,
+ isFoldersTab = false,
+ currentFolder = null,
+ folderRootPath = "",
+ folderRootLabel = "",
+ onFolderClick = {},
+ onNavigateBack = {},
+ trailingContent = {
+ ToggleSegmentButton(
+ modifier = Modifier.size(42.dp),
+ active = useTiles,
+ activeColor = MaterialTheme.colorScheme.primary,
+ inactiveColor = MaterialTheme.colorScheme.surfaceVariant,
+ activeContentColor = MaterialTheme.colorScheme.onPrimary,
+ inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ activeCornerRadius = 26.dp,
+ onClick = { viewModel.setUseTiles(true) },
+ imageVector = Icons.Rounded.ViewModule,
+ contentDesc = stringResource(R.string.soundcloud_cd_view_tiles),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ ToggleSegmentButton(
+ modifier = Modifier.size(42.dp),
+ active = !useTiles,
+ activeColor = MaterialTheme.colorScheme.primary,
+ inactiveColor = MaterialTheme.colorScheme.surfaceVariant,
+ activeContentColor = MaterialTheme.colorScheme.onPrimary,
+ inactiveContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ activeCornerRadius = 26.dp,
+ onClick = { viewModel.setUseTiles(false) },
+ imageVector = Icons.AutoMirrored.Rounded.ViewList,
+ contentDesc = stringResource(R.string.soundcloud_cd_view_list),
+ )
+ },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val searchBarCornerRadius = 28.dp
+ val searchBarInputFieldColors = SearchBarDefaults.inputFieldColors(
+ focusedTextColor = MaterialTheme.colorScheme.onSurface,
+ unfocusedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ cursorColor = MaterialTheme.colorScheme.primary,
+ )
+ DockedSearchBar(
+ inputField = {
+ SearchBarDefaults.InputField(
+ query = uiState.query,
+ onQueryChange = viewModel::onQueryChange,
+ onSearch = { query ->
+ if (query.isNotBlank()) {
+ viewModel.onQueryChange(query)
+ }
+ keyboardController?.hide()
+ },
+ expanded = false,
+ onExpandedChange = {},
+ placeholder = {
+ Text(
+ stringResource(R.string.soundcloud_query_hint),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Rounded.Search,
+ contentDescription = stringResource(R.string.search_cd_search_icon),
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp),
+ )
+ },
+ trailingIcon = {
+ if (uiState.query.isNotBlank()) {
+ IconButton(
+ onClick = { viewModel.onQueryChange("") },
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f),
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Close,
+ contentDescription = stringResource(R.string.search_cd_clear_search_query),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ },
+ colors = searchBarInputFieldColors,
+ )
+ },
+ expanded = false,
+ onExpandedChange = {},
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .clip(RoundedCornerShape(searchBarCornerRadius)),
+ colors = SearchBarDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
+ dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
+ inputFieldColors = searchBarInputFieldColors,
+ ),
+ content = {},
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ 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),
+ )
+ }
+
+
+ val pullToRefreshState = rememberPullToRefreshState()
+ val showContentLoading =
+ uiState.isLoading && uiState.resolvingKey == null && !uiState.isRefreshing
+ val sectionSwipeEnabled =
+ uiState.section != SoundCloudSection.SEARCH && uiState.browsingPlaylistUrl == null
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (sectionSwipeEnabled) {
+ HorizontalPager(
+ state = sectionPagerState,
+ modifier = Modifier.fillMaxSize(),
+ beyondViewportPageCount = 0,
+ key = { sections[it].name },
+ ) { page ->
+ val pageSection = sections[page]
+ PullToRefreshBox(
+ isRefreshing = uiState.isRefreshing && pageSection == uiState.section,
+ onRefresh = viewModel::refresh,
+ state = pullToRefreshState,
+ modifier = Modifier.fillMaxSize(),
+ indicator = {
+ PullToRefreshDefaults.LoadingIndicator(
+ state = pullToRefreshState,
+ isRefreshing = uiState.isRefreshing && pageSection == uiState.section,
+ modifier = Modifier.align(Alignment.TopCenter),
+ containerColor = Color.Transparent,
+ elevation = 0.dp,
+ )
+ },
+ ) {
+ if (pageSection != uiState.section) {
+ Box(modifier = Modifier.fillMaxSize())
+ } else if (
+ uiState.section == SoundCloudSection.FEED &&
+ uiState.browsingPlaylistUrl == null &&
+ uiState.feedShelves.isNotEmpty()
+ ) {
+ SoundCloudFeedHome(
+ shelves = uiState.feedShelves,
+ useTiles = useTiles,
+ enabled = !uiState.isLoading,
+ resolvingKey = uiState.resolvingKey,
+ contentPadding = contentPadding,
+ isWideScreen = isWideScreen,
+ likedTrackUrls = uiState.likedTrackUrls,
+ likingUrl = uiState.likingUrl,
+ unplayableUrls = uiState.unplayableUrls,
+ currentSong = currentSong,
+ isPlaying = isPlaying,
+ onPlay = onHitPlay,
+ onDownload = viewModel::download,
+ onToggleLike = viewModel::toggleLike,
+ onLoadMore = viewModel::loadMore,
+ )
+ } else if (useTiles) {
+ SoundCloudResultsGrid(
+ uiState = uiState,
+ gridColumns = gridColumns,
+ contentPadding = contentPadding,
+ downloadsSelectionActive = downloadsSelectionActive,
+ selectedSongIds = selectedSongIds,
+ getSelectionIndex = multiSelectionState::getSelectionIndex,
+ currentSongId = currentSongId,
+ isPlaying = isPlaying,
+ hitIsCurrent = { hitIsCurrent(it) },
+ onHitPlay = onHitPlay,
+ onDownloadPlay = onDownloadPlay,
+ onDownloadLongPress = onDownloadLongPress,
+ onDownloadSelectionToggle = onDownloadSelectionToggle,
+ onDownload = viewModel::download,
+ onToggleLike = viewModel::toggleLike,
+ onLoadMore = viewModel::loadMore,
+ )
+ } else {
+ SoundCloudResultsList(
+ uiState = uiState,
+ contentPadding = contentPadding,
+ downloadsSelectionActive = downloadsSelectionActive,
+ selectedSongIds = selectedSongIds,
+ getSelectionIndex = multiSelectionState::getSelectionIndex,
+ currentSongId = currentSongId,
+ isPlaying = isPlaying,
+ hitIsCurrent = { hitIsCurrent(it) },
+ onHitPlay = onHitPlay,
+ onDownloadPlay = onDownloadPlay,
+ onDownloadLongPress = onDownloadLongPress,
+ onDownloadSelectionToggle = onDownloadSelectionToggle,
+ onDownload = viewModel::download,
+ onToggleLike = viewModel::toggleLike,
+ onLoadMore = viewModel::loadMore,
+ )
+ }
+ }
+ }
+ } else {
+ PullToRefreshBox(
+ isRefreshing = uiState.isRefreshing,
+ onRefresh = viewModel::refresh,
+ state = pullToRefreshState,
+ modifier = Modifier.fillMaxSize(),
+ indicator = {
+ PullToRefreshDefaults.LoadingIndicator(
+ state = pullToRefreshState,
+ isRefreshing = uiState.isRefreshing,
+ modifier = Modifier.align(Alignment.TopCenter),
+ containerColor = Color.Transparent,
+ elevation = 0.dp,
+ )
+ },
+ ) {
+ if (useTiles) {
+ SoundCloudResultsGrid(
+ uiState = uiState,
+ gridColumns = gridColumns,
+ contentPadding = contentPadding,
+ downloadsSelectionActive = downloadsSelectionActive,
+ selectedSongIds = selectedSongIds,
+ getSelectionIndex = multiSelectionState::getSelectionIndex,
+ currentSongId = currentSongId,
+ isPlaying = isPlaying,
+ hitIsCurrent = { hitIsCurrent(it) },
+ onHitPlay = onHitPlay,
+ onDownloadPlay = onDownloadPlay,
+ onDownloadLongPress = onDownloadLongPress,
+ onDownloadSelectionToggle = onDownloadSelectionToggle,
+ onDownload = viewModel::download,
+ onToggleLike = viewModel::toggleLike,
+ onLoadMore = viewModel::loadMore,
+ )
+ } else {
+ SoundCloudResultsList(
+ uiState = uiState,
+ contentPadding = contentPadding,
+ downloadsSelectionActive = downloadsSelectionActive,
+ selectedSongIds = selectedSongIds,
+ getSelectionIndex = multiSelectionState::getSelectionIndex,
+ currentSongId = currentSongId,
+ isPlaying = isPlaying,
+ hitIsCurrent = { hitIsCurrent(it) },
+ onHitPlay = onHitPlay,
+ onDownloadPlay = onDownloadPlay,
+ onDownloadLongPress = onDownloadLongPress,
+ onDownloadSelectionToggle = onDownloadSelectionToggle,
+ onDownload = viewModel::download,
+ onToggleLike = viewModel::toggleLike,
+ onLoadMore = viewModel::loadMore,
+ )
+ }
+ }
+ } // sectionSwipeEnabled else
+ if (showContentLoading) {
+ LoadingIndicator(
+ modifier = Modifier
+ .size(48.dp)
+ .align(Alignment.Center),
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ if (downloadsSelectionActive && selectedSongs.isNotEmpty()) {
+ SelectionCountPill(
+ selectedCount = selectedSongs.size,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = bottomBarHeightDp + MiniPlayerHeight + 16.dp)
+ .zIndex(2f),
+ )
+ }
+ } // Box (content + loading overlay)
+ } // Column (surface content)
+ } // Surface
+ } // header Column
+ } // Scaffold
+
+ if (showMultiSelectionSheet && selectedSongs.isNotEmpty()) {
+ val activity = context as? android.app.Activity
+ MultiSelectionBottomSheet(
+ selectedSongs = selectedSongs,
+ favoriteSongIds = favoriteSongIds.toSet(),
+ onDismiss = { showMultiSelectionSheet = false },
+ onPlayAll = {
+ playerViewModel.playSelectedSongs(selectedSongs)
+ showMultiSelectionSheet = false
+ },
+ onAddToQueue = {
+ playerViewModel.addSelectedToQueue(selectedSongs)
+ showMultiSelectionSheet = false
+ },
+ onPlayNext = {
+ playerViewModel.addSelectedAsNext(selectedSongs)
+ showMultiSelectionSheet = false
+ },
+ onAddToPlaylist = {
+ playlistSheetSongs = selectedSongs
+ showMultiSelectionSheet = false
+ showPlaylistBottomSheet = true
+ },
+ onToggleLikeAll = { shouldLike ->
+ if (shouldLike) {
+ playerViewModel.likeSelectedSongs(selectedSongs)
+ } else {
+ playerViewModel.unlikeSelectedSongs(selectedSongs)
+ }
+ showMultiSelectionSheet = false
+ },
+ onShareAll = {
+ playerViewModel.shareSelectedAsZip(selectedSongs)
+ showMultiSelectionSheet = false
+ },
+ onDeleteAll = { _, onComplete ->
+ activity?.let {
+ playerViewModel.deleteSelectedFromDevice(it, selectedSongs) {
+ showMultiSelectionSheet = false
+ viewModel.refreshDownloads()
+ onComplete(true)
+ }
+ }
+ },
+ onBatchEdit = {
+ showMultiSelectionSheet = false
+ showBatchEditSheet = true
+ },
+ )
+ }
+
+ if (showPlaylistBottomSheet && playlistSheetSongs.isNotEmpty()) {
+ val playlistUiState by playlistViewModel.uiState.collectAsStateWithLifecycle()
+ PlaylistBottomSheet(
+ playlistUiState = playlistUiState,
+ songs = playlistSheetSongs,
+ onDismiss = {
+ showPlaylistBottomSheet = false
+ playlistSheetSongs = emptyList()
+ },
+ bottomBarHeight = bottomBarHeightDp,
+ playerViewModel = playerViewModel,
+ playlistViewModel = playlistViewModel,
+ )
+ }
+
+ if (showBatchEditSheet && selectedSongs.isNotEmpty()) {
+ EditMultipleSongsSheet(
+ visible = showBatchEditSheet,
+ songs = selectedSongs,
+ onDismiss = { showBatchEditSheet = false },
+ onSave = { songs, title, artist, album, albumArtist, composer, genre, lyrics, trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate ->
+ playerViewModel.saveBatchMetadata(
+ songs = songs,
+ title = title,
+ artist = artist,
+ album = album,
+ albumArtist = albumArtist,
+ composer = composer,
+ genre = genre,
+ lyrics = lyrics,
+ trackNumber = trackNumber,
+ discNumber = discNumber,
+ replayGainTrackGainDb = replayGainTrackGainDb,
+ replayGainAlbumGainDb = replayGainAlbumGainDb,
+ coverArtUpdate = coverArtUpdate,
+ )
+ showBatchEditSheet = false
+ multiSelectionState.clearSelection()
+ },
+ )
+ }
+
+ if (showSectionSwitcherSheet) {
+ SoundCloudSectionSwitcherSheet(
+ sections = sections,
+ currentIndex = selectedIndex.coerceAtLeast(0),
+ onSectionSelected = { index ->
+ sections.getOrNull(index)?.let { section ->
+ viewModel.selectSection(section)
+ scope.launch { sectionPagerState.animateScrollToPage(index) }
+ }
+ showSectionSwitcherSheet = false
+ },
+ onDismiss = { showSectionSwitcherSheet = false },
+ )
+ }
+}
+
+@Composable
+private fun SoundCloudResultsGrid(
+ uiState: SoundCloudUiState,
+ gridColumns: Int,
+ contentPadding: PaddingValues,
+ downloadsSelectionActive: Boolean,
+ selectedSongIds: Set,
+ getSelectionIndex: (String) -> Int?,
+ currentSongId: String?,
+ isPlaying: Boolean,
+ hitIsCurrent: (SoundCloudSearchHit) -> Boolean,
+ onHitPlay: (SoundCloudSearchHit, List) -> Unit,
+ onDownloadPlay: (Song) -> Unit,
+ onDownloadLongPress: (Song) -> Unit,
+ onDownloadSelectionToggle: (Song) -> Unit,
+ onDownload: (SoundCloudSearchHit) -> Unit,
+ onToggleLike: (SoundCloudSearchHit) -> Unit,
+ onLoadMore: () -> Unit,
+) {
+ val context = LocalContext.current
+ val gridState = rememberLazyGridState()
+ LaunchedEffect(gridState, uiState.results.size, uiState.downloads.size) {
+ snapshotFlow {
+ val info = gridState.layoutInfo
+ val total = info.totalItemsCount
+ total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - gridColumns
+ }
+ .distinctUntilChanged()
+ .filter { it }
+ .collect { onLoadMore() }
+ }
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(gridColumns),
+ state = gridState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = contentPadding,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (uiState.section == SoundCloudSection.DOWNLOADS) {
+ items(uiState.downloads, key = { it.id }) { song ->
+ SoundCloudSongTile(
+ song = song,
+ isCurrent = currentSongId == song.id,
+ isPlaying = currentSongId == song.id && isPlaying,
+ isSelectionMode = downloadsSelectionActive,
+ isSelected = song.id in selectedSongIds,
+ selectionIndex = if (downloadsSelectionActive) getSelectionIndex(song.id) else null,
+ onPlay = { onDownloadPlay(song) },
+ onLongPress = { onDownloadLongPress(song) },
+ onSelectionToggle = { onDownloadSelectionToggle(song) },
+ )
+ }
+ } else {
+ items(uiState.results, key = { it.url + it.kind.name }) { hit ->
+ val isCurrent = hitIsCurrent(hit)
+ SoundCloudResultTile(
+ hit = hit,
+ enabled = !uiState.isLoading,
+ isResolving = uiState.resolvingKey == hit.url,
+ isLiked = hit.url in uiState.likedTrackUrls,
+ isLiking = uiState.likingUrl == hit.url,
+ unavailable = hit.kind == SoundCloudSearchHit.Kind.TRACK && hit.url in uiState.unplayableUrls,
+ isCurrent = isCurrent,
+ isPlaying = isCurrent && isPlaying,
+ onPlay = { onHitPlay(hit, uiState.results) },
+ onDownload = { onDownload(hit) },
+ onToggleLike = { onToggleLike(hit) },
+ onShare = { shareSoundCloudLink(context, hit) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SoundCloudResultsList(
+ uiState: SoundCloudUiState,
+ contentPadding: PaddingValues,
+ downloadsSelectionActive: Boolean,
+ selectedSongIds: Set,
+ getSelectionIndex: (String) -> Int?,
+ currentSongId: String?,
+ isPlaying: Boolean,
+ hitIsCurrent: (SoundCloudSearchHit) -> Boolean,
+ onHitPlay: (SoundCloudSearchHit, List) -> Unit,
+ onDownloadPlay: (Song) -> Unit,
+ onDownloadLongPress: (Song) -> Unit,
+ onDownloadSelectionToggle: (Song) -> Unit,
+ onDownload: (SoundCloudSearchHit) -> Unit,
+ onToggleLike: (SoundCloudSearchHit) -> Unit,
+ onLoadMore: () -> Unit,
+) {
+ val context = LocalContext.current
+ val listState = rememberLazyListState()
+ LaunchedEffect(listState, uiState.results.size, uiState.downloads.size) {
+ snapshotFlow {
+ val info = listState.layoutInfo
+ val total = info.totalItemsCount
+ total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - 3
+ }
+ .distinctUntilChanged()
+ .filter { it }
+ .collect { onLoadMore() }
+ }
+ LazyColumn(
+ state = listState,
+ 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 = {},
+ isSelectionMode = downloadsSelectionActive,
+ isSelected = song.id in selectedSongIds,
+ selectionIndex = if (downloadsSelectionActive) getSelectionIndex(song.id) else null,
+ onLongPress = { onDownloadLongPress(song) },
+ onClick = {
+ if (downloadsSelectionActive) {
+ onDownloadSelectionToggle(song)
+ } else {
+ onDownloadPlay(song)
+ }
+ },
+ )
+ }
+ } else {
+ items(uiState.results, key = { it.url + it.kind.name }) { hit ->
+ val isCurrent = hitIsCurrent(hit)
+ SoundCloudResultRow(
+ hit = hit,
+ enabled = !uiState.isLoading,
+ isResolving = uiState.resolvingKey == hit.url,
+ isLiked = hit.url in uiState.likedTrackUrls,
+ isLiking = uiState.likingUrl == hit.url,
+ unavailable = hit.kind == SoundCloudSearchHit.Kind.TRACK && hit.url in uiState.unplayableUrls,
+ isCurrent = isCurrent,
+ isPlaying = isCurrent && isPlaying,
+ onPlay = { onHitPlay(hit, uiState.results) },
+ onDownload = { onDownload(hit) },
+ onToggleLike = { onToggleLike(hit) },
+ onShare = { shareSoundCloudLink(context, hit) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SoundCloudFeedHome(
+ shelves: List,
+ useTiles: Boolean,
+ enabled: Boolean,
+ resolvingKey: String?,
+ contentPadding: PaddingValues,
+ isWideScreen: Boolean,
+ likedTrackUrls: Set,
+ likingUrl: String?,
+ unplayableUrls: Set,
+ currentSong: Song?,
+ isPlaying: Boolean,
+ onPlay: (SoundCloudSearchHit, List) -> Unit,
+ onDownload: (SoundCloudSearchHit) -> Unit,
+ onToggleLike: (SoundCloudSearchHit) -> Unit,
+ onLoadMore: () -> Unit,
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ if (isWideScreen) {
+ val listState = rememberLazyListState()
+ LaunchedEffect(listState, shelves.sumOf { it.items.size }) {
+ snapshotFlow {
+ val info = listState.layoutInfo
+ val total = info.totalItemsCount
+ total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - 2
+ }
+ .distinctUntilChanged()
+ .filter { it }
+ .collect { onLoadMore() }
+ }
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = contentPadding,
+ verticalArrangement = Arrangement.spacedBy(22.dp),
+ ) {
+ items(shelves, key = { it.id }) { shelf ->
+ SoundCloudFeedShelfBody(
+ shelf = shelf,
+ useTiles = useTiles,
+ tileColumns = 3,
+ enabled = enabled,
+ resolvingKey = resolvingKey,
+ likedTrackUrls = likedTrackUrls,
+ likingUrl = likingUrl,
+ unplayableUrls = unplayableUrls,
+ currentSong = currentSong,
+ isPlaying = isPlaying,
+ showTitle = true,
+ onPlay = onPlay,
+ onDownload = onDownload,
+ onToggleLike = onToggleLike,
+ )
+ }
+ }
+ return
+ }
+
+ val shelfPagerState = rememberPagerState(initialPage = 0) { shelves.size.coerceAtLeast(1) }
+ LaunchedEffect(shelves.map { it.id }) {
+ if (shelfPagerState.currentPage >= shelves.size && shelves.isNotEmpty()) {
+ shelfPagerState.scrollToPage(0)
+ }
+ }
+ Column(modifier = Modifier.fillMaxSize()) {
+ LazyRow(
+ contentPadding = PaddingValues(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(bottom = 10.dp),
+ ) {
+ items(shelves, key = { it.id }) { shelf ->
+ val index = shelves.indexOfFirst { it.id == shelf.id }
+ FilterChip(
+ selected = shelfPagerState.currentPage == index,
+ onClick = {
+ scope.launch { shelfPagerState.animateScrollToPage(index.coerceAtLeast(0)) }
+ },
+ label = {
+ Text(
+ text = shelf.title,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.SemiBold,
+ )
+ },
+ )
+ }
+ }
+ HorizontalPager(
+ state = shelfPagerState,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ key = { shelves.getOrNull(it)?.id ?: it },
+ ) { page ->
+ val shelf = shelves.getOrNull(page) ?: return@HorizontalPager
+ val listState = rememberLazyListState()
+ LaunchedEffect(listState, shelf.items.size, page) {
+ if (page != shelfPagerState.settledPage) return@LaunchedEffect
+ snapshotFlow {
+ val info = listState.layoutInfo
+ val total = info.totalItemsCount
+ total > 0 && (info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= total - 2
+ }
+ .distinctUntilChanged()
+ .filter { it }
+ .collect { onLoadMore() }
+ }
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = contentPadding,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item(key = "shelf-title-${shelf.id}") {
+ Text(
+ text = shelf.title,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ style = MaterialTheme.typography.headlineSmall,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.ExtraBold,
+ )
+ }
+ item(key = "shelf-body-${shelf.id}") {
+ SoundCloudFeedShelfBody(
+ shelf = shelf,
+ useTiles = useTiles,
+ tileColumns = 2,
+ enabled = enabled,
+ resolvingKey = resolvingKey,
+ likedTrackUrls = likedTrackUrls,
+ likingUrl = likingUrl,
+ unplayableUrls = unplayableUrls,
+ currentSong = currentSong,
+ isPlaying = isPlaying,
+ showTitle = false,
+ onPlay = onPlay,
+ onDownload = onDownload,
+ onToggleLike = onToggleLike,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SoundCloudFeedShelfBody(
+ shelf: SoundCloudFeedShelf,
+ useTiles: Boolean,
+ tileColumns: Int,
+ enabled: Boolean,
+ resolvingKey: String?,
+ likedTrackUrls: Set,
+ likingUrl: String?,
+ unplayableUrls: Set,
+ currentSong: Song?,
+ isPlaying: Boolean,
+ showTitle: Boolean,
+ onPlay: (SoundCloudSearchHit, List) -> Unit,
+ onDownload: (SoundCloudSearchHit) -> Unit,
+ onToggleLike: (SoundCloudSearchHit) -> Unit,
+) {
+ val context = LocalContext.current
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ if (showTitle) {
+ Text(
+ text = shelf.title,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ style = MaterialTheme.typography.headlineSmall,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.ExtraBold,
+ )
+ }
+ if (useTiles) {
+ Column(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ shelf.items.chunked(tileColumns).forEach { rowHits ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ rowHits.forEach { hit ->
+ val isCurrent = isSoundCloudHitCurrent(hit, currentSong)
+ Box(modifier = Modifier.weight(1f)) {
+ SoundCloudResultTile(
+ hit = hit,
+ enabled = enabled,
+ isResolving = resolvingKey == hit.url,
+ isLiked = hit.url in likedTrackUrls,
+ isLiking = likingUrl == hit.url,
+ unavailable = hit.kind == SoundCloudSearchHit.Kind.TRACK &&
+ hit.url in unplayableUrls,
+ isCurrent = isCurrent,
+ isPlaying = isCurrent && isPlaying,
+ onPlay = { onPlay(hit, shelf.items) },
+ onDownload = { onDownload(hit) },
+ onToggleLike = { onToggleLike(hit) },
+ onShare = { shareSoundCloudLink(context, hit) },
+ )
+ }
+ }
+ repeat(tileColumns - rowHits.size) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val rows = if (showTitle) shelf.items else shelf.items
+ rows.forEach { hit ->
+ val isCurrent = isSoundCloudHitCurrent(hit, currentSong)
+ SoundCloudResultRow(
+ hit = hit,
+ enabled = enabled,
+ isResolving = resolvingKey == hit.url,
+ isLiked = hit.url in likedTrackUrls,
+ isLiking = likingUrl == hit.url,
+ unavailable = hit.kind == SoundCloudSearchHit.Kind.TRACK &&
+ hit.url in unplayableUrls,
+ isCurrent = isCurrent,
+ isPlaying = isCurrent && isPlaying,
+ onPlay = { onPlay(hit, shelf.items) },
+ onDownload = { onDownload(hit) },
+ onToggleLike = { onToggleLike(hit) },
+ onShare = { shareSoundCloudLink(context, hit) },
+ )
+ }
+ }
+ }
+ }
+}
+
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SoundCloudSectionSwitcherSheet(
+ sections: List,
+ currentIndex: Int,
+ onSectionSelected: (Int) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 18.dp, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.soundcloud_tabs_sheet_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontFamily = GoogleSansRounded,
+ )
+ Text(
+ text = stringResource(R.string.soundcloud_tabs_sheet_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 150.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ contentPadding = PaddingValues(bottom = 24.dp, top = 8.dp),
+ ) {
+ itemsIndexed(
+ items = sections,
+ key = { index, section -> "${section.name}-$index" },
+ ) { index, section ->
+ SoundCloudSectionGridItem(
+ section = section,
+ isSelected = index == currentIndex,
+ onClick = { onSectionSelected(index) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SoundCloudSectionGridItem(
+ section: SoundCloudSection,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+) {
+ val shape = RoundedCornerShape(20.dp)
+ val containerColor =
+ if (isSelected) MaterialTheme.colorScheme.primaryContainer
+ else MaterialTheme.colorScheme.surfaceContainerHigh
+ val iconContainer =
+ if (isSelected) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.secondaryContainer
+ val textColor =
+ if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
+ else MaterialTheme.colorScheme.onSurface
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(shape)
+ .clickable(onClick = onClick),
+ shape = shape,
+ color = containerColor,
+ tonalElevation = if (isSelected) 6.dp else 2.dp,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 14.dp, horizontal = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(52.dp)
+ .clip(CircleShape)
+ .background(iconContainer.copy(alpha = 0.92f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(id = section.iconRes),
+ contentDescription = stringResource(section.labelRes),
+ tint = if (isSelected) {
+ MaterialTheme.colorScheme.onPrimary
+ } else {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ },
+ )
+ }
+ Text(
+ text = stringResource(section.labelRes),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
+ color = textColor,
+ )
+ }
+ }
+}
+
+private val SoundCloudSection.iconRes: Int
+ get() = when (this) {
+ SoundCloudSection.FEED -> R.drawable.rounded_home_24
+ SoundCloudSection.DISCOVER -> R.drawable.rounded_instant_mix_24
+ SoundCloudSection.SEARCH -> R.drawable.rounded_search_24
+ SoundCloudSection.LIKES -> R.drawable.rounded_favorite_24
+ SoundCloudSection.TRACKS -> R.drawable.rounded_music_note_24
+ SoundCloudSection.PLAYLISTS -> R.drawable.rounded_playlist_play_24
+ SoundCloudSection.DOWNLOADS -> R.drawable.rounded_drive_export_24
+ }
+
+private val SoundCloudSection.labelRes: Int
+ get() = when (this) {
+ SoundCloudSection.FEED -> R.string.soundcloud_pill_feed
+ 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,
+ isSelectionMode: Boolean = false,
+ isSelected: Boolean = false,
+ selectionIndex: Int? = null,
+ onLongPress: () -> Unit = {},
+ onSelectionToggle: () -> Unit = {},
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .pointerInput(isSelectionMode) {
+ detectTapGestures(
+ onLongPress = { onLongPress() },
+ onTap = {
+ if (isSelectionMode) onSelectionToggle() else 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 (isSelectionMode && isSelected) {
+ Surface(
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(8.dp)
+ .size(28.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = (selectionIndex ?: 1).toString(),
+ color = MaterialTheme.colorScheme.onPrimary,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ } else if (isCurrent && !isSelectionMode) {
+ Surface(
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.92f),
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(8.dp),
+ ) {
+ PlayingEqIcon(
+ modifier = Modifier
+ .padding(horizontal = 7.dp, vertical = 6.dp)
+ .size(width = 16.dp, height = 14.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ isPlaying = isPlaying,
+ )
+ }
+ }
+ }
+ 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,
+ isLiked: Boolean = false,
+ isLiking: Boolean = false,
+ unavailable: Boolean = false,
+ isCurrent: Boolean = false,
+ isPlaying: Boolean = false,
+ onPlay: () -> Unit,
+ onDownload: () -> Unit,
+ onToggleLike: () -> Unit,
+ onShare: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .alpha(if (unavailable) 0.38f else 1f)
+ .clickable(enabled = enabled && !isResolving && !unavailable, 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 (isCurrent && !isResolving) {
+ Surface(
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.92f),
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(8.dp),
+ ) {
+ PlayingEqIcon(
+ modifier = Modifier
+ .padding(horizontal = 7.dp, vertical = 6.dp)
+ .size(width = 16.dp, height = 14.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ isPlaying = isPlaying,
+ )
+ }
+ }
+ if (isResolving) {
+ Surface(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ LoadingIndicator(
+ modifier = Modifier
+ .size(40.dp)
+ .align(Alignment.Center),
+ )
+ }
+ } else {
+ IconButton(
+ onClick = onShare,
+ enabled = enabled && hit.url.isNotBlank(),
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(2.dp)
+ .size(30.dp),
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f),
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Share,
+ contentDescription = stringResource(R.string.soundcloud_share_link),
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+ if (!isResolving && hit.kind == SoundCloudSearchHit.Kind.TRACK && !unavailable) {
+ IconButton(
+ onClick = onToggleLike,
+ enabled = enabled && !isLiking,
+ modifier = Modifier
+ .align(if (isCurrent) Alignment.BottomCenter else Alignment.BottomStart)
+ .padding(2.dp)
+ .size(30.dp),
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f),
+ contentColor = MaterialTheme.colorScheme.primary,
+ ),
+ ) {
+ if (isLiking) {
+ LoadingIndicator(modifier = Modifier.size(16.dp))
+ } else {
+ Icon(
+ imageVector = if (isLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder,
+ contentDescription = stringResource(
+ if (isLiked) R.string.soundcloud_unlike else R.string.soundcloud_like,
+ ),
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+ IconButton(
+ onClick = onDownload,
+ enabled = enabled && !unavailable,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(2.dp)
+ .size(30.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),
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = hit.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 = if (unavailable) stringResource(R.string.soundcloud_unavailable) else 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,
+ isLiked: Boolean = false,
+ isLiking: Boolean = false,
+ unavailable: Boolean = false,
+ isCurrent: Boolean = false,
+ isPlaying: Boolean = false,
+ onPlay: () -> Unit,
+ onDownload: () -> Unit,
+ onToggleLike: () -> Unit,
+ onShare: () -> Unit,
+) {
+ val colors = MaterialTheme.colorScheme
+ Surface(
+ color = if (isCurrent) colors.primaryContainer.copy(alpha = 0.55f) else colors.surfaceContainer,
+ shape = RoundedCornerShape(12.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .alpha(if (unavailable) 0.38f else 1f)
+ .clip(RoundedCornerShape(12.dp))
+ .clickable(enabled = enabled && !isResolving && !unavailable, 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,
+ color = if (isCurrent) colors.primary else colors.onSurface,
+ )
+ Text(
+ text = if (unavailable) stringResource(R.string.soundcloud_unavailable) else hit.artist,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ val meta = when {
+ unavailable -> null
+ hit.kind == SoundCloudSearchHit.Kind.PLAYLIST && hit.streamCount > 0 ->
+ stringResource(R.string.soundcloud_playlist_count, hit.streamCount)
+ hit.durationSec > 0 -> formatDuration(hit.durationSec)
+ else -> null
+ }
+ meta?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ if (!isResolving && hit.url.isNotBlank()) {
+ IconButton(
+ onClick = onShare,
+ enabled = enabled,
+ modifier = Modifier.size(32.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Share,
+ contentDescription = stringResource(R.string.soundcloud_share_link),
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ if (hit.kind == SoundCloudSearchHit.Kind.TRACK && !isResolving) {
+ IconButton(
+ onClick = onToggleLike,
+ enabled = enabled && !isLiking,
+ modifier = Modifier.size(32.dp),
+ ) {
+ if (isLiking) {
+ LoadingIndicator(modifier = Modifier.size(16.dp))
+ } else {
+ Icon(
+ imageVector = if (isLiked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder,
+ contentDescription = stringResource(
+ if (isLiked) R.string.soundcloud_unlike else R.string.soundcloud_like,
+ ),
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ IconButton(
+ onClick = onDownload,
+ enabled = enabled && !unavailable,
+ modifier = Modifier.size(32.dp),
+ ) {
+ Icon(
+ Icons.Rounded.Download,
+ contentDescription = stringResource(R.string.soundcloud_download),
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ if (isResolving) {
+ LoadingIndicator(modifier = Modifier.size(22.dp))
+ } else if (isCurrent) {
+ PlayingEqIcon(
+ modifier = Modifier.size(width = 16.dp, height = 14.dp),
+ color = colors.primary,
+ isPlaying = isPlaying,
+ )
+ } 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,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ }
+}
+
+
+private fun isSoundCloudHitCurrent(hit: SoundCloudSearchHit, currentSong: Song?): Boolean {
+ if (currentSong == null || hit.kind != SoundCloudSearchHit.Kind.TRACK) return false
+ if (currentSong.id == SoundCloudClient.songIdForUrl(hit.url)) return true
+ // Fallback when an older queue used a resolved URL hash instead of the list permalink.
+ return currentSong.album == "SoundCloud" &&
+ currentSong.title == hit.title &&
+ currentSong.artist == hit.artist
+}
+
+private fun shareSoundCloudLink(context: android.content.Context, hit: SoundCloudSearchHit) {
+ val url = hit.url.trim()
+ if (url.isBlank()) return
+ val send = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_SUBJECT, hit.title)
+ putExtra(Intent.EXTRA_TEXT, url)
+ }
+ context.startActivity(
+ Intent.createChooser(send, context.getString(R.string.soundcloud_share_link)),
+ )
+}
+
+private fun formatDuration(seconds: Long): String {
+ val m = seconds / 60
+ val s = seconds % 60
+ return "%d:%02d".format(m, s)
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudSettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudSettingsScreen.kt
new file mode 100644
index 0000000000..1964516476
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SoundCloudSettingsScreen.kt
@@ -0,0 +1,222 @@
+package com.theveloper.pixelplay.presentation.screens
+
+import android.content.Intent
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Cloud
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavController
+import com.theveloper.pixelplay.R
+import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar
+import com.theveloper.pixelplay.presentation.soundcloud.auth.SoundCloudLoginActivity
+import com.theveloper.pixelplay.soundcloud.SoundCloudPrefsViewModel
+import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
+
+@Composable
+fun SoundCloudSettingsScreen(
+ navController: NavController,
+ viewModel: SoundCloudPrefsViewModel = hiltViewModel(),
+) {
+ val clientId by viewModel.clientId.collectAsStateWithLifecycle()
+ val username by viewModel.username.collectAsStateWithLifecycle()
+ val session by viewModel.session.collectAsStateWithLifecycle()
+ val includeDownloadsInLibrary by viewModel.includeDownloadsInLibrary.collectAsStateWithLifecycle()
+ var clientIdDraft by remember(clientId) { mutableStateOf(clientId) }
+ var usernameDraft by remember(username) { mutableStateOf(username) }
+ val context = LocalContext.current
+
+ val density = LocalDensity.current
+ val coroutineScope = rememberCoroutineScope()
+ val lazyListState = rememberLazyListState()
+
+ val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
+ val minTopBarHeight = 64.dp + statusBarHeight
+ val maxTopBarHeight = 180.dp
+ val minTopBarHeightPx = with(density) { minTopBarHeight.toPx() }
+ val maxTopBarHeightPx = with(density) { maxTopBarHeight.toPx() }
+ val topBarHeight = remember { Animatable(maxTopBarHeightPx) }
+ var collapseFraction by remember { mutableStateOf(0f) }
+
+ LaunchedEffect(topBarHeight.value) {
+ collapseFraction = 1f - (
+ (topBarHeight.value - minTopBarHeightPx) / (maxTopBarHeightPx - minTopBarHeightPx)
+ ).coerceIn(0f, 1f)
+ }
+
+ val nestedScrollConnection = remember {
+ object : NestedScrollConnection {
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ val delta = available.y
+ val isScrollingDown = delta < 0
+ if (!isScrollingDown &&
+ (lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0)
+ ) {
+ return Offset.Zero
+ }
+ val previousHeight = topBarHeight.value
+ val newHeight = (previousHeight + delta).coerceIn(minTopBarHeightPx, maxTopBarHeightPx)
+ val consumed = newHeight - previousHeight
+ if (consumed.roundToInt() != 0) {
+ coroutineScope.launch { topBarHeight.snapTo(newHeight) }
+ }
+ val canConsumeScroll = !(isScrollingDown && newHeight == minTopBarHeightPx)
+ return if (canConsumeScroll) Offset(0f, consumed) else Offset.Zero
+ }
+ }
+ }
+
+ LaunchedEffect(lazyListState.isScrollInProgress) {
+ if (!lazyListState.isScrollInProgress) {
+ val shouldExpand = topBarHeight.value > (minTopBarHeightPx + maxTopBarHeightPx) / 2
+ val canExpand = lazyListState.firstVisibleItemIndex == 0 &&
+ lazyListState.firstVisibleItemScrollOffset == 0
+ val targetValue = if (shouldExpand && canExpand) maxTopBarHeightPx else minTopBarHeightPx
+ if (topBarHeight.value != targetValue) {
+ coroutineScope.launch {
+ topBarHeight.animateTo(targetValue, spring(stiffness = Spring.StiffnessMedium))
+ }
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .nestedScroll(nestedScrollConnection)
+ .fillMaxSize(),
+ ) {
+ val currentTopBarHeightDp = with(density) { topBarHeight.value.toDp() }
+ LazyColumn(
+ state = lazyListState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(top = currentTopBarHeightDp + 8.dp, bottom = 100.dp),
+ ) {
+ item(key = "soundcloud_settings") {
+ SettingsSection(
+ title = stringResource(R.string.soundcloud_section),
+ icon = {
+ Icon(
+ imageVector = Icons.Rounded.Cloud,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.soundcloud_settings_help),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
+ )
+ if (session.isSignedIn) {
+ Text(
+ text = stringResource(
+ R.string.soundcloud_signed_in_as,
+ session.displayName.ifBlank { session.permalink }.ifBlank { "account" },
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ )
+ FilledTonalButton(
+ onClick = { viewModel.signOut() },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.soundcloud_sign_out))
+ }
+ } else {
+ FilledTonalButton(
+ onClick = {
+ context.startActivity(Intent(context, SoundCloudLoginActivity::class.java))
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.soundcloud_sign_in))
+ }
+ }
+ OutlinedTextField(
+ value = clientIdDraft,
+ onValueChange = { clientIdDraft = it },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ label = { Text(stringResource(R.string.soundcloud_client_id_label)) },
+ placeholder = { Text(stringResource(R.string.soundcloud_client_id_hint)) },
+ )
+ OutlinedTextField(
+ value = usernameDraft,
+ onValueChange = { usernameDraft = it },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ label = { Text(stringResource(R.string.soundcloud_username_label)) },
+ placeholder = { Text(stringResource(R.string.soundcloud_username_hint)) },
+ )
+ SwitchSettingItem(
+ title = stringResource(R.string.soundcloud_include_downloads_in_library),
+ subtitle = stringResource(R.string.soundcloud_include_downloads_in_library_subtitle),
+ checked = includeDownloadsInLibrary,
+ onCheckedChange = viewModel::setIncludeDownloadsInLibrary,
+ )
+ FilledTonalButton(
+ onClick = {
+ viewModel.saveClientId(clientIdDraft)
+ viewModel.saveUsername(usernameDraft)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.soundcloud_save_settings))
+ }
+ }
+ }
+ }
+ }
+
+ CollapsibleCommonTopBar(
+ title = stringResource(R.string.soundcloud_settings_title),
+ collapseFraction = collapseFraction,
+ headerHeight = currentTopBarHeightDp,
+ onBackClick = { navController.popBackStack() },
+ expandedTitleStartPadding = 20.dp,
+ )
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt
index 0cbace0d4b..9b7d36df19 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt
@@ -93,6 +93,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -2039,13 +2040,12 @@ private fun TopAlbumsCard(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
- SmartImage(
+ StatsArtwork(
model = albumSummary.albumArtUri,
contentDescription = albumSummary.album,
- modifier = Modifier
- .size(56.dp)
- .clip(RoundedCornerShape(16.dp)),
- shape = RoundedCornerShape(16.dp)
+ size = 56.dp,
+ corner = 16.dp,
+ origin = albumSummary.origin,
)
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -2179,13 +2179,12 @@ private fun SongStatsCard(
accentOnColor = accentOnColor,
highlighted = position == 0
)
- SmartImage(
+ StatsArtwork(
model = songSummary.albumArtUri,
contentDescription = songSummary.title,
- modifier = Modifier
- .size(52.dp)
- .clip(RoundedCornerShape(14.dp)),
- shape = RoundedCornerShape(14.dp)
+ size = 52.dp,
+ corner = 14.dp,
+ origin = songSummary.origin,
)
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -2259,6 +2258,67 @@ private fun SongStatsCard(
}
}
+@Composable
+private fun StatsArtwork(
+ model: String?,
+ contentDescription: String,
+ size: androidx.compose.ui.unit.Dp,
+ corner: androidx.compose.ui.unit.Dp,
+ origin: PlaybackStatsRepository.PlaybackOrigin?,
+) {
+ val shape = RoundedCornerShape(corner)
+ Box(modifier = Modifier.size(size)) {
+ SmartImage(
+ model = model,
+ contentDescription = contentDescription,
+ modifier = Modifier
+ .matchParentSize()
+ .clip(shape),
+ shape = shape,
+ )
+ if (origin != null) {
+ Surface(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(2.dp)
+ .size(16.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surface,
+ shadowElevation = 1.dp,
+ ) {
+ Icon(
+ painter = painterResource(origin.iconRes),
+ contentDescription = null,
+ modifier = Modifier.padding(2.dp),
+ tint = origin.logoTint,
+ )
+ }
+ }
+ }
+}
+
+private val PlaybackStatsRepository.PlaybackOrigin.iconRes: Int
+ get() = when (this) {
+ PlaybackStatsRepository.PlaybackOrigin.SOUNDCLOUD -> R.drawable.ic_soundcloud_24
+ PlaybackStatsRepository.PlaybackOrigin.TELEGRAM -> R.drawable.telegram
+ PlaybackStatsRepository.PlaybackOrigin.NETEASE -> R.drawable.netease_cloud_music_logo_icon_206716__1_
+ PlaybackStatsRepository.PlaybackOrigin.QQ_MUSIC -> R.drawable.qq_music
+ PlaybackStatsRepository.PlaybackOrigin.NAVIDROME -> R.drawable.ic_navidrome_md3
+ PlaybackStatsRepository.PlaybackOrigin.JELLYFIN -> R.drawable.ic_jellyfin
+ PlaybackStatsRepository.PlaybackOrigin.GOOGLE_DRIVE -> R.drawable.rounded_drive_export_24
+ }
+
+private val PlaybackStatsRepository.PlaybackOrigin.logoTint: Color
+ get() = when (this) {
+ PlaybackStatsRepository.PlaybackOrigin.SOUNDCLOUD -> Color(0xFFFF5500)
+ PlaybackStatsRepository.PlaybackOrigin.TELEGRAM -> Color(0xFF2AABEE)
+ PlaybackStatsRepository.PlaybackOrigin.NETEASE -> Color(0xFFE85959)
+ PlaybackStatsRepository.PlaybackOrigin.QQ_MUSIC -> Color(0xFF31C27C)
+ PlaybackStatsRepository.PlaybackOrigin.NAVIDROME -> Color.Unspecified
+ PlaybackStatsRepository.PlaybackOrigin.JELLYFIN -> Color.Unspecified
+ PlaybackStatsRepository.PlaybackOrigin.GOOGLE_DRIVE -> Color(0xFF4285F4)
+ }
+
@Composable
private fun TrackConcentrationCard(
summary: PlaybackStatsRepository.PlaybackStatsSummary?,
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt
new file mode 100644
index 0000000000..bce144fff4
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeScreen.kt
@@ -0,0 +1,1334 @@
+@file:OptIn(
+ androidx.compose.material3.ExperimentalMaterial3Api::class,
+ androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class,
+ androidx.compose.foundation.ExperimentalFoundationApi::class,
+)
+
+package com.theveloper.pixelplay.presentation.screens
+
+import android.content.Intent
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.outlined.AutoGraph
+import androidx.compose.material.icons.rounded.AccountCircle
+import androidx.compose.material.icons.rounded.Favorite
+import androidx.compose.material.icons.rounded.FavoriteBorder
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.Search
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.MusicNote
+import androidx.compose.material.icons.rounded.SelectAll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.DockedSearchBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeExtendedFloatingActionButton
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.compose.AsyncImage
+import com.theveloper.pixelplay.R
+import com.theveloper.pixelplay.data.model.Song
+import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight
+import com.theveloper.pixelplay.presentation.components.MultiSelectionBottomSheet
+import com.theveloper.pixelplay.presentation.components.resolveNavBarOccupiedHeight
+import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem
+import com.theveloper.pixelplay.presentation.components.subcomps.PlayingEqIcon
+import com.theveloper.pixelplay.presentation.components.subcomps.SelectionActionRow
+import androidx.navigation.NavController
+import com.theveloper.pixelplay.presentation.navigation.Screen
+import com.theveloper.pixelplay.presentation.navigation.navigateSafely
+import com.theveloper.pixelplay.presentation.components.AlbumArtCollage
+import com.theveloper.pixelplay.presentation.components.BetaInfoBottomSheet
+import com.theveloper.pixelplay.presentation.components.ChangelogModalSheet
+import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel
+import com.theveloper.pixelplay.presentation.youtube.auth.YoutubeLoginActivity
+import com.theveloper.pixelplay.ui.theme.GoogleSansRounded
+import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape
+import com.theveloper.pixelplay.youtube.YoutubeClient
+import com.theveloper.pixelplay.youtube.YoutubeHit
+import com.theveloper.pixelplay.youtube.YoutubePlaybackQueue
+import com.theveloper.pixelplay.youtube.YoutubeSearchFilter
+import com.theveloper.pixelplay.youtube.YoutubeSection
+import com.theveloper.pixelplay.youtube.YoutubeViewModel
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
+
+private const val WIDE_SCREEN_DP = 600
+/** How close to the end of the list a row must be to pull the next page. */
+private const val LOAD_MORE_THRESHOLD = 12
+
+@Composable
+fun YoutubeScreen(
+ section: YoutubeSection,
+ playerViewModel: PlayerViewModel,
+ navController: NavController,
+ viewModel: YoutubeViewModel = hiltViewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val liked by viewModel.likedTracks.collectAsStateWithLifecycle()
+ val recent by viewModel.recentTracks.collectAsStateWithLifecycle()
+ val likedIds by viewModel.likedSongIds.collectAsStateWithLifecycle()
+ val account by viewModel.account.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val haptic = LocalHapticFeedback.current
+ var showChangelog by remember { mutableStateOf(false) }
+ var showBeta by remember { mutableStateOf(false) }
+ var selecting by remember { mutableStateOf(false) }
+ var selectedUrls by remember { mutableStateOf(listOf()) }
+ var showAddToPlaylist by remember { mutableStateOf(false) }
+ var showCreatePlaylist by remember { mutableStateOf(false) }
+ var showMultiSelectionSheet by remember { mutableStateOf(false) }
+ var newPlaylistName by remember { mutableStateOf("") }
+ val libraryPlaylists by viewModel.libraryPlaylists.collectAsStateWithLifecycle()
+ val stablePlayer by playerViewModel.stablePlayerState.collectAsStateWithLifecycle()
+ val scope = rememberCoroutineScope()
+ val searchFocusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val configuration = LocalConfiguration.current
+ val isWideScreen = configuration.screenWidthDp >= WIDE_SCREEN_DP
+ val systemNavBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ val navBarCompactMode by playerViewModel.navBarCompactMode.collectAsStateWithLifecycle()
+ val bottomBarHeight = resolveNavBarOccupiedHeight(systemNavBarInset, navBarCompactMode)
+ val contentPadding = PaddingValues(
+ start = 16.dp,
+ end = 16.dp,
+ top = 8.dp,
+ bottom = bottomBarHeight + MiniPlayerHeight + 30.dp,
+ )
+ val currentSongId = stablePlayer.currentSong?.id
+ val isPlaying = stablePlayer.isPlaying
+ val browseSection = when (section) {
+ YoutubeSection.SEARCH -> YoutubeSection.SEARCH
+ YoutubeSection.LIKED -> YoutubeSection.LIKED
+ YoutubeSection.PLAYLISTS -> YoutubeSection.PLAYLISTS
+ YoutubeSection.RADIO -> YoutubeSection.RADIO
+ else -> YoutubeSection.HOME
+ }
+ val title = when (browseSection) {
+ YoutubeSection.HOME -> stringResource(R.string.youtube_home_title)
+ YoutubeSection.SEARCH -> stringResource(R.string.youtube_search_title)
+ YoutubeSection.LIKED -> stringResource(R.string.youtube_liked_title)
+ YoutubeSection.PLAYLISTS -> stringResource(R.string.youtube_playlists_title)
+ YoutubeSection.RADIO -> stringResource(R.string.youtube_radio_title)
+ }
+ val rows = when (browseSection) {
+ // Signed-in likes stay in YouTube Music order (newest first). Local likes
+ // only appear when there is no account list, so they cannot bury new likes.
+ YoutubeSection.LIKED -> if (account.isSignedIn && uiState.results.isNotEmpty()) {
+ uiState.results
+ } else {
+ uiState.results.ifEmpty { liked }
+ }
+ else -> uiState.results
+ }
+ // Home shelves and recent plays are not in results, so selection actions
+ // have to resolve those tracks or the options button has nothing to open.
+ val selectableHits = if (browseSection == YoutubeSection.HOME && uiState.collectionTitle == null) {
+ (recent + uiState.shelves.flatMap { it.items })
+ .filter { it.isSelectableTrack() }
+ .distinctBy { it.url }
+ } else {
+ rows.filter { it.isSelectableTrack() }
+ }
+ val listState = rememberLazyListState()
+ val pullState = rememberPullToRefreshState()
+
+ LaunchedEffect(section) {
+ viewModel.openSection(section)
+ }
+ LaunchedEffect(section, playerViewModel) {
+ if (section == YoutubeSection.SEARCH) {
+ playerViewModel.searchNavDoubleTapEvents.collect {
+ searchFocusRequester.requestFocus()
+ keyboardController?.show()
+ }
+ }
+ }
+ LaunchedEffect(stablePlayer.currentSong?.id) {
+ stablePlayer.currentSong?.let(viewModel::rememberPlayed)
+ }
+ // Resolve the streams the user is most likely to tap first, so playback is instant.
+ val warmUpCandidates = rows.ifEmpty { recent }.ifEmpty { uiState.shelves.flatMap { it.items } }
+ val warmUpKey = remember(warmUpCandidates) {
+ warmUpCandidates.asSequence().map { it.url }.take(4).joinToString()
+ }
+ LaunchedEffect(warmUpKey) {
+ if (warmUpCandidates.isNotEmpty()) viewModel.warmUpTracks(warmUpCandidates)
+ }
+ LaunchedEffect(browseSection, listState) {
+ if (browseSection != YoutubeSection.LIKED) return@LaunchedEffect
+ snapshotFlow {
+ val info = listState.layoutInfo
+ val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0
+ lastVisible to info.totalItemsCount
+ }.distinctUntilChanged().collect { (lastVisible, total) ->
+ if (total > 0 && lastVisible >= total - LOAD_MORE_THRESHOLD) {
+ viewModel.loadMoreLiked()
+ }
+ }
+ }
+
+ LaunchedEffect(viewModel) {
+ viewModel.notices.collect { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ if (uiState.collectionTitle != null && !selecting) {
+ BackHandler { viewModel.closeCollection() }
+ }
+ if (selecting) {
+ BackHandler {
+ selecting = false
+ selectedUrls = emptyList()
+ showMultiSelectionSheet = false
+ }
+ }
+
+ val playGate = remember { AtomicBoolean(false) }
+ val playWhenReady = stablePlayer.playWhenReady
+ val playHit: (YoutubeHit, Boolean) -> Unit = { hit, asRadio ->
+ val thisId = YoutubeClient.songIdForUrl(hit.url)
+ val isThisSong = currentSongId == thisId
+ when {
+ hit.kind == YoutubeHit.Kind.COLLECTION && !asRadio -> viewModel.openCollection(hit)
+ // Already playing this track: pause. Already paused: resume. Still preparing: ignore.
+ isThisSong && isPlaying -> playerViewModel.playPause()
+ isThisSong && !playWhenReady -> playerViewModel.playPause()
+ isThisSong -> Unit
+ !playGate.compareAndSet(false, true) -> Unit
+ else -> {
+ playerViewModel.showPreparingSong(placeholderSong(hit))
+ scope.launch {
+ try {
+ if (asRadio) {
+ runCatching { viewModel.radioTracks(hit) }
+ .onSuccess { queue ->
+ val playable = queue.ifEmpty { listOf(hit) }
+ val queued = viewModel.songsFromContext(playable.first(), playable)
+ val start = viewModel.resolvePlayable(playable.first())
+ playerViewModel.playSongs(
+ songsToPlay = listOf(start) + queued.drop(1),
+ startSong = start,
+ queueName = "YouTube Radio",
+ )
+ viewModel.warmUpTracks(playable)
+ }
+ .onFailure { error ->
+ viewModel.reportError(error.message ?: "Couldn't start radio")
+ }
+ } else {
+ runCatching {
+ val inPlaylist = uiState.collectionTitle != null
+ val contextHits = if (inPlaylist) {
+ YoutubePlaybackQueue.playlistTracks(hit, rows)
+ } else {
+ YoutubePlaybackQueue.remainingTracks(hit, rows)
+ }
+ val queued = contextHits.map(viewModel::listSong)
+ val start = viewModel.resolvePlayable(hit)
+ val songs = queued.map { song -> if (song.id == start.id) start else song }
+ Triple(start, songs, inPlaylist)
+ }
+ .onSuccess { (start, songs, inPlaylist) ->
+ playerViewModel.playSongs(
+ songsToPlay = songs,
+ startSong = start,
+ queueName = uiState.collectionTitle ?: "YouTube Music",
+ keepPlayedSongs = inPlaylist,
+ )
+ viewModel.warmUpTracks(
+ YoutubePlaybackQueue.remainingTracks(hit, rows),
+ )
+ }
+ .onFailure { error ->
+ viewModel.reportError(error.message ?: "Couldn't play this track")
+ }
+ }
+ } finally {
+ playGate.set(false)
+ }
+ }
+ }
+ }
+ }
+
+ val onSongClick: (YoutubeHit, Boolean) -> Unit = { hit, asRadio ->
+ if (selecting) {
+ if (hit.isSelectableTrack()) {
+ selectedUrls = selectedUrls.toggleUrl(hit.url)
+ if (selectedUrls.isEmpty()) {
+ selecting = false
+ showMultiSelectionSheet = false
+ }
+ }
+ } else {
+ playHit(hit, asRadio)
+ }
+ }
+ val onSongLongPress: (YoutubeHit) -> Unit = { hit ->
+ if (hit.isSelectableTrack()) {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ selecting = true
+ selectedUrls = selectedUrls.toggleUrl(hit.url)
+ if (selectedUrls.isEmpty()) {
+ selecting = false
+ showMultiSelectionSheet = false
+ }
+ viewModel.ensureLibraryPlaylists()
+ }
+ }
+ val onSongActions: (YoutubeHit) -> Unit = { hit ->
+ if (hit.isSelectableTrack()) {
+ selecting = true
+ selectedUrls = listOf(hit.url)
+ showMultiSelectionSheet = true
+ viewModel.ensureLibraryPlaylists()
+ }
+ }
+ val selectedHits = remember(selectedUrls, selectableHits) {
+ val byUrl = selectableHits.associateBy { it.url }
+ selectedUrls.mapNotNull { byUrl[it] }
+ }
+ val selectedSongs = remember(selectedHits) { selectedHits.map(viewModel::listSong) }
+ val canSelectTracks = selectableHits.isNotEmpty()
+ fun clearYoutubeSelection() {
+ selecting = false
+ selectedUrls = emptyList()
+ showMultiSelectionSheet = false
+ }
+
+ val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
+ Scaffold(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(headerColor),
+ containerColor = headerColor,
+ topBar = {
+ YoutubeCompactHeader(
+ title = uiState.collectionTitle ?: title,
+ showTitle = browseSection != YoutubeSection.HOME || uiState.collectionTitle != null,
+ query = uiState.query,
+ onQueryChange = viewModel::onQueryChange,
+ showSearch = browseSection == YoutubeSection.SEARCH && uiState.collectionTitle == null,
+ searchFocusRequester = searchFocusRequester,
+ searchFilter = uiState.filter,
+ onFilter = viewModel::setFilter,
+ onBack = when {
+ selecting -> {
+ { clearYoutubeSelection() }
+ }
+ uiState.collectionTitle != null -> viewModel::closeCollection
+ else -> null
+ },
+ signedIn = account.isSignedIn,
+ onBeta = { showBeta = true },
+ onChangelog = { showChangelog = true },
+ onAccount = { navController.navigateSafely(Screen.YoutubeSettings.route) },
+ onSettings = { navController.navigateSafely(Screen.Settings.route) },
+ onCreatePlaylist = if (
+ browseSection == YoutubeSection.PLAYLISTS &&
+ uiState.collectionTitle == null &&
+ account.isSignedIn
+ ) {
+ {
+ selectedUrls = emptyList()
+ showCreatePlaylist = true
+ }
+ } else {
+ null
+ },
+ onSelectTracks = if (canSelectTracks && !selecting) {
+ { selecting = true }
+ } else {
+ null
+ },
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = innerPadding.calculateTopPadding())
+ .background(headerColor),
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.surface,
+ shape = AbsoluteSmoothCornerShape(
+ cornerRadiusTL = 34.dp,
+ cornerRadiusTR = 34.dp,
+ cornerRadiusBL = 0.dp,
+ cornerRadiusBR = 0.dp,
+ smoothnessAsPercentTL = 60,
+ smoothnessAsPercentTR = 60,
+ smoothnessAsPercentBL = 60,
+ smoothnessAsPercentBR = 60,
+ ),
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ AnimatedVisibility(
+ visible = selecting,
+ enter = slideInHorizontally { -it } + fadeIn(),
+ exit = slideOutHorizontally { it } + fadeOut(),
+ ) {
+ SelectionActionRow(
+ selectedCount = selectedUrls.size,
+ onSelectAll = {
+ selectedUrls = selectableHits.map { it.url }
+ },
+ onDeselect = { clearYoutubeSelection() },
+ onOptionsClick = {
+ if (selectedHits.isNotEmpty()) {
+ showMultiSelectionSheet = true
+ viewModel.ensureLibraryPlaylists()
+ }
+ },
+ modifier = Modifier
+ .padding(top = 6.dp, start = 10.dp, end = 10.dp, bottom = 4.dp)
+ .heightIn(min = 56.dp),
+ )
+ }
+ Box(Modifier.weight(1f).fillMaxWidth()) {
+ PullToRefreshBox(
+ isRefreshing = uiState.isRefreshing,
+ onRefresh = viewModel::refresh,
+ state = pullState,
+ modifier = Modifier.fillMaxSize(),
+ indicator = {
+ PullToRefreshDefaults.LoadingIndicator(
+ state = pullState,
+ isRefreshing = uiState.isRefreshing,
+ modifier = Modifier.align(Alignment.TopCenter),
+ containerColor = androidx.compose.ui.graphics.Color.Transparent,
+ elevation = 0.dp,
+ )
+ },
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ uiState.error?.takeUnless { browseSection == YoutubeSection.LIKED && !account.isSignedIn }?.let { message ->
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.errorContainer,
+ ) {
+ Text(
+ text = message,
+ modifier = Modifier.padding(16.dp),
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ fontFamily = GoogleSansRounded,
+ )
+ }
+ }
+
+ when {
+ browseSection == YoutubeSection.HOME && uiState.collectionTitle == null -> {
+ if (uiState.isLoading && uiState.shelves.isEmpty()) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ LoadingIndicator()
+ }
+ } else if (uiState.shelves.isEmpty() && recent.isEmpty()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding),
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ EmptyYoutubeState(message = stringResource(R.string.youtube_home_empty))
+ }
+ FeedListeningStatsButton(
+ onClick = { navController.navigateSafely(Screen.Stats.route) },
+ )
+ }
+ } else {
+ val mixHits = uiState.shelves
+ .firstOrNull { it.id == "yours" }
+ ?.items
+ ?.filter { it.kind == YoutubeHit.Kind.TRACK }
+ .orEmpty()
+ .ifEmpty {
+ uiState.shelves.flatMap { it.items }.filter { it.kind == YoutubeHit.Kind.TRACK }
+ }
+ val mixSongs = remember(mixHits) {
+ mixHits.take(8).map { placeholderSong(it) }.toImmutableList()
+ }
+ LazyColumn(contentPadding = contentPadding) {
+ if (mixHits.isNotEmpty()) {
+ item(key = "your_mix") {
+ YourMixHeader(
+ song = stringResource(R.string.youtube_todays_mix),
+ onPlayShuffled = {
+ val seed = mixHits.shuffled()
+ if (seed.isNotEmpty()) onSongClick(seed.first(), false)
+ },
+ )
+ }
+ if (mixSongs.isNotEmpty()) {
+ item(key = "collage") {
+ AlbumArtCollage(
+ songs = mixSongs,
+ modifier = Modifier.fillMaxWidth(),
+ height = 280.dp,
+ padding = 8.dp,
+ onSongClick = { song ->
+ mixHits.firstOrNull { YoutubeClient.songIdForUrl(it.url) == song.id }
+ ?.let { onSongClick(it, false) }
+ },
+ )
+ }
+ }
+ }
+ if (recent.isNotEmpty()) {
+ item(key = "recent") {
+ Text(
+ text = stringResource(R.string.youtube_recent_title),
+ modifier = Modifier.padding(bottom = 8.dp, top = 12.dp),
+ style = MaterialTheme.typography.titleLarge,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ )
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ items(recent, key = { "recent-${it.url}" }) { hit ->
+ YoutubeTile(
+ hit = hit,
+ modifier = Modifier.width(168.dp),
+ isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url),
+ isPlaying = isPlaying,
+ selecting = selecting,
+ selected = hit.url in selectedUrls,
+ onClick = { onSongClick(hit, false) },
+ onLongPress = { onSongLongPress(hit) },
+ )
+ }
+ }
+ }
+ }
+ items(uiState.shelves, key = { it.id }) { shelf ->
+ Text(
+ text = shelf.title,
+ modifier = Modifier.padding(bottom = 8.dp, top = 12.dp),
+ style = MaterialTheme.typography.titleLarge,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ )
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ items(shelf.items, key = { it.url }) { hit ->
+ YoutubeTile(
+ hit = hit,
+ modifier = Modifier.width(168.dp),
+ isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url),
+ isPlaying = isPlaying,
+ subtitle = if (shelf.startsRadio) {
+ stringResource(R.string.youtube_radio_from, hit.artist)
+ } else {
+ hit.artist
+ },
+ selecting = selecting,
+ selected = hit.url in selectedUrls,
+ onClick = { onSongClick(hit, shelf.startsRadio) },
+ onLongPress = { onSongLongPress(hit) },
+ )
+ }
+ }
+ }
+ item(key = "listening_stats") {
+ FeedListeningStatsButton(
+ onClick = { navController.navigateSafely(Screen.Stats.route) },
+ )
+ }
+ }
+ }
+ }
+ uiState.isLoading && rows.isEmpty() -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ LoadingIndicator()
+ }
+ }
+ rows.isEmpty() -> {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ EmptyYoutubeState(
+ message = when (browseSection) {
+ YoutubeSection.LIKED -> stringResource(
+ if (account.isSignedIn) R.string.youtube_liked_empty else R.string.youtube_liked_signed_out,
+ )
+ YoutubeSection.PLAYLISTS -> stringResource(R.string.youtube_playlists_empty)
+ YoutubeSection.RADIO -> stringResource(R.string.youtube_radio_empty)
+ YoutubeSection.SEARCH -> stringResource(R.string.youtube_search_empty)
+ YoutubeSection.HOME -> stringResource(
+ if (uiState.collectionTitle != null) R.string.youtube_playlist_empty else R.string.youtube_home_empty,
+ )
+ }
+ )
+ if (browseSection == YoutubeSection.LIKED && !account.isSignedIn) {
+ Spacer(Modifier.height(16.dp))
+ Button(
+ onClick = {
+ context.startActivity(Intent(context, YoutubeLoginActivity::class.java))
+ },
+ ) {
+ Text(stringResource(R.string.youtube_sign_in), fontFamily = GoogleSansRounded)
+ }
+ }
+ }
+ }
+ isWideScreen &&
+ uiState.collectionTitle == null &&
+ (browseSection == YoutubeSection.HOME ||
+ rows.any { it.kind == YoutubeHit.Kind.COLLECTION }) -> {
+ val columns = if (configuration.screenWidthDp >= 840) 4 else 3
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(columns),
+ contentPadding = contentPadding,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(rows, key = { it.url }) { hit ->
+ YoutubeTile(
+ hit = hit,
+ isCurrent = currentSongId == YoutubeClient.songIdForUrl(hit.url),
+ isPlaying = isPlaying,
+ selecting = selecting,
+ selected = hit.url in selectedUrls,
+ onClick = { onSongClick(hit, browseSection == YoutubeSection.RADIO) },
+ onLongPress = { onSongLongPress(hit) },
+ )
+ }
+ }
+ }
+ else -> {
+ LazyColumn(
+ state = listState,
+ contentPadding = contentPadding,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ if (browseSection == YoutubeSection.RADIO) {
+ item(key = "radio_explainer") {
+ Text(
+ text = stringResource(R.string.youtube_radio_explainer),
+ modifier = Modifier.padding(bottom = 8.dp),
+ fontFamily = GoogleSansRounded,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ itemsIndexed(rows, key = { _, hit -> hit.url }) { _, hit ->
+ val song = remember(hit) { placeholderSong(hit) }
+ YoutubeTrackRow(
+ song = song,
+ isPlaying = isPlaying && currentSongId == song.id,
+ isCurrent = currentSongId == song.id,
+ liked = song.id in likedIds || viewModel.isLiked(hit),
+ selecting = selecting,
+ selected = selectedUrls.indexOf(hit.url).let { if (it >= 0) it + 1 else null },
+ onPlay = { onSongClick(hit, browseSection == YoutubeSection.RADIO) },
+ onLongPress = { onSongLongPress(hit) },
+ onActions = { onSongActions(hit) },
+ onLike = { viewModel.toggleLike(hit) },
+ )
+ }
+ if (uiState.isLoadingMore) {
+ item(key = "loading_more") {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ LoadingIndicator()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ if (showChangelog) {
+ ChangelogModalSheet(onDismiss = { showChangelog = false })
+ }
+ if (showBeta) {
+ ModalBottomSheet(onDismissRequest = { showBeta = false }) {
+ BetaInfoBottomSheet()
+ }
+ }
+ if (showAddToPlaylist) {
+ ModalBottomSheet(onDismissRequest = { showAddToPlaylist = false }) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp)) {
+ Text(
+ text = stringResource(R.string.youtube_add_to_playlist),
+ style = MaterialTheme.typography.titleLarge,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 12.dp),
+ )
+ TextButton(onClick = {
+ showAddToPlaylist = false
+ showCreatePlaylist = true
+ }) {
+ Text(stringResource(R.string.youtube_new_playlist), fontFamily = GoogleSansRounded)
+ }
+ if (libraryPlaylists.isEmpty()) {
+ Text(
+ text = stringResource(R.string.youtube_add_to_playlist_empty),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 16.dp),
+ )
+ } else {
+ libraryPlaylists.forEach { playlist ->
+ Surface(
+ onClick = {
+ viewModel.addHitsToPlaylist(playlist, selectedHits)
+ showAddToPlaylist = false
+ selecting = false
+ selectedUrls = emptyList()
+ },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Text(
+ text = playlist.title,
+ modifier = Modifier.padding(16.dp),
+ fontFamily = GoogleSansRounded,
+ )
+ }
+ }
+ }
+ Spacer(Modifier.height(24.dp))
+ }
+ }
+ }
+ if (showCreatePlaylist) {
+ AlertDialog(
+ onDismissRequest = { showCreatePlaylist = false },
+ title = { Text(stringResource(R.string.youtube_new_playlist)) },
+ text = {
+ OutlinedTextField(
+ value = newPlaylistName,
+ onValueChange = { newPlaylistName = it },
+ label = { Text(stringResource(R.string.youtube_playlist_name)) },
+ singleLine = true,
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ viewModel.createPlaylistAndAdd(newPlaylistName, selectedHits)
+ newPlaylistName = ""
+ showCreatePlaylist = false
+ selecting = false
+ selectedUrls = emptyList()
+ },
+ enabled = newPlaylistName.trim().isNotEmpty(),
+ ) {
+ Text(stringResource(R.string.youtube_create))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showCreatePlaylist = false }) {
+ Text(stringResource(R.string.youtube_cancel))
+ }
+ },
+ )
+ }
+ if (showMultiSelectionSheet && selectedSongs.isNotEmpty()) {
+ MultiSelectionBottomSheet(
+ selectedSongs = selectedSongs,
+ favoriteSongIds = likedIds + selectedHits.filter(viewModel::isLiked).map {
+ YoutubeClient.songIdForUrl(it.url)
+ }.toSet(),
+ onDismiss = { showMultiSelectionSheet = false },
+ onPlayAll = {
+ if (selectedHits.size == 1) {
+ playHit(selectedHits.first(), browseSection == YoutubeSection.RADIO)
+ } else {
+ playerViewModel.playSongs(
+ songsToPlay = selectedSongs,
+ startSong = selectedSongs.first(),
+ queueName = uiState.collectionTitle ?: "YouTube Music",
+ )
+ }
+ clearYoutubeSelection()
+ },
+ onAddToQueue = {
+ playerViewModel.addSelectedToQueue(selectedSongs)
+ clearYoutubeSelection()
+ },
+ onPlayNext = {
+ playerViewModel.addSelectedAsNext(selectedSongs)
+ clearYoutubeSelection()
+ },
+ onAddToPlaylist = {
+ viewModel.ensureLibraryPlaylists()
+ showAddToPlaylist = true
+ showMultiSelectionSheet = false
+ },
+ onToggleLikeAll = { shouldLike ->
+ selectedHits.forEach { hit ->
+ if (viewModel.isLiked(hit) != shouldLike) viewModel.toggleLike(hit)
+ }
+ showMultiSelectionSheet = false
+ },
+ onShareAll = {
+ val text = selectedHits.joinToString("\n") { it.url }
+ context.startActivity(
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, text)
+ },
+ selectedHits.first().title,
+ )
+ )
+ showMultiSelectionSheet = false
+ },
+ onDeleteAll = { _, onResult -> onResult(false) },
+ onBatchEdit = { showMultiSelectionSheet = false },
+ showBatchEdit = false,
+ showDelete = false,
+ )
+ }
+}
+
+@Composable
+private fun YoutubeCompactHeader(
+ title: String?,
+ showTitle: Boolean,
+ query: String,
+ onQueryChange: (String) -> Unit,
+ showSearch: Boolean,
+ searchFocusRequester: FocusRequester,
+ searchFilter: YoutubeSearchFilter,
+ onFilter: (YoutubeSearchFilter) -> Unit,
+ onBack: (() -> Unit)?,
+ signedIn: Boolean,
+ onBeta: () -> Unit,
+ onChangelog: () -> Unit,
+ onAccount: () -> Unit,
+ onSettings: () -> Unit,
+ onCreatePlaylist: (() -> Unit)? = null,
+ onSelectTracks: (() -> Unit)? = null,
+) {
+ val headerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
+ Column(Modifier.fillMaxWidth().background(headerColor)) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .statusBarsPadding()
+ .padding(start = 8.dp, end = 14.dp, bottom = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (onBack != null) {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.youtube_back),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ } else {
+ FilledTonalButton(
+ onClick = onBeta,
+ shape = CircleShape,
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.topbar_beta_letter) + " " + stringResource(R.string.topbar_beta_label),
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ if (showTitle && !title.isNullOrBlank()) {
+ Text(
+ text = title,
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 8.dp),
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ } else {
+ Spacer(Modifier.weight(1f))
+ }
+ if (onSelectTracks != null) {
+ FilledIconButton(onClick = onSelectTracks) {
+ Icon(
+ imageVector = Icons.Rounded.SelectAll,
+ contentDescription = stringResource(R.string.youtube_select),
+ )
+ }
+ }
+ if (onCreatePlaylist != null) {
+ FilledIconButton(onClick = onCreatePlaylist) {
+ Icon(
+ imageVector = Icons.Rounded.Add,
+ contentDescription = stringResource(R.string.youtube_new_playlist),
+ )
+ }
+ }
+ FilledIconButton(onClick = onAccount) {
+ Icon(
+ imageVector = if (signedIn) Icons.Rounded.AccountCircle else Icons.Rounded.AccountCircle,
+ contentDescription = stringResource(R.string.youtube_sign_in),
+ )
+ }
+ FilledIconButton(onClick = onChangelog) {
+ Icon(
+ painter = painterResource(R.drawable.round_newspaper_24),
+ contentDescription = stringResource(R.string.topbar_cd_changelog),
+ )
+ }
+ FilledIconButton(onClick = onSettings) {
+ Icon(
+ painter = painterResource(R.drawable.rounded_settings_24),
+ contentDescription = stringResource(R.string.common_settings),
+ )
+ }
+ }
+ if (showSearch) {
+ YoutubeSearchField(
+ query = query,
+ onQueryChange = onQueryChange,
+ focusRequester = searchFocusRequester,
+ searchFilter = searchFilter,
+ onFilter = onFilter,
+ )
+ }
+ }
+}
+
+@Composable
+private fun YoutubeSearchField(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ focusRequester: FocusRequester,
+ searchFilter: YoutubeSearchFilter,
+ onFilter: (YoutubeSearchFilter) -> Unit,
+) {
+ val keyboard = LocalSoftwareKeyboardController.current
+ val corner = 28.dp
+ val fieldColors = SearchBarDefaults.inputFieldColors(
+ focusedTextColor = MaterialTheme.colorScheme.onSurface,
+ unfocusedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ cursorColor = MaterialTheme.colorScheme.primary,
+ )
+ DockedSearchBar(
+ inputField = {
+ SearchBarDefaults.InputField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ query = query,
+ onQueryChange = onQueryChange,
+ onSearch = { keyboard?.hide() },
+ expanded = false,
+ onExpandedChange = {},
+ placeholder = {
+ Text(
+ stringResource(R.string.youtube_search_placeholder),
+ fontFamily = GoogleSansRounded,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ leadingIcon = {
+ Icon(
+ Icons.Rounded.Search,
+ contentDescription = stringResource(R.string.youtube_search_title),
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingIcon = if (query.isNotEmpty()) {
+ {
+ IconButton(
+ onClick = {
+ onQueryChange("")
+ keyboard?.hide()
+ },
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape),
+ ) {
+ Icon(
+ Icons.Rounded.Close,
+ contentDescription = stringResource(R.string.youtube_clear_search),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ } else {
+ null
+ },
+ colors = fieldColors,
+ )
+ },
+ expanded = false,
+ onExpandedChange = {},
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .clip(RoundedCornerShape(corner)),
+ shape = RoundedCornerShape(corner),
+ colors = SearchBarDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
+ dividerColor = Color.Transparent,
+ inputFieldColors = fieldColors,
+ ),
+ ) {}
+ Spacer(Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ YoutubeSearchFilter.entries.forEach { filter ->
+ FilterChip(
+ selected = searchFilter == filter,
+ onClick = { onFilter(filter) },
+ label = {
+ Text(
+ stringResource(
+ when (filter) {
+ YoutubeSearchFilter.SONGS -> R.string.youtube_filter_songs
+ YoutubeSearchFilter.ALBUMS -> R.string.youtube_filter_albums
+ YoutubeSearchFilter.PLAYLISTS -> R.string.youtube_filter_playlists
+ }
+ ),
+ fontFamily = GoogleSansRounded,
+ )
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun YoutubeTrackRow(
+ song: Song,
+ isPlaying: Boolean,
+ isCurrent: Boolean,
+ liked: Boolean,
+ selecting: Boolean,
+ selected: Int?,
+ onPlay: () -> Unit,
+ onLongPress: () -> Unit,
+ onActions: () -> Unit,
+ onLike: () -> Unit,
+) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ EnhancedSongListItem(
+ modifier = Modifier
+ .weight(1f)
+ .combinedClickable(onClick = onPlay, onLongClick = onLongPress),
+ song = song,
+ isPlaying = isPlaying,
+ isCurrentSong = isCurrent,
+ isSelected = selected != null,
+ selectionIndex = selected,
+ isSelectionMode = selecting,
+ showMoreOptionsButton = !selecting,
+ handleGestures = false,
+ onLongPress = onLongPress,
+ onMoreOptionsClick = { onActions() },
+ onClick = onPlay,
+ )
+ if (!selecting) {
+ IconButton(onClick = onLike) {
+ Icon(
+ imageVector = if (liked) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder,
+ contentDescription = stringResource(if (liked) R.string.youtube_unlike else R.string.youtube_like),
+ tint = if (liked) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun YoutubeTile(
+ hit: YoutubeHit,
+ isCurrent: Boolean,
+ isPlaying: Boolean,
+ subtitle: String = hit.artist,
+ modifier: Modifier = Modifier,
+ selecting: Boolean = false,
+ selected: Boolean = false,
+ onClick: () -> Unit,
+ onLongPress: () -> Unit = {},
+) {
+ val selectable = hit.isSelectableTrack()
+ Surface(
+ modifier = modifier.combinedClickable(
+ onClick = onClick,
+ onLongClick = { if (selectable) onLongPress() },
+ ),
+ shape = RoundedCornerShape(24.dp),
+ color = when {
+ selected -> MaterialTheme.colorScheme.primaryContainer
+ isCurrent -> MaterialTheme.colorScheme.primaryContainer
+ else -> MaterialTheme.colorScheme.surfaceContainerLow
+ },
+ ) {
+ Column(Modifier.padding(10.dp)) {
+ Box {
+ AsyncImage(
+ model = hit.thumbnailUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(18.dp)),
+ contentScale = ContentScale.Crop,
+ )
+ if (selecting && selectable) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(
+ MaterialTheme.colorScheme.primary.copy(
+ alpha = if (selected) 0.45f else 0.18f,
+ ),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (selected) {
+ Icon(
+ imageVector = Icons.Rounded.SelectAll,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+ }
+ } else if (isCurrent && isPlaying) {
+ PlayingEqIcon(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(8.dp)
+ .size(22.dp),
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ Spacer(Modifier.height(10.dp))
+ Text(
+ text = hit.title,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = GoogleSansRounded,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun FeedListeningStatsButton(onClick: () -> Unit) {
+ val shape = AbsoluteSmoothCornerShape(
+ cornerRadiusTL = 32.dp,
+ smoothnessAsPercentTR = 60,
+ cornerRadiusBR = 32.dp,
+ smoothnessAsPercentTL = 60,
+ cornerRadiusBL = 32.dp,
+ smoothnessAsPercentBR = 60,
+ cornerRadiusTR = 32.dp,
+ smoothnessAsPercentBL = 60,
+ )
+ LargeExtendedFloatingActionButton(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 20.dp, bottom = 8.dp)
+ .heightIn(min = 72.dp),
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ shape = shape,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.AutoGraph,
+ contentDescription = null,
+ modifier = Modifier.size(28.dp),
+ )
+ Spacer(Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.stats_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = stringResource(R.string.youtube_feed_stats_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
+ fontFamily = GoogleSansRounded,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun EmptyYoutubeState(message: String) {
+ Column(
+ modifier = Modifier.padding(horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.MusicNote,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = message,
+ fontFamily = GoogleSansRounded,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+private fun placeholderSong(hit: YoutubeHit): Song {
+ val id = YoutubeClient.songIdForUrl(hit.url)
+ YoutubeClient.rememberWatchUrl(id, hit.url)
+ return Song(
+ id = id,
+ title = hit.title,
+ artist = hit.artist,
+ artistId = -1L,
+ artists = emptyList(),
+ album = hit.album,
+ albumId = -1L,
+ path = hit.url,
+ contentUriString = hit.url,
+ albumArtUriString = hit.thumbnailUrl,
+ duration = hit.durationSec.coerceAtLeast(0L) * 1000L,
+ genre = null,
+ mimeType = null,
+ bitrate = null,
+ sampleRate = null,
+ )
+}
+
+private fun YoutubeHit.isSelectableTrack(): Boolean = kind == YoutubeHit.Kind.TRACK
+
+private fun List.toggleUrl(url: String): List =
+ if (url in this) this - url else this + url
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeSettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeSettingsScreen.kt
new file mode 100644
index 0000000000..ace77af630
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/YoutubeSettingsScreen.kt
@@ -0,0 +1,99 @@
+package com.theveloper.pixelplay.presentation.screens
+
+import android.content.Intent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavController
+import com.theveloper.pixelplay.R
+import com.theveloper.pixelplay.presentation.youtube.auth.YoutubeLoginActivity
+import com.theveloper.pixelplay.ui.theme.GoogleSansRounded
+import com.theveloper.pixelplay.youtube.YoutubeViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun YoutubeSettingsScreen(
+ navController: NavController,
+ viewModel: YoutubeViewModel = hiltViewModel(),
+) {
+ val account by viewModel.account.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(R.string.youtube_account_title),
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { navController.popBackStack() }) {
+ Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.youtube_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.youtube_account_body),
+ style = MaterialTheme.typography.bodyLarge,
+ fontFamily = GoogleSansRounded,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (account.isSignedIn) {
+ Text(
+ text = account.displayName.ifBlank { stringResource(R.string.youtube_signed_in) },
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ )
+ OutlinedButton(
+ onClick = viewModel::signOut,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.youtube_sign_out), fontFamily = GoogleSansRounded)
+ }
+ } else {
+ Button(
+ onClick = {
+ context.startActivity(Intent(context, YoutubeLoginActivity::class.java))
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.youtube_sign_in_google), fontFamily = GoogleSansRounded)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/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/presentation/viewmodel/CastStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt
index a9633ec456..5d9f98a46a 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt
@@ -182,7 +182,7 @@ class CastStateHolder @Inject constructor(
}
// MediaRouter State
- private val mediaRouter: MediaRouter = MediaRouter.getInstance(context)
+ private val mediaRouter: MediaRouter by lazy { MediaRouter.getInstance(context) }
private val mediaRouterCallback = object : MediaRouter.Callback() {
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
updateRoutes()
@@ -234,45 +234,60 @@ class CastStateHolder @Inject constructor(
refreshRoutesJob?.cancel()
refreshRoutesJob = scope.launch {
_isRefreshingRoutes.value = true
- mediaRouter.removeCallback(mediaRouterCallback)
- val mediaRouteSelector = buildCastRouteSelector()
- mediaRouter.addCallback(
- mediaRouteSelector,
- mediaRouterCallback,
- MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY or MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN
- )
- updateRoutes()
- syncSelectedRouteFromRouter(mediaRouter)
+ runCatching {
+ mediaRouter.removeCallback(mediaRouterCallback)
+ val mediaRouteSelector = buildCastRouteSelector()
+ mediaRouter.addCallback(
+ mediaRouteSelector,
+ mediaRouterCallback,
+ MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY or MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN
+ )
+ updateRoutes()
+ syncSelectedRouteFromRouter(mediaRouter)
- kotlinx.coroutines.delay(1800)
+ kotlinx.coroutines.delay(1800)
- mediaRouter.removeCallback(mediaRouterCallback)
- mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
- updateRoutes()
- syncSelectedRouteFromRouter(mediaRouter)
+ mediaRouter.removeCallback(mediaRouterCallback)
+ mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
+ updateRoutes()
+ syncSelectedRouteFromRouter(mediaRouter)
+ }.onFailure { error ->
+ Timber.tag(CAST_STATE_TAG).w(error, "Cast route refresh unavailable")
+ }
_isRefreshingRoutes.value = false
}
}
fun startDiscovery() {
- val mediaRouteSelector = buildCastRouteSelector()
- mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
- updateRoutes()
- syncSelectedRouteFromRouter(mediaRouter)
+ runCatching {
+ val mediaRouteSelector = buildCastRouteSelector()
+ mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
+ updateRoutes()
+ syncSelectedRouteFromRouter(mediaRouter)
+ }.onFailure { error ->
+ Timber.tag(CAST_STATE_TAG).w(error, "Cast discovery unavailable")
+ }
}
private fun updateRoutes() {
- _castRoutes.value = mediaRouter.routes.filter { it.isCastRoute() }.distinctBy { it.id }
+ _castRoutes.value = runCatching {
+ mediaRouter.routes.filter { it.isCastRoute() }.distinctBy { it.id }
+ }.getOrDefault(emptyList())
}
private fun syncSelectedRouteFromRouter(router: MediaRouter) {
- val selected = router.selectedRoute
- _selectedRoute.value = selected
- _routeVolume.value = selected.volume
+ runCatching {
+ val selected = router.selectedRoute
+ _selectedRoute.value = selected
+ _routeVolume.value = selected.volume
+ }
}
fun selectRoute(route: MediaRouter.RouteInfo) {
- mediaRouter.selectRoute(route)
+ runCatching { mediaRouter.selectRoute(route) }
+ .onFailure { error ->
+ Timber.tag(CAST_STATE_TAG).w(error, "Cast route select unavailable")
+ }
}
fun setRouteVolume(volume: Int) {
@@ -281,14 +296,18 @@ class CastStateHolder @Inject constructor(
}
fun disconnect() {
- mediaRouter.selectRoute(mediaRouter.defaultRoute)
- syncSelectedRouteFromRouter(mediaRouter)
- updateRoutes()
+ runCatching {
+ mediaRouter.selectRoute(mediaRouter.defaultRoute)
+ syncSelectedRouteFromRouter(mediaRouter)
+ updateRoutes()
+ }.onFailure { error ->
+ Timber.tag(CAST_STATE_TAG).w(error, "Cast disconnect unavailable")
+ }
}
fun onCleared() {
refreshRoutesJob?.cancel()
- mediaRouter.removeCallback(mediaRouterCallback)
+ runCatching { mediaRouter.removeCallback(mediaRouterCallback) }
}
init {
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt
index f86f46be33..bbd93ec33d 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
+import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -126,6 +127,16 @@ class ConnectivityStateHolder @Inject constructor(
if (isInitialized) return
isInitialized = true
+ try {
+ initializeMonitoring()
+ } catch (error: SecurityException) {
+ Timber.e(error, "Connectivity monitoring unavailable without runtime permissions")
+ } catch (error: Exception) {
+ Timber.e(error, "Connectivity monitoring failed to start")
+ }
+ }
+
+ private fun initializeMonitoring() {
// Initial state check
val activeNetwork = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
@@ -134,7 +145,7 @@ class ConnectivityStateHolder @Inject constructor(
if (_isWifiEnabled.value) {
updateWifiInfo()
}
-
+
_isOnline.value = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
@@ -152,15 +163,15 @@ class ConnectivityStateHolder @Inject constructor(
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val isValidated = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
-
+
if (hasInternet && isValidated) {
availableNetworks.add(network)
} else {
availableNetworks.remove(network)
}
-
+
checkConnectivity()
-
+
if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
_isWifiEnabled.value = true
updateWifiInfo()
@@ -170,71 +181,76 @@ class ConnectivityStateHolder @Inject constructor(
override fun onLost(network: Network) {
availableNetworks.remove(network)
checkConnectivity()
-
+
val currentNetwork = connectivityManager.activeNetwork
val caps = connectivityManager.getNetworkCapabilities(currentNetwork)
_isWifiEnabled.value = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
if (!_isWifiEnabled.value) _wifiName.value = null
}
-
+
private fun checkConnectivity() {
_isOnline.value = availableNetworks.isNotEmpty()
}
}
-
+
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback!!)
- // Register receivers
- wifiStateReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- if (intent?.action == WifiManager.WIFI_STATE_CHANGED_ACTION) {
- updateWifiRadioState()
+ if (hasWifiStatePermission()) {
+ wifiStateReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == WifiManager.WIFI_STATE_CHANGED_ACTION) {
+ updateWifiRadioState()
+ }
}
}
+ registerExportedReceiver(wifiStateReceiver, IntentFilter(WifiManager.WIFI_STATE_CHANGED_ACTION))
}
- context.registerReceiver(wifiStateReceiver, IntentFilter(WifiManager.WIFI_STATE_CHANGED_ACTION))
-
- bluetoothStateReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- when (intent?.action) {
- BluetoothAdapter.ACTION_STATE_CHANGED -> {
- updateBluetoothEnabledState()
- if (_isBluetoothEnabled.value) {
- updateAudioDevices()
- } else {
- discoveredBluetoothAudioDevices.clear()
- _bluetoothAudioDeviceStates.value = emptyList()
- _bluetoothAudioDevices.value = emptyList()
- updateBluetoothName(emptyList())
- }
- }
- BluetoothDevice.ACTION_BOND_STATE_CHANGED,
- BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED,
- BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED -> updateAudioDevices()
- BluetoothDevice.ACTION_FOUND -> {
- extractBluetoothDevice(intent)
- ?.takeIf { it.isAudioOutputCandidate() }
- ?.toBluetoothAudioDeviceState(isConnected = false)
- ?.let { deviceState ->
- discoveredBluetoothAudioDevices[deviceState.uniqueKey()] = deviceState
+
+ if (hasBluetoothConnectPermission()) {
+ bluetoothStateReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ when (intent?.action) {
+ BluetoothAdapter.ACTION_STATE_CHANGED -> {
+ updateBluetoothEnabledState()
+ if (_isBluetoothEnabled.value) {
updateAudioDevices()
+ } else {
+ discoveredBluetoothAudioDevices.clear()
+ _bluetoothAudioDeviceStates.value = emptyList()
+ _bluetoothAudioDevices.value = emptyList()
+ updateBluetoothName(emptyList())
}
+ }
+ BluetoothDevice.ACTION_BOND_STATE_CHANGED,
+ BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED,
+ BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED -> updateAudioDevices()
+ BluetoothDevice.ACTION_FOUND -> {
+ extractBluetoothDevice(intent)
+ ?.takeIf { it.isAudioOutputCandidate() }
+ ?.toBluetoothAudioDeviceState(isConnected = false)
+ ?.let { deviceState ->
+ discoveredBluetoothAudioDevices[deviceState.uniqueKey()] = deviceState
+ updateAudioDevices()
+ }
+ }
}
}
}
+ registerExportedReceiver(
+ bluetoothStateReceiver,
+ IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED).apply {
+ addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
+ addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
+ addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
+ if (hasBluetoothScanPermission()) {
+ addAction(BluetoothDevice.ACTION_FOUND)
+ }
+ }
+ )
}
- context.registerReceiver(
- bluetoothStateReceiver,
- IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED).apply {
- addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
- addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
- addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
- addAction(BluetoothDevice.ACTION_FOUND)
- }
- )
// Audio Device Callback
audioDeviceCallback = object : android.media.AudioDeviceCallback() {
@@ -250,8 +266,13 @@ class ConnectivityStateHolder @Inject constructor(
updateAudioDevices()
}
+ private fun registerExportedReceiver(receiver: BroadcastReceiver?, filter: IntentFilter) {
+ if (receiver == null) return
+ ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_EXPORTED)
+ }
+
private fun updateWifiRadioState() {
- _isWifiRadioOn.value = wifiManager?.isWifiEnabled == true
+ _isWifiRadioOn.value = hasWifiStatePermission() && wifiManager?.isWifiEnabled == true
}
private fun updateWifiInfo() {
@@ -418,6 +439,13 @@ class ConnectivityStateHolder @Inject constructor(
}
}
+ private fun hasWifiStatePermission(): Boolean {
+ return ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.ACCESS_WIFI_STATE
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
private fun hasBluetoothConnectPermission(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt
index 8e346ebc43..596092e143 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt
@@ -495,7 +495,13 @@ class PlaybackDispatchStateHolder @Inject constructor(
}
}
- fun playSongs(songsToPlay: List, startSong: Song, queueName: String = "None", playlistId: String? = null) {
+ fun playSongs(
+ songsToPlay: List,
+ startSong: Song,
+ queueName: String = "None",
+ playlistId: String? = null,
+ keepPlayedSongs: Boolean = false,
+ ) {
cancelPendingFullQueuePlayback()
val requestToken = beginDirectPlaybackRequest()
directPlaybackJob = cb.scope.launch {
@@ -566,7 +572,7 @@ class PlaybackDispatchStateHolder @Inject constructor(
throwIfDirectPlaybackRequestIsStale(requestToken)
// Send the final list (shuffled or not) to the player engine
- internalPlaySongs(finalSongsToPlay, validStartSong, queueName, playlistId)
+ internalPlaySongs(finalSongsToPlay, validStartSong, queueName, playlistId, keepPlayedSongs)
if (requestToken == directPlaybackToken) {
directPlaybackJob = null
}
@@ -804,7 +810,13 @@ class PlaybackDispatchStateHolder @Inject constructor(
}
}
- suspend fun internalPlaySongs(songsToPlay: List, startSong: Song, queueName: String = "None", playlistId: String? = null) {
+ suspend fun internalPlaySongs(
+ songsToPlay: List,
+ startSong: Song,
+ queueName: String = "None",
+ playlistId: String? = null,
+ keepPlayedSongs: Boolean = false,
+ ) {
if (songsToPlay.isEmpty()) {
clearPreparingSongIfMatching()
return
@@ -835,7 +847,13 @@ class PlaybackDispatchStateHolder @Inject constructor(
return
}
- cb.updateUiState { it.copy(currentPlaybackQueue = songsToPlay.toPlaybackQueue(), currentQueueSourceName = queueName) }
+ cb.updateUiState {
+ it.copy(
+ currentPlaybackQueue = songsToPlay.toPlaybackQueue(),
+ currentQueueSourceName = queueName,
+ keepPlayedSongsInQueue = keepPlayedSongs,
+ )
+ }
playbackStateHolder.updateStablePlayerState {
it.copy(
currentSong = effectiveStartSong,
@@ -850,7 +868,8 @@ class PlaybackDispatchStateHolder @Inject constructor(
cb.updateUiState {
it.copy(
currentPlaybackQueue = songsToPlay.toPlaybackQueue(),
- currentQueueSourceName = queueName
+ currentQueueSourceName = queueName,
+ keepPlayedSongsInQueue = keepPlayedSongs,
)
}
playbackStateHolder.updateStablePlayerState {
@@ -1049,7 +1068,12 @@ class PlaybackDispatchStateHolder @Inject constructor(
castTransferStateHolder.lastRemoteQueue.size
)
cb.scope.launch {
- internalPlaySongs(localQueue, startSong, cb.getUiState().currentQueueSourceName)
+ internalPlaySongs(
+ localQueue,
+ startSong,
+ cb.getUiState().currentQueueSourceName,
+ keepPlayedSongs = cb.getUiState().keepPlayedSongsInQueue,
+ )
}
} else if (remoteHasQueue) {
// No local queue available to reconcile; fallback to resuming remote queue.
@@ -1084,7 +1108,8 @@ class PlaybackDispatchStateHolder @Inject constructor(
internalPlaySongs(
currentQueue.toList(),
currentSong,
- cb.getUiState().currentQueueSourceName
+ cb.getUiState().currentQueueSourceName,
+ keepPlayedSongs = cb.getUiState().keepPlayedSongsInQueue,
)
}
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt
index 1ef56cb287..906ac24cce 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt
@@ -243,7 +243,11 @@ class PlaybackStateHolder @Inject constructor(
}
fun setCurrentPosition(positionMs: Long) {
- _currentPosition.value = positionMs.coerceAtLeast(0L)
+ val safePosition = positionMs.coerceAtLeast(0L)
+ stuckClockRawPositionMs = safePosition
+ stuckClockBasePositionMs = safePosition
+ stuckClockBaseElapsedMs = SystemClock.elapsedRealtime()
+ _currentPosition.value = safePosition
}
fun syncCurrentPositionFromPlayer(mediaId: String?, reportedPositionMs: Long) {
@@ -570,6 +574,31 @@ class PlaybackStateHolder @Inject constructor(
/* -------------------------------------------------------------------------- */
private var progressJob: kotlinx.coroutines.Job? = null
+ private var stuckClockMediaId: String? = null
+ private var stuckClockRawPositionMs = 0L
+ private var stuckClockBasePositionMs = 0L
+ private var stuckClockBaseElapsedMs = 0L
+
+ private fun positionForProgressUi(mediaId: String?, rawPositionMs: Long, isPlaying: Boolean): Long {
+ val raw = rawPositionMs.coerceAtLeast(0L)
+ val now = SystemClock.elapsedRealtime()
+ if (mediaId != stuckClockMediaId) {
+ stuckClockMediaId = mediaId
+ stuckClockRawPositionMs = raw
+ stuckClockBasePositionMs = raw
+ stuckClockBaseElapsedMs = now
+ return raw
+ }
+ if (!isPlaying || raw != stuckClockRawPositionMs) {
+ stuckClockRawPositionMs = raw
+ stuckClockBasePositionMs = raw
+ stuckClockBaseElapsedMs = now
+ return raw
+ }
+ val hintedDuration = _stablePlayerState.value.currentSong?.duration ?: 0L
+ val estimated = stuckClockBasePositionMs + (now - stuckClockBaseElapsedMs)
+ return if (hintedDuration > 0L) estimated.coerceAtMost(hintedDuration) else estimated
+ }
/**
* Reconciles duration reported by the player with the current song metadata duration.
@@ -705,7 +734,11 @@ class PlaybackStateHolder @Inject constructor(
continue
}
- val currentPosition = controller.currentPosition.coerceAtLeast(0L)
+ val currentPosition = positionForProgressUi(
+ mediaId = currentMediaId,
+ rawPositionMs = controller.currentPosition,
+ isPlaying = controller.isPlaying,
+ )
val songDurationHint = visibleSong?.duration ?: 0L
val duration = resolveEffectiveDuration(
reportedDurationMs = controller.duration,
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt
index 7da60dfe46..9748d5c7f8 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt
@@ -32,6 +32,7 @@ data class PlayerUiState(
val filteredSongs: ImmutableList = persistentListOf(), // For search filtering within lists
val isFiltering: Boolean = false,
val showDismissUndoBar: Boolean = false,
+ val keepPlayedSongsInQueue: Boolean = false,
val dismissedSong: Song? = null,
val dismissedQueue: ImmutableList = persistentListOf(),
val dismissedQueueName: String = "",
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt
index 4ddadbedea..d92ea0219e 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt
@@ -216,7 +216,9 @@ class PlayerViewModel @Inject constructor(
private val playbackDispatchStateHolder: PlaybackDispatchStateHolder,
private val mediaControllerSyncStateHolder: MediaControllerSyncStateHolder,
private val sessionToken: SessionToken,
- private val mediaControllerFactory: com.theveloper.pixelplay.data.media.MediaControllerFactory
+ private val mediaControllerFactory: com.theveloper.pixelplay.data.media.MediaControllerFactory,
+ private val youtubeMusicStore: com.theveloper.pixelplay.youtube.YoutubeMusicStore,
+ private val youtubeClient: com.theveloper.pixelplay.youtube.YoutubeClient,
) : ViewModel() {
private val _playerUiState = MutableStateFlow(PlayerUiState())
@@ -486,7 +488,7 @@ class PlayerViewModel @Inject constructor(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
- initialValue = CarouselStyle.NO_PEEK
+ initialValue = CarouselStyle.ONE_PEEK
)
val hasActiveAiProviderApiKey: StateFlow = combine(
@@ -1213,9 +1215,15 @@ class PlayerViewModel @Inject constructor(
emit(resolveFavoriteSongId(song))
}
},
- favoriteSongIds
- ) { favoriteSongId, ids ->
- favoriteSongId?.let { ids.contains(it) } ?: false
+ favoriteSongIds,
+ youtubeMusicStore.likedSongIds,
+ stablePlayerState.map { it.currentSong?.id }.distinctUntilChanged(),
+ ) { favoriteSongId, ids, youtubeLikedIds, currentSongId ->
+ if (currentSongId?.startsWith("yt_") == true) {
+ currentSongId in youtubeLikedIds
+ } else {
+ favoriteSongId?.let { ids.contains(it) } ?: false
+ }
}.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@@ -1320,7 +1328,7 @@ class PlayerViewModel @Inject constructor(
data class PlayerConfigSlice(
val navBarCornerRadius: Int = 32,
val navBarStyle: String = NavBarStyle.DEFAULT,
- val carouselStyle: String = CarouselStyle.NO_PEEK,
+ val carouselStyle: String = CarouselStyle.ONE_PEEK,
val fullPlayerLoadingTweaks: FullPlayerLoadingTweaks = FullPlayerLoadingTweaks(),
val tapBackgroundClosesPlayer: Boolean = false,
val useSmoothCorners: Boolean = true,
@@ -1719,8 +1727,9 @@ class PlayerViewModel @Inject constructor(
}, ContextCompat.getMainExecutor(context))
- // Start Cast discovery
- castStateHolder.startDiscovery()
+ // Start Cast discovery. Missing nearby/Bluetooth perms must not kill launch.
+ runCatching { castStateHolder.startDiscovery() }
+ .onFailure { Log.w("PlayerViewModel", "Cast discovery skipped", it) }
// Observe selection for HTTP server management
viewModelScope.launch {
@@ -1740,7 +1749,8 @@ class PlayerViewModel @Inject constructor(
}
// Initialize connectivity monitoring (WiFi/Bluetooth)
- connectivityStateHolder.initialize()
+ runCatching { connectivityStateHolder.initialize() }
+ .onFailure { Log.w("PlayerViewModel", "Connectivity monitoring skipped", it) }
// Initialize sleep timer state holder
sleepTimerStateHolder.initialize(
@@ -2204,8 +2214,19 @@ class PlayerViewModel @Inject constructor(
}
// rebuildPlayerQueue functionality moved to PlaybackStateHolder (simplified)
- fun playSongs(songsToPlay: List, startSong: Song, queueName: String = "None", playlistId: String? = null) =
- playbackDispatchStateHolder.playSongs(songsToPlay, startSong, queueName, playlistId)
+ fun playSongs(
+ songsToPlay: List,
+ startSong: Song,
+ queueName: String = "None",
+ playlistId: String? = null,
+ keepPlayedSongs: Boolean = false,
+ ) = playbackDispatchStateHolder.playSongs(
+ songsToPlay,
+ startSong,
+ queueName,
+ playlistId,
+ keepPlayedSongs,
+ )
fun playSongsShuffled(
songsToPlay: List,
@@ -2216,6 +2237,19 @@ class PlayerViewModel @Inject constructor(
fun playExternalUri(uri: Uri) = playbackDispatchStateHolder.playExternalUri(uri)
+ fun showPreparingSong(song: Song) {
+ _isSheetVisible.value = true
+ _sheetState.value = PlayerSheetState.COLLAPSED
+ playbackStateHolder.updateStablePlayerState {
+ it.copy(
+ currentSong = song,
+ isPlaying = false,
+ playWhenReady = true,
+ totalDuration = song.duration.coerceAtLeast(0L),
+ )
+ }
+ }
+
fun showPlayer() {
if (stablePlayerState.value.currentSong != null) {
_isSheetVisible.value = true
@@ -2261,6 +2295,19 @@ class PlayerViewModel @Inject constructor(
fun toggleFavorite() {
val currentSong = playbackStateHolder.stablePlayerState.value.currentSong ?: return
+ if (currentSong.id.startsWith("yt_")) {
+ youtubeMusicStore.toggleLike(currentSong)
+ val videoId = com.theveloper.pixelplay.youtube.YoutubeClient.watchUrlForSongId(currentSong.id)
+ ?.let(com.theveloper.pixelplay.youtube.YoutubeClient::videoIdFromUrl)
+ .orEmpty()
+ if (videoId.length == 11) {
+ viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
+ val liked = currentSong.id in youtubeMusicStore.likedSongIds.value
+ runCatching { youtubeClient.likeVideo(videoId, liked) }
+ }
+ }
+ return
+ }
viewModelScope.launch {
val favoriteSongId = resolveFavoriteSongId(currentSong) ?: return@launch
val currentlyFavorite = favoriteSongIds.value.contains(favoriteSongId)
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt
index abba7eaceb..b93151dce0 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt
@@ -63,7 +63,7 @@ data class SettingsUiState(
val navBarCornerRadius: Int = 32,
val navBarStyle: String = NavBarStyle.DEFAULT,
val navBarCompactMode: Boolean = false,
- val carouselStyle: String = CarouselStyle.NO_PEEK,
+ val carouselStyle: String = CarouselStyle.ONE_PEEK,
val libraryNavigationMode: String = LibraryNavigationMode.TAB_ROW,
val launchTab: String = LaunchTab.HOME,
val keepPlayingInBackground: Boolean = true,
@@ -74,6 +74,7 @@ data class SettingsUiState(
val isCrossfadeEnabled: Boolean = false,
val hiFiModeEnabled: Boolean = false,
val hiFiModeDeviceSupported: Boolean = true,
+ val skipSilenceEnabled: Boolean = false,
val crossfadeDuration: Int = 2000,
val persistentShuffleEnabled: Boolean = false,
val folderBackGestureNavigation: Boolean = true,
@@ -160,6 +161,7 @@ private sealed interface SettingsUiUpdate {
val showQueueHistory: Boolean,
val isCrossfadeEnabled: Boolean,
val hiFiModeEnabled: Boolean,
+ val skipSilenceEnabled: Boolean,
val crossfadeDuration: Int,
val persistentShuffleEnabled: Boolean,
val folderBackGestureNavigation: Boolean,
@@ -653,6 +655,7 @@ class SettingsViewModel @Inject constructor(
userPreferencesRepository.showQueueHistoryFlow,
userPreferencesRepository.isCrossfadeEnabledFlow,
userPreferencesRepository.hiFiModeEnabledFlow,
+ userPreferencesRepository.skipSilenceEnabledFlow,
userPreferencesRepository.crossfadeDurationFlow,
userPreferencesRepository.persistentShuffleEnabledFlow,
userPreferencesRepository.folderBackGestureNavigationFlow,
@@ -675,19 +678,20 @@ class SettingsViewModel @Inject constructor(
showQueueHistory = values[4] as Boolean,
isCrossfadeEnabled = values[5] as Boolean,
hiFiModeEnabled = values[6] as Boolean,
- crossfadeDuration = values[7] as Int,
- persistentShuffleEnabled = values[8] as Boolean,
- folderBackGestureNavigation = values[9] as Boolean,
- lyricsSourcePreference = values[10] as LyricsSourcePreference,
- autoScanLrcFiles = values[11] as Boolean,
- blockedDirectories = @Suppress("UNCHECKED_CAST") (values[12] as Set),
- hapticsEnabled = values[13] as Boolean,
- immersiveLyricsEnabled = values[14] as Boolean,
- immersiveLyricsTimeout = values[15] as Long,
- animatedLyricsBlurEnabled = values[16] as Boolean,
- animatedLyricsBlurStrength = values[17] as Float,
- disableBlurAllOver = values[18] as Boolean,
- showScrollbar = values[19] as Boolean
+ skipSilenceEnabled = values[7] as Boolean,
+ crossfadeDuration = values[8] as Int,
+ persistentShuffleEnabled = values[9] as Boolean,
+ folderBackGestureNavigation = values[10] as Boolean,
+ lyricsSourcePreference = values[11] as LyricsSourcePreference,
+ autoScanLrcFiles = values[12] as Boolean,
+ blockedDirectories = @Suppress("UNCHECKED_CAST") (values[13] as Set),
+ hapticsEnabled = values[14] as Boolean,
+ immersiveLyricsEnabled = values[15] as Boolean,
+ immersiveLyricsTimeout = values[16] as Long,
+ animatedLyricsBlurEnabled = values[17] as Boolean,
+ animatedLyricsBlurStrength = values[18] as Float,
+ disableBlurAllOver = values[19] as Boolean,
+ showScrollbar = values[20] as Boolean
)
}.collect { update ->
_uiState.update { state ->
@@ -699,6 +703,7 @@ class SettingsViewModel @Inject constructor(
showQueueHistory = update.showQueueHistory,
isCrossfadeEnabled = update.isCrossfadeEnabled,
hiFiModeEnabled = update.hiFiModeEnabled,
+ skipSilenceEnabled = update.skipSilenceEnabled,
crossfadeDuration = update.crossfadeDuration,
persistentShuffleEnabled = update.persistentShuffleEnabled,
folderBackGestureNavigation = update.folderBackGestureNavigation,
@@ -988,6 +993,12 @@ class SettingsViewModel @Inject constructor(
}
}
+ fun setSkipSilenceEnabled(enabled: Boolean) {
+ viewModelScope.launch {
+ userPreferencesRepository.setSkipSilenceEnabled(enabled)
+ }
+ }
+
fun setShowQueueHistory(show: Boolean) {
viewModelScope.launch {
userPreferencesRepository.setShowQueueHistory(show)
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SetupViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SetupViewModel.kt
index 156e196ee2..c88810b1ca 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SetupViewModel.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SetupViewModel.kt
@@ -51,7 +51,7 @@ data class SetupUiState(
) {
val allPermissionsGranted: Boolean
get() {
- val mediaOk = mediaPermissionGranted
+ val mediaOk = true
val notificationsOk = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) notificationsPermissionGranted else true
return mediaOk && notificationsOk
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt
index 9f26f7c15e..974c3b5e97 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/StatsViewModel.kt
@@ -7,6 +7,10 @@ import com.theveloper.pixelplay.data.repository.MusicRepository
import com.theveloper.pixelplay.data.stats.PlaybackStatsRepository
import com.theveloper.pixelplay.data.stats.PlaybackStatsRepository.PlaybackStatsSummary
import com.theveloper.pixelplay.data.stats.StatsTimeRange
+import com.theveloper.pixelplay.data.stats.mergeStatsSongs
+import com.theveloper.pixelplay.youtube.YoutubeClient
+import com.theveloper.pixelplay.youtube.YoutubeMusicStore
+import com.theveloper.pixelplay.youtube.YoutubeStatsCreditAi
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
@@ -23,7 +27,10 @@ import timber.log.Timber
@HiltViewModel
class StatsViewModel @Inject constructor(
private val playbackStatsRepository: PlaybackStatsRepository,
- private val musicRepository: MusicRepository
+ private val musicRepository: MusicRepository,
+ private val youtubeMusicStore: YoutubeMusicStore,
+ private val youtubeClient: YoutubeClient,
+ private val youtubeStatsCreditAi: YoutubeStatsCreditAi,
) : ViewModel() {
data class StatsUiState(
@@ -47,7 +54,9 @@ class StatsViewModel @Inject constructor(
private var cachedSongs: List? = null
init {
+ observeYoutubeCatalog()
observeStatsRefreshFlow()
+ enrichYoutubeCredits()
refreshRange(
range = StatsTimeRange.WEEK,
showLoading = true,
@@ -158,6 +167,22 @@ class StatsViewModel @Inject constructor(
}
}
+ private fun enrichYoutubeCredits() {
+ viewModelScope.launch(Dispatchers.IO) {
+ runCatching {
+ val played = playbackStatsRepository.exportEventsForBackup()
+ .asSequence()
+ .map { it.songId }
+ .filter { it.startsWith("yt_") }
+ .toSet()
+ youtubeClient.fillPlayedCredits(youtubeMusicStore, played)
+ youtubeStatsCreditAi.fill(youtubeMusicStore, played)
+ }.onFailure { throwable ->
+ Timber.e(throwable, "Failed to load YouTube artist and album names")
+ }
+ }
+ }
+
fun requestStatsRefresh() {
playbackStatsRepository.requestRefresh()
}
@@ -167,13 +192,31 @@ class StatsViewModel @Inject constructor(
playbackStatsRepository.requestRefresh()
}
+ private fun observeYoutubeCatalog() {
+ viewModelScope.launch {
+ youtubeMusicStore.catalogVersion
+ .drop(1)
+ .collectLatest {
+ enrichYoutubeCredits()
+ val selectedRange = _uiState.value.selectedRange
+ refreshRange(
+ range = selectedRange,
+ showLoading = false,
+ updateWeeklyOverview = selectedRange == StatsTimeRange.WEEK
+ )
+ if (selectedRange != StatsTimeRange.WEEK) {
+ refreshWeeklyOverview()
+ }
+ refreshHomeOverview()
+ }
+ }
+ }
+
private suspend fun loadSongs(): List {
- cachedSongs?.let { existing ->
- if (existing.isNotEmpty()) return existing
+ val library = cachedSongs?.takeIf { it.isNotEmpty() } ?: musicRepository.getAllSongsOnce().also { loaded ->
+ if (loaded.isNotEmpty()) cachedSongs = loaded
}
- val songs = musicRepository.getAllSongsOnce()
- cachedSongs = songs
- return songs
+ return mergeStatsSongs(library, youtubeMusicStore.songsForStats())
}
private fun PlaybackStatsSummary.hasListeningActivity(): Boolean {
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt
new file mode 100644
index 0000000000..d054f8770b
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/youtube/auth/YoutubeLoginActivity.kt
@@ -0,0 +1,244 @@
+package com.theveloper.pixelplay.presentation.youtube.auth
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.webkit.CookieManager
+import android.webkit.WebChromeClient
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import com.theveloper.pixelplay.R
+import com.theveloper.pixelplay.ui.theme.GoogleSansRounded
+import com.theveloper.pixelplay.ui.theme.PixelPlayTheme
+import com.theveloper.pixelplay.youtube.YoutubeViewModel
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class YoutubeLoginActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ PixelPlayTheme {
+ YoutubeWebLoginScreen(onClose = { finish() })
+ }
+ }
+ }
+
+ companion object {
+ const val TARGET_URL = "https://accounts.google.com/ServiceLogin?service=youtube&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2F&hl=en"
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun YoutubeWebLoginScreen(
+ onClose: () -> Unit,
+ viewModel: YoutubeViewModel = hiltViewModel(),
+) {
+ var progress by remember { mutableIntStateOf(0) }
+ var webView by remember { mutableStateOf(null) }
+ BackHandler { onClose() }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(R.string.youtube_sign_in),
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.youtube_back))
+ }
+ },
+ actions = {
+ TextButton(
+ onClick = {
+ val cookies = extractYoutubeSession()
+ if (cookies != null && viewModel.importSession(cookies)) {
+ onClose()
+ } else {
+ Toast.makeText(
+ webView?.context,
+ R.string.youtube_sign_in_incomplete,
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ },
+ ) {
+ Text(stringResource(R.string.youtube_sign_in_done), fontFamily = GoogleSansRounded)
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.fillMaxSize().padding(padding)) {
+ if (progress in 1..99) {
+ LinearProgressIndicator(
+ progress = { progress / 100f },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ Box(Modifier.fillMaxSize()) {
+ var openedLikedMusic by remember { mutableStateOf(false) }
+ var importing by remember { mutableStateOf(false) }
+ YoutubeWebView(
+ onProgress = { progress = it },
+ onWebViewCreated = { webView = it },
+ onCookiesMaybeReady = { url ->
+ if (importing) return@YoutubeWebView
+ val cookies = extractYoutubeSession() ?: return@YoutubeWebView
+ val hasAuth = listOf("SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID")
+ .any { key -> cookies.contains("$key=", ignoreCase = true) }
+ if (!hasAuth) return@YoutubeWebView
+ val onMusic = url.orEmpty().contains("music.youtube.com")
+ if (!onMusic) {
+ if (!openedLikedMusic) {
+ openedLikedMusic = true
+ webView?.loadUrl("https://music.youtube.com/playlist?list=LM")
+ }
+ return@YoutubeWebView
+ }
+ importing = true
+ val view = webView
+ if (view == null) {
+ if (viewModel.importSession(cookies)) onClose() else importing = false
+ return@YoutubeWebView
+ }
+ // Same trick KuroMusic/InnerTune use: read the page's InnerTube config
+ // so requests carry the signed-in visitorData and channel (DATASYNC_ID).
+ view.evaluateJavascript(IDENTITY_SCRIPT) { result ->
+ val (visitor, dataSync) = parseIdentity(result)
+ val fresh = extractYoutubeSession() ?: cookies
+ if (viewModel.importSession(fresh, visitor, dataSync)) onClose() else importing = false
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+private fun YoutubeWebView(
+ onProgress: (Int) -> Unit,
+ onWebViewCreated: (WebView) -> Unit,
+ onCookiesMaybeReady: (String?) -> Unit,
+) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ WebView(ctx).apply {
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ settings.cacheMode = WebSettings.LOAD_DEFAULT
+ 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(url)
+ }
+ }
+ loadUrl(YoutubeLoginActivity.TARGET_URL)
+ onWebViewCreated(this)
+ }
+ },
+ )
+}
+
+private const val IDENTITY_SCRIPT = """
+(function () {
+ try {
+ var cfg = (window.ytcfg && (ytcfg.data_ || (ytcfg.get && {VISITOR_DATA: ytcfg.get('VISITOR_DATA'), DATASYNC_ID: ytcfg.get('DATASYNC_ID')})))
+ || (window.yt && yt.config_) || {};
+ return JSON.stringify({ v: cfg.VISITOR_DATA || '', d: cfg.DATASYNC_ID || '' });
+ } catch (e) { return '{}'; }
+})()
+"""
+
+/** evaluateJavascript hands back a JSON string literal wrapping our JSON payload. */
+internal fun parseIdentity(result: String?): Pair {
+ if (result.isNullOrBlank() || result == "null") return "" to ""
+ return runCatching {
+ val inner = org.json.JSONTokener(result).nextValue()
+ val json = when (inner) {
+ is org.json.JSONObject -> inner
+ is String -> org.json.JSONObject(inner)
+ else -> return "" to ""
+ }
+ json.optString("v").trim() to json.optString("d").trim()
+ }.getOrDefault("" to "")
+}
+
+internal fun extractYoutubeSession(): String? {
+ val manager = CookieManager.getInstance()
+ val map = linkedMapOf()
+ // music.youtube.com first: those are the cookies YouTube Music itself sends. Other hosts only
+ // fill in names that are missing so we never overwrite a value with one from a different domain.
+ listOf(
+ "https://music.youtube.com",
+ "https://www.youtube.com",
+ "https://youtube.com",
+ "https://accounts.google.com",
+ "https://google.com",
+ ).forEach { host ->
+ manager.getCookie(host).orEmpty()
+ .split(';')
+ .map { it.trim() }
+ .filter { it.contains('=') }
+ .forEach { part ->
+ val index = part.indexOf('=')
+ val key = part.substring(0, index).trim()
+ val value = part.substring(index + 1).trim()
+ if (key.isNotEmpty() && value.isNotEmpty() && !map.containsKey(key)) map[key] = value
+ }
+ }
+ val signedIn = map.keys.any { key ->
+ key.equals("SAPISID", ignoreCase = true) ||
+ key.equals("__Secure-1PAPISID", ignoreCase = true) ||
+ key.equals("__Secure-3PAPISID", ignoreCase = true)
+ }
+ if (!signedIn) return null
+ return map.entries.joinToString("; ") { "${it.key}=${it.value}" }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt
new file mode 100644
index 0000000000..76b4fae185
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudClient.kt
@@ -0,0 +1,488 @@
+package com.theveloper.pixelplay.soundcloud
+
+import com.theveloper.pixelplay.data.model.Song
+import org.schabi.newpipe.extractor.InfoItem
+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.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicReference
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * SoundCloud access via NewPipe Extractor (web api-v2).
+ *
+ * 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 playability: SoundCloudPlayabilityStore,
+) {
+ private val downloader = SoundCloudDownloader()
+ private val runtimeClientId = AtomicReference(null)
+ private val resolveCache = ConcurrentHashMap()
+ private val _likedPermalinks = MutableStateFlow>(emptySet())
+ val likedPermalinks: StateFlow> = _likedPermalinks.asStateFlow()
+
+ private data class CachedResolve(
+ val track: SoundCloudResolvedTrack,
+ val cachedAtMs: Long,
+ )
+
+ 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 loadRecentlyPlayed(limit: Int = 20): List =
+ sessionApi.loadRecentlyPlayed(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 setTrackLiked(trackUrl: String, liked: Boolean) {
+ val url = trackUrl.trim()
+ sessionApi.setTrackLiked(url, liked)
+ _likedPermalinks.update { current -> if (liked) current + url else current - url }
+ }
+
+ fun rememberLikes(urls: Collection) {
+ val clean = urls.map { it.trim() }.filter { it.isNotEmpty() }
+ if (clean.isEmpty()) return
+ _likedPermalinks.update { it + clean }
+ }
+
+ fun permalinkForSong(songId: String): String? = permalinkForSongId(songId)
+
+ @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() }
+ .take(limit)
+ .toList()
+ }
+
+ @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() }
+ .take(limit)
+ .toList()
+ }
+
+ @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()
+ try {
+ val info = PlaylistInfo.getInfo(soundCloud(), playlistUrl.trim())
+ return info.relatedItems
+ .asSequence()
+ .filterIsInstance()
+ .map { it.toHit() }
+ .filter { it.url.isNotBlank() }
+ .take(limit)
+ .toList()
+ } catch (t: Throwable) {
+ if (looksLikeClientIdOrRateLimit(t)) {
+ refreshClientIdFromWeb()
+ val info = PlaylistInfo.getInfo(soundCloud(), playlistUrl.trim())
+ return info.relatedItems
+ .asSequence()
+ .filterIsInstance()
+ .map { it.toHit() }
+ .filter { it.url.isNotBlank() }
+ .take(limit)
+ .toList()
+ }
+ throw wrapSoundCloudError(t, "Couldn't open this playlist")
+ }
+ }
+
+ @Throws(Exception::class)
+ fun resolveTrack(url: String): SoundCloudResolvedTrack {
+ val key = url.trim()
+ resolveCache[key]?.takeIf {
+ System.currentTimeMillis() - it.cachedAtMs < RESOLVE_CACHE_TTL_MS
+ }?.let { return it.track }
+
+ val resolved = try {
+ resolveTrackOnce(key)
+ } catch (t: Throwable) {
+ if (looksLikeClientIdOrRateLimit(t)) {
+ refreshClientIdFromWeb()
+ resolveTrackOnce(key)
+ } else {
+ throw wrapSoundCloudError(t, "No playable stream")
+ }
+ }
+ resolveCache[key] = CachedResolve(resolved, System.currentTimeMillis())
+ return resolved
+ }
+
+ private fun resolveTrackOnce(url: String): SoundCloudResolvedTrack {
+ ensureClientIdReady()
+ val trimmedUrl = url.trim()
+ val info = StreamInfo.getInfo(soundCloud(), trimmedUrl)
+ val stream = pickBestAudioStream(info.audioStreams)
+ ?: throw IllegalStateException(
+ "No progressive/plain stream available. DRM-protected tracks can't be played or downloaded.",
+ )
+ 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 isKnownUnplayable(url: String): Boolean = playability.isUnplayable(url)
+
+ fun knowsPlayability(url: String): Boolean = playability.knows(url)
+
+ fun rememberPlayable(url: String) {
+ playability.remember(url, playable = true)
+ }
+
+ fun rememberUnplayable(url: String) {
+ playability.remember(url, playable = false)
+ }
+
+ /**
+ * Cheap api-v2 check (no stream extraction). Null means the probe failed and
+ * the row should stay unchanged so a blip doesn't gray the whole list.
+ */
+ fun probePlayable(url: String): Boolean? {
+ val key = url.trim()
+ if (playability.isUnplayable(key)) return false
+ if (playability.knows(key)) return true
+ resolveCache[key]?.let {
+ playability.remember(key, playable = true)
+ return true
+ }
+ val playable = sessionApi.playbackAvailability(key) ?: return null
+ playability.remember(key, playable)
+ return playable
+ }
+
+ fun isPermanentStreamFailure(error: Throwable): Boolean {
+ if (looksLikeClientIdOrRateLimit(error)) return false
+ val msg = generateSequence(error) { it.cause }
+ .mapNotNull { it.message }
+ .joinToString(" ")
+ .lowercase()
+ return msg.contains("no playable stream") ||
+ msg.contains("could not get any stream") ||
+ msg.contains("no progressive") ||
+ msg.contains("drm") ||
+ msg.contains("encrypted")
+ }
+
+ fun fileTags(track: SoundCloudResolvedTrack): SoundCloudFileTags {
+ val fromApi = runCatching { sessionApi.loadFileTags(track.url) }.getOrNull()
+ val artist = fromApi?.artist?.takeUnless { it.isSoundCloudPlaceholder() }
+ ?: track.artist.takeUnless { it.isSoundCloudPlaceholder() }
+ ?: "Unknown artist"
+ val title = fromApi?.title?.takeIf { it.isNotBlank() } ?: track.title
+ val album = fromApi?.album?.takeUnless { it.isSoundCloudPlaceholder() } ?: artist
+ val genre = fromApi?.genre?.takeUnless { it.isSoundCloudPlaceholder() }
+ val artwork = fromApi?.artworkUrl?.takeIf { it.isNotBlank() } ?: track.artworkUrl
+ return SoundCloudFileTags(
+ title = title,
+ artist = artist,
+ album = album,
+ albumArtist = artist,
+ genre = genre,
+ artworkUrl = artwork,
+ )
+ }
+
+ fun toSong(track: SoundCloudResolvedTrack, permalinkUrl: String = track.url): Song {
+ // Prefer the list permalink so UI can match the now-playing row without resolving again.
+ val id = songIdForUrl(permalinkUrl.ifBlank { track.url })
+ val permalink = permalinkUrl.ifBlank { track.url }.trim()
+ if (permalink.isNotEmpty()) rememberPermalink(id, permalink)
+ 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 SoundCloud settings" }
+
+ 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() }
+ .take(limit)
+ .toList()
+ }
+
+ /**
+ * Scrapes a fresh web client_id via NewPipe and injects it.
+ * Call when prefs are empty or the cached id has stopped working.
+ */
+ @Throws(Exception::class)
+ fun refreshClientIdFromWeb(): String {
+ clearInjectedClientId()
+ val scraped = try {
+ SoundcloudParsingHelper.clientId()
+ } catch (t: Throwable) {
+ throw IllegalStateException(
+ "Couldn't auto-fetch SoundCloud client_id. Paste one in SoundCloud settings. (${t.message})",
+ t,
+ )
+ }
+ val trimmed = scraped.trim()
+ require(trimmed.isNotEmpty()) { "SoundCloud returned an empty client_id" }
+ setClientIdOverride(trimmed)
+ Timber.i("SoundCloud: refreshed client_id from web")
+ return trimmed
+ }
+
+ private fun InfoItem.toHitOrNull(): SoundCloudSearchHit? = when (this) {
+ 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() {
+ var id = runtimeClientId.get()?.trim().orEmpty()
+ if (id.isEmpty()) {
+ id = refreshClientIdFromWeb()
+ }
+ injectClientId(id)
+ }
+
+ private fun clearInjectedClientId() {
+ try {
+ val field = SoundcloudParsingHelper::class.java.getDeclaredField("clientId")
+ field.isAccessible = true
+ synchronized(SoundcloudParsingHelper::class.java) {
+ field.set(null, null)
+ }
+ } catch (t: Throwable) {
+ Timber.w(t, "SoundCloud: could not clear injected client_id")
+ }
+ }
+
+ private fun injectClientId(clientId: String) {
+ try {
+ val field = SoundcloudParsingHelper::class.java.getDeclaredField("clientId")
+ 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 looksLikeClientIdOrRateLimit(t: Throwable): Boolean {
+ val msg = generateSequence(t) { it.cause }
+ .mapNotNull { it.message }
+ .joinToString(" ")
+ .lowercase()
+ return msg.contains("clientid") ||
+ msg.contains("client_id") ||
+ msg.contains("client id") ||
+ msg.contains("rate limited") ||
+ msg.contains("429") ||
+ msg.contains("recaptcha")
+ }
+
+ private fun wrapSoundCloudError(t: Throwable, prefix: String): IllegalStateException {
+ val msg = generateSequence(t) { it.cause }
+ .mapNotNull { it.message }
+ .firstOrNull { it.isNotBlank() }
+ .orEmpty()
+ val friendly = when {
+ msg.contains("429", ignoreCase = true) ||
+ msg.contains("rate limited", ignoreCase = true) ->
+ "$prefix: SoundCloud rate-limited you. Wait a bit and try again."
+ msg.contains("client", ignoreCase = true) ->
+ "$prefix: client_id missing/expired. Open SoundCloud settings and save a fresh client_id (or retry)."
+ else -> "$prefix. $msg"
+ }
+ return IllegalStateException(friendly, t)
+ }
+
+ private fun pickBestAudioStream(streams: List): AudioStream? {
+ if (streams.isEmpty()) return null
+ // Progressive HTTP starts fastest (single file). Prefer it over HLS when available.
+ val progressive = streams.filter { it.deliveryMethod == DeliveryMethod.PROGRESSIVE_HTTP }
+ if (progressive.isNotEmpty()) {
+ return progressive.maxWithOrNull(
+ compareBy { it.averageBitrate }.thenBy { it.bitrate },
+ )
+ }
+ // Plain HLS only — skip encrypted/DRM. Prefer a mid/low bitrate for quicker first buffer.
+ val hls = streams.filter {
+ it.deliveryMethod == DeliveryMethod.HLS && !it.content.contains("encrypted")
+ }
+ if (hls.isNotEmpty()) {
+ return hls.minWithOrNull(
+ compareBy {
+ val br = it.averageBitrate.takeIf { b -> b > 0 } ?: it.bitrate
+ if (br > 0) br else Int.MAX_VALUE
+ },
+ ) ?: hls.first()
+ }
+ return streams.firstOrNull { !it.content.contains("encrypted") } ?: streams.firstOrNull()
+ }
+
+ private fun soundCloud(): SoundcloudService = ServiceList.SoundCloud
+
+ companion object {
+ const val EXTRA_PERMALINK = "com.theveloper.pixelplay.soundcloud.PERMALINK"
+ private val permalinkBySongId = ConcurrentHashMap()
+ private const val RESOLVE_CACHE_TTL_MS = 15 * 60 * 1000L
+
+ fun rememberPermalink(songId: String, permalink: String) {
+ val id = songId.trim()
+ val url = permalink.trim()
+ if (id.isNotEmpty() && url.startsWith("http")) permalinkBySongId[id] = url
+ }
+
+ fun permalinkForSongId(songId: String): String? = permalinkBySongId[songId]
+
+ fun songIdForUrl(url: String): String =
+ "sc_${url.trim().hashCode().toUInt()}"
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt
new file mode 100644
index 0000000000..5159c9bb69
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloadService.kt
@@ -0,0 +1,445 @@
+package com.theveloper.pixelplay.soundcloud
+
+import android.content.ContentUris
+import android.content.ContentValues
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.os.ParcelFileDescriptor
+import android.provider.MediaStore
+import androidx.core.net.toUri
+import com.kyant.taglib.Picture
+import com.kyant.taglib.TagLib
+import com.theveloper.pixelplay.data.model.Song
+import com.theveloper.pixelplay.utils.AlbumArtUtils
+import dagger.hilt.android.qualifiers.ApplicationContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import timber.log.Timber
+import java.io.File
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+data class SoundCloudDownloadCatalog(
+ val songs: List,
+ val repairs: List = emptyList(),
+)
+
+@Singleton
+class SoundCloudDownloadService @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val artworkPreferences by lazy {
+ context.getSharedPreferences(ARTWORK_PREFERENCES, Context.MODE_PRIVATE)
+ }
+ private val repairedPreferences by lazy {
+ context.getSharedPreferences(REPAIRED_PREFERENCES, Context.MODE_PRIVATE)
+ }
+ private val http = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(120, TimeUnit.SECONDS)
+ .followRedirects(true)
+ .build()
+
+ /**
+ * Saves a progressive/HTTP audio URL into public Music/PixelPlayer/SoundCloud
+ * and writes the track's real title, artist, album, and cover into the file.
+ */
+ fun downloadToMusicFolder(track: SoundCloudResolvedTrack, tags: SoundCloudFileTags): File {
+ val safeTitle = tags.title.replace(UNSAFE_FILENAME, "_").take(80).ifBlank { "track" }
+ val safeArtist = tags.artist.replace(UNSAFE_FILENAME, "_").take(40).ifBlank { "Unknown artist" }
+ val ext = when {
+ track.mimeType?.contains("mpeg") == true -> "mp3"
+ track.mimeType?.contains("mp4") == true || track.mimeType?.contains("m4a") == true -> "m4a"
+ 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 = audioRequest(track.streamUrl)
+
+ http.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw IllegalStateException("Download HTTP ${response.code}")
+ }
+ val body = response.body ?: throw IllegalStateException("Empty download body")
+ val artwork = tags.artworkUrl?.let { downloadArtwork(it) }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val values = contentValuesFor(fileName, track.mimeType, tags)
+ values.put(MediaStore.Audio.Media.IS_PENDING, 1)
+ val resolver = context.contentResolver
+ val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
+ val uri = resolver.insert(collection, values)
+ ?: throw IllegalStateException("MediaStore insert failed")
+ resolver.openOutputStream(uri)?.use { out -> body.byteStream().copyTo(out) }
+ ?: throw IllegalStateException("Could not open MediaStore stream")
+ val embedded = embedTags(uri, tags, artwork)
+ values.clear()
+ values.putAll(metadataValues(tags))
+ values.put(MediaStore.Audio.Media.IS_PENDING, 0)
+ resolver.update(uri, values, null, null)
+ rememberArtwork(fileName, tags.artworkUrl)
+ if (embedded) markRepaired(fileName)
+ return File(downloadDirectory(), fileName)
+ }
+
+ val dir = downloadDirectory()
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw IllegalStateException("Could not create ${dir.absolutePath}")
+ }
+ val outFile = File(dir, fileName)
+ outFile.outputStream().use { out -> body.byteStream().copyTo(out) }
+ val embedded = embedTags(outFile.toUri(), tags, artwork)
+ rememberArtwork(fileName, tags.artworkUrl)
+ if (embedded) markRepaired(fileName)
+ return outFile
+ }
+ }
+
+ /**
+ * Writes corrected tags and cover art into a download that was saved with
+ * placeholder metadata. Returns an artwork URI the downloads tab can show.
+ */
+ fun rewriteTags(song: Song, tags: SoundCloudFileTags): String? {
+ val uri = song.contentUriString.toUri()
+ val artwork = tags.artworkUrl?.let { downloadArtwork(it) }
+ val tagged = embedTags(uri, tags, artwork)
+ if (!tagged) return null
+ if (uri.scheme == "content") {
+ val values = metadataValues(tags)
+ values.put(MediaStore.Audio.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000L)
+ context.contentResolver.update(uri, values, null, null)
+ }
+ val fileName = song.path.takeIf { it.isNotBlank() }?.let { File(it).name }
+ if (!fileName.isNullOrBlank()) {
+ rememberArtwork(fileName, tags.artworkUrl)
+ markRepaired(fileName)
+ }
+ val mediaStoreId = song.id.toLongOrNull()
+ val localArt = if (mediaStoreId != null && !song.path.isBlank()) {
+ AlbumArtUtils.getAlbumArtUri(
+ appContext = context,
+ path = song.path,
+ songId = mediaStoreId,
+ forceRefresh = true,
+ )
+ } else {
+ null
+ }
+ return localArt ?: tags.artworkUrl
+ }
+
+ fun rememberArtwork(song: Song, artworkUrl: String?) {
+ val fileName = song.path.takeIf { it.isNotBlank() }?.let { File(it).name } ?: return
+ rememberArtwork(fileName, artworkUrl)
+ }
+
+ private fun rememberArtwork(fileName: String, artworkUrl: String?) {
+ val usableUrl = artworkUrl?.takeIf { it.isNotBlank() } ?: return
+ artworkPreferences.edit().putString(fileName, usableUrl).apply()
+ }
+
+ private fun savedArtwork(fileName: String?): String? =
+ fileName?.let { artworkPreferences.getString(it, null) }?.takeIf { it.isNotBlank() }
+
+ private fun isRepaired(fileName: String): Boolean =
+ repairedPreferences.getBoolean(fileName, false)
+
+ private fun markRepaired(fileName: String) {
+ repairedPreferences.edit().putBoolean(fileName, true).apply()
+ }
+
+ /** Local SoundCloud downloads as playable [Song]s (MediaStore first, file fallback). */
+ fun listDownloadedSongs(): SoundCloudDownloadCatalog {
+ val fromStore = queryMediaStoreDownloads()
+ if (fromStore.songs.isNotEmpty() || fromStore.repairs.isNotEmpty()) return fromStore
+ return listDownloadedFiles().map { fileToEntry(it) }.toCatalog()
+ }
+
+ fun listDownloadedFiles(): List {
+ val dir = downloadDirectory()
+ if (!dir.isDirectory) return emptyList()
+ return dir.listFiles()
+ ?.filter { it.isFile && it.extension.lowercase() in AUDIO_EXTS }
+ ?.sortedByDescending { it.lastModified() }
+ .orEmpty()
+ }
+
+ private fun queryMediaStoreDownloads(): SoundCloudDownloadCatalog {
+ val entries = ArrayList()
+ val projection = arrayOf(
+ MediaStore.Audio.Media._ID,
+ MediaStore.Audio.Media.TITLE,
+ MediaStore.Audio.Media.ARTIST,
+ MediaStore.Audio.Media.ALBUM,
+ MediaStore.Audio.Media.ALBUM_ID,
+ MediaStore.Audio.Media.ALBUM_ARTIST,
+ MediaStore.Audio.Media.GENRE,
+ MediaStore.Audio.Media.DURATION,
+ MediaStore.Audio.Media.DATA,
+ MediaStore.Audio.Media.MIME_TYPE,
+ 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 albumArtistCol = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ARTIST)
+ val genreCol = cursor.getColumnIndex(MediaStore.Audio.Media.GENRE)
+ val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
+ val dataCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)
+ val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE)
+ 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 displayName = cursor.getString(displayCol)
+ val (fileArtist, fileTitle) = splitDisplayName(displayName ?: File(path).name)
+ val rawTitle = cursor.getString(titleCol)
+ val rawArtist = cursor.getString(artistCol)
+ val rawAlbum = cursor.getString(albumCol)
+ val rawAlbumArtist = if (albumArtistCol >= 0) cursor.getString(albumArtistCol) else null
+ val rawGenre = if (genreCol >= 0) cursor.getString(genreCol) else null
+ val title = rawTitle?.takeUnless { it.isSoundCloudPlaceholder() || it == "" }
+ ?: fileTitle
+ val artist = rawArtist?.takeUnless { it.isSoundCloudPlaceholder() } ?: fileArtist
+ val album = rawAlbum?.takeUnless { it.isSoundCloudPlaceholder() } ?: artist
+ val albumArtist = rawAlbumArtist?.takeUnless { it.isSoundCloudPlaceholder() } ?: artist
+ val genre = rawGenre?.takeUnless { it.isSoundCloudPlaceholder() }
+ val fileName = displayName ?: File(path).name
+ val albumArt = AlbumArtUtils.getAlbumArtUri(
+ appContext = context,
+ path = path,
+ songId = id,
+ forceRefresh = false,
+ ) ?: savedArtwork(fileName)
+ val song = Song(
+ id = id.toString(),
+ title = title,
+ artist = artist,
+ artistId = -1L,
+ artists = emptyList(),
+ album = album,
+ albumId = cursor.getLong(albumIdCol),
+ albumArtist = albumArtist,
+ path = path,
+ contentUriString = ContentUris.withAppendedId(
+ MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
+ id,
+ ).toString(),
+ albumArtUriString = albumArt,
+ duration = cursor.getLong(durationCol).coerceAtLeast(0L),
+ genre = genre,
+ dateAdded = cursor.getLong(addedCol),
+ dateModified = cursor.getLong(modifiedCol),
+ mimeType = cursor.getString(mimeCol),
+ bitrate = null,
+ sampleRate = null,
+ )
+ val needsRepair = !isRepaired(fileName) && (
+ rawArtist.isSoundCloudPlaceholder() ||
+ rawAlbum.isSoundCloudPlaceholder() ||
+ rawAlbumArtist.isSoundCloudPlaceholder() ||
+ rawGenre.isSoundCloudPlaceholder() ||
+ albumArt.isNullOrBlank() ||
+ albumArt.startsWith("http")
+ )
+ entries.add(DownloadEntry(song, needsRepair))
+ }
+ }
+ } catch (_: SecurityException) {
+ // Fall back to filesystem listing.
+ } catch (_: Exception) {
+ // Fall back to filesystem listing.
+ }
+ return entries.toCatalog()
+ }
+
+ private fun fileToEntry(file: File): DownloadEntry {
+ val (fileArtist, fileTitle) = splitDisplayName(file.name)
+ val id = "sc_dl_${file.absolutePath.hashCode().toUInt()}"
+ val artwork = savedArtwork(file.name)
+ val song = Song(
+ id = id,
+ title = fileTitle,
+ artist = fileArtist,
+ artistId = -1L,
+ artists = emptyList(),
+ album = fileArtist,
+ albumId = -1L,
+ albumArtist = fileArtist,
+ path = file.absolutePath,
+ contentUriString = file.toURI().toString(),
+ albumArtUriString = artwork,
+ duration = 0L,
+ genre = null,
+ 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,
+ )
+ return DownloadEntry(song, needsRepair = !isRepaired(file.name))
+ }
+
+ private fun List.toCatalog(): SoundCloudDownloadCatalog {
+ return SoundCloudDownloadCatalog(
+ songs = map { it.song },
+ repairs = filter { it.needsRepair }.map { it.song },
+ )
+ }
+
+ private fun splitDisplayName(displayName: String): Pair {
+ val base = displayName.substringBeforeLast('.')
+ val parts = base.split(" - ", limit = 2)
+ return if (parts.size == 2) {
+ parts[0].trim().ifBlank { "Unknown artist" } to parts[1].trim().ifBlank { base }
+ } else {
+ "Unknown artist" to base
+ }
+ }
+
+ private fun contentValuesFor(
+ fileName: String,
+ mimeType: String?,
+ tags: SoundCloudFileTags,
+ ): ContentValues = metadataValues(tags).apply {
+ put(MediaStore.Audio.Media.DISPLAY_NAME, fileName)
+ put(MediaStore.Audio.Media.MIME_TYPE, mimeType ?: "audio/mpeg")
+ put(MediaStore.Audio.Media.RELATIVE_PATH, "Music/PixelPlayer/SoundCloud")
+ }
+
+ private fun metadataValues(tags: SoundCloudFileTags): ContentValues = ContentValues().apply {
+ put(MediaStore.Audio.Media.TITLE, tags.title)
+ put(MediaStore.Audio.Media.ARTIST, tags.artist)
+ put(MediaStore.Audio.Media.ALBUM, tags.album)
+ put(MediaStore.Audio.Media.ALBUM_ARTIST, tags.albumArtist)
+ if (tags.genre.isNullOrBlank()) {
+ putNull(MediaStore.Audio.Media.GENRE)
+ } else {
+ put(MediaStore.Audio.Media.GENRE, tags.genre)
+ }
+ }
+
+ private fun audioRequest(url: String): Request = Request.Builder()
+ .url(url)
+ .header("User-Agent", USER_AGENT)
+ .header("Referer", "https://soundcloud.com/")
+ .build()
+
+ private fun downloadArtwork(url: String): Pair? {
+ return try {
+ http.newCall(audioRequest(url)).execute().use { response ->
+ if (!response.isSuccessful) return null
+ val bytes = response.body?.bytes() ?: return null
+ if (bytes.isEmpty() || bytes.size > MAX_ARTWORK_BYTES) return null
+ val mime = response.header("Content-Type")
+ ?.substringBefore(';')
+ ?.trim()
+ ?.takeIf { it.startsWith("image/") }
+ ?: "image/jpeg"
+ bytes to mime
+ }
+ } catch (t: Throwable) {
+ Timber.w(t, "SoundCloud artwork download failed")
+ null
+ }
+ }
+
+ private fun embedTags(
+ uri: Uri,
+ tags: SoundCloudFileTags,
+ artwork: Pair?,
+ ): Boolean {
+ return try {
+ openReadWrite(uri)?.use { fd ->
+ val existing = TagLib.getMetadata(fd.dup().detachFd())
+ val propertyMap = HashMap(existing?.propertyMap ?: emptyMap())
+ propertyMap["TITLE"] = arrayOf(tags.title)
+ propertyMap["ARTIST"] = arrayOf(tags.artist)
+ propertyMap["ALBUM"] = arrayOf(tags.album)
+ propertyMap["ALBUMARTIST"] = arrayOf(tags.albumArtist)
+ if (tags.genre.isNullOrBlank()) {
+ propertyMap.remove("GENRE")
+ } else {
+ propertyMap["GENRE"] = arrayOf(tags.genre)
+ }
+ val saved = TagLib.savePropertyMap(fd.dup().detachFd(), propertyMap)
+ if (!saved) return false
+ if (artwork != null) {
+ TagLib.savePictures(
+ fd.dup().detachFd(),
+ arrayOf(
+ Picture(
+ data = artwork.first,
+ description = "Front Cover",
+ pictureType = "Front Cover",
+ mimeType = artwork.second,
+ ),
+ ),
+ )
+ }
+ true
+ } ?: false
+ } catch (t: Throwable) {
+ Timber.w(t, "SoundCloud tag embed failed for %s", uri)
+ false
+ }
+ }
+
+ private fun openReadWrite(uri: Uri): ParcelFileDescriptor? {
+ return when (uri.scheme) {
+ "file" -> {
+ val path = uri.path ?: return null
+ ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_WRITE)
+ }
+ else -> context.contentResolver.openFileDescriptor(uri, "rw")
+ }
+ }
+
+ private data class DownloadEntry(val song: Song, val needsRepair: Boolean)
+
+ companion object {
+ private const val ARTWORK_PREFERENCES = "soundcloud_download_artwork"
+ private const val REPAIRED_PREFERENCES = "soundcloud_download_tags_repaired"
+ private const val USER_AGENT =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0"
+ private const val MAX_ARTWORK_BYTES = 8 * 1024 * 1024
+ private val AUDIO_EXTS = setOf("mp3", "m4a", "ogg", "aac", "wav", "flac")
+ private val UNSAFE_FILENAME = Regex("""[\\/:*?"<>|]""")
+
+ fun downloadDirectory(): File = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
+ "PixelPlayer/SoundCloud",
+ )
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt
new file mode 100644
index 0000000000..5c81e65f06
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudDownloader.kt
@@ -0,0 +1,77 @@
+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 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)
+ 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 IOException("SoundCloud rate limited (HTTP 429). Wait a moment and try again.")
+ }
+ 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/SoundCloudLibraryGate.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudLibraryGate.kt
new file mode 100644
index 0000000000..2b45284dd8
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudLibraryGate.kt
@@ -0,0 +1,36 @@
+package com.theveloper.pixelplay.soundcloud
+
+import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository
+import com.theveloper.pixelplay.data.worker.SyncManager
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Keeps Music/PixelPlayer/SoundCloud in or out of the local library without
+ * blocking the SoundCloud screen. The folder rule is applied in the background
+ * scan the library already uses.
+ */
+@Singleton
+class SoundCloudLibraryGate @Inject constructor(
+ private val preferences: UserPreferencesRepository,
+ private val syncManager: SyncManager,
+) {
+ suspend fun apply(includeInLibrary: Boolean) {
+ val path = SoundCloudDownloadService.downloadDirectory().absolutePath
+ val blocked = preferences.blockedDirectoriesFlow.first()
+ .map { it.trimEnd('/', '\\') }
+ .toMutableSet()
+ val allowed = preferences.allowedDirectoriesFlow.first()
+ val currentlyBlocked = blocked.any { it.equals(path, ignoreCase = true) }
+ val shouldBlock = !includeInLibrary
+ if (currentlyBlocked == shouldBlock) return
+ if (shouldBlock) {
+ blocked.add(path)
+ } else {
+ blocked.removeAll { it.equals(path, ignoreCase = true) }
+ }
+ preferences.updateDirectorySelections(allowed, blocked)
+ syncManager.incrementalSync()
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt
new file mode 100644
index 0000000000..04dec8319e
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudModels.kt
@@ -0,0 +1,61 @@
+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,
+ /**
+ * Session lists already know a track can stream. NewPipe lists stay [Availability.UNKNOWN]
+ * so playability is checked after the list is on screen.
+ */
+ val availability: Availability = Availability.UNKNOWN,
+) {
+ enum class Kind { TRACK, PLAYLIST }
+ enum class Availability { UNKNOWN, AVAILABLE }
+}
+
+data class SoundCloudFileTags(
+ val title: String,
+ val artist: String,
+ val album: String,
+ val albumArtist: String,
+ val genre: String?,
+ val artworkUrl: String?,
+)
+
+internal fun String?.isSoundCloudPlaceholder(): Boolean {
+ val value = this?.trim().orEmpty()
+ return value.isEmpty() ||
+ value.equals("SoundCloud", ignoreCase = true) ||
+ value.equals("", ignoreCase = true)
+}
+
+data class SoundCloudFeedShelf(
+ val id: String,
+ val title: String,
+ val items: List,
+)
+
+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/SoundCloudPlayabilityStore.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPlayabilityStore.kt
new file mode 100644
index 0000000000..6d5e36fd4b
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPlayabilityStore.kt
@@ -0,0 +1,55 @@
+package com.theveloper.pixelplay.soundcloud
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Remembers which permalinks cannot be streamed.
+ * Unplayable URLs are persisted so the next list can gray them out without
+ * resolving streams again. Playable results stay in memory for this process.
+ */
+@Singleton
+class SoundCloudPlayabilityStore @Inject constructor(
+ @ApplicationContext context: Context,
+) {
+ private val preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ private val known = ConcurrentHashMap()
+
+ init {
+ preferences.getStringSet(KEY_UNPLAYABLE, emptySet()).orEmpty().forEach { url ->
+ if (url.isNotBlank()) known[url] = false
+ }
+ }
+
+ fun knows(url: String): Boolean = known.containsKey(url)
+
+ fun isUnplayable(url: String): Boolean = known[url] == false
+
+ fun remember(url: String, playable: Boolean) {
+ val key = url.trim()
+ if (key.isEmpty()) return
+ if (!playable && known[key] == false) return
+ known[key] = playable
+ if (!playable) persistUnplayable()
+ }
+
+ private fun persistUnplayable() {
+ val urls = known.entries
+ .asSequence()
+ .filter { !it.value }
+ .map { it.key }
+ .toList()
+ .takeLast(MAX_PERSISTED)
+ .toSet()
+ preferences.edit().putStringSet(KEY_UNPLAYABLE, urls).apply()
+ }
+
+ private companion object {
+ const val PREFS = "soundcloud_playability"
+ const val KEY_UNPLAYABLE = "unplayable_urls"
+ const val MAX_PERSISTED = 800
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt
new file mode 100644
index 0000000000..afc4a7d113
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudPrefsViewModel.kt
@@ -0,0 +1,100 @@
+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.first
+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,
+ private val libraryGate: SoundCloudLibraryGate,
+) : 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(),
+ )
+ val includeDownloadsInLibrary: StateFlow = settings.includeDownloadsInLibrary.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5_000),
+ true,
+ )
+
+ 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 {
+ val knownClientId = settings.clientId.first().ifBlank { clientId.value }
+ client.setSession(oauthToken, cookieHeader)
+ sessionApi.updateSession(oauthToken, cookieHeader, knownClientId.ifBlank { null })
+ val profile = withContext(Dispatchers.IO) {
+ runCatching { sessionApi.fetchMe() }.getOrNull()
+ }
+ settings.setSession(
+ oauthToken = oauthToken,
+ cookieHeader = cookieHeader,
+ displayName = profile?.first.orEmpty(),
+ permalink = profile?.second.orEmpty(),
+ )
+ }
+ }
+
+ fun setIncludeDownloadsInLibrary(include: Boolean) {
+ viewModelScope.launch {
+ settings.setIncludeDownloadsInLibrary(include)
+ runCatching { libraryGate.apply(include) }
+ }
+ }
+
+ fun signOut() {
+ viewModelScope.launch {
+ settings.clearSession()
+ client.setSession(null, null)
+ sessionApi.updateSession(null, null, clientId.value)
+ }
+ }
+
+ val likedPermalinks: StateFlow> = client.likedPermalinks
+
+ fun permalinkFor(songId: String): String? = client.permalinkForSong(songId)
+
+ /**
+ * Returns an error message when the like could not be saved.
+ */
+ suspend fun toggleSoundCloudLike(songId: String): String? = withContext(Dispatchers.IO) {
+ val url = client.permalinkForSong(songId)
+ ?: return@withContext "This SoundCloud track can't be liked from here"
+ if (!client.hasSession) return@withContext "Sign in to SoundCloud to like tracks"
+ val shouldLike = url !in client.likedPermalinks.value
+ runCatching { client.setTrackLiked(url, shouldLike) }
+ .exceptionOrNull()
+ ?.message
+ ?.ifBlank { "Couldn't update the SoundCloud like" }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt
new file mode 100644
index 0000000000..c97c2b5cbe
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSessionApi.kt
@@ -0,0 +1,412 @@
+package com.theveloper.pixelplay.soundcloud
+
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit
+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 loadRecentlyPlayed(limit: Int = 20): List {
+ return getFirstCollection(
+ limit,
+ "https://api-v2.soundcloud.com/me/play-history/tracks?limit=${limit * 2}&linked_partitioning=1",
+ "https://api-v2.soundcloud.com/me/play-history?limit=${limit * 2}&linked_partitioning=1",
+ )
+ }
+
+ @Throws(Exception::class)
+ fun loadMyLikes(limit: Int = 40): List {
+ val userId = requireUserId()
+ 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",
+ )
+ }
+
+ @Throws(Exception::class)
+ fun setTrackLiked(trackUrl: String, liked: Boolean) {
+ val encoded = java.net.URLEncoder.encode(trackUrl.trim(), Charsets.UTF_8.name())
+ val track = getJson(
+ "https://api-v2.soundcloud.com/resolve?url=$encoded",
+ requireAuth = true,
+ )
+ val trackId = track.optLong("id", -1L)
+ require(trackId > 0) { "SoundCloud could not resolve this track for liking" }
+ val userId = requireUserId()
+ mutate(
+ url = "https://api-v2.soundcloud.com/users/$userId/track_likes/$trackId",
+ method = if (liked) "PUT" else "DELETE",
+ )
+ }
+
+ private fun requireUserId(): Long {
+ cachedUserId.get()?.let { return it }
+ return fetchMe().third
+ }
+
+ 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 (isUnplayableTrack(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() }
+ .let { if (it.isBlank()) null else upgradeArtworkUrl(it) },
+ kind = SoundCloudSearchHit.Kind.TRACK,
+ availability = SoundCloudSearchHit.Availability.AVAILABLE,
+ )
+ }
+
+ private fun playlistToHit(playlist: JSONObject): SoundCloudSearchHit? {
+ val url = playlist.optString("permalink_url").ifBlank { return null }
+ val user = playlist.optJSONObject("user")
+ val firstTrackArt = playlist.optJSONArray("tracks")
+ ?.optJSONObject(0)
+ ?.optString("artwork_url")
+ .orEmpty()
+ val art = sequenceOf(
+ playlist.optString("artwork_url"),
+ playlist.optString("calculated_artwork_url"),
+ firstTrackArt,
+ user?.optString("avatar_url").orEmpty(),
+ )
+ .map { it.trim() }
+ .firstOrNull { it.isNotBlank() }
+ ?.let { upgradeArtworkUrl(it) }
+ return SoundCloudSearchHit(
+ url = url,
+ title = playlist.optString("title").ifBlank { "Playlist" },
+ artist = user?.optString("username").orEmpty().ifBlank { "SoundCloud" },
+ durationSec = 0,
+ thumbnailUrl = art,
+ kind = SoundCloudSearchHit.Kind.PLAYLIST,
+ streamCount = playlist.optLong("track_count", 0L).coerceAtLeast(0L),
+ )
+ }
+
+ private fun upgradeArtworkUrl(url: String): String {
+ return url
+ .replace("-large.", "-t500x500.")
+ .replace("-t200x200.", "-t500x500.")
+ .replace("-t67x67.", "-t500x500.")
+ .replace("-badge.", "-t500x500.")
+ }
+
+ /**
+ * True when the track JSON itself says it cannot be streamed: blocked, not
+ * streamable, or every audio transcoding is DRM. Usable when the payload is
+ * already in memory, so list loading does not gain extra requests.
+ */
+ internal fun isUnplayableTrack(track: JSONObject): Boolean {
+ if (track.has("streamable") && !track.optBoolean("streamable", true)) return true
+ val policy = track.optString("policy").lowercase()
+ if (policy == "block" || policy == "blocked") return true
+ return isDrmOnlyTrack(track)
+ }
+
+ /**
+ * Returns true when every known audio transcoding is DRM encrypted-hls
+ * (cbc-/ctr-encrypted-hls). Tracks with progressive or plain HLS stay visible.
+ */
+ 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
+ }
+
+ /**
+ * True when the permalink can stream, false when the track is blocked or DRM-only.
+ * Null when the lookup itself failed — callers must not treat that as unplayable.
+ */
+ fun playbackAvailability(trackUrl: String): Boolean? {
+ return try {
+ resolvedTrack(trackUrl)?.let { !isUnplayableTrack(it) }
+ } catch (_: Throwable) {
+ null
+ }
+ }
+
+ /** Title, artist, album, genre, and artwork from api-v2. Null when the lookup fails. */
+ fun loadFileTags(trackUrl: String): SoundCloudFileTags? {
+ val track = try {
+ resolvedTrack(trackUrl)
+ } catch (_: Throwable) {
+ null
+ } ?: return null
+ val user = track.optJSONObject("user")
+ val publisher = track.optJSONObject("publisher_metadata")
+ val title = track.optString("title").trim()
+ if (title.isEmpty()) return null
+ val artist = publisher?.optString("artist").orEmpty().trim()
+ .ifBlank { user?.optString("username").orEmpty().trim() }
+ .ifBlank { return null }
+ val album = publisher?.optString("album_title").orEmpty().trim()
+ .ifBlank { publisher?.optString("release_title").orEmpty().trim() }
+ .ifBlank { artist }
+ val genre = track.optString("genre").trim().ifBlank { null }
+ val artwork = track.optString("artwork_url")
+ .ifBlank { user?.optString("avatar_url").orEmpty() }
+ .trim()
+ .ifBlank { null }
+ ?.let { upgradeArtworkUrl(it) }
+ return SoundCloudFileTags(
+ title = title,
+ artist = artist,
+ album = album,
+ albumArtist = artist,
+ genre = genre?.takeUnless { it.isSoundCloudPlaceholder() },
+ artworkUrl = artwork,
+ )
+ }
+
+ private fun resolvedTrack(trackUrl: String): JSONObject? {
+ val encoded = java.net.URLEncoder.encode(trackUrl.trim(), Charsets.UTF_8.name())
+ val json = getJson(
+ "https://api-v2.soundcloud.com/resolve?url=$encoded",
+ requireAuth = false,
+ )
+ return when {
+ json.has("media") || (json.has("permalink_url") && json.has("title")) -> json
+ else -> json.optJSONObject("track")
+ }
+ }
+
+ 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
+ }
+ }
+
+ private fun mutate(url: String, method: String) {
+ val token = oauthToken.get()
+ require(token.isNotBlank()) { "Sign in to SoundCloud to like tracks" }
+ val id = clientId.get()
+ val finalUrl = if (id.isNotBlank() && !url.contains("client_id=")) {
+ "$url?client_id=$id"
+ } else {
+ url
+ }
+ val builder = Request.Builder()
+ .url(finalUrl)
+ .method(method, ByteArray(0).toRequestBody(null))
+ .header("User-Agent", USER_AGENT)
+ .header("Accept", "application/json, text/javascript, */*; q=0.01")
+ .header("Authorization", "OAuth $token")
+ .header("Origin", "https://soundcloud.com")
+ .header("Referer", "https://soundcloud.com/")
+ cookieHeader.get().takeIf { it.isNotBlank() }?.let { builder.header("Cookie", it) }
+ http.newCall(builder.build()).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw IllegalStateException(
+ "SoundCloud like request failed (${response.code}): ${response.body.string().take(180)}",
+ )
+ }
+ }
+ }
+
+ companion object {
+ private const val USER_AGENT =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0"
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt
new file mode 100644
index 0000000000..c1baa47db3
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudSettings.kt
@@ -0,0 +1,113 @@
+package com.theveloper.pixelplay.soundcloud
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+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 }
+
+ /** When false, SoundCloud downloads stay on the downloads tab and out of the library. */
+ val includeDownloadsInLibrary: Flow = dataStore.data.map { prefs ->
+ prefs[Keys.INCLUDE_DOWNLOADS_IN_LIBRARY] ?: true
+ }
+
+ suspend fun setClientId(value: String) {
+ dataStore.edit { it[Keys.CLIENT_ID] = value.trim() }
+ }
+
+ 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 setIncludeDownloadsInLibrary(include: Boolean) {
+ dataStore.edit { it[Keys.INCLUDE_DOWNLOADS_IN_LIBRARY] = include }
+ }
+
+ 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")
+ val INCLUDE_DOWNLOADS_IN_LIBRARY = booleanPreferencesKey("include_downloads_in_library")
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt
new file mode 100644
index 0000000000..0220a70788
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/soundcloud/SoundCloudViewModel.kt
@@ -0,0 +1,897 @@
+package com.theveloper.pixelplay.soundcloud
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableStateFlow
+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 feedShelves: 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,
+ val isLoadingMore: Boolean = false,
+ val endReached: Boolean = false,
+ val likedTrackUrls: Set = emptySet(),
+ val likingUrl: String? = null,
+ /** Permalinks that cannot be streamed. Populated after the list is shown. */
+ val unplayableUrls: Set = emptySet(),
+)
+
+@HiltViewModel
+class SoundCloudViewModel @Inject constructor(
+ private val client: SoundCloudClient,
+ private val settings: SoundCloudSettings,
+ private val downloader: SoundCloudDownloadService,
+ private val libraryGate: SoundCloudLibraryGate,
+) : ViewModel() {
+
+ private data class NavFrame(
+ val section: SoundCloudSection,
+ val results: List,
+ val browsingPlaylistTitle: String?,
+ val browsingPlaylistUrl: String?,
+ val query: String,
+ )
+
+ private val _uiState = MutableStateFlow(SoundCloudUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var searchJob: Job? = null
+ private var loadJob: Job? = null
+ private var repairJob: Job? = null
+ private var probeJob: Job? = null
+ private var queuePrefetchJob: Job? = null
+ private val artworkAttempts = mutableSetOf()
+ 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 {
+ settings.includeDownloadsInLibrary.collect { include ->
+ runCatching { libraryGate.apply(include) }
+ .onFailure { Timber.w(it, "SoundCloud library visibility update failed") }
+ }
+ }
+ viewModelScope.launch {
+ delay(350)
+ val state = _uiState.value
+ 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,
+ feedShelves = if (section == SoundCloudSection.FEED) it.feedShelves else emptyList(),
+ errorMessage = null,
+ statusMessage = null,
+ canNavigateBack = false,
+ isRefreshing = false,
+ isLoadingMore = false,
+ endReached = 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 -> loadFeed(refreshing = true)
+ 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(),
+ isLoadingMore = false,
+ endReached = false,
+ )
+ }
+ return true
+ }
+
+ private fun pushNavFrame() {
+ val state = _uiState.value
+ navStack.addLast(
+ NavFrame(
+ 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), permalinkUrl = hit.url)
+ }
+
+ /**
+ * Resolves the tapped track, then the following tracks, until one can play.
+ * Unplayable permalinks are remembered so the list can gray them without
+ * another resolve. Playback is not stopped when a track in the list fails.
+ */
+ suspend fun resolvePlayableStart(
+ hit: SoundCloudSearchHit,
+ queueHits: List,
+ ): Pair {
+ val tracks = queueHits
+ .asSequence()
+ .filter { it.kind == SoundCloudSearchHit.Kind.TRACK }
+ .distinctBy { it.url }
+ .toList()
+ val startIndex = tracks.indexOfFirst { it.url == hit.url }
+ val ordered = when {
+ hit.kind == SoundCloudSearchHit.Kind.TRACK && startIndex >= 0 -> tracks.drop(startIndex)
+ hit.kind == SoundCloudSearchHit.Kind.TRACK -> listOf(hit) + tracks.filter { it.url != hit.url }
+ else -> tracks
+ }
+ var lastError: Throwable? = null
+ for (candidate in ordered) {
+ if (client.isKnownUnplayable(candidate.url)) {
+ noteUnplayable(candidate.url)
+ continue
+ }
+ setBusy(candidate.url)
+ try {
+ val song = withContext(Dispatchers.IO) {
+ ensureClient()
+ client.toSong(client.resolveTrack(candidate.url), permalinkUrl = candidate.url)
+ }
+ client.rememberPlayable(candidate.url)
+ return song to candidate
+ } catch (t: CancellationException) {
+ throw t
+ } catch (t: Throwable) {
+ if (!client.isPermanentStreamFailure(t)) throw t
+ client.rememberUnplayable(candidate.url)
+ noteUnplayable(candidate.url)
+ lastError = t
+ Timber.w(t, "Skip unplayable SoundCloud track: %s", candidate.url)
+ }
+ }
+ throw lastError ?: IllegalStateException("No playable streams in this list")
+ }
+
+ /**
+ * Background-resolves tracks after [startHit] in list order and invokes [onSong]
+ * for each success (typically to append to the player queue).
+ */
+ fun prefetchQueueAfter(
+ startHit: SoundCloudSearchHit,
+ queueHits: List,
+ limit: Int = 24,
+ onSong: (Song) -> Unit,
+ ) {
+ val tracks = queueHits
+ .asSequence()
+ .filter { it.kind == SoundCloudSearchHit.Kind.TRACK }
+ .distinctBy { it.url }
+ .toList()
+ val startIndex = tracks.indexOfFirst { it.url == startHit.url }
+ val following = if (startIndex >= 0) {
+ tracks.drop(startIndex + 1).take(limit)
+ } else {
+ tracks.filter { it.url != startHit.url }.take(limit)
+ }
+ prefetchHits(following, onSong)
+ }
+
+ /**
+ * Resolves the first playable track quickly so shuffle can start immediately.
+ * Prefetch the rest with [prefetchHits] after calling play.
+ */
+ suspend fun resolveShuffleStart(limit: Int = 30): Pair> {
+ 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(tracks.first().url)
+ val startSong = withContext(Dispatchers.IO) {
+ ensureClient()
+ var first: Song? = null
+ for (hit in tracks) {
+ if (client.isKnownUnplayable(hit.url)) {
+ noteUnplayable(hit.url)
+ continue
+ }
+ runCatching {
+ client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url)
+ }.onSuccess { song ->
+ client.rememberPlayable(hit.url)
+ first = song
+ }.onFailure { error ->
+ if (client.isPermanentStreamFailure(error)) {
+ client.rememberUnplayable(hit.url)
+ noteUnplayable(hit.url)
+ }
+ Timber.w(error, "Skip unplayable SoundCloud track: %s", hit.url)
+ }
+ if (first != null) break
+ }
+ first ?: throw IllegalStateException("No playable streams in this list")
+ }
+ val remaining = tracks.filter { SoundCloudClient.songIdForUrl(it.url) != startSong.id }
+ return startSong to remaining
+ }
+
+ /**
+ * Background-resolves [hits] and invokes [onSong] for each success.
+ * Call after playback has started so songs append to the new queue.
+ */
+ fun prefetchHits(
+ hits: List,
+ onSong: (Song) -> Unit,
+ ) {
+ if (hits.isEmpty()) return
+ queuePrefetchJob?.cancel()
+ queuePrefetchJob = viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ ensureClient()
+ for (hit in hits) {
+ ensureActive()
+ if (hit.kind != SoundCloudSearchHit.Kind.TRACK) continue
+ if (client.isKnownUnplayable(hit.url)) {
+ noteUnplayable(hit.url)
+ continue
+ }
+ runCatching {
+ client.toSong(client.resolveTrack(hit.url), permalinkUrl = hit.url)
+ }.onSuccess { song ->
+ client.rememberPlayable(hit.url)
+ withContext(Dispatchers.Main.immediate) { onSong(song) }
+ }.onFailure { error ->
+ if (client.isPermanentStreamFailure(error)) {
+ client.rememberUnplayable(hit.url)
+ noteUnplayable(hit.url)
+ }
+ Timber.w(error, "Skip unplayable SoundCloud track: %s", hit.url)
+ }
+ }
+ }
+ }
+ }
+
+ 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, client.fileTags(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 toggleLike(hit: SoundCloudSearchHit) {
+ if (hit.kind != SoundCloudSearchHit.Kind.TRACK || _uiState.value.likingUrl != null) return
+ if (!_uiState.value.isSignedIn) {
+ _uiState.update { it.copy(errorMessage = "Sign in to SoundCloud to like tracks") }
+ return
+ }
+ val shouldLike = hit.url !in _uiState.value.likedTrackUrls
+ viewModelScope.launch {
+ _uiState.update { it.copy(likingUrl = hit.url, errorMessage = null) }
+ try {
+ withContext(Dispatchers.IO) { client.setTrackLiked(hit.url, shouldLike) }
+ _uiState.update { state ->
+ val liked = if (shouldLike) {
+ state.likedTrackUrls + hit.url
+ } else {
+ state.likedTrackUrls - hit.url
+ }
+ state.copy(
+ likedTrackUrls = liked,
+ likingUrl = null,
+ results = if (!shouldLike && state.section == SoundCloudSection.LIKES) {
+ state.results.filterNot { it.url == hit.url }
+ } else {
+ state.results
+ },
+ )
+ }
+ } catch (t: Throwable) {
+ Timber.w(t, "SoundCloud like toggle failed")
+ _uiState.update {
+ it.copy(likingUrl = null, errorMessage = t.message ?: t::class.java.simpleName)
+ }
+ }
+ }
+ }
+
+ fun setBusy(key: String? = null) {
+ _uiState.update {
+ it.copy(isLoading = true, statusMessage = null, errorMessage = null, resolvingKey = key)
+ }
+ }
+
+ fun setIdle(error: String? = null) {
+ _uiState.update {
+ it.copy(isLoading = false, statusMessage = null, errorMessage = error, resolvingKey = null)
+ }
+ }
+
+ fun refreshDownloads() {
+ val catalog = downloader.listDownloadedSongs()
+ _uiState.update {
+ it.copy(
+ downloads = catalog.songs,
+ results = emptyList(),
+ isLoading = false,
+ statusMessage = null,
+ )
+ }
+ repairDownloadedFiles(catalog.repairs)
+ }
+
+ /**
+ * Rewrites tags and embeds cover art after the downloads list is already shown.
+ * One file at a time so opening the tab stays as fast as the MediaStore query.
+ */
+ private fun repairDownloadedFiles(songs: List) {
+ if (songs.isEmpty()) return
+ repairJob?.cancel()
+ repairJob = viewModelScope.launch(Dispatchers.IO) {
+ if (_uiState.value.clientId.isNotBlank()) ensureClient()
+ for (song in songs) {
+ ensureActive()
+ val artworkUrl = song.albumArtUriString?.takeIf { it.startsWith("http") }
+ ?: findArtworkUrl(song)
+ val artist = song.artist.takeUnless { it.isSoundCloudPlaceholder() } ?: "Unknown artist"
+ val album = song.album.takeUnless { it.isSoundCloudPlaceholder() } ?: artist
+ val tags = SoundCloudFileTags(
+ title = song.title,
+ artist = artist,
+ album = album,
+ albumArtist = song.albumArtist?.takeUnless { it.isSoundCloudPlaceholder() } ?: artist,
+ genre = song.genre?.takeUnless { it.isSoundCloudPlaceholder() },
+ artworkUrl = artworkUrl,
+ )
+ val updatedArt = runCatching { downloader.rewriteTags(song, tags) }.getOrNull()
+ if (!updatedArt.isNullOrBlank()) {
+ _uiState.update { state ->
+ state.copy(
+ downloads = state.downloads.map { current ->
+ if (current.id == song.id) {
+ current.copy(albumArtUriString = updatedArt)
+ } else {
+ current
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private fun findArtworkUrl(song: Song): String? {
+ if (_uiState.value.clientId.isBlank() || !artworkAttempts.add(song.id)) return null
+ val query = "${song.artist} ${song.title}".trim()
+ val candidates = runCatching { client.search(query, 5) }.getOrDefault(emptyList())
+ val normalizedTitle = song.title.normalizedForArtworkMatch()
+ val match = candidates.firstOrNull {
+ it.kind == SoundCloudSearchHit.Kind.TRACK &&
+ it.title.normalizedForArtworkMatch() == normalizedTitle &&
+ !it.thumbnailUrl.isNullOrBlank()
+ } ?: candidates.firstOrNull {
+ it.kind == SoundCloudSearchHit.Kind.TRACK && !it.thumbnailUrl.isNullOrBlank()
+ }
+ return match?.thumbnailUrl
+ }
+
+ private fun String.normalizedForArtworkMatch(): String =
+ lowercase().replace(Regex("[^a-z0-9]+"), "")
+
+ 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)
+ }
+ }
+ }
+ }
+
+ /** Requests a larger result window when the current list reaches its end. */
+ fun loadMore() {
+ val state = _uiState.value
+ if (state.isLoading || state.isRefreshing || state.isLoadingMore || state.endReached) return
+ val nextLimit = (state.results.size + PAGE_SIZE).coerceAtMost(MAX_RESULTS)
+ if (nextLimit <= state.results.size) {
+ _uiState.update { it.copy(endReached = true) }
+ return
+ }
+ val playlistUrl = state.browsingPlaylistUrl
+ if (!playlistUrl.isNullOrBlank()) {
+ runLoad("Loading more…", clearResults = false, loadingMore = true) {
+ client.loadPlaylistTracks(playlistUrl, nextLimit)
+ }
+ return
+ }
+ when (state.section) {
+ SoundCloudSection.FEED -> loadFeed(limit = nextLimit, loadingMore = true)
+ SoundCloudSection.DISCOVER ->
+ runLoad("Loading more…", clearResults = false, loadingMore = true) {
+ client.loadDiscover(nextLimit)
+ }
+ SoundCloudSection.SEARCH -> {
+ val query = state.query.trim()
+ if (query.length >= 2) {
+ runLoad("Loading more…", clearResults = false, loadingMore = true) {
+ client.search(query, nextLimit)
+ }
+ }
+ }
+ SoundCloudSection.LIKES -> loadLikes(limit = nextLimit, loadingMore = true)
+ SoundCloudSection.TRACKS -> loadTracks(limit = nextLimit, loadingMore = true)
+ SoundCloudSection.PLAYLISTS -> loadPlaylists(limit = nextLimit, loadingMore = true)
+ SoundCloudSection.DOWNLOADS -> _uiState.update { it.copy(endReached = true) }
+ }
+ }
+
+ private fun loadFeed(
+ refreshing: Boolean = false,
+ limit: Int = INITIAL_FEED_SIZE,
+ loadingMore: Boolean = false,
+ ) = runLoad(
+ "Loading feed…",
+ clearResults = !refreshing && !loadingMore,
+ refreshing = refreshing,
+ loadingMore = loadingMore,
+ ) {
+ if (!client.hasSession) {
+ throw IllegalStateException("Sign in to SoundCloud to see your personal feed")
+ }
+ val shelfLimit = limit.coerceAtMost(40)
+ val feed = client.loadFeed(limit)
+ val likes = runCatching { client.loadMyLikes(shelfLimit) }.getOrDefault(emptyList())
+ val history = runCatching { client.loadRecentlyPlayed(shelfLimit) }.getOrDefault(emptyList())
+ val playlists = runCatching { client.loadMyPlaylists(shelfLimit) }.getOrDefault(emptyList())
+ val discover = runCatching { client.loadDiscover(shelfLimit) }.getOrDefault(emptyList())
+ val mixed = (likes.take(6) + feed.take(8) + discover.take(6))
+ .distinctBy { it.url }
+ .take(16)
+ val user = _uiState.value.displayName.ifBlank { _uiState.value.username }
+ val shelves = buildList {
+ if (likes.isNotEmpty()) add(
+ SoundCloudFeedShelf("more_like", "More of what you like", likes),
+ )
+ if (history.isNotEmpty()) add(
+ SoundCloudFeedShelf("recent", "Recently played", history),
+ )
+ if (mixed.isNotEmpty()) add(
+ SoundCloudFeedShelf(
+ "mixed",
+ if (user.isBlank()) "Mixed for you" else "Mixed for $user",
+ mixed,
+ ),
+ )
+ if (discover.isNotEmpty()) add(
+ SoundCloudFeedShelf("curated", "Curated by SoundCloud", discover),
+ )
+ if (playlists.isNotEmpty()) add(
+ SoundCloudFeedShelf("playlists", "Playlists for you", playlists),
+ )
+ if (feed.isNotEmpty()) add(
+ SoundCloudFeedShelf("following", "Latest from people you follow", feed),
+ )
+ }
+ _uiState.update { it.copy(feedShelves = shelves) }
+ feed
+ }
+
+ private fun loadDiscover() = runLoad("Loading discover…") { client.loadDiscover() }
+
+ private fun loadLikes(
+ refreshing: Boolean = false,
+ limit: Int = INITIAL_LIST_SIZE,
+ loadingMore: Boolean = false,
+ ) = runLoad(
+ "Loading likes…",
+ clearResults = !refreshing && !loadingMore,
+ refreshing = refreshing,
+ loadingMore = loadingMore,
+ ) {
+ val hits = if (client.hasSession) {
+ client.loadMyLikes(limit)
+ } else {
+ client.loadUserLikes(_uiState.value.username, limit)
+ }
+ if (client.hasSession) {
+ val likedUrls = hits
+ .filter { it.kind == SoundCloudSearchHit.Kind.TRACK }
+ .map { it.url }
+ client.rememberLikes(likedUrls)
+ _uiState.update { state ->
+ state.copy(likedTrackUrls = state.likedTrackUrls + likedUrls)
+ }
+ }
+ hits
+ }
+
+ private fun loadTracks(
+ refreshing: Boolean = false,
+ limit: Int = INITIAL_LIST_SIZE,
+ loadingMore: Boolean = false,
+ ) = runLoad(
+ "Loading tracks…",
+ clearResults = !refreshing && !loadingMore,
+ refreshing = refreshing,
+ loadingMore = loadingMore,
+ ) {
+ if (client.hasSession) {
+ client.loadMyTracks(limit)
+ } else {
+ client.loadUserTracks(_uiState.value.username, limit)
+ }
+ }
+
+ private fun loadPlaylists(
+ refreshing: Boolean = false,
+ limit: Int = INITIAL_LIST_SIZE,
+ loadingMore: Boolean = false,
+ ) = runLoad(
+ "Loading playlists…",
+ clearResults = !refreshing && !loadingMore,
+ refreshing = refreshing,
+ loadingMore = loadingMore,
+ ) {
+ if (client.hasSession) {
+ client.loadMyPlaylists(limit)
+ } else {
+ client.loadUserPlaylists(_uiState.value.username, limit)
+ }
+ }
+
+ private fun noteUnplayable(url: String) {
+ _uiState.update { it.copy(unplayableUrls = it.unplayableUrls + url) }
+ }
+
+ /**
+ * Checks tracks that NewPipe could not classify, after the list is visible.
+ * Known results are reused. Concurrency stays low so a tap can still resolve first.
+ */
+ private fun schedulePlayabilityProbe(hits: List) {
+ val targets = hits
+ .asSequence()
+ .filter { it.kind == SoundCloudSearchHit.Kind.TRACK }
+ .filter { it.availability == SoundCloudSearchHit.Availability.UNKNOWN }
+ .filter { !client.knowsPlayability(it.url) }
+ .distinctBy { it.url }
+ .take(PROBE_LIMIT)
+ .toList()
+ probeJob?.cancel()
+ if (targets.isEmpty()) return
+ probeJob = viewModelScope.launch(Dispatchers.IO) {
+ delay(PROBE_START_DELAY_MS)
+ val gate = Semaphore(PROBE_CONCURRENCY)
+ coroutineScope {
+ for (hit in targets) {
+ ensureActive()
+ launch {
+ gate.withPermit {
+ ensureActive()
+ while (_uiState.value.resolvingKey != null) {
+ ensureActive()
+ delay(150)
+ }
+ val playable = client.probePlayable(hit.url) ?: return@withPermit
+ if (!playable) noteUnplayable(hit.url)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun runLoad(
+ @Suppress("UNUSED_PARAMETER") status: String,
+ cancelPrevious: Boolean = true,
+ clearResults: Boolean = true,
+ refreshing: Boolean = false,
+ loadingMore: Boolean = false,
+ block: suspend () -> List,
+ ) {
+ if (cancelPrevious) {
+ loadJob?.cancel()
+ probeJob?.cancel()
+ }
+ val job = viewModelScope.launch {
+ val previousCount = _uiState.value.results.size
+ _uiState.update {
+ it.copy(
+ isLoading = !refreshing && !loadingMore,
+ isRefreshing = refreshing,
+ isLoadingMore = loadingMore,
+ errorMessage = null,
+ statusMessage = null,
+ results = if (clearResults) emptyList() else it.results,
+ )
+ }
+ try {
+ ensureClient()
+ val hits = withContext(Dispatchers.IO) { block() }
+ val visible = hits + _uiState.value.feedShelves.flatMap { shelf -> shelf.items }
+ val knownUnplayable = visible
+ .asSequence()
+ .map { it.url }
+ .filter { client.isKnownUnplayable(it) }
+ .toSet()
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isRefreshing = false,
+ isLoadingMore = false,
+ results = hits,
+ unplayableUrls = knownUnplayable,
+ endReached = loadingMore && hits.size <= previousCount,
+ statusMessage = null,
+ )
+ }
+ schedulePlayabilityProbe(visible)
+ } catch (t: Throwable) {
+ if (t is kotlinx.coroutines.CancellationException) throw t
+ Timber.w(t, "SoundCloud load failed")
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isRefreshing = false,
+ isLoadingMore = false,
+ errorMessage = t.message ?: t::class.java.simpleName,
+ statusMessage = null,
+ )
+ }
+ }
+ }
+ if (cancelPrevious) {
+ loadJob = job
+ }
+ }
+
+ private suspend fun ensureClient() {
+ var id = _uiState.value.clientId.trim()
+ if (id.isBlank()) {
+ id = withContext(Dispatchers.IO) { client.refreshClientIdFromWeb() }
+ settings.setClientId(id)
+ _uiState.update { it.copy(clientId = id) }
+ }
+ client.setClientIdOverride(id)
+ }
+
+ companion object {
+ private const val SEARCH_DEBOUNCE_MS = 350L
+ private const val INITIAL_FEED_SIZE = 40
+ private const val INITIAL_LIST_SIZE = 40
+ private const val PAGE_SIZE = 30
+ private const val MAX_RESULTS = 200
+ private const val PROBE_LIMIT = 40
+ private const val PROBE_CONCURRENCY = 2
+ private const val PROBE_START_DELAY_MS = 450L
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/LocalArtworkUri.kt b/app/src/main/java/com/theveloper/pixelplay/utils/LocalArtworkUri.kt
index ee405d4c71..d039377d01 100644
--- a/app/src/main/java/com/theveloper/pixelplay/utils/LocalArtworkUri.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/utils/LocalArtworkUri.kt
@@ -46,15 +46,23 @@ object LocalArtworkUri {
if (!looksLikeVolatileArtworkUri(uriString)) return null
val fileName = uriString.substringAfterLast('/').substringBefore('?')
- if (!fileName.startsWith("song_art_")) {
- return null
+ if (fileName.startsWith("song_art_")) {
+ return fileName
+ .removePrefix("song_art_")
+ .substringBefore('_')
+ .substringBefore('.')
+ .toLongOrNull()
}
- return fileName
- .removePrefix("song_art_")
- .substringBefore('_')
- .substringBefore('.')
- .toLongOrNull()
+ val sharedMarker = ".artwork/song/"
+ val markerIndex = uriString.lowercase().indexOf(sharedMarker)
+ if (markerIndex >= 0) {
+ return uriString.substring(markerIndex + sharedMarker.length)
+ .substringBefore('?')
+ .substringBefore('/')
+ .toLongOrNull()
+ }
+ return null
}
fun extractCacheBustToken(uriString: String?): String? {
diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt b/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt
index 48a8e1bcea..4f108b3466 100644
--- a/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt
@@ -13,6 +13,8 @@ import androidx.media3.common.MediaMetadata.PICTURE_TYPE_FRONT_COVER
import androidx.media3.common.util.UnstableApi
import com.theveloper.pixelplay.data.provider.SharedArtworkContentProvider
import com.theveloper.pixelplay.data.model.Song
+import com.theveloper.pixelplay.soundcloud.SoundCloudClient
+import com.theveloper.pixelplay.youtube.YoutubeClient
import java.io.File
object MediaItemBuilder {
@@ -99,12 +101,16 @@ object MediaItemBuilder {
const val EXTERNAL_EXTRA_NAVIDROME_ID = EXTERNAL_EXTRA_PREFIX + "NAVIDROME_ID"
fun build(song: Song): MediaItem {
- return MediaItem.Builder()
- .setMediaId(song.id)
- .setUri(playbackUri(song))
- .setMimeType(playbackMimeType(song))
- .setMediaMetadata(buildMediaMetadataForSong(song))
- .build()
+ return withYoutubeWatchUrl(
+ withSoundCloudPermalink(
+ MediaItem.Builder()
+ .setMediaId(song.id)
+ .setUri(playbackUri(song))
+ .setMimeType(playbackMimeType(song))
+ .setMediaMetadata(buildMediaMetadataForSong(song))
+ .build(),
+ ),
+ )
}
fun buildForExternalController(context: Context, song: Song): MediaItem {
@@ -124,9 +130,35 @@ object MediaItemBuilder {
)
)
.build()
+ .let(::withSoundCloudPermalink)
+ .let(::withYoutubeWatchUrl)
}
}
+ fun withSoundCloudPermalink(item: MediaItem): MediaItem {
+ val permalink = item.mediaMetadata.extras?.getString(SoundCloudClient.EXTRA_PERMALINK)
+ ?: SoundCloudClient.permalinkForSongId(item.mediaId)
+ ?: return item
+ val metadata = item.mediaMetadata
+ val extras = Bundle(metadata.extras ?: Bundle())
+ extras.putString(SoundCloudClient.EXTRA_PERMALINK, permalink)
+ return item.buildUpon()
+ .setMediaMetadata(metadata.buildUpon().setExtras(extras).build())
+ .build()
+ }
+
+ fun withYoutubeWatchUrl(item: MediaItem): MediaItem {
+ val watchUrl = item.mediaMetadata.extras?.getString(YoutubeClient.EXTRA_WATCH_URL)
+ ?: YoutubeClient.watchUrlForSongId(item.mediaId)
+ ?: return item
+ val metadata = item.mediaMetadata
+ val extras = Bundle(metadata.extras ?: Bundle())
+ extras.putString(YoutubeClient.EXTRA_WATCH_URL, watchUrl)
+ return item.buildUpon()
+ .setMediaMetadata(metadata.buildUpon().setExtras(extras).build())
+ .build()
+ }
+
fun playbackUri(song: Song): Uri = playbackUri(
contentUriString = song.contentUriString,
filePath = song.path,
@@ -300,12 +332,22 @@ object MediaItemBuilder {
putInt(EXTERNAL_EXTRA_SAMPLE_RATE, song.sampleRate ?: 0)
putString(EXTERNAL_EXTRA_FILE_PATH, song.path)
song.navidromeId?.let { putString(EXTERNAL_EXTRA_NAVIDROME_ID, it) }
+ youtubeWatchUrlForSong(song)?.let { watchUrl ->
+ putString(YoutubeClient.EXTRA_WATCH_URL, watchUrl)
+ }
}
metadataBuilder.setExtras(extras)
return metadataBuilder.build()
}
+ private fun youtubeWatchUrlForSong(song: Song): String? {
+ YoutubeClient.watchUrlForSongId(song.id)?.let { return it }
+ val candidate = song.contentUriString.takeIf { it.startsWith("http") }
+ ?: song.path.takeIf { it.startsWith("http") }
+ return candidate?.takeIf { YoutubeClient.videoIdFromUrl(it).length == 11 }
+ }
+
private fun normalizeArtworkUri(
rawArtworkUri: String?,
supportedSchemes: Set
diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/PackageInstallAccess.kt b/app/src/main/java/com/theveloper/pixelplay/utils/PackageInstallAccess.kt
new file mode 100644
index 0000000000..5d4eddcf96
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/utils/PackageInstallAccess.kt
@@ -0,0 +1,14 @@
+package com.theveloper.pixelplay.utils
+
+import android.os.Build
+
+/**
+ * Package-install queries throw [SecurityException] on Android 8+ when
+ * REQUEST_INSTALL_PACKAGES is missing from the manifest.
+ */
+object PackageInstallAccess {
+ fun canRequestInstalls(sdkInt: Int = Build.VERSION.SDK_INT, query: () -> Boolean): Boolean {
+ if (sdkInt < Build.VERSION_CODES.O) return true
+ return runCatching(query).getOrDefault(false)
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt
new file mode 100644
index 0000000000..8d7bb9abed
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeClient.kt
@@ -0,0 +1,1439 @@
+package com.theveloper.pixelplay.youtube
+
+import com.theveloper.pixelplay.data.model.Song
+import org.schabi.newpipe.extractor.InfoItem
+import org.schabi.newpipe.extractor.NewPipe
+import org.schabi.newpipe.extractor.ServiceList
+import org.schabi.newpipe.extractor.kiosk.KioskInfo
+import org.schabi.newpipe.extractor.playlist.PlaylistInfo
+import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
+import org.json.JSONArray
+import org.json.JSONObject
+import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper
+import org.schabi.newpipe.extractor.services.youtube.YoutubeService
+import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
+import org.schabi.newpipe.extractor.stream.AudioStream
+import org.schabi.newpipe.extractor.stream.DeliveryMethod
+import org.schabi.newpipe.extractor.stream.StreamInfo
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import timber.log.Timber
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class YoutubeClient @Inject constructor(
+ private val session: YoutubeSession,
+) {
+ private val downloader = YoutubeDownloader()
+ private val resolveCache = ConcurrentHashMap()
+ private val inFlightResolves = java.util.Collections.newSetFromMap(ConcurrentHashMap())
+ @Volatile var feedShelves: List = emptyList()
+ @Volatile var playlistHits: List = emptyList()
+ @Volatile var radioHits: List = emptyList()
+ @Volatile var likedHits: List = emptyList()
+ @Volatile private var visitorId: String? = null
+ @Volatile private var identityChecked = false
+
+ private data class CachedResolve(
+ val track: YoutubeResolvedTrack,
+ val cachedAtMs: Long,
+ )
+
+ init {
+ ensureInitialized(downloader)
+ }
+
+ fun loadTrendingMusic(limit: Int = 30): List {
+ applySession()
+ val extractor = youtube().kioskList.getExtractorById("trending_music", null)
+ extractor.fetchPage()
+ val info = KioskInfo.getInfo(extractor)
+ return info.relatedItems
+ .asSequence()
+ .mapNotNull { it.toHitOrNull() }
+ .filter { it.url.isNotBlank() && it.kind == YoutubeHit.Kind.TRACK }
+ .take(limit)
+ .toList()
+ }
+
+ fun search(
+ query: String,
+ filter: YoutubeSearchFilter,
+ limit: Int = 30,
+ ): List {
+ val trimmed = query.trim()
+ require(trimmed.isNotEmpty()) { "Query is empty" }
+ applySession()
+ val contentFilter = when (filter) {
+ YoutubeSearchFilter.SONGS -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS
+ YoutubeSearchFilter.ALBUMS -> YoutubeSearchQueryHandlerFactory.MUSIC_ALBUMS
+ YoutubeSearchFilter.PLAYLISTS -> YoutubeSearchQueryHandlerFactory.MUSIC_PLAYLISTS
+ }
+ val handler = youtube().searchQHFactory.fromQuery(trimmed, listOf(contentFilter), "")
+ val extractor = youtube().getSearchExtractor(handler)
+ extractor.fetchPage()
+ return extractor.initialPage.items
+ .asSequence()
+ .mapNotNull { it.toHitOrNull() }
+ .filter { it.url.isNotBlank() }
+ .take(limit)
+ .toList()
+ }
+
+ /**
+ * One page of Liked Music, newest like first (YouTube Music's own order).
+ * Pass [YoutubePage.continuation] back in to get the next page.
+ */
+ fun loadLikedPage(continuation: String? = null, pageSize: Int = LIKED_PAGE_SIZE): YoutubePage {
+ applySession()
+ if (!session.isSignedIn()) return YoutubePage(emptyList(), null)
+ val hits = linkedSetOf()
+ var token = continuation
+ var loggedIn: Boolean? = null
+ var requests = 0
+ do {
+ val before = hits.size
+ val root = musicBrowse(
+ browseId = if (token.isNullOrBlank()) LIKED_MUSIC_BROWSE_ID else null,
+ continuation = token,
+ )
+ if (loggedIn == null) loggedIn = loggedInFlag(root)
+ collectLikedShelf(root, hits, Int.MAX_VALUE)
+ token = findPlaylistContinuation(root)
+ requests++
+ // Empty continuation pages mean we reached the end of Liked Music.
+ if (hits.size == before) {
+ token = null
+ break
+ }
+ } while (!token.isNullOrBlank() && hits.size < pageSize && requests < MAX_REQUESTS_PER_PAGE)
+ if (hits.isEmpty() && continuation.isNullOrBlank()) {
+ throw IllegalStateException(
+ if (loggedIn == false) "YouTube Music did not accept this session. Sign in again from Account."
+ else "YouTube Music returned no liked songs.",
+ )
+ }
+ return YoutubePage(hits.toList(), token?.takeIf { it.isNotBlank() })
+ }
+
+ fun loadLikedMusic(limit: Int = 24): List {
+ if (!session.isSignedIn()) return emptyList()
+ return runCatching { loadLikedPage(pageSize = limit).hits }
+ .getOrDefault(emptyList())
+ .take(limit)
+ }
+
+ /**
+ * Personalized YouTube Music home (Quick picks, Listen again, Mixed for you, ...).
+ * Requires a signed-in session; anonymous sessions only get regional charts.
+ */
+ fun loadHome(maxShelves: Int = 8): List {
+ applySession()
+ if (!session.isSignedIn()) return emptyList()
+ val shelves = mutableListOf()
+ var root = musicBrowse(browseId = "FEmusic_home")
+ var sectionList = root.optJSONObject("contents")
+ ?.optJSONObject("singleColumnBrowseResultsRenderer")
+ ?.optJSONArray("tabs")
+ ?.optJSONObject(0)
+ ?.optJSONObject("tabRenderer")
+ ?.optJSONObject("content")
+ ?.optJSONObject("sectionListRenderer")
+ var page = 0
+ while (sectionList != null && shelves.size < maxShelves && page < 3) {
+ collectCarouselShelves(sectionList.optJSONArray("contents"), shelves)
+ val continuation = findContinuation(sectionList.opt("continuations")) ?: break
+ if (shelves.size >= maxShelves) break
+ root = musicBrowse(continuation = continuation)
+ sectionList = root.optJSONObject("continuationContents")?.optJSONObject("sectionListContinuation")
+ page++
+ }
+ return shelves.take(maxShelves)
+ }
+
+ /** Playlists saved in the user's YouTube Music library (FEmusic_liked_playlists). */
+ fun loadLibraryPlaylists(limit: Int = 60): List {
+ applySession()
+ if (!session.isSignedIn()) return emptyList()
+ val hits = linkedSetOf()
+ var root = musicBrowse(browseId = "FEmusic_liked_playlists")
+ var grid = root.optJSONObject("contents")
+ ?.optJSONObject("singleColumnBrowseResultsRenderer")
+ ?.optJSONArray("tabs")
+ ?.optJSONObject(0)
+ ?.optJSONObject("tabRenderer")
+ ?.optJSONObject("content")
+ ?.optJSONObject("sectionListRenderer")
+ ?.optJSONArray("contents")
+ ?.let { contents ->
+ (0 until contents.length()).firstNotNullOfOrNull { index ->
+ val section = contents.optJSONObject(index) ?: return@firstNotNullOfOrNull null
+ section.optJSONObject("gridRenderer") ?: section.optJSONObject("musicShelfRenderer")
+ }
+ }
+ var page = 0
+ while (grid != null && hits.size < limit && page < 4) {
+ collectShelfItems(grid.optJSONArray("items") ?: grid.optJSONArray("contents"), hits, limit)
+ val continuation = findContinuation(grid.opt("continuations")) ?: break
+ root = musicBrowse(continuation = continuation)
+ grid = root.optJSONObject("continuationContents")?.let { cont ->
+ cont.optJSONObject("gridContinuation") ?: cont.optJSONObject("musicShelfContinuation")
+ }
+ page++
+ }
+ return hits.filter { it.kind == YoutubeHit.Kind.COLLECTION && !it.url.contains("list=LM") }
+ .take(limit)
+ }
+
+ /** Verifies the session against InnerTube and returns the signed-in account name. */
+ fun loadAccountName(): String {
+ applySession()
+ if (!session.isSignedIn()) return ""
+ val root = musicPost("account/account_menu", """{"context":${musicContext()}}""")
+ val header = root.optJSONArray("actions")
+ ?.optJSONObject(0)
+ ?.optJSONObject("openPopupAction")
+ ?.optJSONObject("popup")
+ ?.optJSONObject("multiPageMenuRenderer")
+ ?.optJSONObject("header")
+ ?.optJSONObject("activeAccountHeaderRenderer")
+ ?: throw IllegalStateException("YouTube Music did not recognise this session.")
+ return jsonText(header.optJSONObject("accountName"))
+ }
+
+ /**
+ * Makes sure visitorData/dataSyncId for the signed-in account are known.
+ * Mirrors KuroMusic: visitorData from sw.js_data (with cookies), dataSyncId from the page config.
+ */
+ fun resetIdentity() {
+ identityChecked = false
+ visitorId = null
+ }
+
+ fun refreshIdentity() {
+ applySession()
+ val account = session.account.value
+ if (!account.isSignedIn) return
+ identityChecked = true
+ var visitor = account.visitorData
+ var dataSync = account.dataSyncId
+ if (visitor.isBlank()) visitor = fetchVisitorFromServiceWorker()
+ if (visitor.isBlank() || dataSync.isBlank()) {
+ val html = runCatching {
+ downloader.getText("https://music.youtube.com/", MUSIC_USER_AGENT, withCookies = true)
+ }.getOrNull().orEmpty()
+ if (visitor.isBlank()) visitor = configValue(html, "VISITOR_DATA")
+ if (dataSync.isBlank()) dataSync = configValue(html, "DATASYNC_ID")
+ }
+ session.updateIdentity(visitorData = visitor, dataSyncId = dataSync)
+ }
+
+ private fun fetchVisitorFromServiceWorker(): String {
+ val body = runCatching {
+ downloader.getText("https://music.youtube.com/sw.js_data", MUSIC_USER_AGENT, withCookies = true)
+ }.getOrNull().orEmpty()
+ if (body.length < 6) return ""
+ return runCatching {
+ val candidates = JSONArray(body.substring(5)).optJSONArray(0)?.optJSONArray(2) ?: return ""
+ (0 until candidates.length()).asSequence()
+ .mapNotNull { candidates.opt(it) as? String }
+ .firstOrNull { VISITOR_REGEX.containsMatchIn(it) }
+ .orEmpty()
+ }.getOrDefault("")
+ }
+
+ private fun configValue(html: String, key: String): String {
+ val raw = Regex(""""$key"\s*:\s*"([^"]*)"""").find(html)?.groupValues?.getOrNull(1).orEmpty()
+ return raw.replace("\\/", "/").replace("\\u003d", "=").replace("\\u0026", "&")
+ }
+
+ /**
+ * Artist and album for one video, from YouTube Music's watch-next panel.
+ * Shelf rows often only carry the artist, so playback and stats call this
+ * when the album is still missing.
+ */
+ fun creditsFor(videoId: String): YoutubeCredits {
+ val id = videoId.trim()
+ if (id.length != 11) return YoutubeCredits("", "")
+ applySession()
+ val body = JSONObject()
+ .put("context", JSONObject(musicContext()))
+ .put("videoId", id)
+ .put("playlistId", "RDAMVM$id")
+ val root = musicPost("next", body.toString())
+ val panels = mutableListOf()
+ collectCreditRenderers(root, panels)
+ val match = panels.firstOrNull { renderer ->
+ renderer.optString("videoId") == id || firstVideoId(renderer) == id
+ } ?: panels.firstOrNull() ?: return YoutubeCredits("", "")
+ val (artist, album) = creditsFrom(match)
+ return YoutubeCredits(artist, album)
+ }
+
+ fun fillPlayedCredits(store: YoutubeMusicStore, playedSongIds: Set, limit: Int = 40) {
+ if (playedSongIds.isEmpty()) return
+ store.creditGaps(playedSongIds, limit).forEach { gap ->
+ if (!store.claimCreditLookup(gap.videoId)) return@forEach
+ val credits = runCatching { creditsFor(gap.videoId) }.getOrNull()
+ if (credits == null) {
+ store.releaseCreditLookup(gap.videoId)
+ return@forEach
+ }
+ val artist = credits.artist.ifBlank { gap.artist }
+ val album = credits.album.ifBlank { gap.album }
+ if (artist == gap.artist && album == gap.album) return@forEach
+ store.rememberPlayback(
+ songId = gap.songId,
+ title = gap.title,
+ artist = artist,
+ album = album,
+ artworkUrl = gap.artworkUrl,
+ videoId = gap.videoId,
+ )
+ }
+ }
+
+ private fun collectCreditRenderers(node: Any?, found: MutableList) {
+ when (node) {
+ is JSONObject -> {
+ node.optJSONObject("playlistPanelVideoRenderer")?.let { found += it }
+ node.optJSONObject("musicResponsiveListItemRenderer")?.let { found += it }
+ val keys = node.keys()
+ while (keys.hasNext()) collectCreditRenderers(node.opt(keys.next()), found)
+ }
+ is JSONArray -> {
+ for (index in 0 until node.length()) collectCreditRenderers(node.opt(index), found)
+ }
+ }
+ }
+
+ private fun creditsFrom(renderer: JSONObject): Pair {
+ val runs = buildList {
+ addAll(textRuns(renderer.optJSONObject("longBylineText")))
+ addAll(textRuns(renderer.optJSONObject("shortBylineText")))
+ addAll(textRuns(renderer.optJSONObject("subtitle")))
+ addAll(textRuns(renderer.optJSONObject("secondSubtitle")))
+ val flex = renderer.optJSONArray("flexColumns") ?: return@buildList
+ for (index in 1 until flex.length()) {
+ val text = flex.optJSONObject(index)
+ ?.optJSONObject("musicResponsiveListItemFlexColumnRenderer")
+ ?.optJSONObject("text")
+ val parsed = textRuns(text)
+ if (parsed.isNotEmpty()) addAll(parsed) else addAll(splitCreditRuns(jsonText(text)))
+ }
+ }
+ return pickCredits(runs)
+ }
+
+ private fun splitCredits(value: String): Pair = pickCredits(splitCreditRuns(value))
+
+ private fun splitCreditRuns(value: String): List> =
+ value.split('•', '·')
+ .map { it.trim() to "" }
+ .filter { it.first.isNotBlank() }
+
+ private fun textRuns(node: JSONObject?): List> {
+ if (node == null) return emptyList()
+ val runs = node.optJSONArray("runs")
+ if (runs == null) return splitCreditRuns(node.optString("simpleText"))
+ return buildList {
+ for (index in 0 until runs.length()) {
+ val run = runs.optJSONObject(index) ?: continue
+ val text = run.optString("text").trim()
+ if (text.isBlank() || text == "•" || text == "·") continue
+ val browseId = run.optJSONObject("navigationEndpoint")
+ ?.optJSONObject("browseEndpoint")
+ ?.optString("browseId")
+ .orEmpty()
+ if (browseId.isEmpty() && (text.contains('•') || text.contains('·'))) {
+ addAll(splitCreditRuns(text))
+ } else {
+ add(text to browseId)
+ }
+ }
+ }
+ }
+
+ private fun pickCredits(runs: List>): Pair {
+ val useful = runs.filter { (text, _) -> isCreditLabel(text) }
+ val artist = useful.firstOrNull { it.second.startsWith("UC") }?.first
+ ?: useful.firstOrNull()?.first
+ .orEmpty()
+ .removeSuffix(" - Topic")
+ .trim()
+ val album = useful.firstOrNull { it.second.startsWith("MPRE") }?.first
+ ?: useful.firstOrNull { (text, id) ->
+ !text.equals(artist, ignoreCase = true) && !id.startsWith("UC")
+ }?.first
+ .orEmpty()
+ return artist to album
+ }
+
+ private fun isCreditLabel(text: String): Boolean {
+ val trimmed = text.trim()
+ if (trimmed.isBlank()) return false
+ if (trimmed.equals("YouTube Music", ignoreCase = true)) return false
+ if (trimmed.equals("Song", ignoreCase = true)) return false
+ if (trimmed.equals("Single", ignoreCase = true)) return false
+ if (trimmed.equals("EP", ignoreCase = true)) return false
+ if (trimmed.contains("view", ignoreCase = true)) return false
+ if (CREDIT_DURATION.matches(trimmed) || CREDIT_YEAR.matches(trimmed)) return false
+ return true
+ }
+
+ private fun musicBrowse(
+ browseId: String? = null,
+ continuation: String? = null,
+ params: String? = null,
+ ): JSONObject {
+ val account = session.account.value
+ if (account.isSignedIn && account.visitorData.isBlank() && !identityChecked) {
+ identityChecked = true
+ runCatching { refreshIdentity() }
+ }
+ val body = JSONObject()
+ .put("context", JSONObject(musicContext()))
+ .apply {
+ if (!browseId.isNullOrBlank()) put("browseId", browseId)
+ if (!params.isNullOrBlank()) put("params", params)
+ if (!continuation.isNullOrBlank()) put("continuation", continuation)
+ }
+ val query = if (continuation.isNullOrBlank()) "" else {
+ val token = java.net.URLEncoder.encode(continuation, Charsets.UTF_8.name())
+ "&continuation=$token&ctoken=$token&type=next"
+ }
+ return musicPost("browse", body.toString(), query)
+ }
+
+ private fun musicPost(endpoint: String, body: String, query: String = ""): JSONObject {
+ val json = downloader.postJson(
+ url = "https://music.youtube.com/youtubei/v1/$endpoint?prettyPrint=false&key=$MUSIC_API_KEY$query",
+ json = body,
+ origin = "https://music.youtube.com",
+ clientName = MUSIC_CLIENT_ID,
+ clientVersion = MUSIC_CLIENT_VERSION,
+ userAgent = MUSIC_USER_AGENT,
+ visitorId = currentVisitor(),
+ )
+ return JSONObject(json)
+ }
+
+ private fun currentVisitor(): String {
+ val account = session.account.value
+ if (account.isSignedIn && account.visitorData.isNotBlank()) return account.visitorData
+ return musicVisitorId().ifBlank { INNER_TUNE_VISITOR }
+ }
+
+ private fun musicContext(): String {
+ val account = session.account.value
+ val client = JSONObject()
+ .put("clientName", "WEB_REMIX")
+ .put("clientVersion", MUSIC_CLIENT_VERSION)
+ .put("gl", "US")
+ .put("hl", "en")
+ .put("visitorData", currentVisitor())
+ val context = JSONObject().put("client", client)
+ val user = JSONObject().put("lockedSafetyMode", false)
+ if (account.isSignedIn && account.dataSyncId.isNotBlank()) {
+ user.put("onBehalfOfUser", account.dataSyncId)
+ }
+ context.put("user", user)
+ return context.toString()
+ }
+
+ private fun loggedInFlag(root: JSONObject): Boolean? {
+ val services = root.optJSONObject("responseContext")?.optJSONArray("serviceTrackingParams") ?: return null
+ for (index in 0 until services.length()) {
+ val params = services.optJSONObject(index)?.optJSONArray("params") ?: continue
+ for (p in 0 until params.length()) {
+ val param = params.optJSONObject(p) ?: continue
+ if (param.optString("key") == "logged_in") return param.optString("value") == "1"
+ }
+ }
+ return null
+ }
+
+ private fun collectCarouselShelves(sections: JSONArray?, shelves: MutableList) {
+ if (sections == null) return
+ for (index in 0 until sections.length()) {
+ val carousel = sections.optJSONObject(index)?.optJSONObject("musicCarouselShelfRenderer") ?: continue
+ val title = jsonText(
+ carousel.optJSONObject("header")
+ ?.optJSONObject("musicCarouselShelfBasicHeaderRenderer")
+ ?.optJSONObject("title"),
+ ).ifBlank { "For you" }
+ val hits = linkedSetOf()
+ collectShelfItems(carousel.optJSONArray("contents"), hits, 30)
+ if (hits.isEmpty()) continue
+ val id = "home_${index}_${title.hashCode().toUInt()}"
+ if (shelves.any { it.id == id }) continue
+ shelves += YoutubeShelf(id = id, title = title, items = hits.toList())
+ }
+ }
+
+ private fun collectShelfItems(items: JSONArray?, hits: MutableSet, limit: Int) {
+ if (items == null) return
+ for (index in 0 until items.length()) {
+ if (hits.size >= limit) return
+ val item = items.optJSONObject(index) ?: continue
+ item.optJSONObject("musicTwoRowItemRenderer")?.let { twoRowHit(it) }?.let { hits += it }
+ item.optJSONObject("musicResponsiveListItemRenderer")?.let { listItemHit(it) }?.let { hits += it }
+ }
+ }
+
+ private fun twoRowHit(renderer: JSONObject): YoutubeHit? {
+ val title = jsonText(renderer.optJSONObject("title")).trim()
+ if (title.isBlank()) return null
+ val subtitle = jsonText(renderer.optJSONObject("subtitle")).trim()
+ val navigation = renderer.optJSONObject("navigationEndpoint")
+ val thumbnail = lastThumbnail(
+ renderer.optJSONObject("thumbnailRenderer")
+ ?.optJSONObject("musicThumbnailRenderer")
+ ?.optJSONObject("thumbnail"),
+ )
+ navigation?.optJSONObject("watchEndpoint")?.let { watch ->
+ val videoId = watch.optString("videoId")
+ val playlistId = watch.optString("playlistId")
+ if (isCatalogPlaylist(playlistId)) {
+ return YoutubeHit(
+ url = "https://music.youtube.com/playlist?list=$playlistId",
+ title = title,
+ artist = subtitle,
+ durationSec = 0,
+ thumbnailUrl = thumbnail,
+ kind = YoutubeHit.Kind.COLLECTION,
+ )
+ }
+ if (videoId.length == 11) {
+ val isMix = isRadioMix(playlistId)
+ val (artist, album) = if (isMix) subtitle to "" else splitCredits(subtitle)
+ return YoutubeHit(
+ url = if (isMix) "https://www.youtube.com/watch?v=$videoId&list=$playlistId"
+ else "https://music.youtube.com/watch?v=$videoId",
+ title = title,
+ artist = artist,
+ durationSec = 0,
+ thumbnailUrl = if (isMix) thumbnail else fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"),
+ kind = if (isMix) YoutubeHit.Kind.COLLECTION else YoutubeHit.Kind.TRACK,
+ album = album,
+ )
+ }
+ }
+ navigation?.optJSONObject("watchPlaylistEndpoint")?.optString("playlistId")
+ ?.takeIf { it.isNotBlank() }
+ ?.let { playlistId ->
+ return YoutubeHit(
+ url = "https://music.youtube.com/playlist?list=$playlistId",
+ title = title,
+ artist = subtitle,
+ durationSec = 0,
+ thumbnailUrl = thumbnail,
+ kind = YoutubeHit.Kind.COLLECTION,
+ )
+ }
+ val browseId = navigation?.optJSONObject("browseEndpoint")?.optString("browseId").orEmpty()
+ val playlistId = renderer.optJSONObject("thumbnailOverlay")
+ ?.optJSONObject("musicItemThumbnailOverlayRenderer")
+ ?.optJSONObject("content")
+ ?.optJSONObject("musicPlayButtonRenderer")
+ ?.optJSONObject("playNavigationEndpoint")
+ ?.let { play ->
+ play.optJSONObject("watchPlaylistEndpoint")?.optString("playlistId")
+ ?.takeIf { it.isNotBlank() }
+ ?: play.optJSONObject("watchEndpoint")?.optString("playlistId")?.takeIf { it.isNotBlank() }
+ }
+ ?: browseId.takeIf { it.startsWith("VL") }?.removePrefix("VL")
+ if (playlistId.isNullOrBlank()) return null
+ if (browseId.startsWith("UC") || browseId.startsWith("MPLA")) return null // artists
+ return YoutubeHit(
+ url = "https://music.youtube.com/playlist?list=$playlistId",
+ title = title,
+ artist = subtitle,
+ durationSec = 0,
+ thumbnailUrl = thumbnail,
+ kind = YoutubeHit.Kind.COLLECTION,
+ )
+ }
+
+ private fun listItemHit(renderer: JSONObject): YoutubeHit? {
+ val title = flexColumnText(renderer, 0).trim()
+ if (title.isBlank()) return null
+ val videoId = renderer.optJSONObject("playlistItemData")?.optString("videoId").orEmpty()
+ .ifBlank { firstVideoId(renderer.optJSONObject("overlay")) }
+ .ifBlank {
+ renderer.optJSONObject("navigationEndpoint")?.optJSONObject("watchEndpoint")?.optString("videoId").orEmpty()
+ }
+ if (videoId.length == 11) {
+ val (artist, album) = creditsFrom(renderer)
+ return YoutubeHit(
+ url = "https://music.youtube.com/watch?v=$videoId",
+ title = title,
+ artist = artist,
+ durationSec = 0,
+ thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"),
+ kind = YoutubeHit.Kind.TRACK,
+ album = album,
+ )
+ }
+ val browseId = renderer.optJSONObject("navigationEndpoint")
+ ?.optJSONObject("browseEndpoint")
+ ?.optString("browseId")
+ .orEmpty()
+ val playlistId = browseId.takeIf { it.startsWith("VL") }?.removePrefix("VL")
+ ?: firstPlaylistId(renderer.optJSONObject("overlay"))
+ if (playlistId.isBlank()) return null
+ return YoutubeHit(
+ url = "https://music.youtube.com/playlist?list=$playlistId",
+ title = title,
+ artist = flexColumnText(renderer, 1),
+ durationSec = 0,
+ thumbnailUrl = lastThumbnail(
+ renderer.optJSONObject("thumbnail")?.optJSONObject("musicThumbnailRenderer")?.optJSONObject("thumbnail"),
+ ),
+ kind = YoutubeHit.Kind.COLLECTION,
+ )
+ }
+
+ private fun firstPlaylistId(node: JSONObject?): String {
+ if (node == null) return ""
+ node.optString("playlistId").takeIf { it.isNotBlank() }?.let { return it }
+ val keys = node.keys()
+ while (keys.hasNext()) {
+ when (val child = node.opt(keys.next())) {
+ is JSONObject -> firstPlaylistId(child).takeIf { it.isNotBlank() }?.let { return it }
+ is JSONArray -> {
+ for (index in 0 until child.length()) {
+ (child.opt(index) as? JSONObject)?.let { item ->
+ firstPlaylistId(item).takeIf { it.isNotBlank() }?.let { return it }
+ }
+ }
+ }
+ }
+ }
+ return ""
+ }
+
+ private fun lastThumbnail(thumbnail: JSONObject?): String? {
+ val list = thumbnail?.optJSONArray("thumbnails") ?: return null
+ var best: String? = null
+ var bestSize = -1
+ for (index in 0 until list.length()) {
+ val entry = list.optJSONObject(index) ?: continue
+ val size = entry.optInt("width", 0)
+ val url = entry.optString("url").takeIf { it.isNotBlank() } ?: continue
+ if (size >= bestSize) {
+ bestSize = size
+ best = url
+ }
+ }
+ return best?.let { url ->
+ // Ask Google's image CDN for a larger square when the URL is a googleusercontent one.
+ if (url.contains("googleusercontent.com") && url.contains("=w")) {
+ url.replace(Regex("=w\\d+-h\\d+"), "=w544-h544")
+ } else fullBleedArtwork(url)
+ }
+ }
+
+ private fun collectLikedShelf(root: JSONObject, hits: MutableSet, limit: Int) {
+ val rows = findLikedRows(root)
+ if (rows == null) {
+ collectVideoHits(root, hits, limit)
+ return
+ }
+ addResponsiveListHits(rows, hits, limit)
+ }
+
+ private fun collectPlaylistShelf(root: JSONObject, hits: MutableSet, limit: Int) {
+ val rows = findPlaylistRows(root)
+ if (rows == null) {
+ collectVideoHits(root, hits, limit)
+ return
+ }
+ addResponsiveListHits(rows, hits, limit)
+ }
+
+ private fun addResponsiveListHits(rows: JSONArray, hits: MutableSet, limit: Int) {
+ for (index in 0 until rows.length()) {
+ if (hits.size >= limit) return
+ val item = rows.optJSONObject(index) ?: continue
+ if (item.optJSONObject("continuationItemRenderer") != null) continue
+ val renderer = item.optJSONObject("musicResponsiveListItemRenderer") ?: continue
+ val videoId = renderer.optJSONObject("playlistItemData")?.optString("videoId").orEmpty()
+ val title = flexColumnText(renderer, 0)
+ if (videoId.length != 11 || title.isBlank()) continue
+ val (artist, album) = creditsFrom(renderer)
+ hits += YoutubeHit(
+ url = "https://music.youtube.com/watch?v=$videoId",
+ title = title,
+ artist = artist,
+ durationSec = 0,
+ thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"),
+ kind = YoutubeHit.Kind.TRACK,
+ album = album,
+ )
+ }
+ }
+
+ /** Playlist pages only: the playlist shelf, not related mixes below it. */
+ private fun findPlaylistRows(root: JSONObject): JSONArray? {
+ root.optJSONObject("contents")
+ ?.optJSONObject("twoColumnBrowseResultsRenderer")
+ ?.optJSONObject("secondaryContents")
+ ?.optJSONObject("sectionListRenderer")
+ ?.optJSONArray("contents")
+ ?.let { contents -> firstPlaylistShelfContents(contents) }
+ ?.let { return it }
+
+ root.optJSONObject("contents")
+ ?.optJSONObject("singleColumnBrowseResultsRenderer")
+ ?.optJSONArray("tabs")
+ ?.optJSONObject(0)
+ ?.optJSONObject("tabRenderer")
+ ?.optJSONObject("content")
+ ?.optJSONObject("sectionListRenderer")
+ ?.optJSONArray("contents")
+ ?.let { contents -> firstPlaylistShelfContents(contents) }
+ ?.let { return it }
+
+ return root.optJSONObject("continuationContents")
+ ?.optJSONObject("musicPlaylistShelfContinuation")
+ ?.optJSONArray("contents")
+ }
+
+ private fun firstPlaylistShelfContents(sections: JSONArray): JSONArray? {
+ for (index in 0 until sections.length()) {
+ val section = sections.optJSONObject(index) ?: continue
+ section.optJSONObject("musicPlaylistShelfRenderer")?.optJSONArray("contents")?.let { return it }
+ }
+ for (index in 0 until sections.length()) {
+ val section = sections.optJSONObject(index) ?: continue
+ section.optJSONObject("musicShelfRenderer")?.optJSONArray("contents")?.let { return it }
+ }
+ return null
+ }
+
+ /** Desktop two-column Liked Music, mobile single-column, or a continuation page. */
+ private fun findLikedRows(root: JSONObject): JSONArray? {
+ root.optJSONObject("contents")
+ ?.optJSONObject("twoColumnBrowseResultsRenderer")
+ ?.optJSONObject("secondaryContents")
+ ?.optJSONObject("sectionListRenderer")
+ ?.optJSONArray("contents")
+ ?.let { contents -> firstShelfContents(contents) }
+ ?.let { return it }
+
+ root.optJSONObject("contents")
+ ?.optJSONObject("singleColumnBrowseResultsRenderer")
+ ?.optJSONArray("tabs")
+ ?.optJSONObject(0)
+ ?.optJSONObject("tabRenderer")
+ ?.optJSONObject("content")
+ ?.optJSONObject("sectionListRenderer")
+ ?.optJSONArray("contents")
+ ?.let { contents -> firstShelfContents(contents) }
+ ?.let { return it }
+
+ val continuation = root.optJSONObject("continuationContents") ?: return null
+ return continuation.optJSONObject("musicPlaylistShelfContinuation")?.optJSONArray("contents")
+ ?: continuation.optJSONObject("musicShelfContinuation")?.optJSONArray("contents")
+ }
+
+ private fun firstShelfContents(sections: JSONArray): JSONArray? {
+ for (index in 0 until sections.length()) {
+ val section = sections.optJSONObject(index) ?: continue
+ section.optJSONObject("musicPlaylistShelfRenderer")?.optJSONArray("contents")?.let { return it }
+ section.optJSONObject("musicShelfRenderer")?.optJSONArray("contents")?.let { return it }
+ }
+ return null
+ }
+
+ private fun musicVisitorId(): String {
+ visitorId?.takeIf { it.isNotBlank() }?.let { return it }
+ val html = runCatching {
+ downloader.getText("https://music.youtube.com", MUSIC_USER_AGENT)
+ }.getOrNull().orEmpty()
+ val found = Regex(""""VISITOR_DATA"\s*:\s*"([^"]+)"""")
+ .find(html)
+ ?.groupValues
+ ?.getOrNull(1)
+ .orEmpty()
+ if (found.isNotBlank()) visitorId = found
+ return found
+ }
+
+ fun loadSubscriptionFeed(limit: Int = 20): List {
+ applySession()
+ val extractor = youtube().getFeedExtractor("https://www.youtube.com/feed/subscriptions")
+ extractor.fetchPage()
+ return extractor.initialPage.items
+ .asSequence()
+ .mapNotNull { it.toHitOrNull() }
+ .filter { it.url.isNotBlank() }
+ .take(limit)
+ .toList()
+ }
+
+ fun loadRadio(watchUrl: String, limit: Int = 25): List {
+ applySession()
+ val videoId = watchUrl.substringAfter("v=", "")
+ .substringBefore('&')
+ .substringBefore('?')
+ .trim()
+ require(videoId.length >= 6) { "This track can't start a radio" }
+ return loadCollectionTracks("https://www.youtube.com/watch?v=$videoId&list=RD$videoId", limit)
+ }
+
+ fun loadCollectionTracks(url: String, limit: Int = 200): List {
+ applySession()
+ val playlistId = playlistIdFromUrl(url)
+ if (playlistId == "LM") return loadLikedPage(pageSize = limit).hits.take(limit)
+ if (playlistId.isNotBlank()) {
+ val viaMusic = runCatching { loadPlaylistViaInnerTube(playlistId, limit) }
+ .getOrDefault(emptyList())
+ if (viaMusic.isNotEmpty()) return viaMusic
+ }
+ val info = PlaylistInfo.getInfo(youtube(), url.trim())
+ return info.relatedItems
+ .asSequence()
+ .mapNotNull { it.toHitOrNull() }
+ .filter { it.url.isNotBlank() && it.kind == YoutubeHit.Kind.TRACK }
+ .take(limit)
+ .toList()
+ }
+
+ /**
+ * Same path KuroMusic/InnerTune use: browse `VL` + playlist id on music.youtube.com.
+ * Recap / official playlists (RDCLAK…) fail in NewPipe but work here.
+ */
+ private fun loadPlaylistViaInnerTube(playlistId: String, limit: Int): List {
+ val browseId = if (playlistId.startsWith("VL")) playlistId else "VL$playlistId"
+ val hits = linkedSetOf()
+ var token: String? = null
+ var first = true
+ var requests = 0
+ do {
+ val before = hits.size
+ val root = musicBrowse(
+ browseId = if (first) browseId else null,
+ continuation = token,
+ )
+ first = false
+ collectPlaylistShelf(root, hits, limit)
+ token = findPlaylistContinuation(root)
+ requests++
+ if (hits.size == before) break
+ } while (!token.isNullOrBlank() && hits.size < limit && requests < 8)
+ return hits.take(limit)
+ }
+
+ /** InnerTube like/like and like/removelike — this is what actually updates Liked Music. */
+ fun likeVideo(videoId: String, like: Boolean) {
+ applySession()
+ require(session.isSignedIn()) { "Sign in to like songs on YouTube Music." }
+ require(videoId.length == 11) { "Missing video id." }
+ val endpoint = if (like) "like/like" else "like/removelike"
+ val body = JSONObject()
+ .put("context", JSONObject(musicContext()))
+ .put("target", JSONObject().put("videoId", videoId))
+ musicPost(endpoint, body.toString())
+ }
+
+ /** Browse/edit_playlist ACTION_ADD_VIDEO, same payload KuroMusic sends. */
+ fun addVideosToPlaylist(playlistId: String, videoIds: List) {
+ applySession()
+ require(session.isSignedIn()) { "Sign in to add songs to a playlist." }
+ val ids = videoIds.map { it.trim() }.filter { it.length == 11 }.distinct()
+ require(ids.isNotEmpty()) { "No songs to add." }
+ val actions = JSONArray()
+ ids.forEach { videoId ->
+ actions.put(
+ JSONObject()
+ .put("action", "ACTION_ADD_VIDEO")
+ .put("addedVideoId", videoId),
+ )
+ }
+ val body = JSONObject()
+ .put("context", JSONObject(musicContext()))
+ .put("playlistId", playlistId.removePrefix("VL"))
+ .put("actions", actions)
+ musicPost("browse/edit_playlist", body.toString())
+ }
+
+ fun createPlaylist(title: String, videoIds: List = emptyList()): String {
+ applySession()
+ require(session.isSignedIn()) { "Sign in to create a playlist." }
+ val name = title.trim()
+ require(name.isNotEmpty()) { "Give the playlist a name." }
+ val body = JSONObject()
+ .put("context", JSONObject(musicContext()))
+ .put("title", name)
+ .put("privacyStatus", "PRIVATE")
+ val ids = videoIds.map { it.trim() }.filter { it.length == 11 }
+ if (ids.isNotEmpty()) body.put("videoIds", JSONArray(ids))
+ val root = musicPost("playlist/create", body.toString())
+ val created = root.optString("playlistId").ifBlank {
+ root.optJSONObject("playlistId")?.optString("playlistId").orEmpty()
+ }
+ require(created.isNotBlank()) { "YouTube Music did not create the playlist." }
+ return created
+ }
+
+ /**
+ * Resolves a stream ahead of a possible tap so playback can start immediately.
+ * Errors are swallowed: this is best effort, and a real tap will surface them.
+ */
+ fun prefetchTrack(watchUrl: String) {
+ val key = watchUrl.trim()
+ if (key.isBlank() || cachedTrack(key) != null) return
+ if (!inFlightResolves.add(key)) return
+ try {
+ resolveTrack(key)
+ } catch (error: Exception) {
+ Timber.d(error, "YouTube: prefetch failed for %s", key)
+ } finally {
+ inFlightResolves.remove(key)
+ }
+ }
+
+ fun resolveTrack(watchUrl: String, avoidStreamUrl: String? = null): YoutubeResolvedTrack {
+ applySession()
+ val key = watchUrl.trim()
+ val avoided = avoidStreamUrl?.trim().orEmpty()
+ cachedTrack(key)?.takeIf { it.streamUrl != avoided }?.let { return it }
+
+ val extracted = runCatching { extractResolved(key) }.getOrNull()
+ ?.takeIf { it.streamUrl.isNotBlank() && it.streamUrl != avoided }
+ if (extracted != null) {
+ resolveCache[key] = CachedResolve(extracted, System.currentTimeMillis())
+ return extracted
+ }
+
+ val videoId = videoIdFromUrl(key)
+ val fallback = if (videoId.length == 11) {
+ runCatching { audioFromPlayerClients(videoId, avoided) }.getOrNull()
+ } else {
+ null
+ }
+ if (fallback != null) {
+ resolveCache[key] = CachedResolve(fallback, System.currentTimeMillis())
+ return fallback
+ }
+ throw IllegalStateException("No playable audio stream for this track.")
+ }
+
+ private fun extractResolved(key: String): YoutubeResolvedTrack {
+ val info = StreamInfo.getInfo(youtube(), key)
+ val stream = pickBestAudioStream(info.audioStreams)
+ ?: throw IllegalStateException("No playable audio stream for this track.")
+ val videoId = videoIdFromUrl(info.url.orEmpty()).ifBlank { videoIdFromUrl(key) }
+ val credits = if (videoId.length == 11) {
+ runCatching { creditsFor(videoId) }.getOrDefault(YoutubeCredits("", ""))
+ } else {
+ YoutubeCredits("", "")
+ }
+ return YoutubeResolvedTrack(
+ watchUrl = info.url.orEmpty().ifBlank { key },
+ title = info.name.orEmpty().ifBlank { "Unknown title" },
+ artist = credits.artist.ifBlank {
+ info.uploaderName.orEmpty().removeSuffix(" - Topic").trim()
+ },
+ durationMs = info.duration.coerceAtLeast(0L) * 1000L,
+ streamUrl = stream.content,
+ mimeType = stream.format?.mimeType,
+ artworkUrl = fullBleedArtwork(info.thumbnails.maxByOrNull { it.height }?.url),
+ album = credits.album,
+ )
+ }
+
+ /**
+ * NewPipe's web player has no audio file for some YouTube Music tracks
+ * (the link is SABR-only, or the catalog video is blocked on that client).
+ * Those are the songs a playlist skips. A music or embedded player response
+ * still carries a direct audio URL for them.
+ */
+ private fun audioFromPlayerClients(videoId: String, avoidUrl: String): YoutubeResolvedTrack? {
+ val responses = listOf(
+ runCatching { musicPlayer(videoId) },
+ runCatching { embeddedPlayer(videoId) },
+ runCatching { androidVrPlayer(videoId) },
+ )
+ for (response in responses) {
+ val root = response.getOrNull() ?: continue
+ val status = root.optJSONObject("playabilityStatus")?.optString("status").orEmpty()
+ if (status.isNotEmpty() && status != "OK") continue
+ val picked = pickPlayableAudio(audioCandidates(root), avoidUrl.ifBlank { null }) ?: continue
+ val details = root.optJSONObject("videoDetails")
+ val title = details?.optString("title").orEmpty().ifBlank { "Unknown title" }
+ val author = details?.optString("author").orEmpty().removeSuffix(" - Topic").trim()
+ val length = details?.optString("lengthSeconds")?.toLongOrNull() ?: 0L
+ val thumb = details?.optJSONObject("thumbnail")
+ ?.optJSONArray("thumbnails")
+ ?.let { thumbs ->
+ (0 until thumbs.length())
+ .mapNotNull { thumbs.optJSONObject(it)?.optString("url") }
+ .lastOrNull { it.isNotBlank() }
+ }
+ return YoutubeResolvedTrack(
+ watchUrl = "https://music.youtube.com/watch?v=$videoId",
+ title = title,
+ artist = author,
+ durationMs = length.coerceAtLeast(0L) * 1000L,
+ streamUrl = picked.url,
+ mimeType = picked.mimeType.substringBefore(';').trim().ifBlank { null },
+ artworkUrl = fullBleedArtwork(thumb),
+ )
+ }
+ return null
+ }
+
+ private fun musicPlayer(videoId: String): JSONObject {
+ val body = JSONObject()
+ .put("context", JSONObject(musicContext()))
+ .put("videoId", videoId)
+ .put("contentCheckOk", true)
+ .put("racyCheckOk", true)
+ return musicPost("player", body.toString())
+ }
+
+ private fun embeddedPlayer(videoId: String): JSONObject = innertubePlayer(
+ videoId = videoId,
+ clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
+ clientVersion = "2.0",
+ clientId = "85",
+ userAgent = MUSIC_USER_AGENT,
+ )
+
+ private fun androidVrPlayer(videoId: String): JSONObject = innertubePlayer(
+ videoId = videoId,
+ clientName = "ANDROID_VR",
+ clientVersion = "1.65.10",
+ clientId = "28",
+ userAgent = "com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12; eureka-user Build/SQ3A.220605.009.A1) gzip",
+ extraClient = JSONObject()
+ .put("deviceMake", "Oculus")
+ .put("deviceModel", "Quest 3")
+ .put("androidSdkVersion", 32)
+ .put("osName", "Android")
+ .put("osVersion", "12"),
+ )
+
+ private fun innertubePlayer(
+ videoId: String,
+ clientName: String,
+ clientVersion: String,
+ clientId: String,
+ userAgent: String,
+ extraClient: JSONObject? = null,
+ ): JSONObject {
+ val client = JSONObject()
+ .put("clientName", clientName)
+ .put("clientVersion", clientVersion)
+ .put("hl", "en")
+ .put("gl", "US")
+ extraClient?.keys()?.let { keys ->
+ while (keys.hasNext()) {
+ val name = keys.next()
+ client.put(name, extraClient.get(name))
+ }
+ }
+ val body = JSONObject()
+ .put("context", JSONObject().put("client", client))
+ .put("videoId", videoId)
+ .put("contentCheckOk", true)
+ .put("racyCheckOk", true)
+ val json = downloader.postJson(
+ url = "https://www.youtube.com/youtubei/v1/player?prettyPrint=false",
+ json = body.toString(),
+ origin = "https://www.youtube.com",
+ clientName = clientId,
+ clientVersion = clientVersion,
+ userAgent = userAgent,
+ )
+ return JSONObject(json)
+ }
+
+ private fun audioCandidates(root: JSONObject): List {
+ val streaming = root.optJSONObject("streamingData") ?: return emptyList()
+ return buildList {
+ addAll(candidatesFrom(streaming.optJSONArray("adaptiveFormats")))
+ addAll(candidatesFrom(streaming.optJSONArray("formats")))
+ }
+ }
+
+ private fun candidatesFrom(formats: JSONArray?): List {
+ if (formats == null) return emptyList()
+ return buildList {
+ for (index in 0 until formats.length()) {
+ val format = formats.optJSONObject(index) ?: continue
+ val url = format.optString("url")
+ if (!url.startsWith("http")) continue
+ add(
+ YoutubeAudioCandidate(
+ url = url,
+ mimeType = format.optString("mimeType"),
+ bitrate = format.optInt("bitrate"),
+ ),
+ )
+ }
+ }
+ }
+
+ private fun cachedTrack(key: String): YoutubeResolvedTrack? = resolveCache[key]
+ ?.takeIf { System.currentTimeMillis() - it.cachedAtMs < RESOLVE_CACHE_TTL_MS }
+ ?.track
+
+ fun toSong(track: YoutubeResolvedTrack, watchUrl: String = track.watchUrl): Song {
+ val permalink = watchUrl.ifBlank { track.watchUrl }.trim()
+ val id = songIdForUrl(permalink)
+ if (permalink.startsWith("http")) rememberWatchUrl(id, permalink)
+ return Song(
+ id = id,
+ title = track.title,
+ artist = track.artist,
+ artistId = -1L,
+ artists = emptyList(),
+ album = track.album,
+ albumId = -1L,
+ albumArtist = track.artist,
+ path = track.streamUrl,
+ contentUriString = track.streamUrl,
+ albumArtUriString = track.artworkUrl,
+ duration = track.durationMs,
+ genre = null,
+ mimeType = track.mimeType,
+ bitrate = null,
+ sampleRate = null,
+ )
+ }
+
+ fun listSong(hit: YoutubeHit): Song {
+ val id = songIdForUrl(hit.url)
+ rememberWatchUrl(id, hit.url)
+ return Song(
+ id = id,
+ title = hit.title,
+ artist = hit.artist,
+ artistId = -1L,
+ artists = emptyList(),
+ album = hit.album,
+ albumId = -1L,
+ albumArtist = hit.artist,
+ path = hit.url,
+ contentUriString = hit.url,
+ albumArtUriString = hit.thumbnailUrl,
+ duration = hit.durationSec.coerceAtLeast(0L) * 1000L,
+ genre = null,
+ mimeType = null,
+ bitrate = null,
+ sampleRate = null,
+ )
+ }
+
+ private fun youtube(): YoutubeService = ServiceList.YouTube
+
+ private fun applySession() {
+ val cookies = session.cookieHeader().takeIf { it.isNotBlank() }
+ downloader.cookieHeader = cookies
+ injectCookie(cookies)
+ }
+
+ private fun injectCookie(cookie: String?) {
+ val helper = YoutubeParsingHelper::class.java
+ val field = helper.declaredFields.firstOrNull { candidate ->
+ java.lang.reflect.Modifier.isStatic(candidate.modifiers) &&
+ candidate.type == String::class.java &&
+ candidate.name.contains("cookie", ignoreCase = true)
+ } ?: return
+ field.isAccessible = true
+ field.set(null, cookie)
+ }
+
+ private fun collectVideoHits(node: Any?, hits: MutableSet, limit: Int) {
+ if (hits.size >= limit || node == null) return
+ when (node) {
+ is JSONObject -> {
+ val videoId = node.optString("videoId").ifBlank {
+ node.optJSONObject("playlistItemData")?.optString("videoId").orEmpty()
+ }.ifBlank {
+ node.optJSONObject("navigationEndpoint")
+ ?.optJSONObject("watchEndpoint")
+ ?.optString("videoId")
+ .orEmpty()
+ }.ifBlank {
+ if (node.has("flexColumns")) firstVideoId(node.optJSONObject("overlay")) else ""
+ }
+ if (videoId.length == 11 && looksLikeTrack(node)) {
+ val title = videoTitle(node)
+ if (title.isNotBlank()) {
+ val (artist, album) = creditsFrom(node)
+ hits += YoutubeHit(
+ url = "https://music.youtube.com/watch?v=$videoId",
+ title = title,
+ artist = artist,
+ durationSec = 0,
+ thumbnailUrl = fullBleedArtwork("https://i.ytimg.com/vi/$videoId/hq720.jpg"),
+ kind = YoutubeHit.Kind.TRACK,
+ album = album,
+ )
+ }
+ }
+ val keys = node.keys()
+ while (keys.hasNext() && hits.size < limit) {
+ collectVideoHits(node.opt(keys.next()), hits, limit)
+ }
+ }
+ is JSONArray -> {
+ for (index in 0 until node.length()) {
+ if (hits.size >= limit) return
+ collectVideoHits(node.opt(index), hits, limit)
+ }
+ }
+ }
+ }
+
+ private fun looksLikeTrack(node: JSONObject): Boolean {
+ return node.has("playlistItemData") ||
+ node.has("flexColumns") ||
+ node.has("longBylineText") ||
+ node.has("shortBylineText") ||
+ node.has("lengthText") ||
+ node.optJSONObject("title") != null
+ }
+
+ private fun firstVideoId(node: JSONObject?): String {
+ if (node == null) return ""
+ node.optString("videoId").takeIf { it.length == 11 }?.let { return it }
+ val keys = node.keys()
+ while (keys.hasNext()) {
+ when (val child = node.opt(keys.next())) {
+ is JSONObject -> firstVideoId(child).takeIf { it.length == 11 }?.let { return it }
+ is JSONArray -> {
+ for (index in 0 until child.length()) {
+ val item = child.opt(index)
+ if (item is JSONObject) {
+ firstVideoId(item).takeIf { it.length == 11 }?.let { return it }
+ }
+ }
+ }
+ }
+ }
+ return ""
+ }
+
+ private fun findPlaylistContinuation(node: JSONObject): String? {
+ val shelf = findNamedObject(node, "musicPlaylistShelfRenderer")
+ ?: findNamedObject(node, "musicPlaylistShelfContinuation")
+ ?: findNamedObject(node, "musicShelfRenderer")
+ ?: findNamedObject(node, "musicShelfContinuation")
+ if (shelf != null) {
+ findContinuation(shelf.opt("continuations"))?.let { return it }
+ tokenFromContinuationItems(shelf.optJSONArray("contents"))?.let { return it }
+ }
+ return null
+ }
+
+ /** Next-page tokens sit on continuationItemRenderer, usually the last row of the shelf. */
+ private fun tokenFromContinuationItems(contents: JSONArray?): String? {
+ if (contents == null) return null
+ for (index in contents.length() - 1 downTo 0) {
+ val renderer = contents.optJSONObject(index)?.optJSONObject("continuationItemRenderer")
+ ?: continue
+ findContinuation(renderer)?.let { return it }
+ }
+ return null
+ }
+
+ private fun findNamedObject(node: Any?, name: String): JSONObject? {
+ when (node) {
+ is JSONObject -> {
+ node.optJSONObject(name)?.let { return it }
+ val keys = node.keys()
+ while (keys.hasNext()) {
+ findNamedObject(node.opt(keys.next()), name)?.let { return it }
+ }
+ }
+ is JSONArray -> {
+ for (index in 0 until node.length()) {
+ findNamedObject(node.opt(index), name)?.let { return it }
+ }
+ }
+ }
+ return null
+ }
+
+ private fun findContinuation(node: Any?): String? {
+ when (node) {
+ is JSONObject -> {
+ node.optJSONObject("nextContinuationData")
+ ?.optString("continuation")
+ ?.takeIf { it.isNotBlank() }
+ ?.let { return it }
+ node.optJSONObject("continuationCommand")
+ ?.optString("token")
+ ?.takeIf { it.isNotBlank() }
+ ?.let { return it }
+ val keys = node.keys()
+ while (keys.hasNext()) {
+ findContinuation(node.opt(keys.next()))?.let { return it }
+ }
+ }
+ is JSONArray -> {
+ for (index in 0 until node.length()) {
+ findContinuation(node.opt(index))?.let { return it }
+ }
+ }
+ }
+ return null
+ }
+
+ private fun videoTitle(node: JSONObject): String {
+ jsonText(node.optJSONObject("title")).takeIf { it.isNotBlank() }?.let { return it }
+ node.optString("title").takeIf { it.isNotBlank() && !it.trimStart().startsWith("{") }?.let { return it }
+ flexColumnText(node, 0).takeIf { it.isNotBlank() }?.let { return it }
+ val label = node.optJSONObject("accessibility")
+ ?.optJSONObject("accessibilityData")
+ ?.optString("label")
+ .orEmpty()
+ return label.substringBefore(" by ").substringBefore(',').trim()
+ }
+
+ private fun flexColumnText(node: JSONObject, column: Int): String {
+ val flex = node.optJSONArray("flexColumns") ?: return ""
+ val text = flex.optJSONObject(column)
+ ?.optJSONObject("musicResponsiveListItemFlexColumnRenderer")
+ ?.optJSONObject("text")
+ return jsonText(text)
+ }
+
+ private fun jsonText(node: JSONObject?): String {
+ if (node == null) return ""
+ node.optString("simpleText").takeIf { it.isNotBlank() }?.let { return it }
+ val runs = node.optJSONArray("runs") ?: return ""
+ return buildString {
+ for (index in 0 until runs.length()) {
+ append(runs.optJSONObject(index)?.optString("text").orEmpty())
+ }
+ }
+ }
+
+ private fun InfoItem.toHitOrNull(): YoutubeHit? = when (this) {
+ is StreamInfoItem -> YoutubeHit(
+ url = url.orEmpty(),
+ title = name.orEmpty().ifBlank { "Unknown title" },
+ artist = uploaderName.orEmpty().removeSuffix(" - Topic").trim(),
+ durationSec = duration.coerceAtLeast(0L),
+ thumbnailUrl = fullBleedArtwork(thumbnails.maxByOrNull { it.height }?.url),
+ kind = YoutubeHit.Kind.TRACK,
+ )
+ is PlaylistInfoItem -> YoutubeHit(
+ url = url.orEmpty(),
+ title = name.orEmpty().ifBlank { "Playlist" },
+ artist = uploaderName.orEmpty().removeSuffix(" - Topic").trim(),
+ durationSec = 0L,
+ thumbnailUrl = fullBleedArtwork(thumbnails.maxByOrNull { it.height }?.url),
+ kind = YoutubeHit.Kind.COLLECTION,
+ )
+ else -> null
+ }
+
+ private fun pickBestAudioStream(streams: List): AudioStream? {
+ if (streams.isEmpty()) return null
+ val picked = pickPlayableAudio(
+ streams.map { stream ->
+ val mime = stream.format?.mimeType
+ ?: if (stream.deliveryMethod == DeliveryMethod.HLS) "application/vnd.apple.mpegurl" else ""
+ YoutubeAudioCandidate(
+ url = stream.content.orEmpty(),
+ mimeType = mime,
+ bitrate = stream.averageBitrate.takeIf { it > 0 } ?: stream.bitrate,
+ )
+ },
+ ) ?: return null
+ return streams.firstOrNull { it.content == picked.url }
+ }
+
+ companion object {
+ const val EXTRA_WATCH_URL = "com.theveloper.pixelplay.youtube.WATCH_URL"
+ private val watchUrlBySongId = ConcurrentHashMap()
+ private val initialized = AtomicBoolean(false)
+ private const val RESOLVE_CACHE_TTL_MS = 15 * 60 * 1000L
+ private const val MUSIC_USER_AGENT =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
+ private const val MUSIC_API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"
+ private const val MUSIC_CLIENT_ID = "67"
+ private const val MUSIC_CLIENT_VERSION = "1.20260804.16.00"
+ private const val INNER_TUNE_VISITOR = "CgtsZG1ySnZiQWtSbyiMjuGSBg=="
+ private const val LIKED_MUSIC_BROWSE_ID = "VLLM"
+ // One InnerTube response per scroll page so older likes arrive as you go down.
+ const val LIKED_PAGE_SIZE = 40
+ private const val MAX_REQUESTS_PER_PAGE = 1
+ private val VISITOR_REGEX = Regex("^Cg[ts]")
+ private val CREDIT_DURATION = Regex("""^\d+:\d{2}(?::\d{2})?$""")
+ private val CREDIT_YEAR = Regex("""^\d{4}$""")
+
+ fun rememberWatchUrl(songId: String, watchUrl: String) {
+ val id = songId.trim()
+ val url = watchUrl.trim()
+ if (id.isNotEmpty() && url.startsWith("http")) watchUrlBySongId[id] = url
+ }
+
+ fun watchUrlForSongId(songId: String): String? = watchUrlBySongId[songId]
+
+ fun songIdForUrl(url: String): String = "yt_${url.trim().hashCode().toUInt()}"
+
+ fun videoIdFromUrl(url: String): String {
+ val fromQuery = url.substringAfter("v=", "")
+ .substringBefore('&')
+ .substringBefore('?')
+ .trim()
+ return if (fromQuery.length == 11) fromQuery else ""
+ }
+
+ fun playlistIdFromUrl(url: String): String {
+ val fromList = url.substringAfter("list=", "")
+ .substringBefore('&')
+ .substringBefore('?')
+ .trim()
+ if (fromList.isNotBlank()) return fromList.removePrefix("VL")
+ return Regex("""browse/(VL)?([^/?&]+)""")
+ .find(url)
+ ?.groupValues
+ ?.getOrNull(2)
+ .orEmpty()
+ .removePrefix("VL")
+ }
+
+ fun isCatalogPlaylist(playlistId: String): Boolean {
+ if (playlistId.isBlank()) return false
+ return playlistId.startsWith("PL") ||
+ playlistId.startsWith("OLAK") ||
+ playlistId.startsWith("RDCLAK") ||
+ playlistId.startsWith("RDTMA") ||
+ playlistId == "LM" ||
+ playlistId == "WL" ||
+ playlistId.startsWith("VL")
+ }
+
+ fun isRadioMix(playlistId: String): Boolean {
+ if (!playlistId.startsWith("RD")) return false
+ return !isCatalogPlaylist(playlistId) && !playlistId.startsWith("RDAMVM")
+ }
+
+ fun fullBleedArtwork(url: String?): String? {
+ val raw = url?.trim().orEmpty()
+ if (raw.isEmpty()) return null
+ val videoId = Regex("/vi/([A-Za-z0-9_-]{6,})/").find(raw)?.groupValues?.getOrNull(1)
+ ?: return raw
+ return "https://i.ytimg.com/vi/$videoId/hq720.jpg"
+ }
+
+ private fun ensureInitialized(downloader: YoutubeDownloader) {
+ if (initialized.compareAndSet(false, true)) {
+ NewPipe.init(downloader)
+ Timber.i("YouTube: NewPipe Extractor initialized")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt
new file mode 100644
index 0000000000..924871401c
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeDownloader.kt
@@ -0,0 +1,141 @@
+package com.theveloper.pixelplay.youtube
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.schabi.newpipe.extractor.downloader.Downloader
+import org.schabi.newpipe.extractor.downloader.Request
+import org.schabi.newpipe.extractor.downloader.Response
+import java.security.MessageDigest
+import java.util.concurrent.TimeUnit
+
+/**
+ * NewPipe HTTP layer with a normal browser user agent.
+ * The app-wide OkHttp client sends a PixelPlayer user agent that YouTube rejects.
+ */
+internal class YoutubeDownloader : Downloader() {
+ @Volatile
+ var cookieHeader: String? = null
+ private val client: OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .followRedirects(true)
+ .followSslRedirects(true)
+ .build()
+
+ override fun execute(request: Request): Response {
+ val bodyBytes = request.dataToSend()
+ val requestBody = bodyBytes?.toRequestBody(null)
+ val builder = okhttp3.Request.Builder()
+ .method(request.httpMethod(), requestBody)
+ .url(request.url())
+ .header("User-Agent", USER_AGENT)
+ .header("Accept-Language", "en-US,en;q=0.9")
+
+ request.headers().forEach { (name, values) ->
+ builder.removeHeader(name)
+ values.forEach { value -> builder.addHeader(name, value) }
+ }
+ cookieHeader?.trim()?.takeIf { it.isNotEmpty() }?.let { cookies ->
+ builder.header("Cookie", cookies)
+ }
+
+ client.newCall(builder.build()).execute().use { response ->
+ val responseBody = response.body.string()
+ if (response.code == 429) {
+ throw java.io.IOException("YouTube rate limited this request. Wait a moment and try again.")
+ }
+ return Response(
+ response.code,
+ response.message,
+ response.headers.toMultimap(),
+ responseBody,
+ response.request.url.toString(),
+ )
+ }
+ }
+
+ fun postJson(
+ url: String,
+ json: String,
+ origin: String = "https://www.youtube.com",
+ clientName: String? = null,
+ clientVersion: String? = null,
+ userAgent: String = USER_AGENT,
+ visitorId: String? = null,
+ ): String {
+ val cookies = cookieHeader?.trim().orEmpty()
+ val request = okhttp3.Request.Builder()
+ .url(url)
+ .post(json.toRequestBody(JSON))
+ .header("User-Agent", userAgent)
+ .header("Content-Type", "application/json")
+ .header("Origin", origin)
+ .header("Referer", "$origin/")
+ .header("X-Origin", origin)
+ .header("X-Goog-AuthUser", "0")
+ .header("X-Goog-Api-Format-Version", "1")
+ .apply {
+ if (!clientName.isNullOrBlank()) header("X-YouTube-Client-Name", clientName)
+ if (!clientVersion.isNullOrBlank()) header("X-YouTube-Client-Version", clientVersion)
+ visitorId?.takeIf { it.isNotBlank() }?.let { header("X-Goog-Visitor-Id", it) }
+ if (cookies.isNotEmpty()) {
+ header("Cookie", cookies)
+ authorization(cookies, origin)?.let { header("Authorization", it) }
+ }
+ }
+ .build()
+ client.newCall(request).execute().use { response ->
+ val responseBody = response.body.string()
+ if (!response.isSuccessful) {
+ val hint = responseBody.replace(Regex("\\s+"), " ").take(160)
+ throw java.io.IOException("YouTube Music returned ${response.code}. $hint")
+ }
+ return responseBody
+ }
+ }
+
+ fun getText(url: String, userAgent: String, withCookies: Boolean = false): String {
+ val cookies = cookieHeader?.trim().orEmpty()
+ val request = okhttp3.Request.Builder()
+ .url(url)
+ .header("User-Agent", userAgent)
+ .header("Accept-Language", "en-US,en;q=0.9")
+ .apply {
+ if (withCookies && cookies.isNotEmpty()) {
+ header("Cookie", cookies)
+ authorization(cookies, "https://music.youtube.com")?.let { header("Authorization", it) }
+ header("X-Goog-AuthUser", "0")
+ }
+ }
+ .build()
+ client.newCall(request).execute().use { response ->
+ return response.body.string()
+ }
+ }
+
+ companion object {
+ private val JSON = "application/json".toMediaType()
+ internal const val USER_AGENT =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0"
+
+ private fun authorization(cookies: String, origin: String): String? {
+ val values = cookies.split(';')
+ .map { it.trim() }
+ .filter { it.contains('=') }
+ .associate { part ->
+ val index = part.indexOf('=')
+ part.substring(0, index) to part.substring(index + 1)
+ }
+ val secret = values["SAPISID"].orEmpty()
+ .ifBlank { values["__Secure-3PAPISID"].orEmpty() }
+ .ifBlank { values["__Secure-1PAPISID"].orEmpty() }
+ if (secret.isBlank()) return null
+ val timestamp = System.currentTimeMillis() / 1000
+ val digest = MessageDigest.getInstance("SHA-1")
+ .digest("$timestamp $secret $origin".toByteArray(Charsets.UTF_8))
+ val hash = digest.joinToString("") { byte -> "%02x".format(byte) }
+ return "SAPISIDHASH ${timestamp}_$hash"
+ }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt
new file mode 100644
index 0000000000..1a93458d59
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeModels.kt
@@ -0,0 +1,56 @@
+package com.theveloper.pixelplay.youtube
+
+enum class YoutubeSection {
+ HOME,
+ SEARCH,
+ LIKED,
+ PLAYLISTS,
+ RADIO,
+}
+
+data class YoutubeShelf(
+ val id: String,
+ val title: String,
+ val items: List,
+ val startsRadio: Boolean = false,
+)
+
+enum class YoutubeSearchFilter {
+ SONGS,
+ ALBUMS,
+ PLAYLISTS,
+}
+
+data class YoutubeHit(
+ val url: String,
+ val title: String,
+ val artist: String,
+ val durationSec: Long,
+ val thumbnailUrl: String?,
+ val kind: Kind,
+ val album: String = "",
+) {
+ enum class Kind { TRACK, COLLECTION }
+}
+
+/** A page of hits plus the token that fetches the next one, or null at the end. */
+data class YoutubePage(
+ val hits: List,
+ val continuation: String?,
+)
+
+data class YoutubeResolvedTrack(
+ val watchUrl: String,
+ val title: String,
+ val artist: String,
+ val durationMs: Long,
+ val streamUrl: String,
+ val mimeType: String?,
+ val artworkUrl: String?,
+ val album: String = "",
+)
+
+data class YoutubeCredits(
+ val artist: String,
+ val album: String,
+)
diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt
new file mode 100644
index 0000000000..08abfa3685
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeMusicStore.kt
@@ -0,0 +1,406 @@
+package com.theveloper.pixelplay.youtube
+
+import android.content.Context
+import com.theveloper.pixelplay.data.model.Song
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.json.JSONArray
+import org.json.JSONObject
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Liked YouTube Music tracks. These lists are not device files.
+ */
+@Singleton
+class YoutubeMusicStore @Inject constructor(
+ @ApplicationContext context: Context,
+) {
+ private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ private val _liked = MutableStateFlow(read(KEY_LIKED))
+ private val _recent = MutableStateFlow(read(KEY_RECENT))
+ private val _likedSongIds = MutableStateFlow(idsOf(_liked.value))
+
+ val liked: StateFlow> = _liked.asStateFlow()
+ val recent: StateFlow> = _recent.asStateFlow()
+ val likedSongIds: StateFlow> = _likedSongIds.asStateFlow()
+ private val catalog = LinkedHashMap()
+ private val creditLookups = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
+ private val _catalogVersion = MutableStateFlow(0)
+ val catalogVersion: StateFlow = _catalogVersion.asStateFlow()
+
+ init {
+ synchronized(catalog) {
+ readCatalog().forEach { track -> catalog[track.songId] = track }
+ }
+ rememberHits(_liked.value + _recent.value)
+ }
+
+ fun toggleLike(hit: YoutubeHit) {
+ rememberHits(listOf(hit))
+ _liked.value = toggle(_liked.value, hit)
+ _likedSongIds.value = idsOf(_liked.value)
+ persist(KEY_LIKED, _liked.value)
+ }
+
+ fun toggleLike(song: Song) {
+ hitFromSong(song)?.let(::toggleLike)
+ }
+
+ fun rememberRecent(hit: YoutubeHit) {
+ if (hit.url.isBlank() || hit.kind != YoutubeHit.Kind.TRACK) return
+ val next = listOf(hit) + _recent.value.filterNot { it.url == hit.url }
+ _recent.value = next.take(MAX_RECENT)
+ persist(KEY_RECENT, _recent.value)
+ rememberHits(listOf(hit))
+ }
+
+ fun rememberRecent(song: Song) {
+ hitFromSong(song)?.let(::rememberRecent)
+ }
+
+ /** Keep titles and artists for tracks that are not in the on-device library. */
+ fun rememberHits(hits: List) {
+ val tracks = hits.filter { it.kind == YoutubeHit.Kind.TRACK && it.url.isNotBlank() }
+ if (tracks.isEmpty()) return
+ var changed = false
+ synchronized(catalog) {
+ tracks.forEach { hit ->
+ val (artist, album) = credits(hit)
+ if (upsert(
+ CatalogTrack(
+ songId = YoutubeClient.songIdForUrl(hit.url),
+ title = hit.title.trim(),
+ artist = artist,
+ album = album,
+ artworkUrl = hit.thumbnailUrl,
+ videoId = YoutubeClient.videoIdFromUrl(hit.url),
+ )
+ )
+ ) {
+ changed = true
+ }
+ }
+ if (changed) persistCatalog()
+ }
+ if (changed) _catalogVersion.value += 1
+ }
+
+ fun rememberPlayback(
+ songId: String,
+ title: String,
+ artist: String,
+ album: String,
+ artworkUrl: String?,
+ videoId: String = "",
+ ) {
+ val id = songId.trim()
+ if (!id.startsWith("yt_") || title.isBlank()) return
+ val changed = synchronized(catalog) {
+ val updated = upsert(
+ CatalogTrack(
+ songId = id,
+ title = title.trim(),
+ artist = cleanName(artist),
+ album = cleanAlbum(album),
+ artworkUrl = artworkUrl,
+ videoId = videoId.trim(),
+ aiAsked = false,
+ )
+ )
+ if (updated) persistCatalog()
+ updated
+ }
+ if (changed) _catalogVersion.value += 1
+ }
+
+ fun creditGapsForAi(playedSongIds: Set, limit: Int): List {
+ if (playedSongIds.isEmpty() || limit <= 0) return emptyList()
+ return synchronized(catalog) {
+ val tracks = catalog.values.filter { track ->
+ track.songId in playedSongIds &&
+ !track.aiAsked &&
+ track.title.isNotBlank() &&
+ (track.artist.isBlank() || track.album.isBlank())
+ }.take(limit)
+ if (tracks.isEmpty()) return@synchronized emptyList()
+ tracks.forEach { track ->
+ catalog[track.songId] = track.copy(aiAsked = true)
+ }
+ persistCatalog()
+ tracks.map { track ->
+ CatalogCreditGap(
+ songId = track.songId,
+ title = track.title,
+ artist = track.artist,
+ album = track.album,
+ artworkUrl = track.artworkUrl,
+ videoId = track.videoId,
+ )
+ }
+ }
+ }
+
+ fun releaseAiCreditGaps(songIds: Set) {
+ if (songIds.isEmpty()) return
+ synchronized(catalog) {
+ var changed = false
+ songIds.forEach { songId ->
+ val track = catalog[songId] ?: return@forEach
+ if (!track.aiAsked) return@forEach
+ catalog[songId] = track.copy(aiAsked = false)
+ changed = true
+ }
+ if (changed) persistCatalog()
+ }
+ }
+
+ fun creditGaps(playedSongIds: Set, limit: Int): List {
+ if (playedSongIds.isEmpty() || limit <= 0) return emptyList()
+ return synchronized(catalog) {
+ catalog.values.asSequence()
+ .filter { track ->
+ track.songId in playedSongIds &&
+ track.videoId.length == 11 &&
+ track.videoId !in creditLookups &&
+ (track.artist.isBlank() || track.album.isBlank())
+ }
+ .take(limit)
+ .map { track ->
+ CatalogCreditGap(
+ songId = track.songId,
+ title = track.title,
+ artist = track.artist,
+ album = track.album,
+ artworkUrl = track.artworkUrl,
+ videoId = track.videoId,
+ )
+ }
+ .toList()
+ }
+ }
+
+ fun claimCreditLookup(videoId: String): Boolean = creditLookups.add(videoId)
+
+ fun releaseCreditLookup(videoId: String) {
+ creditLookups.remove(videoId)
+ }
+
+ fun songsForStats(): List {
+ val tracks = synchronized(catalog) { catalog.values.toList() }
+ return tracks.map { it.toSong() }
+ }
+
+ private fun idsOf(hits: List): Set =
+ hits.map { YoutubeClient.songIdForUrl(it.url) }.toSet()
+
+ private fun toggle(current: List, hit: YoutubeHit): List {
+ if (hit.url.isBlank() || hit.kind != YoutubeHit.Kind.TRACK) return current
+ val without = current.filterNot { it.url == hit.url }
+ return if (without.size == current.size) listOf(hit) + current else without
+ }
+
+ private fun hitFromSong(song: Song): YoutubeHit? {
+ val url = YoutubeClient.watchUrlForSongId(song.id) ?: return null
+ return YoutubeHit(
+ url = url,
+ title = song.title.ifBlank { "Unknown title" },
+ artist = song.displayArtist,
+ durationSec = song.duration.coerceAtLeast(0L) / 1000L,
+ thumbnailUrl = song.albumArtUriString,
+ kind = YoutubeHit.Kind.TRACK,
+ album = song.album,
+ )
+ }
+
+ private fun read(key: String): List {
+ val raw = prefs.getString(key, null) ?: return emptyList()
+ return runCatching { decode(raw) }.getOrDefault(emptyList())
+ }
+
+ private fun persist(key: String, hits: List) {
+ prefs.edit().putString(key, encode(hits.take(MAX_TRACKS))).apply()
+ }
+
+ private fun encode(hits: List): String {
+ val array = JSONArray()
+ hits.forEach { hit ->
+ array.put(
+ JSONObject()
+ .put("url", hit.url)
+ .put("title", hit.title)
+ .put("artist", hit.artist)
+ .put("durationSec", hit.durationSec)
+ .put("thumbnailUrl", hit.thumbnailUrl ?: "")
+ .put("album", hit.album)
+ )
+ }
+ return array.toString()
+ }
+
+ private fun decode(raw: String): List {
+ val array = JSONArray(raw)
+ return buildList {
+ for (index in 0 until array.length()) {
+ val item = array.getJSONObject(index)
+ val url = item.optString("url")
+ if (url.isBlank()) continue
+ add(
+ YoutubeHit(
+ url = url,
+ title = item.optString("title").ifBlank { "Unknown title" },
+ artist = item.optString("artist"),
+ durationSec = item.optLong("durationSec"),
+ thumbnailUrl = item.optString("thumbnailUrl").ifBlank { null },
+ kind = YoutubeHit.Kind.TRACK,
+ album = item.optString("album"),
+ )
+ )
+ }
+ }
+ }
+
+ private fun upsert(track: CatalogTrack): Boolean {
+ if (track.songId.isBlank() || track.title.isBlank()) return false
+ val merged = catalog[track.songId]?.merge(track) ?: track
+ if (merged == catalog[track.songId]) return false
+ catalog.remove(track.songId)
+ catalog[track.songId] = merged
+ while (catalog.size > MAX_CATALOG) {
+ val eldest = catalog.keys.firstOrNull() ?: break
+ catalog.remove(eldest)
+ }
+ return true
+ }
+
+ private fun persistCatalog() {
+ val array = JSONArray()
+ catalog.values.forEach { track ->
+ array.put(
+ JSONObject()
+ .put("songId", track.songId)
+ .put("title", track.title)
+ .put("artist", track.artist)
+ .put("album", track.album)
+ .put("artworkUrl", track.artworkUrl ?: "")
+ .put("videoId", track.videoId)
+ .put("aiAsked", track.aiAsked)
+ )
+ }
+ prefs.edit().putString(KEY_CATALOG, array.toString()).apply()
+ }
+
+ private fun readCatalog(): List {
+ val raw = prefs.getString(KEY_CATALOG, null) ?: return emptyList()
+ return runCatching {
+ val array = JSONArray(raw)
+ buildList {
+ for (index in 0 until array.length()) {
+ val item = array.getJSONObject(index)
+ val songId = item.optString("songId")
+ val title = item.optString("title")
+ if (songId.isBlank() || title.isBlank()) continue
+ add(
+ CatalogTrack(
+ songId = songId,
+ title = title,
+ artist = cleanName(item.optString("artist")),
+ album = cleanAlbum(item.optString("album")),
+ artworkUrl = item.optString("artworkUrl").ifBlank { null },
+ videoId = item.optString("videoId"),
+ aiAsked = item.optBoolean("aiAsked"),
+ )
+ )
+ }
+ }
+ }.getOrDefault(emptyList())
+ }
+
+ private fun credits(hit: YoutubeHit): Pair {
+ val parts = hit.artist.split('•', '·')
+ .map { it.trim() }
+ .filter { it.isNotBlank() }
+ val artist = cleanName(parts.firstOrNull().orEmpty().ifBlank { hit.artist })
+ val fromByline = parts.getOrNull(1).orEmpty()
+ val album = cleanAlbum(hit.album).ifBlank { cleanAlbum(fromByline) }
+ return artist to album
+ }
+
+ private fun cleanName(value: String): String {
+ val trimmed = value.trim().removeSuffix(" - Topic").trim()
+ if (trimmed.isBlank()) return ""
+ if (trimmed.equals("Unknown Artist", ignoreCase = true)) return ""
+ if (trimmed.equals("YouTube Music", ignoreCase = true)) return ""
+ return trimmed
+ }
+
+ private fun cleanAlbum(value: String): String {
+ val trimmed = value.trim()
+ if (trimmed.isBlank()) return ""
+ if (trimmed.equals("YouTube Music", ignoreCase = true)) return ""
+ if (trimmed.equals("Unknown Album", ignoreCase = true)) return ""
+ if (trimmed.contains("view", ignoreCase = true)) return ""
+ if (DURATION.matches(trimmed)) return ""
+ return trimmed
+ }
+
+ private data class CatalogTrack(
+ val songId: String,
+ val title: String,
+ val artist: String,
+ val album: String,
+ val artworkUrl: String?,
+ val videoId: String = "",
+ val aiAsked: Boolean = false,
+ ) {
+ fun merge(incoming: CatalogTrack): CatalogTrack = copy(
+ title = incoming.title.ifBlank { title },
+ artist = incoming.artist.ifBlank { artist },
+ album = incoming.album.ifBlank { album },
+ artworkUrl = incoming.artworkUrl ?: artworkUrl,
+ videoId = incoming.videoId.ifBlank { videoId },
+ aiAsked = aiAsked || incoming.aiAsked,
+ )
+
+ fun toSong(): Song = Song(
+ id = songId,
+ title = title,
+ artist = artist,
+ artistId = -1L,
+ artists = emptyList(),
+ album = album,
+ albumId = -1L,
+ albumArtist = artist.ifBlank { null },
+ path = "",
+ contentUriString = "",
+ albumArtUriString = artworkUrl,
+ duration = 0L,
+ genre = null,
+ mimeType = null,
+ bitrate = null,
+ sampleRate = null,
+ )
+ }
+
+ companion object {
+ private const val PREFS = "youtube_music"
+ private const val KEY_LIKED = "liked"
+ private const val KEY_RECENT = "recent"
+ private const val KEY_CATALOG = "catalog"
+ private const val MAX_TRACKS = 200
+ private const val MAX_RECENT = 24
+ private const val MAX_CATALOG = 2000
+ private val DURATION = Regex("""^\d+:\d{2}(?::\d{2})?$""")
+ }
+}
+
+data class CatalogCreditGap(
+ val songId: String,
+ val title: String,
+ val artist: String,
+ val album: String,
+ val artworkUrl: String?,
+ val videoId: String,
+)
diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt
new file mode 100644
index 0000000000..82afb46f30
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubePlaybackQueue.kt
@@ -0,0 +1,21 @@
+package com.theveloper.pixelplay.youtube
+
+/**
+ * A playlist keeps every track, including ones already played. Playback still
+ * starts at the song you tapped. [remainingTracks] is only the upcoming suffix,
+ * used to warm streams ahead of the current song.
+ */
+object YoutubePlaybackQueue {
+ fun playlistTracks(start: YoutubeHit, source: List): List {
+ if (start.kind != YoutubeHit.Kind.TRACK) return emptyList()
+ val tracks = source.filter { it.kind == YoutubeHit.Kind.TRACK }
+ return if (tracks.any { it.url == start.url }) tracks else listOf(start)
+ }
+
+ fun remainingTracks(start: YoutubeHit, source: List): List {
+ if (start.kind != YoutubeHit.Kind.TRACK) return emptyList()
+ val tracks = source.filter { it.kind == YoutubeHit.Kind.TRACK }
+ val index = tracks.indexOfFirst { it.url == start.url }
+ return if (index >= 0) tracks.drop(index) else listOf(start)
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt
new file mode 100644
index 0000000000..902d31a9c7
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeSession.kt
@@ -0,0 +1,144 @@
+package com.theveloper.pixelplay.youtube
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.webkit.CookieManager
+import android.webkit.WebStorage
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.util.concurrent.atomic.AtomicInteger
+import javax.inject.Inject
+import javax.inject.Singleton
+
+data class YoutubeAccount(
+ val cookieHeader: String = "",
+ val displayName: String = "",
+ /** InnerTube visitorData captured from the signed-in music.youtube.com page. */
+ val visitorData: String = "",
+ /** DATASYNC_ID of the signed-in channel; sent as context.user.onBehalfOfUser. */
+ val dataSyncId: String = "",
+) {
+ val isSignedIn: Boolean
+ get() = listOf("SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID")
+ .any { key -> cookieHeader.contains("$key=", ignoreCase = true) }
+}
+
+@Singleton
+class YoutubeSession @Inject constructor(
+ @ApplicationContext context: Context,
+) {
+ private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ private val _account = MutableStateFlow(read())
+ val account: StateFlow = _account.asStateFlow()
+
+ private val generation = AtomicInteger(0)
+
+ fun cookieHeader(): String = _account.value.cookieHeader
+
+ fun isSignedIn(): Boolean = _account.value.isSignedIn
+
+ fun generation(): Int = generation.get()
+
+ fun save(
+ cookieHeader: String,
+ displayName: String = "",
+ visitorData: String = "",
+ dataSyncId: String = "",
+ ) {
+ val account = YoutubeAccount(
+ cookieHeader = cookieHeader.trim(),
+ displayName = displayName.trim().ifBlank { "Signed in" },
+ visitorData = visitorData.trim(),
+ dataSyncId = normalizeDataSyncId(dataSyncId),
+ )
+ prefs.edit()
+ .putString(KEY_COOKIES, account.cookieHeader)
+ .putString(KEY_NAME, account.displayName)
+ .putString(KEY_VISITOR, account.visitorData)
+ .putString(KEY_DATASYNC, account.dataSyncId)
+ .apply()
+ _account.value = account
+ }
+
+ /** Fills in identity fields discovered after sign-in without touching the cookies. */
+ fun updateIdentity(
+ visitorData: String? = null,
+ dataSyncId: String? = null,
+ displayName: String? = null,
+ ) {
+ val current = _account.value
+ if (!current.isSignedIn) return
+ val updated = current.copy(
+ visitorData = visitorData?.trim()?.takeIf { it.isNotEmpty() } ?: current.visitorData,
+ dataSyncId = dataSyncId?.let(::normalizeDataSyncId)?.takeIf { it.isNotEmpty() } ?: current.dataSyncId,
+ displayName = displayName?.trim()?.takeIf { it.isNotEmpty() } ?: current.displayName,
+ )
+ if (updated == current) return
+ prefs.edit()
+ .putString(KEY_NAME, updated.displayName)
+ .putString(KEY_VISITOR, updated.visitorData)
+ .putString(KEY_DATASYNC, updated.dataSyncId)
+ .apply()
+ _account.value = updated
+ }
+
+ fun clear() {
+ generation.incrementAndGet()
+ prefs.edit().clear().commit()
+ _account.value = YoutubeAccount()
+ val wipe = Runnable {
+ val cookies = CookieManager.getInstance()
+ expireStoredCookies(cookies)
+ cookies.removeAllCookies { cookies.flush() }
+ cookies.removeSessionCookies(null)
+ cookies.flush()
+ runCatching { WebStorage.getInstance().deleteAllData() }
+ }
+ if (Looper.myLooper() == Looper.getMainLooper()) wipe.run()
+ else Handler(Looper.getMainLooper()).post(wipe)
+ }
+
+ private fun expireStoredCookies(cookies: CookieManager) {
+ val expired = "Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Path=/; Secure"
+ listOf(
+ "https://youtube.com",
+ "https://www.youtube.com",
+ "https://m.youtube.com",
+ "https://music.youtube.com",
+ "https://accounts.google.com",
+ "https://www.google.com",
+ "https://google.com",
+ "https://consent.youtube.com",
+ ).forEach { url ->
+ cookies.getCookie(url).orEmpty().split(';').forEach { part ->
+ val name = part.substringBefore('=').trim()
+ if (name.isNotEmpty()) cookies.setCookie(url, "$name=; $expired")
+ }
+ }
+ }
+
+ private fun read(): YoutubeAccount = YoutubeAccount(
+ cookieHeader = prefs.getString(KEY_COOKIES, "").orEmpty(),
+ displayName = prefs.getString(KEY_NAME, "").orEmpty(),
+ visitorData = prefs.getString(KEY_VISITOR, "").orEmpty(),
+ dataSyncId = prefs.getString(KEY_DATASYNC, "").orEmpty(),
+ )
+
+ companion object {
+ private const val PREFS = "youtube_session"
+ private const val KEY_COOKIES = "cookies"
+ private const val KEY_NAME = "display_name"
+ private const val KEY_VISITOR = "visitor_data"
+ private const val KEY_DATASYNC = "datasync_id"
+
+ /** DATASYNC_ID looks like "channel||user"; InnerTube wants the first non-empty part. */
+ fun normalizeDataSyncId(raw: String): String {
+ val trimmed = raw.trim()
+ if (!trimmed.contains("||")) return trimmed
+ return trimmed.split("||").map { it.trim() }.firstOrNull { it.isNotEmpty() }.orEmpty()
+ }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStatsCredits.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStatsCredits.kt
new file mode 100644
index 0000000000..57386708db
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStatsCredits.kt
@@ -0,0 +1,135 @@
+package com.theveloper.pixelplay.youtube
+
+import com.theveloper.pixelplay.data.ai.AiHandler
+import com.theveloper.pixelplay.data.ai.AiResponseCleaner
+import com.theveloper.pixelplay.data.ai.AiSystemPromptType
+import javax.inject.Inject
+import javax.inject.Singleton
+import timber.log.Timber
+
+internal data class StatsCreditGuess(
+ val songId: String,
+ val artist: String,
+ val album: String,
+)
+
+internal fun statsCreditPrompt(gaps: List): String = buildString {
+ appendLine("Name the artist and album for these played songs.")
+ gaps.forEach { gap ->
+ append("id=").append(gap.songId)
+ append(" title=\"").append(gap.title.replace("\"", "'"))
+ append("\" artist=\"").append(gap.artist.replace("\"", "'"))
+ append("\" album=\"").append(gap.album.replace("\"", "'"))
+ appendLine("\"")
+ }
+}
+
+internal fun parseStatsCredits(raw: String): List {
+ val body = AiResponseCleaner.extractJsonArray(raw) ?: return emptyList()
+ val guesses = mutableListOf()
+ var index = 0
+ while (index < body.length) {
+ val start = body.indexOf('{', index)
+ if (start < 0) break
+ val end = matchingBrace(body, start)
+ if (end < 0) break
+ val obj = body.substring(start, end + 1)
+ val songId = jsonField(obj, "id").ifBlank { jsonField(obj, "songId") }
+ if (songId.isNotBlank()) {
+ guesses += StatsCreditGuess(
+ songId = songId,
+ artist = cleanStatsCredit(jsonField(obj, "artist"), album = false),
+ album = cleanStatsCredit(jsonField(obj, "album"), album = true),
+ )
+ }
+ index = end + 1
+ }
+ return guesses
+}
+
+internal fun cleanStatsCredit(value: String, album: Boolean): String {
+ val trimmed = value.trim().removeSuffix(" - Topic").trim()
+ if (trimmed.isBlank()) return ""
+ if (trimmed.equals("Unknown Artist", ignoreCase = true)) return ""
+ if (trimmed.equals("Unknown Album", ignoreCase = true)) return ""
+ if (trimmed.equals("YouTube Music", ignoreCase = true)) return ""
+ if (album && trimmed.contains("view", ignoreCase = true)) return ""
+ return trimmed
+}
+
+private fun jsonField(obj: String, name: String): String {
+ val pattern = Regex(""""$name"\s*:\s*"((?:\\.|[^"\\])*)"""")
+ val raw = pattern.find(obj)?.groupValues?.getOrNull(1) ?: return ""
+ return raw.replace("\\\"", "\"").replace("\\\\", "\\")
+}
+
+private fun matchingBrace(text: String, start: Int): Int {
+ var depth = 0
+ var inString = false
+ var escaped = false
+ for (index in start until text.length) {
+ val char = text[index]
+ if (escaped) {
+ escaped = false
+ continue
+ }
+ if (inString) {
+ if (char == '\\') escaped = true
+ else if (char == '"') inString = false
+ continue
+ }
+ when (char) {
+ '\\' -> escaped = true
+ '"' -> inString = true
+ '{' -> depth++
+ '}' -> {
+ depth--
+ if (depth == 0) return index
+ }
+ }
+ }
+ return -1
+}
+
+/**
+ * Fills artist and album names that YouTube did not provide, using the AI
+ * provider the user already configured. Songs are asked once.
+ */
+@Singleton
+class YoutubeStatsCreditAi @Inject constructor(
+ private val aiHandler: AiHandler,
+) {
+ suspend fun fill(store: YoutubeMusicStore, playedSongIds: Set, limit: Int = 20) {
+ val gaps = store.creditGapsForAi(playedSongIds, limit)
+ if (gaps.isEmpty()) return
+ val response = runCatching {
+ aiHandler.generateContent(
+ prompt = statsCreditPrompt(gaps),
+ type = AiSystemPromptType.STATS_CREDITS,
+ temperature = 0.1f,
+ )
+ }.getOrElse { error ->
+ store.releaseAiCreditGaps(gaps.map { it.songId }.toSet())
+ val message = error.message.orEmpty()
+ if (!message.contains("No API key", ignoreCase = true)) {
+ Timber.w(error, "AI credits lookup failed")
+ }
+ return
+ }
+ val byId = parseStatsCredits(response).associateBy { it.songId }
+ gaps.forEach { gap ->
+ val guess = byId[gap.songId] ?: return@forEach
+ val artist = gap.artist.ifBlank { guess.artist }
+ val album = gap.album.ifBlank { guess.album }
+ if (artist == gap.artist && album == gap.album) return@forEach
+ store.rememberPlayback(
+ songId = gap.songId,
+ title = gap.title,
+ artist = artist,
+ album = album,
+ artworkUrl = gap.artworkUrl,
+ videoId = gap.videoId,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStreamPicker.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStreamPicker.kt
new file mode 100644
index 0000000000..273a5b04c7
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeStreamPicker.kt
@@ -0,0 +1,49 @@
+package com.theveloper.pixelplay.youtube
+
+internal data class YoutubeAudioCandidate(
+ val url: String,
+ val mimeType: String,
+ val bitrate: Int,
+)
+
+/**
+ * Chooses a URL ExoPlayer can open.
+ * YouTube Music tracks often expose a SABR or HLS link next to a plain audio file.
+ * Playing the manifest first makes that song fail and the playlist skips it.
+ */
+internal fun pickPlayableAudio(
+ candidates: List,
+ avoidUrl: String? = null,
+): YoutubeAudioCandidate? {
+ val ranked = candidates
+ .filter(::isPlayableAudioCandidate)
+ .sortedByDescending(::audioCandidateScore)
+ return ranked.firstOrNull { it.url != avoidUrl }
+}
+
+private fun isPlayableAudioCandidate(candidate: YoutubeAudioCandidate): Boolean {
+ val url = candidate.url.trim()
+ if (!url.startsWith("http")) return false
+ if (url.contains("encrypted", ignoreCase = true)) return false
+ val lower = url.lowercase()
+ if ("/sabr" in lower || "source=sabr" in lower) return false
+ val mime = candidate.mimeType.lowercase()
+ val audio = mime.startsWith("audio/")
+ val muxedAudio = mime.startsWith("video/") && (
+ "mp4a" in mime || "opus" in mime || "vorbis" in mime
+ )
+ val hls = "mpegurl" in mime || lower.contains(".m3u8")
+ return audio || muxedAudio || hls
+}
+
+private fun audioCandidateScore(candidate: YoutubeAudioCandidate): Int {
+ val mime = candidate.mimeType.lowercase()
+ val kind = when {
+ mime.startsWith("audio/mp4") || mime.startsWith("audio/m4a") -> 4_000_000
+ mime.startsWith("audio/webm") -> 3_000_000
+ mime.startsWith("audio/") -> 2_000_000
+ mime.startsWith("video/") -> 1_000_000
+ else -> 0
+ }
+ return kind + candidate.bitrate.coerceAtLeast(0)
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt
new file mode 100644
index 0000000000..a734d1cc61
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/youtube/YoutubeViewModel.kt
@@ -0,0 +1,718 @@
+package com.theveloper.pixelplay.youtube
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.theveloper.pixelplay.data.model.Song
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlin.coroutines.cancellation.CancellationException
+import javax.inject.Inject
+
+data class YoutubeUiState(
+ val section: YoutubeSection = YoutubeSection.HOME,
+ val query: String = "",
+ val filter: YoutubeSearchFilter = YoutubeSearchFilter.SONGS,
+ val results: List = emptyList(),
+ val shelves: List = emptyList(),
+ val collectionTitle: String? = null,
+ val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
+ val isLoadingMore: Boolean = false,
+ val hasMore: Boolean = false,
+ val error: String? = null,
+)
+
+@HiltViewModel
+class YoutubeViewModel @Inject constructor(
+ private val client: YoutubeClient,
+ private val musicStore: YoutubeMusicStore,
+ private val session: YoutubeSession,
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(YoutubeUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+ private val _notices = MutableSharedFlow(extraBufferCapacity = 1)
+ val notices: SharedFlow = _notices.asSharedFlow()
+ private val _libraryPlaylists = MutableStateFlow>(emptyList())
+ val libraryPlaylists: StateFlow> = _libraryPlaylists.asStateFlow()
+ val likedTracks: StateFlow> = musicStore.liked
+ val recentTracks: StateFlow> = musicStore.recent
+ val likedSongIds: StateFlow> = musicStore.likedSongIds
+ val account: StateFlow = session.account
+ private var searchJob: Job? = null
+ private var likedJob: Job? = null
+ private var warmUpJob: Job? = null
+ private var likedMoreJob: Job? = null
+ private var likedContinuation: String? = null
+ @Volatile
+ private var loadingMoreLiked = false
+
+ init {
+ viewModelScope.launch {
+ uiState.collect { state ->
+ val hits = state.results + state.shelves.flatMap { it.items }
+ if (hits.isNotEmpty()) musicStore.rememberHits(hits)
+ }
+ }
+ viewModelScope.launch {
+ session.account.collect { account ->
+ if (_uiState.value.section == YoutubeSection.LIKED) {
+ loadLiked(refreshing = account.isSignedIn)
+ }
+ }
+ }
+ }
+
+ fun openSection(section: YoutubeSection) {
+ val current = _uiState.value
+ if (current.section == section && current.collectionTitle == null) {
+ val hasContent = when (section) {
+ YoutubeSection.HOME -> current.shelves.isNotEmpty()
+ YoutubeSection.SEARCH -> current.query.trim().length < 2 || current.results.isNotEmpty()
+ else -> current.results.isNotEmpty()
+ }
+ if (hasContent) return
+ }
+ _uiState.update {
+ it.copy(
+ section = section,
+ collectionTitle = null,
+ error = null,
+ isLoadingMore = false,
+ hasMore = false,
+ )
+ }
+ when (section) {
+ YoutubeSection.HOME -> {
+ if (client.feedShelves.isNotEmpty()) {
+ _uiState.update { it.copy(shelves = client.feedShelves, isLoading = false) }
+ }
+ loadFeed(refreshing = client.feedShelves.isNotEmpty())
+ }
+ YoutubeSection.SEARCH -> {
+ if (_uiState.value.query.trim().length >= 2) search(_uiState.value.query)
+ else _uiState.update { it.copy(results = emptyList(), shelves = emptyList(), isLoading = false) }
+ }
+ YoutubeSection.LIKED -> {
+ if (client.likedHits.isNotEmpty()) {
+ _uiState.update {
+ it.copy(
+ results = client.likedHits,
+ isLoading = false,
+ hasMore = likedContinuation != null,
+ )
+ }
+ }
+ loadLiked(refreshing = client.likedHits.isNotEmpty())
+ }
+ YoutubeSection.PLAYLISTS -> {
+ if (client.playlistHits.isNotEmpty()) {
+ _uiState.update { it.copy(results = client.playlistHits, isLoading = false) }
+ }
+ loadPlaylists(refreshing = client.playlistHits.isNotEmpty())
+ }
+ YoutubeSection.RADIO -> {
+ if (client.radioHits.isNotEmpty()) {
+ _uiState.update { it.copy(results = client.radioHits, isLoading = false) }
+ }
+ loadRadioStations(refreshing = client.radioHits.isNotEmpty())
+ }
+ }
+ }
+
+ fun onQueryChange(query: String) {
+ _uiState.update { it.copy(query = query, error = null, collectionTitle = null) }
+ searchJob?.cancel()
+ if (query.trim().length < 2) {
+ _uiState.update { it.copy(results = emptyList(), isLoading = false) }
+ return
+ }
+ searchJob = viewModelScope.launch {
+ delay(250)
+ search(query)
+ }
+ }
+
+ fun setFilter(filter: YoutubeSearchFilter) {
+ _uiState.update { it.copy(filter = filter, collectionTitle = null) }
+ val query = _uiState.value.query
+ if (query.trim().length >= 2) search(query)
+ }
+
+ fun refresh() {
+ when (_uiState.value.section) {
+ YoutubeSection.HOME -> loadFeed(refreshing = true)
+ YoutubeSection.SEARCH -> {
+ val query = _uiState.value.query
+ if (query.trim().length >= 2) search(query, refreshing = true)
+ }
+ YoutubeSection.LIKED -> loadLiked(refreshing = true)
+ YoutubeSection.PLAYLISTS -> loadPlaylists(refreshing = true)
+ YoutubeSection.RADIO -> loadRadioStations(refreshing = true)
+ }
+ }
+
+ fun openCollection(hit: YoutubeHit) {
+ if (hit.kind != YoutubeHit.Kind.COLLECTION) return
+ viewModelScope.launch {
+ _uiState.update {
+ it.copy(isLoading = true, error = null, collectionTitle = hit.title, results = emptyList())
+ }
+ val loaded = runCatching {
+ withContext(Dispatchers.IO) { client.loadCollectionTracks(hit.url, limit = 200) }
+ }
+ loaded.fold(
+ onSuccess = { tracks ->
+ _uiState.update { it.copy(isLoading = false, results = tracks) }
+ if (tracks.isEmpty()) {
+ _uiState.update { it.copy(error = "This playlist did not return any tracks.") }
+ }
+ },
+ onFailure = { error ->
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = error.message ?: "Couldn't open this playlist",
+ )
+ }
+ },
+ )
+ }
+ }
+
+ fun reportError(message: String) {
+ _uiState.update { it.copy(error = message, isLoading = false) }
+ }
+
+ fun closeCollection() {
+ val state = _uiState.value
+ _uiState.update { it.copy(collectionTitle = null, error = null) }
+ if (state.section == YoutubeSection.SEARCH && state.query.trim().length >= 2) {
+ search(state.query)
+ } else if (state.section == YoutubeSection.HOME) {
+ loadFeed()
+ } else if (state.section == YoutubeSection.PLAYLISTS) {
+ loadPlaylists()
+ }
+ }
+
+ suspend fun resolvePlayable(hit: YoutubeHit): Song = withContext(Dispatchers.IO) {
+ var lastError: Throwable? = null
+ repeat(2) {
+ val resolved = runCatching {
+ val track = client.resolveTrack(hit.url)
+ client.toSong(track, hit.url)
+ }
+ if (resolved.isSuccess) return@withContext resolved.getOrThrow()
+ lastError = resolved.exceptionOrNull()
+ }
+ throw lastError ?: IllegalStateException("Couldn't play this track")
+ }
+
+ fun isLiked(hit: YoutubeHit): Boolean {
+ val id = YoutubeClient.songIdForUrl(hit.url)
+ return id in musicStore.likedSongIds.value || client.likedHits.any { it.url == hit.url }
+ }
+
+ fun toggleLike(hit: YoutubeHit) {
+ val videoId = YoutubeClient.videoIdFromUrl(hit.url)
+ val wasLiked = isLiked(hit)
+ val locallyLiked = YoutubeClient.songIdForUrl(hit.url) in musicStore.likedSongIds.value
+ if (locallyLiked == wasLiked || !wasLiked) {
+ musicStore.toggleLike(hit)
+ } else {
+ // Liked on the account but not stored locally — drop it from the account list.
+ client.likedHits = client.likedHits.filterNot { it.url == hit.url }
+ if (_uiState.value.section == YoutubeSection.LIKED) {
+ _uiState.update { state ->
+ state.copy(results = state.results.filterNot { it.url == hit.url })
+ }
+ }
+ }
+ if (!session.isSignedIn() || videoId.isBlank()) return
+ viewModelScope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) { client.likeVideo(videoId, like = !wasLiked) }
+ }.onFailure { error ->
+ // Roll the optimistic change back.
+ if (locallyLiked == wasLiked || !wasLiked) musicStore.toggleLike(hit)
+ else {
+ client.likedHits = (listOf(hit) + client.likedHits).distinctBy { it.url }
+ if (_uiState.value.section == YoutubeSection.LIKED) {
+ _uiState.update { state ->
+ state.copy(results = (listOf(hit) + state.results).distinctBy { it.url })
+ }
+ }
+ }
+ _uiState.update {
+ it.copy(error = error.message ?: "Couldn't update the like on YouTube Music")
+ }
+ }.onSuccess {
+ if (wasLiked) {
+ client.likedHits = client.likedHits.filterNot { it.url == hit.url }
+ if (_uiState.value.section == YoutubeSection.LIKED) {
+ _uiState.update { state ->
+ state.copy(results = state.results.filterNot { it.url == hit.url })
+ }
+ }
+ } else {
+ client.likedHits = (listOf(hit) + client.likedHits).distinctBy { it.url }
+ }
+ }
+ }
+ }
+
+ fun ensureLibraryPlaylists() {
+ if (_libraryPlaylists.value.isNotEmpty() || client.playlistHits.isNotEmpty()) {
+ if (_libraryPlaylists.value.isEmpty()) _libraryPlaylists.value = client.playlistHits
+ return
+ }
+ viewModelScope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) { client.loadLibraryPlaylists() }
+ }.onSuccess { hits ->
+ client.playlistHits = hits
+ _libraryPlaylists.value = hits
+ }
+ }
+ }
+ fun addHitsToPlaylist(playlist: YoutubeHit, hits: List) {
+ val playlistId = YoutubeClient.playlistIdFromUrl(playlist.url)
+ val videoIds = hits.map { YoutubeClient.videoIdFromUrl(it.url) }.filter { it.length == 11 }
+ if (playlistId.isBlank() || videoIds.isEmpty()) {
+ _uiState.update { it.copy(error = "Couldn't add those songs to the playlist.") }
+ return
+ }
+ viewModelScope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) { client.addVideosToPlaylist(playlistId, videoIds) }
+ }.onSuccess {
+ _notices.emit("Added ${videoIds.size} to ${playlist.title}")
+ }.onFailure { error ->
+ _uiState.update {
+ it.copy(error = error.message ?: "Couldn't add songs to that playlist")
+ }
+ }
+ }
+ }
+
+ fun createPlaylistAndAdd(title: String, hits: List) {
+ val videoIds = hits.map { YoutubeClient.videoIdFromUrl(it.url) }.filter { it.length == 11 }
+ viewModelScope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) {
+ val id = client.createPlaylist(title, videoIds)
+ client.loadLibraryPlaylists()
+ id
+ }
+ }.onSuccess {
+ val refreshed = runCatching {
+ withContext(Dispatchers.IO) { client.loadLibraryPlaylists() }
+ }.getOrDefault(client.playlistHits)
+ client.playlistHits = refreshed
+ _libraryPlaylists.value = refreshed
+ _notices.emit("Created $title")
+ if (_uiState.value.section == YoutubeSection.PLAYLISTS) loadPlaylists(refreshing = true)
+ }.onFailure { error ->
+ _uiState.update {
+ it.copy(error = error.message ?: "Couldn't create the playlist")
+ }
+ }
+ }
+ }
+
+ fun rememberPlayed(song: Song) {
+ musicStore.rememberRecent(song)
+ }
+
+ fun importSession(cookieHeader: String, visitorData: String = "", dataSyncId: String = ""): Boolean {
+ val normalized = cookieHeader.trim()
+ val signedIn = listOf("SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID")
+ .any { key -> normalized.contains("$key=", ignoreCase = true) }
+ if (!signedIn) return false
+ session.save(normalized, visitorData = visitorData, dataSyncId = dataSyncId)
+ clearCaches()
+ val epoch = session.generation()
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ runCatching { client.refreshIdentity() }
+ val name = runCatching { client.loadAccountName() }.getOrDefault("")
+ if (name.isNotBlank() && session.generation() == epoch) session.updateIdentity(displayName = name)
+ }
+ if (session.generation() != epoch) return@launch
+ when (_uiState.value.section) {
+ YoutubeSection.LIKED -> loadLiked(refreshing = true)
+ YoutubeSection.HOME -> loadFeed(refreshing = true)
+ YoutubeSection.PLAYLISTS -> loadPlaylists(refreshing = true)
+ YoutubeSection.RADIO -> loadRadioStations(refreshing = true)
+ else -> Unit
+ }
+ }
+ return true
+ }
+
+ private fun clearCaches() {
+ warmUpJob?.cancel()
+ likedMoreJob?.cancel()
+ likedContinuation = null
+ loadingMoreLiked = false
+ client.resetIdentity()
+ client.likedHits = emptyList()
+ client.feedShelves = emptyList()
+ client.playlistHits = emptyList()
+ _libraryPlaylists.value = emptyList()
+ client.radioHits = emptyList()
+ }
+
+ fun signOut() {
+ likedJob?.cancel()
+ session.clear()
+ clearCaches()
+ _uiState.update {
+ it.copy(error = null, results = emptyList(), isLoading = false, isRefreshing = false)
+ }
+ when (_uiState.value.section) {
+ YoutubeSection.LIKED -> loadLiked(refreshing = false)
+ YoutubeSection.HOME -> loadFeed(refreshing = true)
+ else -> Unit
+ }
+ }
+
+ private fun loadLiked(refreshing: Boolean = false) {
+ likedJob?.cancel()
+ likedMoreJob?.cancel()
+ likedContinuation = null
+ loadingMoreLiked = false
+ val epoch = session.generation()
+ likedJob = viewModelScope.launch {
+ _uiState.update {
+ it.copy(
+ isLoading = !refreshing && it.results.isEmpty(),
+ isRefreshing = refreshing,
+ isLoadingMore = false,
+ hasMore = false,
+ error = null,
+ )
+ }
+ val local = musicStore.liked.value
+ val remote = if (session.isSignedIn() && session.generation() == epoch) {
+ try {
+ Result.success(withContext(Dispatchers.IO) { client.loadLikedPage() })
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (error: Exception) {
+ Result.failure(error)
+ }
+ } else {
+ Result.success(YoutubePage(emptyList(), null))
+ }
+ if (session.generation() != epoch) return@launch
+ remote.fold(
+ onSuccess = { page ->
+ if (session.generation() != epoch) return@launch
+ // Keep YouTube Music's newest-first order. Local likes only fill in
+ // when the account list is empty so they cannot jump the queue.
+ val account = if (session.isSignedIn()) page.hits else emptyList()
+ val rows = account.ifEmpty { local }
+ client.likedHits = account
+ likedContinuation = page.continuation
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isRefreshing = false,
+ error = null,
+ results = rows,
+ hasMore = page.continuation != null,
+ )
+ }
+ },
+ onFailure = { error ->
+ if (session.generation() != epoch || !session.isSignedIn()) {
+ _uiState.update {
+ it.copy(isLoading = false, isRefreshing = false, error = null, results = local)
+ }
+ return@fold
+ }
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isRefreshing = false,
+ results = local,
+ error = error.message ?: "Couldn't load liked music",
+ )
+ }
+ },
+ )
+ }
+ }
+
+ /** Appends the next page of Liked Music; safe to call repeatedly while scrolling. */
+ fun loadMoreLiked() {
+ val state = _uiState.value
+ if (state.section != YoutubeSection.LIKED || state.collectionTitle != null) return
+ val token = likedContinuation ?: return
+ // Set synchronously: several rows near the end ask for the next page at once.
+ if (loadingMoreLiked) return
+ loadingMoreLiked = true
+ val epoch = session.generation()
+ likedMoreJob = viewModelScope.launch {
+ _uiState.update { it.copy(isLoadingMore = true) }
+ val page = try {
+ Result.success(withContext(Dispatchers.IO) { client.loadLikedPage(token) })
+ } catch (cancelled: CancellationException) {
+ loadingMoreLiked = false
+ throw cancelled
+ } catch (error: Exception) {
+ Result.failure(error)
+ }
+ loadingMoreLiked = false
+ if (session.generation() != epoch) return@launch
+ page.fold(
+ onSuccess = { loaded ->
+ val appended = loaded.hits.filter { hit ->
+ client.likedHits.none { existing -> existing.url == hit.url }
+ }
+ likedContinuation = loaded.continuation.takeIf { appended.isNotEmpty() }
+ client.likedHits = client.likedHits + appended
+ _uiState.update {
+ it.copy(
+ isLoadingMore = false,
+ hasMore = likedContinuation != null,
+ results = (it.results + appended).distinctBy { hit -> hit.url },
+ )
+ }
+ },
+ onFailure = {
+ // Keep what is already on screen; the next scroll can retry.
+ _uiState.update { it.copy(isLoadingMore = false) }
+ },
+ )
+ }
+ }
+
+ /**
+ * Resolves the first few tracks of a list in the background so tapping one starts
+ * playing without waiting for the stream lookup. The first resolve also primes the
+ * extractor's player-script cache, which is what makes the very first tap slow.
+ */
+ fun warmUpTracks(hits: List) {
+ val tracks = hits.asSequence()
+ .filter { it.kind == YoutubeHit.Kind.TRACK }
+ .take(WARM_UP_COUNT)
+ .toList()
+ if (tracks.isEmpty()) return
+ warmUpJob?.cancel()
+ warmUpJob = viewModelScope.launch(Dispatchers.IO) {
+ tracks.forEach { hit ->
+ if (!isActive) return@launch
+ runCatching { client.prefetchTrack(hit.url) }
+ }
+ }
+ }
+
+ fun songsFromContext(start: YoutubeHit, source: List): List {
+ return YoutubePlaybackQueue.remainingTracks(start, source).map(client::listSong)
+ }
+
+ fun listSong(hit: YoutubeHit): Song = client.listSong(hit)
+
+ fun prefetchAfter(start: YoutubeHit, queue: List, onSong: (Song) -> Unit) {
+ val startIndex = queue.indexOfFirst { it.url == start.url }
+ if (startIndex < 0) return
+ val upcoming = queue.drop(startIndex + 1)
+ .filter { it.kind == YoutubeHit.Kind.TRACK }
+ .take(4)
+ viewModelScope.launch {
+ upcoming.forEach { hit ->
+ val song = runCatching { resolvePlayable(hit) }.getOrNull() ?: return@forEach
+ onSong(song)
+ }
+ }
+ }
+
+ suspend fun radioTracks(seed: YoutubeHit): List = withContext(Dispatchers.IO) {
+ client.loadRadio(seed.url)
+ }
+
+ private fun loadFeed(refreshing: Boolean = false) {
+ viewModelScope.launch {
+ _uiState.update {
+ it.copy(
+ isLoading = !refreshing && it.shelves.isEmpty(),
+ isRefreshing = refreshing,
+ error = null,
+ collectionTitle = null,
+ )
+ }
+ val built = runCatching {
+ withContext(Dispatchers.IO) {
+ val signedIn = session.isSignedIn()
+ // Signed in: the real YouTube Music home (Quick picks, Listen again, Mixed for you...).
+ val home = if (signedIn) runCatching { client.loadHome() }.getOrElse { emptyList() } else emptyList()
+ val liked = if (signedIn) {
+ runCatching { client.loadLikedMusic(24) }.getOrDefault(emptyList())
+ } else {
+ emptyList()
+ }
+ val yours = liked.ifEmpty { musicStore.recent.value }
+ val mix = yours.ifEmpty {
+ if (signedIn) emptyList()
+ else runCatching { client.loadTrendingMusic(18) }.getOrDefault(emptyList())
+ }
+ val followed = if (signedIn && home.isEmpty() && yours.isEmpty()) {
+ runCatching { client.loadSubscriptionFeed(12) }.getOrDefault(emptyList())
+ } else {
+ emptyList()
+ }
+ val radioSeeds = (liked.ifEmpty { home.flatMap { it.items } }.filter { it.kind == YoutubeHit.Kind.TRACK })
+ .ifEmpty { musicStore.recent.value }
+ listOfNotNull(
+ mix.takeIf { it.isNotEmpty() && (home.isEmpty() || liked.isNotEmpty()) }?.let {
+ YoutubeShelf(
+ id = "yours",
+ title = when {
+ liked.isNotEmpty() -> "Your likes"
+ signedIn -> "For you"
+ else -> "Trending songs"
+ },
+ items = it,
+ )
+ },
+ ) + home + listOfNotNull(
+ radioSeeds.shuffled().take(12).takeIf { it.isNotEmpty() && signedIn }?.let {
+ YoutubeShelf("radio", "Radio", it, startsRadio = true)
+ },
+ followed.takeIf { it.isNotEmpty() }?.let {
+ YoutubeShelf("followed", "From your feed", it)
+ },
+ )
+ }
+ }
+ built.fold(
+ onSuccess = { shelves ->
+ client.feedShelves = shelves
+ _uiState.update { it.copy(isLoading = false, isRefreshing = false, shelves = shelves) }
+ },
+ onFailure = { error ->
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isRefreshing = false,
+ error = error.message ?: "Couldn't load the feed",
+ )
+ }
+ },
+ )
+ }
+ }
+
+ private fun loadPlaylists(refreshing: Boolean = false) {
+ viewModelScope.launch {
+ _uiState.update {
+ it.copy(isLoading = !refreshing && it.results.isEmpty(), isRefreshing = refreshing, error = null, shelves = emptyList())
+ }
+ val loaded = runCatching {
+ withContext(Dispatchers.IO) {
+ if (session.isSignedIn()) {
+ // The user's own YouTube Music library, never generic search results.
+ client.loadLibraryPlaylists()
+ } else {
+ listOf("today's hits", "workout", "focus", "party").flatMap { query ->
+ runCatching { client.search(query, YoutubeSearchFilter.PLAYLISTS, 6) }.getOrDefault(emptyList())
+ }.distinctBy { it.url }
+ }
+ }
+ }
+ loaded.fold(
+ onSuccess = { hits ->
+ client.playlistHits = hits
+ _libraryPlaylists.value = hits
+ _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = hits) }
+ },
+ onFailure = { error ->
+ _uiState.update {
+ it.copy(isLoading = false, isRefreshing = false, error = error.message ?: "Couldn't load playlists")
+ }
+ },
+ )
+ }
+ }
+
+ private fun loadRadioStations(refreshing: Boolean = false) {
+ viewModelScope.launch {
+ _uiState.update {
+ it.copy(isLoading = !refreshing && it.results.isEmpty(), isRefreshing = refreshing, error = null, shelves = emptyList())
+ }
+ val loaded = runCatching {
+ withContext(Dispatchers.IO) {
+ val yours = if (session.isSignedIn()) {
+ client.loadLikedMusic(16)
+ } else {
+ emptyList()
+ }.ifEmpty { musicStore.recent.value }.shuffled()
+ yours.ifEmpty {
+ if (session.isSignedIn()) emptyList() else client.loadTrendingMusic(16)
+ }
+ }
+ }
+ loaded.fold(
+ onSuccess = { hits ->
+ client.radioHits = hits
+ _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = hits) }
+ },
+ onFailure = { error ->
+ _uiState.update {
+ it.copy(isLoading = false, isRefreshing = false, error = error.message ?: "Couldn't load radio")
+ }
+ },
+ )
+ }
+ }
+
+ private fun search(query: String, refreshing: Boolean = false) {
+ val filter = _uiState.value.filter
+ viewModelScope.launch {
+ _uiState.update {
+ it.copy(
+ isLoading = !refreshing,
+ isRefreshing = refreshing,
+ error = null,
+ collectionTitle = null,
+ )
+ }
+ val loaded = runCatching {
+ withContext(Dispatchers.IO) { client.search(query, filter) }
+ }
+ if (_uiState.value.query.trim() != query.trim()) return@launch
+ loaded.fold(
+ onSuccess = { hits ->
+ _uiState.update { it.copy(isLoading = false, isRefreshing = false, results = hits) }
+ },
+ onFailure = { error ->
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isRefreshing = false,
+ error = error.message ?: "Search failed",
+ )
+ }
+ },
+ )
+ }
+ }
+
+ private companion object {
+ const val WARM_UP_COUNT = 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..b5e0a3c07b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,9 +8,116 @@
Home
+ Feed
+ Radio
+ Playlists
Search
+ Liked
+ SoundCloud
Library
+ Feed
+ Recently played
+ Plays, time, and top tracks
+ Trending, radio, and playlists
+ Radio
+ Playlists
+ Search
+ Liked
+ Home
+ Liked
+ Like
+ Unlike
+ Songs, albums, playlists
+ Clear search
+ Songs
+ Albums
+ Playlists
+ No liked music on this account yet.
+ Sign in to load your liked music.
+ Sign in
+ Sign out
+ Done
+ Finish signing in, then tap Done.
+ Search YouTube Music.
+ The feed is unavailable right now.
+ This playlist did not return any tracks.
+ Radio stations are unavailable right now.
+ Playlists are unavailable right now.
+ Radio from %1$s
+ Today\'s Mix for you
+ Account
+ Sign in with the Google account you use on YouTube. Liked videos and your feed then load from that account.
+ Sign in with Google
+ 1. Tap Open YouTube in Chrome and sign in there.\n2. On a computer, open youtube.com, press F12, open Network, click any youtube.com request, and copy the full Cookie request header.\n3. Paste it below and save. Liked videos then load from that account.
+ Open YouTube in Chrome
+ Cookie header
+ Save session
+ That cookie is missing a signed-in YouTube session. Copy the Cookie header from a youtube.com request after you are logged in.
+ Signed in
+ Radio keeps playing songs that sound like the one you start.
+ Add to playlist
+ New playlist
+ Playlist name
+ Create
+ Cancel
+ %1$d selected
+ No playlists in your library yet.
+ Play next
+ Select
+ Back
+
+
+ SoundCloud
+ SoundCloud tabs
+ Jump directly to any section.
+ SoundCloud settings
+ Sign in for your personal feed and likes, or set a public username. Some tracks are DRM-only and won\'t play or download.
+ Signed in as @%1$s. Feed and likes use your SoundCloud session.
+ 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
+ Add downloads to library
+ On: downloaded songs show in your library and on the Downloads tab. Off: they stay only on the SoundCloud Downloads tab.
+ Unavailable
+ Sign in to SoundCloud
+ Sign out
+ I\'m signed in
+ Couldn\'t find a SoundCloud session yet. Finish signing in, then tap again.
+ Signed in as @%1$s
+ Search / resolve
+ Play
+ Download
+ Like track
+ Unlike track
+ Share link
+ Shuffle
+ Shuffle play current list
+ Tiles
+ List
+ Tile view
+ List view
+ SoundCloud
+ SoundCloud
+ Signed in
+ client_id saved
+ Set client_id and sign in
+ Profile: @%1$s
+ Playlist: %1$s
+ %1$d tracks
+ 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.
@@ -37,6 +144,13 @@
PixelPlayer
Release announcement
Coming soon
+ A new version is available
+ PixelPlayer %1$s is ready to install. Open the latest release and download the APK for your phone.
+ Download
+ Downloading…
+ Allow PixelPlayer to install updates, then tap Download again.
+ Couldn\'t download the update. Check your connection and try again.
+ Later
Thank you for using PixelPlayer!
diff --git a/app/src/main/res/values/strings_changelogs.xml b/app/src/main/res/values/strings_changelogs.xml
index 21e8758403..c6cdcf56f0 100644
--- a/app/src/main/res/values/strings_changelogs.xml
+++ b/app/src/main/res/values/strings_changelogs.xml
@@ -2,6 +2,9 @@
Changelog
View on GitHub
+ Check for updates
+ You are on the latest release.
+ Couldn\'t load the changelog. View it on GitHub.
Improvements
Fixes
What\'s New
diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml
index 1c6ae4e284..40064c9b9d 100644
--- a/app/src/main/res/values/strings_settings.xml
+++ b/app/src/main/res/values/strings_settings.xml
@@ -287,6 +287,8 @@
Hi-Fi Mode
Float 32-bit audio output. Disable if playback stutters on your device.
Not supported on this device (PCM_FLOAT AudioTrack unavailable).
+ Skip Silence
+ Trim silent gaps inside a track so playback keeps moving.
Persistent Shuffle
Remember shuffle setting even after closing the app.
Show queue history
diff --git a/app/src/test/java/com/theveloper/pixelplay/data/backup/model/BackupSectionTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/backup/model/BackupSectionTest.kt
index aca06c6528..033d76cbe8 100644
--- a/app/src/test/java/com/theveloper/pixelplay/data/backup/model/BackupSectionTest.kt
+++ b/app/src/test/java/com/theveloper/pixelplay/data/backup/model/BackupSectionTest.kt
@@ -33,6 +33,7 @@ class BackupSectionTest {
assertEquals(BackupSection.QUICK_FILL, BackupSection.fromKey("quick_fill"))
assertEquals(BackupSection.ARTIST_IMAGES, BackupSection.fromKey("artist_images"))
assertEquals(BackupSection.EQUALIZER, BackupSection.fromKey("equalizer"))
+ assertEquals(BackupSection.AI_USAGE_LOGS, BackupSection.fromKey("ai_usage_logs"))
}
@Test
@@ -47,8 +48,8 @@ class BackupSectionTest {
}
@Test
- fun `there are exactly 11 backup sections`() {
- assertEquals(11, BackupSection.entries.size)
+ fun `there are exactly 12 backup sections`() {
+ assertEquals(12, BackupSection.entries.size)
}
@Test
diff --git a/app/src/test/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateServiceTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateServiceTest.kt
new file mode 100644
index 0000000000..4592bf6848
--- /dev/null
+++ b/app/src/test/java/com/theveloper/pixelplay/data/github/GitHubReleaseUpdateServiceTest.kt
@@ -0,0 +1,41 @@
+package com.theveloper.pixelplay.data.github
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class GitHubReleaseUpdateServiceTest {
+
+ @Test
+ fun releasePage_allowsThisRepoOnly() {
+ assertThat(
+ GitHubReleaseUpdateService.isAllowedReleasePage(
+ "https://github.com/kibetmasi/PixelPlayer/releases/tag/vcontinuous-20260928-23f4e155",
+ ),
+ ).isTrue()
+ assertThat(
+ GitHubReleaseUpdateService.isAllowedReleasePage("https://evil.example/releases"),
+ ).isFalse()
+ assertThat(
+ GitHubReleaseUpdateService.isAllowedReleasePage("http://github.com/kibetmasi/PixelPlayer/releases"),
+ ).isFalse()
+ }
+
+ @Test
+ fun apkDownload_allowsThisRepoApkOnly() {
+ assertThat(
+ GitHubReleaseUpdateService.isAllowedApkDownload(
+ "https://github.com/kibetmasi/PixelPlayer/releases/download/v1/app-arm64-v8a-release.apk",
+ ),
+ ).isTrue()
+ assertThat(
+ GitHubReleaseUpdateService.isAllowedApkDownload(
+ "https://github.com/kibetmasi/PixelPlayer/releases/download/v1/notes.txt",
+ ),
+ ).isFalse()
+ assertThat(
+ GitHubReleaseUpdateService.isAllowedApkDownload(
+ "https://github.com/someone/else/releases/download/v1/app.apk",
+ ),
+ ).isFalse()
+ }
+}
diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/player/LoadControlBufferProfileTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/player/LoadControlBufferProfileTest.kt
index 0c89e99a91..4c3a1fe58b 100644
--- a/app/src/test/java/com/theveloper/pixelplay/data/service/player/LoadControlBufferProfileTest.kt
+++ b/app/src/test/java/com/theveloper/pixelplay/data/service/player/LoadControlBufferProfileTest.kt
@@ -11,7 +11,7 @@ class LoadControlBufferProfileTest {
assertThat(profile.minBufferMs).isEqualTo(30_000)
assertThat(profile.maxBufferMs).isEqualTo(60_000)
- assertThat(profile.bufferForPlaybackMs).isEqualTo(2_000)
+ assertThat(profile.bufferForPlaybackMs).isEqualTo(1_000)
assertThat(profile.bufferForPlaybackAfterRebufferMs).isEqualTo(5_000)
}
diff --git a/app/src/test/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepositoryTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepositoryTest.kt
index ad2faa3eb1..cf747590de 100644
--- a/app/src/test/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepositoryTest.kt
+++ b/app/src/test/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepositoryTest.kt
@@ -223,6 +223,49 @@ class PlaybackStatsRepositoryTest {
assertThat(summary.topGenres.single().uniqueArtists).isEqualTo(2)
}
+ @Test
+ fun `streamed songs supply artist album and title when missing from the library`() = runTest {
+ val repository = createRepository()
+ val zoneId = ZoneId.systemDefault()
+ val start = LocalDate.of(2026, 4, 10)
+ .atTime(16, 0)
+ .atZone(zoneId)
+ .toInstant()
+ .toEpochMilli()
+ val durationMs = 90_000L
+ val event = PlaybackStatsRepository.PlaybackEvent(
+ songId = "yt_super_shy",
+ timestamp = start + durationMs,
+ durationMs = durationMs,
+ startTimestamp = start,
+ endTimestamp = start + durationMs
+ )
+ val streamed = song(
+ songId = "yt_super_shy",
+ artist = "NewJeans",
+ album = "NewJeans"
+ ).copy(title = "Super Shy")
+
+ val summary = repository.buildSummaryFromEvents(
+ range = StatsTimeRange.DAY,
+ songs = mergeStatsSongs(emptyList(), listOf(streamed)),
+ allEvents = listOf(event),
+ nowMillis = start + durationMs + 1_000L
+ )
+
+ assertThat(summary.topArtists.map { it.artist }).containsExactly("NewJeans")
+ assertThat(summary.topAlbums.map { it.album }).containsExactly("NewJeans")
+ assertThat(summary.songs.map { it.title }).containsExactly("Super Shy")
+ }
+
+ @Test
+ fun `mergeStatsSongs keeps the library copy when ids collide`() {
+ val library = song(songId = "yt_1", artist = "Library Artist")
+ val streamed = song(songId = "yt_1", artist = "Stream Artist")
+ val merged = mergeStatsSongs(listOf(library), listOf(streamed))
+ assertThat(merged).containsExactly(library)
+ }
+
private fun createRepository(): PlaybackStatsRepository {
val uniqueDir = createTempDirectory(
"playback-stats-test-${Instant.now().toEpochMilli()}-"
@@ -237,14 +280,15 @@ class PlaybackStatsRepositoryTest {
durationMs: Long = 5 * 60 * 1000L,
artist: String = "Artist",
artists: List = emptyList(),
- genre: String? = null
+ genre: String? = null,
+ album: String = "Album",
): Song = Song(
id = songId,
title = "Song $songId",
artist = artist,
artistId = 1L,
artists = artists,
- album = "Album",
+ album = album,
albumId = 1L,
path = "/music/$songId.mp3",
contentUriString = "content://media/external/audio/media/$songId",
diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayoutTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayoutTest.kt
new file mode 100644
index 0000000000..491728bd60
--- /dev/null
+++ b/app/src/test/java/com/theveloper/pixelplay/presentation/components/player/FoldPlayerLayoutTest.kt
@@ -0,0 +1,37 @@
+package com.theveloper.pixelplay.presentation.components.player
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class FoldPlayerLayoutTest {
+
+ @Test
+ fun noHingeStaysOnThePhonePlayerInEitherOrientation() {
+ assertEquals(FoldPlayerLayout.Phone, foldPlayerLayout(null, isLandscape = false))
+ assertEquals(FoldPlayerLayout.Phone, foldPlayerLayout(null, isLandscape = true))
+ }
+
+ @Test
+ fun flatPortraitStaysOnThePhonePlayer() {
+ val flat = FoldSignal(halfOpened = false, flat = true, horizontal = true, vertical = false)
+ assertEquals(FoldPlayerLayout.Phone, foldPlayerLayout(flat, isLandscape = false))
+ }
+
+ @Test
+ fun halfOpenHorizontalIsTabletop() {
+ val tabletop = FoldSignal(halfOpened = true, flat = false, horizontal = true, vertical = false)
+ assertEquals(FoldPlayerLayout.Tabletop, foldPlayerLayout(tabletop, isLandscape = false))
+ }
+
+ @Test
+ fun halfOpenVerticalIsBook() {
+ val book = FoldSignal(halfOpened = true, flat = false, horizontal = false, vertical = true)
+ assertEquals(FoldPlayerLayout.Book, foldPlayerLayout(book, isLandscape = true))
+ }
+
+ @Test
+ fun flatLandscapeUsesTheWideInnerLayout() {
+ val flat = FoldSignal(halfOpened = false, flat = true, horizontal = false, vertical = true)
+ assertEquals(FoldPlayerLayout.FlatWide, foldPlayerLayout(flat, isLandscape = true))
+ }
+}
diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt
index dfafcc3bce..e846687312 100644
--- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt
+++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt
@@ -9,14 +9,15 @@ import com.theveloper.pixelplay.data.repository.MusicRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
-import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.delay
import org.junit.Test
-@OptIn(ExperimentalCoroutinesApi::class)
class LyricsStateHolderTest {
@Test
@@ -55,7 +56,8 @@ class LyricsStateHolderTest {
userPreferencesRepository = userPreferencesRepository,
songMetadataEditor = songMetadataEditor
)
- val scope = TestScope(StandardTestDispatcher())
+ val job = SupervisorJob()
+ val scope = CoroutineScope(job + Dispatchers.Unconfined)
val callback = RecordingLyricsLoadCallback()
val state = MutableStateFlow(StablePlayerState())
val song = testSong(albumArtUriString = "content://art/song_art_1.jpg").copy(
@@ -63,20 +65,31 @@ class LyricsStateHolderTest {
)
val storedLyrics = Lyrics(plain = listOf("Stored lyrics"), areFromRemote = false)
- holder.initialize(scope, callback, state)
- coEvery { musicRepository.getStoredLyrics(song) } returns (storedLyrics to "Stored lyrics")
+ try {
+ coEvery { userPreferencesRepository.getLyricsSyncOffset(any()) } returns 0
+ coEvery { musicRepository.getStoredLyrics(song) } returns (storedLyrics to "Stored lyrics")
+ holder.initialize(scope, callback, state)
- holder.fetchLyricsForSong(
- song = song,
- forcePickResults = false,
- sourcePreference = com.theveloper.pixelplay.data.model.LyricsSourcePreference.API_FIRST
- ) { "Lyrics already available" }
- scope.advanceUntilIdle()
+ holder.fetchLyricsForSong(
+ song = song,
+ forcePickResults = false,
+ sourcePreference = com.theveloper.pixelplay.data.model.LyricsSourcePreference.API_FIRST
+ ) { "Lyrics already available" }
+ runBlocking {
+ withTimeout(5_000) {
+ while (holder.searchUiState.value !is LyricsSearchUiState.Success) {
+ delay(10)
+ }
+ }
+ }
- assertThat(holder.searchUiState.value).isEqualTo(LyricsSearchUiState.Success(storedLyrics))
- coVerify(exactly = 1) { musicRepository.getStoredLyrics(song) }
- coVerify(exactly = 0) { musicRepository.getLyricsFromRemote(any()) }
- coVerify(exactly = 0) { musicRepository.searchRemoteLyrics(any()) }
+ assertThat(holder.searchUiState.value).isEqualTo(LyricsSearchUiState.Success(storedLyrics))
+ coVerify(exactly = 1) { musicRepository.getStoredLyrics(song) }
+ coVerify(exactly = 0) { musicRepository.getLyricsFromRemote(any()) }
+ coVerify(exactly = 0) { musicRepository.searchRemoteLyrics(any()) }
+ } finally {
+ job.cancel()
+ }
}
private fun testSong(albumArtUriString: String?): Song {
diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt
index 7996811c67..a155c92d02 100644
--- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt
+++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt
@@ -99,6 +99,8 @@ class PlayerViewModelTest {
private val mockThemeStateHolder: ThemeStateHolder = mockk(relaxed = true)
private val mockMultiSelectionStateHolder: MultiSelectionStateHolder = mockk(relaxed = true)
private val mockPlaylistSelectionStateHolder: PlaylistSelectionStateHolder = mockk(relaxed = true)
+ private val mockYoutubeMusicStore: com.theveloper.pixelplay.youtube.YoutubeMusicStore = mockk(relaxed = true)
+ private val mockYoutubeClient: com.theveloper.pixelplay.youtube.YoutubeClient = mockk(relaxed = true)
private val mockMediaMapper: com.theveloper.pixelplay.data.media.MediaMapper = mockk(relaxed = true)
private lateinit var mockMediaControllerFactory: com.theveloper.pixelplay.data.media.MediaControllerFactory
@@ -167,6 +169,7 @@ class PlayerViewModelTest {
every { mockLibraryStateHolder.currentFolderSortOption } returns MutableStateFlow