diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a9514e..0caaea1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,8 +17,8 @@ jobs: - name: Checkout companion light-sdk uses: actions/checkout@v4 with: - repository: LooseWireDev/light-sdk - ref: codex/kelp-official-sdk + repository: gavdevs/light-sdk + ref: 251237ef3778c907c5dbd89e84685532c9d99a28 path: light-sdk - name: Set up JDK 17 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bdcea8..1c8d309 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,8 +18,8 @@ jobs: - name: Checkout companion light-sdk uses: actions/checkout@v4 with: - repository: LooseWireDev/light-sdk - ref: codex/kelp-official-sdk + repository: gavdevs/light-sdk + ref: 251237ef3778c907c5dbd89e84685532c9d99a28 path: light-sdk - name: Set up JDK 17 diff --git a/AGENTS.md b/AGENTS.md index ebe97ba..55bd287 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Kelp – agent notes -Light Phone III TIDAL client built on `lightphone/light-sdk` (composite build from `../light-sdk`, branch `codex/kelp-official-sdk`, based on official v0.1.1). Kotlin + Compose throughout. +Light Phone III TIDAL client built on the `gavdevs/light-sdk` fork (composite build from `../light-sdk`, pinned to `main` at `251237e`). Kotlin + Compose throughout. ## Module map @@ -17,9 +17,9 @@ Distribution lane: **sideload-only, experimental/local.** Not eligible for Light - **`:app` never imports TIDAL SDK, okhttp, Media3, or `:server` types.** Everything crosses the Light binder via `KelpRemoteMethod` IDs (`com.loosewire.kelp.*`). Add a feature by extending `:protocol` contracts, dispatch in `KelpServiceMethods`, and a client method in `KelpClient.kt`. - **`:protocol` stays Android/TIDAL-free.** Serializable models only, no platform imports. - **Two TIDAL sessions** (`KelpRuntime`): developer-app auth3 login for the v2 Public API (catalog) + `TidalStreamingAuth` first-party PKCE for playback. Catalog endpoints only accept the developer token; full-length streams only resolve with the first-party token. Do not collapse them. -- **Playback is service-owned**: `KelpPlaybackService` owns the MediaSession and `KelpPlayerController`/ExoPlayer. It explicitly calls `addSession` because Binder RPCs start playback without a MediaController connection. `TidalMediaSource` lazily resolves BTS/clear-DASH through `TidalStreamResolver`. Never create a second player from a screen or reintroduce the official TIDAL Player SDK. -- **Playback threading**: queue mutations and ExoPlayer calls are main-looper confined; Binder reads an immutable `@Volatile` snapshot. MediaSource lifecycle uses ExoPlayer's playback looper and resolution runs on IO. Cancellation must prevent stale stream/continuous-queue results; screen hide must not release playback. -- **Media3 is strict-pinned to 1.5.0** in both `app` and `server` (Light SDK resolves a newer version). Keep both pins in sync. +- **The Light SDK owns playback** through `LightAudioQueueSessions` and its detached `MediaSessionService`. Kelp owns TIDAL stream resolution (`playbackinfopostpaywall`, BTS/clear-DASH) and catalog recommendations. Do not create a second player or reintroduce the official TIDAL Player SDK. +- **Playback threading**: the SDK queue is main-thread confined. TIDAL resolver callbacks use IO. Synchronous playback RPCs must stay off Main. Never hold `catalogLock` while waiting for the SDK. Playback commands and sign-out serialize under `playerLock`. +- **The SDK owns Media3 versions and decoder dependencies**, including DASH. The old app/server 1.5.0 pins are removed. Both Kelp modules exclude desktop Tink from their SDK-client dependency because authentication requires `tink-android`. - **Only implemented settings**: continuous playback is applied at queue start. Quality selection, normalization, explicit filtering, and offline playback are not implemented; do not reintroduce preference-only controls. - **`lighttool.toml` is the identity source** (app id, version, capabilities, `serverPackage = "com.loosewire.kelp"`). Do not hand-write what the plugin generates. - **Secrets**: `local.properties` holds the developer-app client id and is git-ignored. Never embed it. The first-party client id in `TidalStreamingAuth` is intentionally embedded (public, shared with orpheusdl/phono; rotation procedure in `docs/TIDAL_ARCHITECTURE.md` and README). @@ -35,9 +35,11 @@ scripts/build-release.sh # tests + minified release APK in dist/ (handl Verification for any change: the narrowest useful unit tests first (`:protocol:test`, `:app:testDebugUnitTest`, `:server:testDebugUnitTest`), then `:app:assembleDebug`; run `:app:assembleRelease` for anything touching server code (R8 path) — or just run `scripts/build-release.sh`. Install: `adb -s install -r app/build/outputs/apk/debug/app-debug.apk`; pick an explicit serial when devices are ambiguous. +Playback changes also require the companion SDK's `:sdk:client:testDebugUnitTest` and a device lifecycle check. Verify screen-off playback across track transitions after the Activity and controller are released. A stock emulator cannot prove LP3-specific power behavior. + ## CI / releases -- `.github/workflows/build.yml`: tests + debug assemble on push/PR. CI checks out the companion `LooseWireDev/light-sdk` (branch `codex/kelp-official-sdk`) next to the repo; locally that checkout must sit at `../light-sdk` (override with `-Ptide.sdkPath=...`). +- `.github/workflows/build.yml`: tests + debug assemble on push/PR. CI checks out the companion `gavdevs/light-sdk` (pinned revision on `main`) next to the repo; locally that checkout must sit at `../light-sdk` (override with `-Ptide.sdkPath=...`). - `.github/workflows/release.yml`: tag-triggered (`v*`) signed release. Tag must match `versionName` in `app/lighttool.toml`; bump `versionCode` each release. Signing uses Actions secrets `TIDE_RELEASE_*`; the keystore backup lives at `~/.tide-keys/tide-release.jks` (storepass in `tide-release.storepass`, 600), outside the repo. - GitHub Release APKs are signed with the release key; dev-key-signed installs must be uninstalled first (signature mismatch on upgrade). diff --git a/README.md b/README.md index e24ffc2..03421ea 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ## What is it? -Kelp streams TIDAL through saved songs, albums, artists, playlists, daily mixes, and search in a text-first Light UI. The Light SDK owns the tool UI and navigation; a Kelp-owned Media3 media-session service owns background playback and publishes system transport controls. Full-length stream resolution uses the unofficial first-party-client technique used by [phono](https://github.com/jonathancaudill/phono), not the official TIDAL Player SDK. +Kelp provides saved songs, albums, artists, playlists, daily mixes, and search in a monochrome interface. Kelp resolves full-length TIDAL streams with first-party credentials, using the approach of [phono](https://github.com/jonathancaudill/phono). The Light SDK owns the audio player and its background service. **You need a paid TIDAL subscription.** Like phono, kelp never works around that and never will. @@ -43,6 +43,7 @@ Kelp is **sideload-only** today. It can't go through Light's hosted tool builder - Foreground-service playback outside Kelp, with track metadata and system play/pause/previous/next controls - Full-width navigation and transport touch targets, with Now Playing accessible from the top-left waveform - LOSSLESS streaming by default (falls back to lower quality if needed) +- SDK-owned background playback, track transitions, and system media controls - Follows LightOS theming and hardware-button navigation Loading audio is distinct from playing; preparation failures remain visible in Now Playing. Returning from playback keeps loaded browse results. Settings exposes continuous playback and sign-out, with read-only information about the actual streaming policy. Nonfunctional quality, normalization, explicit-filter, and offline controls are not presented as working features. @@ -51,7 +52,7 @@ Background playback is not persistent playback recovery: force-stop, reboot, or ## Build it yourself -You'll need JDK 17, an Android SDK (platform 36), and the companion Light SDK checkout at `../light-sdk` (branch `codex/kelp-official-sdk`, based on upstream v0.1.1 plus kelp's small compatibility patches): +Builds require JDK 17, Android SDK platform 36, and the companion `gavdevs/light-sdk` fork at `../light-sdk` on `main`. The fork carries the `LightAudioQueueSessions` streaming API and the companion cert override Kelp needs; Kelp and its companion SDK changes must ship together. ```bash ./gradlew :app:assembleDebug @@ -70,6 +71,16 @@ Local builds default to one worker, two JVM-visible processors, a 1.5 GiB heap, CI runs tests + assemble on every push; pushing a matching `v*` tag publishes a signed release automatically. +### Background playback + +`detached-audio` declares the SDK media service and its required permissions. Kelp starts a service-owned streaming queue through `LightAudioQueueSessions`. The service resolves each track when needed and loads recommendations without a screen or controller. + +Closing Kelp disconnects the UI without stopping playback. Reopening Kelp reads the existing queue and position. Sign-out clears the queue and cancels pending playback work. An abandoned paused session stops after 60 seconds. Queue restoration after service or process destruction is not supported. + +The SDK owns Media3 and the DASH decoder dependency. Kelp no longer pins the older Media3 runtime used by its separate player. + +For device verification, play several tracks with the screen off for at least 15 minutes. Verify automatic transitions, system pause/resume and next controls, reconnection, and sign-out. JVM tests cannot establish LightOS power-management behavior. + ## Legal / disclaimers Unofficial project, unaffiliated with TIDAL or Light. A paid TIDAL subscription is required for everything to work. If TIDAL rotates their first-party client id (you'll notice as playback suddenly failing), the fix procedure is documented in [docs/TIDAL_ARCHITECTURE.md](docs/TIDAL_ARCHITECTURE.md). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index edcafa2..4f8a84b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,6 +66,15 @@ android { error += "RestrictedApi" } + packaging { + resources { + // Legacy jar indexes and version manifests from the SDK server's + // Netty/ktor stack; nothing reads them at runtime. + excludes += "META-INF/INDEX.LIST" + pickFirsts += "META-INF/io.netty.versions.properties" + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -87,22 +96,6 @@ dependencies { } implementation(libs.kotlinx.coroutines) - // Keep the SDK client and Kelp's foreground media session on one tested - // Media3 runtime; TIDAL Auth/API do not own the player. - listOf( - "media3-common", - "media3-exoplayer", - "media3-session", - "media3-exoplayer-dash", - "media3-exoplayer-hls", - "media3-datasource-okhttp", - ).forEach { module -> - implementation("androidx.media3:$module") { - version { strictly("1.5.0") } - because("Keep the SDK client and Kelp playback service on the same Media3 runtime") - } - } - testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 9569458..8ae573a 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -56,7 +56,7 @@ Following Lightious's lifecycle lessons, browse screens retain loaded results on 1. **Shell:** authentication, five-icon catalog navigation, search entry, persistent local playback settings, and Light-native styling. 2. **Catalog:** album details and tracks, playlists, artists, saved tracks, and live search results. -3. **Player:** TIDAL stream resolution, service-owned media queue, Now Playing, native system transports, and media-state reconnection. Per-network quality selection remains unimplemented. +3. **Player:** TIDAL stream resolution through an SDK-owned detached queue, Now Playing, queue controls, and media-state reconnection. Quality application remains separate work. 4. **Offline:** downloads and storage management, only after online playback is reliable. -The current implementation includes the shell, paginated collection reads, artist/album/playlist details, grouped search, and basic online playback from every song list. Offline controls must only appear as active actions when their backing behavior is connected. +The current implementation includes the shell, paginated collection reads, artist/album/playlist details, grouped search, and SDK-owned online playback from song lists. The audio service owns background track transitions and continuous playback. Offline controls must only appear as active actions when their backing behavior is connected. diff --git a/docs/TIDAL_ARCHITECTURE.md b/docs/TIDAL_ARCHITECTURE.md index 055b788..6c48a67 100644 --- a/docs/TIDAL_ARCHITECTURE.md +++ b/docs/TIDAL_ARCHITECTURE.md @@ -4,10 +4,10 @@ This document records the TIDAL Developer Platform constraints that shape Kelp. ## Boundaries -- The Light SDK owns the tool lifecycle, screen stack, system typography, monochrome UI primitives, LightOS icons, binder transport, and detached-audio capability declaration. Declaring that capability alone does not host a player. +- The Light SDK owns the tool lifecycle, UI primitives, binder transport, and detached audio service. Its service owns the player, queue, media session, and playback jobs. - The official TIDAL Android Auth module owns developer-app OAuth PKCE credentials and refresh for catalog calls. Kelp requests credentials from its `CredentialsProvider` for API work; it never copies tokens into the app protocol or preferences. A second, phono-style first-party PKCE sign-in (`TidalStreamingAuth`) stores separate streaming tokens in a private SharedPreferences file on the server side only. - The official TIDAL API module owns generated catalog calls. Kelp maps generated JSON:API resource objects into small protocol-owned models before they cross the Light binder. A narrow Retrofit adapter covers only the current Search route while the latest Android release still carries the preceding generated shape. -- Kelp resolves streams just-in-time against the private `playbackinfopostpaywall` endpoint with first-party credentials and plays them in `KelpPlaybackService`, a foreground-capable Media3 `MediaSessionService`. Developer-app tokens only resolve previews. The official TIDAL Player module is not used. +- Kelp resolves full-length streams through the private `playbackinfopostpaywall` endpoint with first-party credentials. Developer-app tokens only resolve previews. The SDK plays these sources. The official TIDAL Player module is not used. - The app module never imports TIDAL SDK types. The merged server module is the integration boundary and exposes typed, versioned Kelp RPC methods. ## Public API map @@ -17,7 +17,7 @@ This document records the TIDAL Developer Platform constraints that shape Kelp. | Saved albums | `userCollectionAlbums/me/relationships/items` | `collection.read` | Third party | Sort by `-addedAt`; documented include is `items`. | | Saved artists | `userCollectionArtists/me/relationships/items` | `collection.read` | Third party | Sort by `name`; documented include is `items`. | | Saved songs | `userCollectionTracks/me/relationships/items` | `collection.read` | Third party | Sort by `-addedAt`; documented include is `items`. | -| Search | `searchResults?filter[query]=…`; then `searchResults/{id}/relationships/{section}` | `search.read` | Third party | Android API 0.3.53 was generated from schema 1.10.86. Kelp's narrow adapter follows the current collection search and relationship-page contract. | +| Search | `searchResults?filter[query]=…` | `search.read` | Third party | Android API 0.3.53 was generated from schema 1.10.86 and still exposes `searchResults/{id}`. Kelp's narrow compatibility adapter implements the current 1.10.115 route and multi-resource response. | | Artist albums | `artists/{id}/relationships/albums` | none beyond valid credentials | Third party | Required include is `albums`. | | Artist songs | `artists/{id}/relationships/tracks` | none beyond valid credentials | Third party | Requires `collapseBy`; include is `tracks`. | | Album songs | `albums/{id}/relationships/items` | none beyond valid credentials | Third party | Include is `items`; Kelp enriches returned tracks with their artist and album relationships. | @@ -30,31 +30,29 @@ TIDAL accepts `me` for the authenticated user's collection and mix resources. Pa Only documented include paths belong in requests. A relationship endpoint's include is relative to that endpoint's root: collection item endpoints use `items`, artist album relationships use `albums`, and artist track relationships use `tracks`. TIDAL can reject unsupported include, sort, or filter values with HTTP 400. -Search treats JSON:API `included` as an unordered resource lookup, never as a ranked result list. Hits follow typed relationship identifiers in upstream order and are not truncated to eight. Track enrichment preserves that order. The initial request sends the full query unchanged; subsequent requests use the returned opaque search-resource ID and the selected relationship's cursor. The app passes Kelp's opaque continuation through unchanged. Contract reference: [official TIDAL schema 1.10.133](https://raw.githubusercontent.com/tidal-music/tidal-sdk-web/72dab1bd87be745d043a027758a1947aecc4cf41/packages/api/bin/tidal-api-oas.json). - ## Home The public API does not expose a third-party playback-history resource, and `playQueues` is internal-access only. Kelp's Home therefore contains the public daily, discovery, and new-release mix resources plus a short saved-playlists section. The dedicated Playlists tab displays the saved playlist collection. ## Playback and quality -Stream entitlement is keyed on the OAuth client: tokens issued to registered developer apps only resolve 30-second previews, so Kelp performs a second PKCE sign-in with TIDAL's first-party Android client id (the clear hi-res tier; no Widevine CDM needed). The login screen chains both sign-ins on one WebView — the developer-app login for the catalog, then the first-party login for playback — and shared cookies usually carry the session into step two without re-entry. +Stream entitlement depends on the OAuth client. Developer-app tokens only resolve 30-second previews. Kelp therefore uses a second PKCE sign-in with TIDAL's first-party Android client ID. This client supports clear streams without a Widevine CDM. The login screen chains catalog and playback sign-ins in one WebView. -`KelpPlaybackService` owns one ExoPlayer, its controller, and one MediaSession. It explicitly registers the session with `addSession`: Kelp's Binder RPCs do not create the MediaController connection that registers a session implicitly in Lightious's SDK audio path. MediaSessionService supplies foreground ownership and media notification updates; the published session exposes metadata and the real queue to system controllers. Screen hide/navigation does not release playback. Sign-out clears the queue and stops the service; service destruction releases the player and cancels its work. Polling an empty player does not start a service. The queue is in memory, not restored after process death or reboot; Android may reclaim an inactive service. +`TidalStreamResolver` resolves each track when the SDK requests it. BTS manifests provide signed progressive URLs. Clear DASH manifests become local MPD files after invalid `group="main"` attributes are removed. Kelp supplies the file and its MIME type to the SDK. The SDK provides the DASH decoder. -This follows the service/session ownership used by Lightious listen mode rather than using its `LightAudioPlayer` wrapper. The current wrapper cannot express Kelp's lazy signed-URL resolution, explicit DASH MIME type, repeat policy, or queue append/replacement. No SDK internals or additional app-side player are used. All Media3/TIDAL types stay in `:server`; `:app` still uses the pure playback protocol. +`KelpPlayerController` is a server-side adapter to `LightAudioQueueSessions`, not an audio player. The SDK service owns resolution jobs, shuffle, repeat, seek, source-error recovery, and queue transitions. A continuation callback asks Kelp's catalog for similar tracks. Neither callback depends on a screen ViewModel. -`TidalMediaSource` prepares an actual ExoPlayer playlist lazily. Only prepared sources resolve through `TidalStreamResolver`: base64 BTS manifests yield progressive signed URLs; clear DASH manifests are sanitized (Media3 rejects TIDAL's non-numeric `AdaptationSet group="main"`) into unique cache MPDs, deleted when their sources release. Source preparation and child timelines use ExoPlayer's playback looper, resolution uses IO, and canceled results cannot install a new source. Play/pause, seek, previous/next, repeat, and shuffle act on the same native queue from both Kelp and system controls. +The SDK publishes the logical queue and track metadata through a platform media session. Temporary controllers release after commands, but the started foreground service retains playback. During source resolution, the session reports buffering and holds a partial wake lock. ExoPlayer manages playback wake locks and audio focus. -Queue policy and ExoPlayer access are main-looper confined. Binder snapshot reads use an immutable volatile snapshot; only position is polled while audio is playing. Snapshots distinguish actual playback, desired play state, buffering, and user-safe failure. A CDN 401/403 gets one fresh resolution per selected track while preserving position and desired play state; further failures are visible and explicitly retryable. There is no unconditional retry/autoplay loop. +The `detached-audio` capability generates the media service, foreground-service permissions, and wake-lock permission. The declaration alone does not attach another player to that service. -The Light SDK's `detached-audio` capability supplies foreground-media permissions; the unrestricted server manifest declares Kelp's actual media service and wake-lock permission. The generated SDK service is not instantiated. Media3 remains strictly pinned to 1.5.0 across app/server. +Sign-out stops the SDK queue before Kelp clears either authentication session. Generation checks reject stale resolution and recommendation results. Reopening the UI reads the live SDK snapshot. An abandoned paused service stops after 60 seconds. Queue state does not survive service or process destruction. -Continuous playback is queue policy, not a looping toggle. With repeat off, reaching the final queued song requests a bounded page of similar tracks, deduplicates it, and appends it without replacing active playback. Superseded queue requests and service destruction cancel that work. Disabling continuous playback stops at the chosen queue's end. Explicit-content filtering is not implemented. +Continuous playback is queue policy, not a looping toggle. At the end of the chosen queue, the SDK asks Kelp for `similarTracks` based on the last seed track. Duplicate tracks are removed before the returned page extends the queue. ## Offline downloads -Downloads are deferred and have no navigation destination or active settings. Offline media must not be presented as functional without a complete entitlement, license, renewal, and deletion flow. Kelp will not implement downloads by caching stream URLs or manifests. +Downloads are deferred and have no navigation destination. The download-quality preference remains staged for future offline support, but offline media must not be presented as functional until the public Android Player/DRM APIs support a complete third-party download, license, renewal, and deletion flow. Kelp will not implement downloads by caching stream URLs or manifests. ## Errors and request pacing diff --git a/gradle.properties b/gradle.properties index ca0aed2..b55ddf2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,3 +9,8 @@ kotlin.code.style=official # Consumed by the light-sdk tool plugin for the generated manifest (manifestPlaceholders). sdkVersion=0.1.1 + +# Kelp is its own companion server (serverPackage = "com.loosewire.kelp"), so the +# SDK's serverPackage guard needs an explicit opt-in. Committed here so local +# builds, scripts, and CI all see it. +systemProp.lightSdk.allowAltServerPackage=true diff --git a/server/build.gradle.kts b/server/build.gradle.kts index a802357..ee145f6 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -89,6 +89,11 @@ kotlin { dependencies { implementation(project(":protocol")) implementation(libs.sdk.server) + implementation(libs.sdk.client) { + exclude(group = "com.google.mlkit") + exclude(group = "androidx.camera") + exclude(group = "com.google.crypto.tink", module = "tink") + } implementation(libs.compose.activity) implementation(libs.androidx.lifecycle.runtime) implementation(libs.kotlinx.coroutines) @@ -101,18 +106,5 @@ dependencies { exclude(group = "com.google.crypto.tink", module = "tink-android") } implementation("com.squareup.okhttp3:okhttp:4.12.0") - // One pinned Media3 runtime hosts Kelp's foreground media session and lazy - // TIDAL stream sources. The official TIDAL Player module is not used. - listOf( - "media3-common", - "media3-exoplayer", - "media3-session", - "media3-exoplayer-dash", - ).forEach { module -> - implementation("androidx.media3:$module") { - version { strictly("1.5.0") } - because("Match the Media3 1.5.0 runtime pinned by the app module") - } - } testImplementation(libs.kotlin.test) } diff --git a/server/src/main/AndroidManifest.xml b/server/src/main/AndroidManifest.xml index cbf4533..93900f0 100644 --- a/server/src/main/AndroidManifest.xml +++ b/server/src/main/AndroidManifest.xml @@ -9,15 +9,6 @@ tools:node="remove" /> - - - - - - + checkCertOverride = { packageName -> if (packageName == appContext.packageName) { ClientCertType.LightSdkSignedUnverified } else { diff --git a/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlaybackService.kt b/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlaybackService.kt deleted file mode 100644 index 13527db..0000000 --- a/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlaybackService.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.loosewire.kelp.server - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSessionService -import com.loosewire.kelp.protocol.PlaybackSnapshot -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout - -/** The only owner of Kelp's player and platform media session; UI visibility is irrelevant. */ -@UnstableApi -class KelpPlaybackService : MediaSessionService() { - private var controller: KelpPlayerController? = null - private var session: MediaSession? = null - - override fun onCreate() { - super.onCreate() - val auth = KelpRuntime.streamingAuth() - if (auth == null) { - ready.completeExceptionally(IllegalStateException("Playback is not initialized")) - stopSelf() - return - } - val playback = KelpPlayerController(this, auth, KelpServiceMethods::similarTracks) - controller = playback - val builder = MediaSession.Builder(this, playback.player) - .setId(packageName) - .setCallback(object : MediaSession.Callback { - override fun onConnect( - session: MediaSession, - controller: MediaSession.ControllerInfo, - ): MediaSession.ConnectionResult { - // System transports may navigate/control the queue, never inject arbitrary URLs. - val commands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() - .remove(Player.COMMAND_CHANGE_MEDIA_ITEMS) - .remove(Player.COMMAND_SET_MEDIA_ITEM) - .build() - return MediaSession.ConnectionResult.AcceptedResultBuilder(session) - .setAvailablePlayerCommands(commands) - .build() - } - }) - packageManager.getLaunchIntentForPackage(packageName)?.let { launch -> - builder.setSessionActivity( - PendingIntent.getActivity( - this, 0, launch, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ), - ) - } - // Binder RPCs start playback directly, so no MediaController connection - // arrives to auto-register onGetSession's result with MediaSessionService. - session = builder.build().also(::addSession) - instance = this - ready.complete(playback) - } - - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session - - override fun onDestroy() { - instance = null - session?.release() - session = null - controller?.release() - controller = null - ready = CompletableDeferred() - super.onDestroy() - } - - companion object { - @Volatile - private var instance: KelpPlaybackService? = null - // Accessed only on Main, including start/stop and lifecycle callbacks. - private var ready = CompletableDeferred() - - internal fun snapshot(): PlaybackSnapshot = instance?.controller?.snapshot() ?: PlaybackSnapshot() - - internal suspend fun withController( - context: Context, - block: (KelpPlayerController) -> T, - ): T = withContext(Dispatchers.Main.immediate) { - val existing = instance?.controller - val playback = existing ?: run { - // Called by an explicit foreground playback action, never snapshot polling. - context.startService(Intent(context, KelpPlaybackService::class.java)) - withTimeout(5_000) { ready.await() } - } - block(playback) - } - - internal suspend fun stop() = withContext(Dispatchers.Main.immediate) { - instance?.let { service -> - service.controller?.clear() - service.stopSelf() - } - } - } -} diff --git a/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlayerController.kt b/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlayerController.kt index 358461f..54c0309 100644 --- a/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlayerController.kt +++ b/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlayerController.kt @@ -1,297 +1,182 @@ package com.loosewire.kelp.server import android.content.Context -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.HttpDataSource -import androidx.media3.exoplayer.DefaultLoadControl -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.MediaSource -import com.loosewire.kelp.protocol.KelpError -import com.loosewire.kelp.protocol.KelpErrorCategory +import android.os.Looper import com.loosewire.kelp.protocol.PlaybackSnapshot import com.loosewire.kelp.protocol.PlayerCommand import com.loosewire.kelp.protocol.RepeatMode import com.loosewire.kelp.protocol.StartPlaybackRequest import com.loosewire.kelp.protocol.TrackSummary -import java.util.logging.Logger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope +import com.thelightphone.sdk.audio.LightAudioQueueCommand +import com.thelightphone.sdk.audio.LightAudioQueueItem +import com.thelightphone.sdk.audio.LightAudioQueueResolver +import com.thelightphone.sdk.audio.LightAudioQueueSessions +import com.thelightphone.sdk.audio.LightAudioQueueSnapshot +import com.thelightphone.sdk.audio.LightAudioRepeatMode +import com.thelightphone.sdk.audio.LightAudioResolvedSource +import com.thelightphone.sdk.audio.LightAudioSource +import com.thelightphone.sdk.audio.LightMediaMetadata +import java.io.File +import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext - -/** Service-owned queue. All mutations and ExoPlayer access run on the main looper. */ -@UnstableApi -internal class KelpPlayerController( - context: Context, - streamingAuth: TidalStreamingAuth, - private val continuousLoader: suspend (TrackSummary) -> List, -) { +import kotlinx.coroutines.withTimeout + +/** + * Server-only TIDAL adapter. The SDK service owns the queue, player and resolution jobs; + * this controller can disappear with an RPC client without interrupting playback. + * Synchronous mutations are binder-thread entry points, never main-thread waits. + */ +internal class KelpPlayerController(context: Context) { private val appContext = context.applicationContext - private val resolver = TidalStreamResolver(streamingAuth) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val logger = Logger.getLogger(KelpPlayerController::class.java.name) - private var queue = emptyList() - private var displayedQueue = emptyList() - private var sourceName: String? = null - private var continuousPlayback = false - private var generation = 0 - private var continuation: Job? = null - private var continuationSeed: String? = null - private var positionJob: Job? = null - private var retryTrackId: String? = null - private var retryUsed = false - private var playbackError: KelpError? = null - - @Volatile - private var currentSnapshot = PlaybackSnapshot() - - val player: ExoPlayer = ExoPlayer.Builder(context) - .setLoadControl( - DefaultLoadControl.Builder() - .setBufferDurationsMs(15_000, 90_000, 2_500, 5_000) - .build(), - ) - .setAudioAttributes(AudioAttributes.DEFAULT, true) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(C.WAKE_MODE_NETWORK) - .setSeekBackIncrementMs(5_000) - .setMaxSeekToPreviousPositionMs(5_000) - .build() - .also { p -> - p.addListener(object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - val id = mediaItem?.mediaId - if (retryTrackId != id) { - retryTrackId = id - retryUsed = false - playbackError = null - } - queue.find { it.id == id }?.let(RecentTracksStore::record) - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - positionJob?.cancel() - positionJob = if (isPlaying) scope.launch { - while (isActive) { - currentSnapshot = currentSnapshot.copy( - positionMs = p.currentPosition.coerceAtLeast(0), - ) - delay(500) - } - } else null - } - - override fun onPlayerError(error: PlaybackException) { - logger.warning("TIDAL playback failed: ${error.errorCodeName}") - val expired = generateSequence(error) { it.cause } - .filterIsInstance() - .any { it.responseCode == 401 || it.responseCode == 403 } - if (expired && !retryUsed) { - retryUsed = true - val expectedGeneration = generation - val id = p.currentMediaItem?.mediaId - val position = p.currentPosition - val shouldPlay = p.playWhenReady - scope.launch { - // Let the failing player's event batch finish before replacing its sources. - kotlinx.coroutines.yield() - if (generation == expectedGeneration && p.currentMediaItem?.mediaId == id) { - prepareQueue(p.currentMediaItemIndex, position, shouldPlay) - } - } - } else { - playbackError = KelpError( - KelpErrorCategory.Unavailable, - "Could not play this song. Press Play to retry, or choose Next.", - ) - } - } - - override fun onEvents(player: Player, events: Player.Events) { - if (events.contains(Player.EVENT_TIMELINE_CHANGED) || - events.contains(Player.EVENT_SHUFFLE_MODE_ENABLED_CHANGED) - ) rebuildDisplayedQueue() - publishSnapshot() - requestContinuation() - } - }) - } - fun snapshot(): PlaybackSnapshot = currentSnapshot + fun snapshot(): PlaybackSnapshot = LightAudioQueueSessions.state.value.toPlaybackSnapshot() fun start(request: StartPlaybackRequest): PlaybackSnapshot { require(request.tracks.isNotEmpty()) { "Playback needs at least one song" } require(request.startIndex in request.tracks.indices) { "Invalid starting song" } - generation++ - continuation?.cancel() - continuation = null - continuationSeed = null - retryUsed = false - playbackError = null - queue = request.tracks.distinctBy { it.id } - sourceName = request.sourceName - continuousPlayback = request.continuousPlayback - val index = queue.indexOfFirst { it.id == request.tracks[request.startIndex].id } - prepareQueue(index, 0, true) - return snapshot() - } - - fun control(command: PlayerCommand, positionMs: Long? = null): PlaybackSnapshot { - when (command) { - PlayerCommand.TogglePlayPause -> when { - player.playerError != null -> { - playbackError = null - retryUsed = false - prepareQueue(player.currentMediaItemIndex, player.currentPosition, true) - } - player.playWhenReady && player.playbackState != Player.STATE_ENDED -> player.pause() - else -> { - if (player.playbackState == Player.STATE_ENDED) player.seekToDefaultPosition() - player.play() - } - } - PlayerCommand.Previous -> player.seekToPrevious() - PlayerCommand.Next -> player.seekToNextMediaItem() - PlayerCommand.ToggleShuffle -> player.shuffleModeEnabled = !player.shuffleModeEnabled - PlayerCommand.CycleRepeat -> player.repeatMode = when (player.repeatMode) { - Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL - Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE - else -> Player.REPEAT_MODE_OFF - } - PlayerCommand.Seek -> if (positionMs != null && player.duration > 0) { - player.seekTo(positionMs.coerceIn(0, player.duration)) + requireBinderThread() + val tracks = request.tracks.distinctBy(TrackSummary::id) + val requestedId = request.tracks[request.startIndex].id + val items = queueItems(tracks, nextGeneration.incrementAndGet().toString()) + return runBlocking { + withTimeout(PLAYER_TIMEOUT_MS) { + LightAudioQueueSessions.start( + context = appContext, + items = items, + startIndex = tracks.indexOfFirst { it.id == requestedId }, + sourceName = request.sourceName, + continuousPlayback = request.continuousPlayback, + ).toPlaybackSnapshot() } } - rebuildDisplayedQueue() - publishSnapshot() - return snapshot() } - private fun prepareQueue(index: Int, positionMs: Long, play: Boolean) { - if (queue.isEmpty()) return - player.setMediaSources(queue.map(::source), index.coerceIn(queue.indices), positionMs.coerceAtLeast(0)) - player.prepare() - player.playWhenReady = play - rebuildDisplayedQueue() - publishSnapshot() + suspend fun control(command: PlayerCommand, positionMs: Long? = null): PlaybackSnapshot = + LightAudioQueueSessions.control( + context = appContext, + command = when (command) { + PlayerCommand.TogglePlayPause -> LightAudioQueueCommand.TogglePlayPause + PlayerCommand.Previous -> LightAudioQueueCommand.Previous + PlayerCommand.Next -> LightAudioQueueCommand.Next + PlayerCommand.ToggleShuffle -> LightAudioQueueCommand.ToggleShuffle + PlayerCommand.CycleRepeat -> LightAudioQueueCommand.CycleRepeat + PlayerCommand.Seek -> LightAudioQueueCommand.Seek + }, + positionMs = positionMs, + ).toPlaybackSnapshot() + + /** Sign-out stops the service and cancels resolution before credentials are cleared. */ + fun release() { + requireBinderThread() + runBlocking { LightAudioQueueSessions.stop() } } - private fun source(track: TrackSummary): MediaSource = TidalMediaSource( - appContext, - resolver, - track, - MediaItem.Builder() - .setMediaId(track.id) - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(track.title) - .setArtist(track.artistName) - .setAlbumTitle(track.albumTitle) - .setDurationMs(track.durationMs) - .build(), - ) - .build(), - ) + companion object { + private const val PLAYER_TIMEOUT_MS = 10_000L + private val nextGeneration = AtomicLong() + private val tracksLock = Any() + + // The SDK retains these exact items for snapshots and resolver callbacks. Weak keys + // retain metadata while any old snapshot is in flight, not for the process lifetime. + // Generation-qualified ids keep replacement queues with the same track ids distinct. + private val tracksByItem = WeakHashMap() + + fun initialize( + context: Context, + continuousLoader: suspend (TrackSummary) -> List, + ) { + val appContext = context.applicationContext + val streams = TidalStreamResolver { + checkNotNull(KelpRuntime.streamingAuth()) { "TIDAL playback session is unavailable" } + } + LightAudioQueueSessions.configure(object : LightAudioQueueResolver { + override suspend fun resolve(item: LightAudioQueueItem): LightAudioResolvedSource = + withContext(Dispatchers.IO) { + val track = trackFor(item) + RecentTracksStore.record(track) + when (val resolved = streams.resolve(track.id)) { + is TidalStreamResolver.ResolvedStream.Progressive -> + LightAudioResolvedSource(LightAudioSource.UrlSource(resolved.url)) + is TidalStreamResolver.ResolvedStream.ClearDash -> { + val directory = File(appContext.cacheDir, "tidal_mpd").apply { mkdirs() } + val file = File( + directory, + "${item.id.substringBefore(':')}_${track.id}_${resolved.audioQuality}.mpd", + ) + file.writeText(resolved.mpdXml) + LightAudioResolvedSource( + LightAudioSource.FileSource(file), + mimeType = "application/dash+xml", + ) + } + } + } - private fun rebuildDisplayedQueue() { - val timeline = player.currentTimeline - if (timeline.isEmpty || !player.shuffleModeEnabled) { - displayedQueue = queue - return + override suspend fun continuation(seed: LightAudioQueueItem): List = + withContext(Dispatchers.IO) { + val additions = continuousLoader(trackFor(seed)) + val currentItems = LightAudioQueueSessions.state.value.items + if (currentItems.none { it.id == seed.id }) return@withContext emptyList() + val generation = seed.id.substringBefore(':') + val existing = currentItems.mapTo(HashSet()) { it.id } + // Never replace a live weak key with an equal recommendation key. + // The engine also deduplicates; filter here to preserve its metadata. + queueItems( + additions.filter { existing.add("$generation:${it.id}") }, + generation, + ) + } + }) } - displayedQueue = buildList { - var index = timeline.getFirstWindowIndex(true) - while (index != C.INDEX_UNSET) { - queue.getOrNull(index)?.let(::add) - index = timeline.getNextWindowIndex(index, Player.REPEAT_MODE_OFF, true) + + fun requireBinderThread() { + check(Looper.myLooper() != Looper.getMainLooper()) { + "Synchronous playback RPCs must run off the main thread" } } - } - - private fun publishSnapshot() { - val current = queue.getOrNull(player.currentMediaItemIndex) - currentSnapshot = PlaybackSnapshot( - current = current, - sourceName = sourceName, - queue = displayedQueue, - currentIndex = displayedQueue.indexOf(current), - positionMs = player.currentPosition.coerceAtLeast(0), - durationMs = player.duration.takeIf { it != C.TIME_UNSET && it > 0 } ?: 0, - isPlaying = player.isPlaying, - playWhenReady = player.playWhenReady && player.playbackState != Player.STATE_ENDED && player.playerError == null, - shuffle = player.shuffleModeEnabled, - repeatMode = when (player.repeatMode) { - Player.REPEAT_MODE_ALL -> RepeatMode.All - Player.REPEAT_MODE_ONE -> RepeatMode.One - else -> RepeatMode.Off - }, - isLoading = player.playbackState == Player.STATE_BUFFERING, - error = playbackError, - ) - } - private fun requestContinuation() { - if (!continuousPlayback || player.repeatMode != Player.REPEAT_MODE_OFF || - player.hasNextMediaItem() || queue.isEmpty() || player.playerError != null - ) return - val seed = queue.getOrNull(player.currentMediaItemIndex) ?: return - if (continuationSeed == seed.id) return - continuationSeed = seed.id - val expectedGeneration = generation - continuation = scope.launch { - try { - val tracks = withContext(Dispatchers.IO) { continuousLoader(seed) } - if (generation != expectedGeneration) return@launch - val ids = queue.mapTo(HashSet(), TrackSummary::id) - val additions = tracks.filter { ids.add(it.id) } - if (additions.isEmpty()) return@launch - val ended = player.playbackState == Player.STATE_ENDED - val nextIndex = queue.size - queue = queue + additions - player.addMediaSources(additions.map(::source)) - if (ended && player.playWhenReady) { - player.seekTo(nextIndex, 0) - player.prepare() - } - rebuildDisplayedQueue() - publishSnapshot() - } catch (error: CancellationException) { - throw error - } catch (_: Exception) { - if (generation == expectedGeneration) { - playbackError = KelpError(KelpErrorCategory.Network, "Could not load more songs.") - publishSnapshot() + private fun queueItems(tracks: List, generation: String): List = + synchronized(tracksLock) { + tracks.map { track -> + LightAudioQueueItem( + id = "$generation:${track.id}", + metadata = LightMediaMetadata( + title = track.title, + artist = track.artistName, + album = track.albumTitle, + durationMs = track.durationMs, + ), + ).also { tracksByItem[it] = track } } } - } - } - fun clear() { - generation++ - continuation?.cancel() - queue = emptyList() - displayedQueue = emptyList() - player.stop() - player.clearMediaItems() - currentSnapshot = PlaybackSnapshot() - } + private fun trackFor(item: LightAudioQueueItem): TrackSummary = synchronized(tracksLock) { + checkNotNull(tracksByItem[item]) { "TIDAL queue metadata is unavailable" } + } - fun release() { - generation++ - scope.cancel() - player.release() - currentSnapshot = PlaybackSnapshot() + private fun LightAudioQueueSnapshot.toPlaybackSnapshot(): PlaybackSnapshot { + // The protocol has no playback-error field. Surface an RPC error instead of + // returning the last playing snapshot after a resolver or service failure. + check(error == null) { "SDK audio playback failed: ${error?.diagnostic}" } + val tracks = items.map(::trackFor) + return PlaybackSnapshot( + current = tracks.getOrNull(currentIndex), + sourceName = sourceName, + queue = tracks, + currentIndex = currentIndex, + positionMs = positionMs, + durationMs = durationMs, + isPlaying = isPlaying, + shuffle = shuffle, + repeatMode = when (repeatMode) { + LightAudioRepeatMode.Off -> RepeatMode.Off + LightAudioRepeatMode.All -> RepeatMode.All + LightAudioRepeatMode.One -> RepeatMode.One + }, + ) + } } } diff --git a/server/src/main/kotlin/com/loosewire/kelp/server/KelpServiceMethods.kt b/server/src/main/kotlin/com/loosewire/kelp/server/KelpServiceMethods.kt index 278c016..8328ef5 100644 --- a/server/src/main/kotlin/com/loosewire/kelp/server/KelpServiceMethods.kt +++ b/server/src/main/kotlin/com/loosewire/kelp/server/KelpServiceMethods.kt @@ -30,12 +30,18 @@ object KelpServiceMethods { private var loginActivityComponent = "com.loosewire.kelp/.server.LoginActivity" private lateinit var applicationContext: Context private val catalogLock = Any() + private val playerLock = Any() @Volatile private var catalog: Catalog? = null + @Volatile + private var playerController: KelpPlayerController? = null fun initialize(context: Context) { applicationContext = context.applicationContext RecentTracksStore.initialize(applicationContext) + KelpPlayerController.initialize(applicationContext) { track -> + currentCatalog()?.similarTracks(track).orEmpty() + } loginActivityComponent = ComponentName( context.applicationContext, LoginActivity::class.java, @@ -63,12 +69,12 @@ object KelpServiceMethods { ) KelpRemoteMethod.Logout.id -> { - runBlocking { - KelpPlaybackService.stop() - KelpRuntime.logout() - } - synchronized(catalogLock) { - catalog = null + KelpPlayerController.requireBinderThread() + synchronized(playerLock) { + playerController?.release() + runBlocking { KelpRuntime.logout() } + playerController = null + synchronized(catalogLock) { catalog = null } } LightResult.Success( KelpRemoteMethod.Logout.encodeResponse(KelpRuntime.currentAuthSnapshot()), @@ -155,9 +161,11 @@ object KelpServiceMethods { handlePlaylistDetail(request) } - KelpRemoteMethod.GetPlayback.id -> LightResult.Success( - KelpRemoteMethod.GetPlayback.encodeResponse(KelpPlaybackService.snapshot()), - ) + KelpRemoteMethod.GetPlayback.id -> withPlayer { controller -> + LightResult.Success( + KelpRemoteMethod.GetPlayback.encodeResponse(controller.snapshot()), + ) + } KelpRemoteMethod.StartPlayback.id -> { val request = KelpRemoteMethod.StartPlayback.decodeRequest(payload ?: "{}") @@ -226,40 +234,48 @@ object KelpServiceMethods { private fun handleStartPlayback(request: StartPlaybackRequest): LightResult = withPlayer { controller -> - runCatching { controller.start(request) }.fold( - onSuccess = { LightResult.Success(KelpRemoteMethod.StartPlayback.encodeResponse(it)) }, - onFailure = { - failure( - KelpErrorCategory.Protocol, - "Choose a valid song to start playback.", - LightResult.ErrorCode.InvalidParameters, - ) - }, - ) + if (request.tracks.isEmpty() || request.startIndex !in request.tracks.indices) { + failure( + KelpErrorCategory.Protocol, + "Choose a valid song to start playback.", + LightResult.ErrorCode.InvalidParameters, + ) + } else { + LightResult.Success( + KelpRemoteMethod.StartPlayback.encodeResponse(controller.start(request)), + ) + } } private fun handlePlayerCommand(request: PlayerCommandRequest): LightResult = withPlayer { controller -> - val snapshot = controller.control(request.command, request.positionMs) + val snapshot = runBlocking { + withTimeout(collectionTimeoutMillis) { + controller.control(request.command, request.positionMs) + } + } LightResult.Success(KelpRemoteMethod.ControlPlayback.encodeResponse(snapshot)) } private fun withPlayer( block: (KelpPlayerController) -> LightResult, ): LightResult { - return try { - runBlocking { - withTimeout(collectionTimeoutMillis) { - KelpPlaybackService.withController(applicationContext, block) - } + KelpPlayerController.requireBinderThread() + return synchronized(playerLock) { + val controller = currentPlayer() ?: return failure( + KelpErrorCategory.Unavailable, + "The TIDAL player is not available yet.", + ) + try { + block(controller) + } catch (_: TimeoutCancellationException) { + failure(KelpErrorCategory.Timeout, "The player took too long to respond. Please retry.") + } catch (error: TidalCatalogException) { + failure(error.category, error.safeMessage) + } catch (error: Exception) { + logger.warning("TIDAL player request failed: ${error.javaClass.simpleName}") + failure(KelpErrorCategory.Unavailable, "The TIDAL player could not complete that action.") } - } catch (_: TimeoutCancellationException) { - failure(KelpErrorCategory.Timeout, "The player took too long to respond. Please retry.") - } catch (error: TidalCatalogException) { - failure(error.category, error.safeMessage) - } catch (error: Exception) { - logger.warning("TIDAL player request failed: ${error.javaClass.simpleName}") - failure(KelpErrorCategory.Unavailable, "The TIDAL player could not complete that action.") } } @@ -313,8 +329,11 @@ object KelpServiceMethods { } } - internal suspend fun similarTracks(track: com.loosewire.kelp.protocol.TrackSummary) = - currentCatalog()?.similarTracks(track).orEmpty() + private fun currentPlayer(): KelpPlayerController? { + playerController?.let { return it } + if (!::applicationContext.isInitialized) return null + return KelpPlayerController(applicationContext).also { playerController = it } + } private fun failure( category: KelpErrorCategory, diff --git a/server/src/main/kotlin/com/loosewire/kelp/server/TidalMediaSource.kt b/server/src/main/kotlin/com/loosewire/kelp/server/TidalMediaSource.kt deleted file mode 100644 index 8845065..0000000 --- a/server/src/main/kotlin/com/loosewire/kelp/server/TidalMediaSource.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.loosewire.kelp.server - -import android.content.Context -import android.os.Handler -import android.os.Looper -import androidx.media3.common.MediaItem -import androidx.media3.common.Timeline -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.TransferListener -import androidx.media3.exoplayer.source.CompositeMediaSource -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.MediaPeriod -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.exoplayer.upstream.Allocator -import com.loosewire.kelp.protocol.TrackSummary -import java.io.File -import java.io.IOException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.android.asCoroutineDispatcher -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** Resolve only sources ExoPlayer prepares, not every signed URL in a long queue. */ -@UnstableApi -internal class TidalMediaSource( - private val context: Context, - private val resolver: TidalStreamResolver, - private val track: TrackSummary, - private val item: MediaItem, -) : CompositeMediaSource() { - private var scope: CoroutineScope? = null - private var child: MediaSource? = null - private var failure: IOException? = null - private var manifestFile: File? = null - - override fun getMediaItem(): MediaItem = item - - override fun prepareSourceInternal(mediaTransferListener: TransferListener?) { - super.prepareSourceInternal(mediaTransferListener) - // MediaSource lifecycle belongs to ExoPlayer's playback looper, NOT the UI looper. - val sourceScope = CoroutineScope( - SupervisorJob() + Handler(checkNotNull(Looper.myLooper())).asCoroutineDispatcher(), - ) - scope = sourceScope - sourceScope.launch { - var resolved: MediaItem? = null - var installed = false - try { - val mediaItem = withContext(Dispatchers.IO) { - resolver.resolveMediaItem(context, track).also { resolved = it } - }.buildUpon().setMediaId(item.mediaId).setMediaMetadata(item.mediaMetadata).build() - manifestFile = mediaItem.manifestFile() - val source = DefaultMediaSourceFactory(context).createMediaSource(mediaItem) - child = source - prepareChildSource(Unit, source) - installed = true - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - failure = IOException("Could not resolve TIDAL audio", error) - } finally { - // withContext can discard an IO result when cancellation wins the return race. - if (!installed) resolved?.manifestFile()?.delete() - } - } - } - - override fun maybeThrowSourceInfoRefreshError() { - failure?.let { throw it } - super.maybeThrowSourceInfoRefreshError() - } - - override fun onChildSourceInfoRefreshed(id: Unit, mediaSource: MediaSource, timeline: Timeline) { - refreshSourceInfo(timeline) - } - - override fun createPeriod( - id: MediaSource.MediaPeriodId, - allocator: Allocator, - startPositionUs: Long, - ): MediaPeriod = checkNotNull(child).createPeriod(id, allocator, startPositionUs) - - override fun releasePeriod(mediaPeriod: MediaPeriod) { - checkNotNull(child).releasePeriod(mediaPeriod) - } - - override fun releaseSourceInternal() { - scope?.cancel() - scope = null - super.releaseSourceInternal() - child = null - failure = null - manifestFile?.delete() - manifestFile = null - } - - private fun MediaItem.manifestFile(): File? = localConfiguration?.uri - ?.takeIf { it.scheme == "file" } - ?.path?.let(::File) -} diff --git a/server/src/main/kotlin/com/loosewire/kelp/server/TidalStreamResolver.kt b/server/src/main/kotlin/com/loosewire/kelp/server/TidalStreamResolver.kt index 371b423..eb98a71 100644 --- a/server/src/main/kotlin/com/loosewire/kelp/server/TidalStreamResolver.kt +++ b/server/src/main/kotlin/com/loosewire/kelp/server/TidalStreamResolver.kt @@ -1,11 +1,5 @@ package com.loosewire.kelp.server -import android.content.Context -import android.net.Uri -import androidx.media3.common.MediaItem -import androidx.media3.common.MimeTypes -import com.loosewire.kelp.protocol.TrackSummary -import java.io.File import java.io.IOException import java.util.concurrent.TimeUnit import kotlinx.serialization.Serializable @@ -21,13 +15,12 @@ import okhttp3.Request * * Responses carry a base64 manifest: either BTS (progressive signed URLs) * or clear DASH. Clear DASH MPDs use `group="main"` on AdaptationSets, which - * crashes Media3's DashManifestParser (spec wants unsigned int) — the - * attribute is stripped before the MPD is handed to ExoPlayer, the same - * treatment high-kelp and mopidy-tidal give it. + * violates the DASH schema (the attribute must be an unsigned integer). + * Strip it before handing the MPD to the SDK, as high-kelp and mopidy-tidal do. * * All methods block; call from a background thread. */ -internal class TidalStreamResolver(private val auth: TidalStreamingAuth) { +internal class TidalStreamResolver(private val auth: () -> TidalStreamingAuth) { private val json = Json { ignoreUnknownKeys = true; isLenient = true } private val client = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) @@ -48,27 +41,12 @@ internal class TidalStreamResolver(private val auth: TidalStreamingAuth) { ) : ResolvedStream } - fun resolveMediaItem(context: Context, track: TrackSummary): MediaItem = - when (val resolved = resolve(track.id)) { - is ResolvedStream.Progressive -> MediaItem.Builder() - .setUri(resolved.url) - .build() - is ResolvedStream.ClearDash -> { - val dir = File(context.cacheDir, "tidal_mpd").apply { mkdirs() } - val file = File.createTempFile("tidal-", ".mpd", dir) - file.writeText(resolved.mpdXml) - MediaItem.Builder() - .setUri(Uri.fromFile(file)) - .setMimeType(MimeTypes.APPLICATION_MPD) - .build() - } - } - fun resolve(trackId: String): ResolvedStream { + val auth = auth() var lastError: Exception? = null for (quality in QUALITY_FALLBACK_LADDER) { try { - val info = playbackInfo(trackId, quality) + val info = playbackInfo(trackId, quality, auth) val mime = info.manifestMimeType val decoded = String(java.util.Base64.getDecoder().decode(info.manifest.trim())) when { @@ -102,7 +80,7 @@ internal class TidalStreamResolver(private val auth: TidalStreamingAuth) { throw lastError ?: IOException("TIDAL stream resolution failed") } - private fun playbackInfo(trackId: String, quality: String): PlaybackInfo { + private fun playbackInfo(trackId: String, quality: String, auth: TidalStreamingAuth): PlaybackInfo { var refreshRetried = false while (true) { val url = "https://api.tidal.com/v1/tracks/$trackId/playbackinfopostpaywall" @@ -139,8 +117,8 @@ internal class TidalStreamResolver(private val auth: TidalStreamingAuth) { } /** - * Strip non-numeric `group="main"` AdaptationSet attributes Media3 cannot - * parse, plus inert ContentProtection stubs on clear streams. + * Strip non-numeric `group="main"` AdaptationSet attributes forbidden by + * the DASH schema, plus inert ContentProtection stubs on clear streams. */ private fun sanitizeMpd(mpdXml: String): String { var s = mpdXml