diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index da1b0f2..0e13b84 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -9,6 +9,9 @@ on: - main workflow_dispatch: +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -17,7 +20,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - submodules: recursive + fetch-depth: 0 + submodules: false + + - name: Initialize required FFmpeg submodule + run: git submodule update --init --depth 1 native/ffmpeg-src - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -25,14 +32,58 @@ jobs: distribution: temurin java-version: 17 + - name: Prepare Just+ v1.2.0 transition source + run: | + bash scripts/prepare-justplus.sh + bash scripts/fix-justplus-v015-gradle.sh + + - name: Inspect bundled FFmpeg extension + run: | + set -euo pipefail + AAR=".justplus-upstream/app/libs/lib-decoder-ffmpeg-release.aar" + unzip -p "$AAR" classes.jar > /tmp/ffmpeg-classes.jar + echo "=== FFmpeg renderer classes ===" + jar tf /tmp/ffmpeg-classes.jar | grep -E 'ExperimentalFfmpegVideoRenderer|FfmpegVideoDecoder|FfmpegAudioRenderer|FfmpegDecoder' || true + echo "=== FFmpeg video renderer bytecode ===" + javap -classpath /tmp/ffmpeg-classes.jar -c -p androidx.media3.decoder.ffmpeg.ExperimentalFfmpegVideoRenderer | sed -n '1,260p' + + - name: Build HEVC-capable Media3 FFmpeg extension + run: bash scripts/build-media3-hevc-ffmpeg.sh + - name: Make gradlew executable run: chmod +x ./gradlew - - name: Build debug APK - run: ./gradlew assembleDebug --stacktrace + - name: Build Just Player 0.0.15 debug APK + id: build + continue-on-error: true + run: | + set -o pipefail + ./gradlew :justplusapp:assembleLatestUniversalDebug \ + -PuseJustPlus=true \ + --stacktrace 2>&1 | tee ci-gradle.log + + - name: Upload Gradle log for diagnosis + if: always() + uses: actions/upload-artifact@v4 + with: + name: v0.0.15-gradle-log-${{ github.run_number }} + path: ci-gradle.log + if-no-files-found: error + + - name: Fail after diagnostic capture + if: steps.build.outcome == 'failure' + run: exit 1 + + - name: Collect debug APK + run: | + set -euo pipefail + mkdir -p build-artifacts + APK="$(find .justplus-upstream/app/build/outputs/apk -type f -name '*.apk' | grep -i latest | grep -i debug | head -n1)" + test -n "$APK" + cp "$APK" build-artifacts/just-player-v0.0.15-debug.apk - name: Upload debug APK uses: actions/upload-artifact@v4 with: - name: dddplayer-debug-apk - path: app/build/outputs/apk/debug/*.apk + name: just-player-v0.0.15-debug + path: build-artifacts/just-player-v0.0.15-debug.apk diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index bbebc7f..e5a59e2 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -29,6 +29,14 @@ jobs: distribution: temurin java-version: 17 + - name: Prepare Just+ v1.2.0 transition source + run: | + bash scripts/prepare-justplus.sh + bash scripts/fix-justplus-v015-gradle.sh + + - name: Build HEVC-capable Media3 FFmpeg extension + run: bash scripts/build-media3-hevc-ffmpeg.sh + - name: Make gradlew executable run: chmod +x ./gradlew @@ -87,21 +95,26 @@ jobs: echo "version_name=$VERSION_NAME" >> "$GITHUB_OUTPUT" echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT" - - name: Build signed release APK + - name: Build signed Just Player release APK env: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | - ./gradlew assembleRelease \ - -PversionNameOverride=${{ steps.version.outputs.version_name }} \ - -PversionCodeOverride=${{ steps.version.outputs.version_code }} \ + ./gradlew :justplusapp:assembleLatestUniversalRelease \ + -PuseJustPlus=true \ --stacktrace - name: Rename release APK run: | + set -euo pipefail mkdir -p release-artifacts - cp app/build/outputs/apk/release/app-release.apk release-artifacts/dddplayer-v${{ steps.version.outputs.version_name }}-release.apk + APK="$(find .justplus-upstream/app/build/outputs/apk -type f -name '*.apk' | grep -i latest | grep -i universal | grep -i release | head -n1)" + if [ -z "$APK" ]; then + APK="$(find .justplus-upstream/app/build/outputs/apk -type f -name '*.apk' | grep -i release | head -n1)" + fi + test -n "$APK" + cp "$APK" release-artifacts/dddplayer-v${{ steps.version.outputs.version_name }}-release.apk - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/app/src/main/java/top/rootu/dddplayer/compat/JustPlusBridgeInterop.kt b/app/src/main/java/top/rootu/dddplayer/compat/JustPlusBridgeInterop.kt new file mode 100644 index 0000000..1c2e5f6 --- /dev/null +++ b/app/src/main/java/top/rootu/dddplayer/compat/JustPlusBridgeInterop.kt @@ -0,0 +1,347 @@ +package top.rootu.dddplayer.compat + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.media3.common.C +import androidx.media3.common.MediaItem as Media3MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import okhttp3.OkHttpClient +import top.rootu.dddplayer.bridge.BridgeConfig +import top.rootu.dddplayer.bridge.BridgeDispatcher +import top.rootu.dddplayer.bridge.BridgeEvent +import top.rootu.dddplayer.bridge.BridgeMediaItem +import top.rootu.dddplayer.bridge.BridgeMode +import top.rootu.dddplayer.bridge.BridgeTransport +import top.rootu.dddplayer.bridge.BroadcastTransport +import top.rootu.dddplayer.bridge.CompositeTransport +import top.rootu.dddplayer.bridge.DddSyncContext +import top.rootu.dddplayer.bridge.DddSyncRemoteClient +import top.rootu.dddplayer.bridge.LocalBridgeManager +import top.rootu.dddplayer.bridge.LocalBridgeTransport +import top.rootu.dddplayer.model.MediaItem +import top.rootu.dddplayer.utils.IntentUtils +import java.util.WeakHashMap +import java.util.concurrent.TimeUnit + +/** + * Compatibility layer used by the Just+ based 0.0.15 transition build. + * + * Playback remains owned by Just+/Media3. This object only observes Player events and feeds the + * existing DDD broadcast/local bridge and DddSync server contracts. Keeping this in the legacy + * library means the Just+ fork needs only two small hooks: attach after player creation and detach + * from Activity.onDestroy(). + */ +object JustPlusBridgeInterop { + const val EXTRA_ORIGINAL_URI = "ddd_original_uri" + const val EXTRA_FORCE_LEGACY = "ddd_force_legacy" + + private val main = Handler(Looper.getMainLooper()) + private val sessions = WeakHashMap() + + @JvmStatic + @Synchronized + fun attach(activity: Activity, launchIntent: Intent, player: Player) { + val existing = sessions[activity] + if (existing != null) { + existing.replacePlayer(player) + return + } + + val intent = restoreOriginalIntent(launchIntent) + val config = runCatching { IntentUtils.parseBridgeConfig(intent) } + .getOrElse { + Log.w(TAG, "Bridge config parse failed", it) + BridgeConfig() + } + val parsed = runCatching { IntentUtils.parseIntent(activity, intent) } + .getOrElse { + Log.w(TAG, "DDD sync intent parse failed", it) + emptyList() to 0 + } + val items = parsed.first + + val transports = mutableListOf() + if (config.enabled && (config.mode == BridgeMode.BROADCAST || config.mode == BridgeMode.BOTH)) { + transports += BroadcastTransport(activity.applicationContext, config) + } + if (config.enabled && (config.mode == BridgeMode.LOCAL || config.mode == BridgeMode.BOTH)) { + val store = LocalBridgeManager.startOrReuse(config) + transports += LocalBridgeTransport(config, store) + } + val dispatcher = when (transports.size) { + 0 -> null + 1 -> BridgeDispatcher(config, transports.first()) + else -> BridgeDispatcher(config, CompositeTransport(transports)) + } + + val hasRemote = items.any { it.dddSyncContext?.enabled == true } + val remoteClient = if (hasRemote) { + DddSyncRemoteClient( + OkHttpClient.Builder() + .connectTimeout(3, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build() + ) + } else null + + val session = Session(activity, config, dispatcher, items, remoteClient, player) + sessions[activity] = session + session.start() + } + + @JvmStatic + @Synchronized + fun detach(activity: Activity, reason: String = "activity_destroyed") { + sessions.remove(activity)?.stop(reason) + } + + private fun restoreOriginalIntent(intent: Intent): Intent { + val original = intent.getStringExtra(EXTRA_ORIGINAL_URI)?.takeIf { it.isNotBlank() } + ?: return Intent(intent) + return Intent(intent).setData(runCatching { Uri.parse(original) }.getOrNull() ?: intent.data) + } + + private class Session( + private val activity: Activity, + private val config: BridgeConfig, + private val dispatcher: BridgeDispatcher?, + private val items: List, + private val remoteClient: DddSyncRemoteClient?, + player: Player + ) { + private var player: Player = player + private var listener: Player.Listener? = null + private var lastPosition = 0L + private var lastIndex = player.currentMediaItemIndex.coerceAtLeast(0) + private var ended = false + private var stopped = false + + private val tick = object : Runnable { + override fun run() { + if (stopped) return + emitPosition("tick") + main.postDelayed(this, config.positionIntervalMs.coerceAtLeast(250L)) + } + } + + fun start() { + installListener() + val now = System.currentTimeMillis() + emit( + BridgeEvent.SessionStarted( + sessionId = sessionId(), + ts = now, + uri = currentUri(), + title = currentTitle(), + playlistSize = player.mediaItemCount, + startIndex = player.currentMediaItemIndex.coerceAtLeast(0), + startPosition = player.currentPosition.coerceAtLeast(0L), + currentItem = currentBridgeItem() + ) + ) + lastPosition = player.currentPosition.coerceAtLeast(0L) + if (config.enabled || remoteClient != null) main.post(tick) + } + + fun replacePlayer(next: Player) { + listener?.let { runCatching { player.removeListener(it) } } + player = next + ended = false + installListener() + } + + fun stop(reason: String) { + if (stopped) return + stopped = true + main.removeCallbacks(tick) + listener?.let { runCatching { player.removeListener(it) } } + emit( + BridgeEvent.SessionFinished( + sessionId = sessionId(), + ts = System.currentTimeMillis(), + uri = currentUri(), + position = player.currentPosition.coerceAtLeast(0L), + duration = durationOrNull(), + endBy = reason, + windowIndex = player.currentMediaItemIndex.coerceAtLeast(0), + playlistSize = player.mediaItemCount, + title = currentTitle() + ) + ) + if (config.mode == BridgeMode.LOCAL || config.mode == BridgeMode.BOTH) { + LocalBridgeManager.stopDelayed() + } + } + + private fun installListener() { + val l = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + emitState(if (isPlaying) "playing" else "paused") + } + + override fun onPlaybackStateChanged(playbackState: Int) { + emitState( + when (playbackState) { + Player.STATE_BUFFERING -> "buffering" + Player.STATE_READY -> "ready" + Player.STATE_ENDED -> "ended" + else -> "idle" + } + ) + if (playbackState == Player.STATE_ENDED && !ended) { + ended = true + emit( + BridgeEvent.PlaybackEnded( + sessionId = sessionId(), + ts = System.currentTimeMillis(), + uri = currentUri(), + windowIndex = player.currentMediaItemIndex.coerceAtLeast(0), + playlistSize = player.mediaItemCount, + title = currentTitle(), + position = player.currentPosition.coerceAtLeast(0L), + duration = durationOrNull() + ) + ) + } + } + + override fun onMediaItemTransition(mediaItem: Media3MediaItem?, reason: Int) { + ended = false + lastIndex = player.currentMediaItemIndex.coerceAtLeast(0) + emit( + BridgeEvent.PlaylistItemChanged( + sessionId = sessionId(), + ts = System.currentTimeMillis(), + uri = currentUri(), + windowIndex = lastIndex, + playlistSize = player.mediaItemCount, + title = currentTitle(), + reason = "media_transition_$reason", + position = player.currentPosition.coerceAtLeast(0L), + duration = durationOrNull(), + hasPrevious = player.hasPreviousMediaItem(), + hasNext = player.hasNextMediaItem(), + currentItem = currentBridgeItem() + ) + ) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + emit( + BridgeEvent.SeekCompleted( + sessionId = sessionId(), + ts = System.currentTimeMillis(), + uri = currentUri(), + fromPosition = oldPosition.positionMs, + toPosition = newPosition.positionMs, + windowIndex = player.currentMediaItemIndex.coerceAtLeast(0) + ) + ) + } + lastPosition = newPosition.positionMs.coerceAtLeast(0L) + } + + override fun onPlayerError(error: PlaybackException) { + emit( + BridgeEvent.Error( + sessionId = sessionId(), + ts = System.currentTimeMillis(), + uri = currentUri(), + code = error.errorCodeName, + errorCode = error.errorCode, + message = error.message, + windowIndex = player.currentMediaItemIndex.coerceAtLeast(0), + position = player.currentPosition.coerceAtLeast(0L), + duration = durationOrNull(), + bufferedPosition = player.bufferedPosition.coerceAtLeast(0L), + bufferedPercentage = player.bufferedPercentage, + playlistSize = player.mediaItemCount, + title = currentTitle(), + fatal = true + ) + ) + } + } + listener = l + player.addListener(l) + } + + private fun emitState(reason: String) { + emit( + BridgeEvent.PlaybackStateChanged( + sessionId = sessionId(), + ts = System.currentTimeMillis(), + uri = currentUri(), + isPlaying = player.isPlaying, + isBuffering = player.playbackState == Player.STATE_BUFFERING, + position = player.currentPosition.coerceAtLeast(0L), + duration = durationOrNull(), + windowIndex = player.currentMediaItemIndex.coerceAtLeast(0), + title = currentTitle(), + reason = reason + ) + ) + } + + private fun emitPosition(reason: String) { + val position = player.currentPosition.coerceAtLeast(0L) + lastPosition = position + emit( + BridgeEvent.PositionTick( + sessionId = sessionId(), + ts = System.currentTimeMillis(), + uri = currentUri(), + position = position, + duration = durationOrNull(), + bufferedPosition = player.bufferedPosition.coerceAtLeast(0L), + bufferedPercentage = player.bufferedPercentage, + windowIndex = player.currentMediaItemIndex.coerceAtLeast(0), + title = currentTitle(), + reason = reason + ) + ) + } + + private fun emit(event: BridgeEvent) { + dispatcher?.emit(event) + currentSyncContext()?.let { context -> remoteClient?.send(event, context) } + } + + private fun currentItem(): MediaItem? = items.getOrNull(player.currentMediaItemIndex.coerceAtLeast(0)) + + private fun currentSyncContext(): DddSyncContext? = currentItem()?.dddSyncContext + + private fun sessionId(): String? = config.sessionId ?: currentSyncContext()?.sessionId + + private fun currentUri(): String? = player.currentMediaItem?.localConfiguration?.uri?.toString() + ?: currentItem()?.uri?.toString() + + private fun currentTitle(): String? = player.currentMediaItem?.mediaMetadata?.title?.toString() + ?: currentItem()?.title + + private fun durationOrNull(): Long? = player.duration.takeIf { it > 0L && it != C.TIME_UNSET } + + private fun currentBridgeItem(): BridgeMediaItem? { + val item = currentItem() + return BridgeMediaItem( + uri = currentUri(), + title = currentTitle(), + filename = item?.filename, + source = "justplus" + ) + } + } + + private const val TAG = "JustPlus/DDDInterop" +} diff --git a/app/src/main/java/top/rootu/dddplayer/player/NativePlaybackBackend.kt b/app/src/main/java/top/rootu/dddplayer/player/NativePlaybackBackend.kt index 5135d9b..85dbb9a 100644 --- a/app/src/main/java/top/rootu/dddplayer/player/NativePlaybackBackend.kt +++ b/app/src/main/java/top/rootu/dddplayer/player/NativePlaybackBackend.kt @@ -4,6 +4,7 @@ import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log +import android.view.Surface import android.view.SurfaceHolder import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec @@ -38,6 +39,7 @@ class NativePlaybackBackend( @Volatile private var listener: PlaybackBackend.Listener? = null @Volatile private var holder: SurfaceHolder? = null + @Volatile private var directSurface: Surface? = null @Volatile private var worker: Thread? = null @Volatile private var demuxHandle = 0L @Volatile private var activeIo: DataSourceEngineIo? = null @@ -62,6 +64,14 @@ class NativePlaybackBackend( override fun attachSurfaceHolder(surfaceHolder: SurfaceHolder?) { holder = surfaceHolder + directSurface = surfaceHolder?.surface + surfaceGeneration.incrementAndGet() + } + + /** Direct Surface entry point for the Just+ compatibility player. */ + fun attachSurface(surface: Surface?) { + holder = null + directSurface = surface surfaceGeneration.incrementAndGet() } @@ -74,6 +84,16 @@ class NativePlaybackBackend( prepareInternal(uri, headers, startPositionMs, shouldPlay = true) } + /** Start directly on libavcodec after Just+/MediaCodec has already rejected HEVC. */ + fun prepareSoftware(uri: Uri, headers: Map = emptyMap(), startPositionMs: Long = 0L) { + availableAudioTracks = emptyList() + selectedAudioTrackId = null + availableSubtitleTracks = listOf(SUBTITLE_OFF) + selectedSubtitleTrackId = SUBTITLE_OFF_ID + forceSoftwareVideo = true + prepareInternal(uri, headers, startPositionMs, shouldPlay = true) + } + private fun prepareInternal( uri: Uri, headers: Map, @@ -380,7 +400,7 @@ class NativePlaybackBackend( } else if (renderer == null || seenSurfaceGeneration != currentSurfaceGeneration) { renderer?.release() renderer = null - renderer = holder?.surface?.takeIf { it.isValid } + renderer = (directSurface ?: holder?.surface)?.takeIf { it.isValid } ?.let { NativeRenderer.forSurface(it) } if (renderer != null) { renderer.setHdrParams(NativeRenderer.HdrParams.from(probe.color, 500f)) diff --git a/build.gradle.kts b/build.gradle.kts index cfa4f5d..45446f3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.ksp) apply false -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index 20e2a01..fc1cc19 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,23 +1,19 @@ # Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. For more details, visit -# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects -# org.gradle.parallel=true -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn +org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8 android.useAndroidX=true -# Kotlin code style for this project: "official" or "obsolete": +android.enableJetifier=true kotlin.code.style=official -# Enables namespacing of each library's R class so that its R class includes only the -# resources declared in the library itself and none from the library's dependencies, -# thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file + +# Just+ uses local Media3 AARs and Java switch statements over resource IDs. +# Keep the same resource/classpath behavior as upstream Just+ for the transition build. +android.useFullClasspathForDexingTransform=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false + +# android-file-chooser is included as a subproject of the Just+ source tree. +VERSION_NAME=1.2.0 +VERSION_CODE=102000 +ANDROID_COMPILE_SDK_VERSION=34 +ANDROID_MIN_SDK_VERSION=21 +ANDROID_TARGET_SDK_VERSION=34 +JAVA_COMPATIBILITY=VERSION_1_8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 66ce192..8b76fa7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,5 +68,6 @@ androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", ve [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/legacy/build.gradle.kts b/legacy/build.gradle.kts new file mode 100644 index 0000000..16e07d4 --- /dev/null +++ b/legacy/build.gradle.kts @@ -0,0 +1,131 @@ +import org.gradle.api.tasks.Sync + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) +} + +val nativeRoot = rootProject.file("native") +val ffmpegAbis = listOf("arm64-v8a") + +val syncLegacyFfmpegLibs = tasks.register("syncLegacyFfmpegLibs") { + description = "Prepare DDD FFmpeg libraries for the legacy HEVC fallback" + into(layout.buildDirectory.dir("ffmpegJniLibs")) + ffmpegAbis.forEach { abi -> + val stripped = File(nativeRoot, "prebuilt/ffmpeg/$abi/stripped") + val full = File(nativeRoot, "prebuilt/ffmpeg/$abi/lib") + from(if (stripped.isDirectory) stripped else full) { + include("*.so") + into(abi) + } + } +} + +android { + namespace = "top.rootu.dddplayer" + compileSdk = 36 + ndkVersion = "27.0.12077973" + + defaultConfig { + minSdk = 24 + vectorDrawables.useSupportLibrary = true + buildConfigField("String", "VERSION_NAME", "\"0.0.15-legacy\"") + buildConfigField("int", "VERSION_CODE", "1500000") + ndk { + abiFilters += ffmpegAbis + } + externalNativeBuild { + cmake { + arguments += listOf( + "-DANDROID_STL=c++_shared", + "-DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY" + ) + } + } + } + + externalNativeBuild { + cmake { + path = File(nativeRoot, "ddd_engine/CMakeLists.txt") + version = "3.22.1" + } + } + + sourceSets { + getByName("main") { + java.srcDir(rootProject.file("app/src/main/java")) + res.srcDir(rootProject.file("app/src/main/res")) + manifest.srcFile("src/main/AndroidManifest.xml") + jniLibs.srcDir(layout.buildDirectory.dir("ffmpegJniLibs")) + } + } + + buildFeatures { + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + packaging { + jniLibs { + excludes += "**/libc++_shared.so" + keepDebugSymbols += "*/*/libav*_ddd.so" + keepDebugSymbols += "*/*/libsw*_ddd.so" + } + } + + lint { + disable.add("MissingTranslation") + disable.add("UnsafeOptInUsageError") + } +} + +tasks.named("preBuild") { dependsOn(syncLegacyFfmpegLibs) } + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.leanback) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.fragment.ktx) + implementation(libs.okhttp) + implementation(libs.gson) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.ui.graphics) + implementation(libs.coil) + ksp(libs.androidx.room.compiler) + + // Legacy DDD sources were written against Media3 1.9.0. Compile against that + // API; Just+ supplies the Media3 runtime used by the final application. + val media3 = "1.11.0-beta01" + compileOnly("androidx.media3:media3-common:$media3") + compileOnly("androidx.media3:media3-container:$media3") + compileOnly("androidx.media3:media3-datasource:$media3") + compileOnly("androidx.media3:media3-datasource-okhttp:$media3") + compileOnly("androidx.media3:media3-decoder:$media3") + compileOnly("androidx.media3:media3-extractor:$media3") + compileOnly("androidx.media3:media3-session:$media3") + compileOnly("androidx.media3:media3-exoplayer:$media3") + compileOnly("androidx.media3:media3-exoplayer-hls:$media3") + compileOnly("androidx.media3:media3-exoplayer-dash:$media3") + compileOnly("androidx.media3:media3-exoplayer-smoothstreaming:$media3") + compileOnly("androidx.media3:media3-exoplayer-rtsp:$media3") + compileOnly(fileTree(mapOf( + "dir" to rootProject.file(".justplus-upstream/app/libs"), + "include" to listOf("lib-*.aar") + ))) + + implementation("org.videolan.android:libvlc-all:3.7.5") +} diff --git a/legacy/src/main/AndroidManifest.xml b/legacy/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c5a1980 --- /dev/null +++ b/legacy/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/legacy/src/main/java/top/rootu/dddplayer/compat/LegacyHevcPlayer.java b/legacy/src/main/java/top/rootu/dddplayer/compat/LegacyHevcPlayer.java new file mode 100644 index 0000000..0d22730 --- /dev/null +++ b/legacy/src/main/java/top/rootu/dddplayer/compat/LegacyHevcPlayer.java @@ -0,0 +1,272 @@ +package top.rootu.dddplayer.compat; + +import android.net.Uri; +import android.os.Looper; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; + +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackParameters; +import androidx.media3.common.Player; +import androidx.media3.common.SimpleBasePlayer; +import androidx.media3.common.VideoSize; +import androidx.media3.datasource.okhttp.OkHttpDataSource; + +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; + +import java.util.Collections; +import java.util.Map; + +import okhttp3.OkHttpClient; +import top.rootu.dddplayer.player.NativePlaybackBackend; +import top.rootu.dddplayer.player.PlaybackBackend; + +/** + * Media3 Player facade over the preserved DDD software HEVC pipeline. + * + * This is deliberately narrow: it exists only after Just+/MediaCodec has failed + * on video/hevc. The UI remains a Media3/Just+ PlayerView while compressed HEVC + * is demuxed/decoded by DDD/libavcodec. + */ +@androidx.media3.common.util.UnstableApi +public final class LegacyHevcPlayer extends SimpleBasePlayer { + + private static final Player.Commands COMMANDS = new Player.Commands.Builder() + .add(Player.COMMAND_PLAY_PAUSE) + .add(Player.COMMAND_PREPARE) + .add(Player.COMMAND_STOP) + .add(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) + .add(Player.COMMAND_SEEK_BACK) + .add(Player.COMMAND_SEEK_FORWARD) + .add(Player.COMMAND_SET_SPEED_AND_PITCH) + .add(Player.COMMAND_SET_VIDEO_SURFACE) + .add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM) + .add(Player.COMMAND_GET_TIMELINE) + .add(Player.COMMAND_RELEASE) + .build(); + + private final NativePlaybackBackend backend; + private final MediaItem mediaItem; + private final Uri uri; + private final Map headers; + private final long initialPositionMs; + + private boolean prepared; + private boolean playWhenReady; + private boolean ended; + private boolean buffering; + private long positionMs; + private long durationMs = C.TIME_UNSET; + private long bufferedPositionMs; + private VideoSize videoSize = VideoSize.UNKNOWN; + private PlaybackParameters playbackParameters = PlaybackParameters.DEFAULT; + private boolean released; + + public LegacyHevcPlayer( + Looper looper, + Uri uri, + @Nullable Map headers, + long initialPositionMs, + @Nullable String title) { + super(looper); + this.uri = uri; + this.headers = headers == null ? Collections.emptyMap() : headers; + this.initialPositionMs = Math.max(0L, initialPositionMs); + this.positionMs = this.initialPositionMs; + this.mediaItem = new MediaItem.Builder() + .setUri(uri) + .setMediaMetadata(new androidx.media3.common.MediaMetadata.Builder() + .setTitle(title) + .build()) + .build(); + + OkHttpDataSource.Factory factory = + new OkHttpDataSource.Factory(new OkHttpClient.Builder().build()); + backend = new NativePlaybackBackend(factory); + backend.setListener(new PlaybackBackend.Listener() { + @Override + public void onBuffering() { + buffering = true; + ended = false; + invalidateState(); + } + + @Override + public void onPlaying() { + buffering = false; + ended = false; + playWhenReady = true; + invalidateState(); + } + + @Override + public void onPaused() { + buffering = false; + playWhenReady = false; + invalidateState(); + } + + @Override + public void onEnded() { + buffering = false; + ended = true; + playWhenReady = false; + invalidateState(); + } + + @Override + public void onError(Throwable error) { + buffering = false; + ended = false; + playWhenReady = false; + invalidateState(); + } + + @Override + public void onPositionChanged(long position, long duration) { + positionMs = Math.max(0L, position); + if (duration > 0L) { + durationMs = duration; + } + bufferedPositionMs = Math.max(positionMs, backend.getBufferedPositionMs()); + invalidateState(); + } + + @Override + public void onVideoSizeChanged(int width, int height, float pixelWidthHeightRatio) { + if (width > 0 && height > 0) { + videoSize = new VideoSize(width, height, 0, pixelWidthHeightRatio); + } + invalidateState(); + } + + @Override + public void onSubtitleTextChanged(String text) { + // Text subtitle bridging is intentionally deferred. The fallback + // is kept narrow until the video path is stable. + } + }); + } + + @Override + protected State getState() { + long safeDurationMs = durationMs > 0L ? durationMs : C.TIME_UNSET; + long safeDurationUs = + safeDurationMs == C.TIME_UNSET ? C.TIME_UNSET : safeDurationMs * 1000L; + + MediaItemData item = new MediaItemData.Builder("legacy-hevc") + .setMediaItem(mediaItem) + .setIsSeekable(true) + .setDurationUs(safeDurationUs) + .build(); + + int playbackState; + if (ended) { + playbackState = Player.STATE_ENDED; + } else if (!prepared) { + playbackState = Player.STATE_IDLE; + } else if (buffering) { + playbackState = Player.STATE_BUFFERING; + } else { + playbackState = Player.STATE_READY; + } + + return new State.Builder() + .setAvailableCommands(COMMANDS) + .setPlaylist(ImmutableList.of(item)) + .setCurrentMediaItemIndex(0) + .setPlayWhenReady( + playWhenReady, + Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setPlaybackState(playbackState) + .setIsLoading(buffering) + .setContentPositionMs(positionMs) + .setContentBufferedPositionMs( + PositionSupplier.getConstant(Math.max(positionMs, bufferedPositionMs))) + .setVideoSize(videoSize) + .setPlaybackParameters(playbackParameters) + .build(); + } + + @Override + protected ListenableFuture handlePrepare() { + if (!prepared) { + prepared = true; + buffering = true; + ended = false; + backend.prepareSoftware(uri, headers, initialPositionMs); + if (!playWhenReady) { + backend.pause(); + } + } + return Futures.immediateVoidFuture(); + } + + @Override + protected ListenableFuture handleSetPlayWhenReady(boolean value) { + playWhenReady = value; + if (value) { + backend.play(); + } else { + backend.pause(); + } + return Futures.immediateVoidFuture(); + } + + @Override + protected ListenableFuture handleStop() { + backend.stop(); + prepared = false; + buffering = false; + ended = false; + return Futures.immediateVoidFuture(); + } + + @Override + protected ListenableFuture handleRelease() { + if (!released) { + released = true; + backend.release(); + } + return Futures.immediateVoidFuture(); + } + + @Override + protected ListenableFuture handleSeek( + int mediaItemIndex, long position, @Player.Command int seekCommand) { + positionMs = Math.max(0L, position); + backend.seekTo(positionMs); + return Futures.immediateVoidFuture(); + } + + @Override + protected ListenableFuture handleSetPlaybackParameters( + PlaybackParameters parameters) { + playbackParameters = parameters; + backend.setPlaybackSpeed(parameters.speed); + return Futures.immediateVoidFuture(); + } + + @Override + protected ListenableFuture handleSetVideoOutput(Object videoOutput) { + if (videoOutput instanceof Surface) { + backend.attachSurface((Surface) videoOutput); + } else if (videoOutput instanceof SurfaceHolder) { + backend.attachSurfaceHolder((SurfaceHolder) videoOutput); + } else if (videoOutput instanceof SurfaceView) { + backend.attachSurfaceHolder(((SurfaceView) videoOutput).getHolder()); + } + return Futures.immediateVoidFuture(); + } + + @Override + protected ListenableFuture handleClearVideoOutput(@Nullable Object videoOutput) { + backend.attachSurface(null); + return Futures.immediateVoidFuture(); + } +} diff --git a/scripts/build-media3-hevc-ffmpeg.sh b/scripts/build-media3-hevc-ffmpeg.sh new file mode 100644 index 0000000..b1a6e79 --- /dev/null +++ b/scripts/build-media3-hevc-ffmpeg.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$ROOT/.media3-hevc-ffmpeg" +MEDIA="$WORK/media" +DONOR="$WORK/donor" +OUT="$ROOT/.justplus-upstream/app/libs/lib-decoder-ffmpeg-release.aar" + +MEDIA_TAG="1.11.0-beta01" +DONOR_COMMIT="57346bbf36a7456e99008298cf55ce16011401db" + +rm -rf "$WORK" +mkdir -p "$WORK" + +echo "==> Clone Media3 $MEDIA_TAG" +git clone --depth 1 --branch "$MEDIA_TAG" https://github.com/androidx/media.git "$MEDIA" + +echo "==> Fetch tested FFmpeg video renderer implementation" +git init -q "$DONOR" +git -C "$DONOR" remote add origin https://github.com/rabbitknight/media.git +git -C "$DONOR" fetch -q --depth 1 origin "$DONOR_COMMIT" +git -C "$DONOR" checkout -q FETCH_HEAD + +MOD="libraries/decoder_ffmpeg" +cp "$DONOR/$MOD/src/main/java/androidx/media3/decoder/ffmpeg/ExperimentalFfmpegVideoRenderer.java" \ + "$MEDIA/$MOD/src/main/java/androidx/media3/decoder/ffmpeg/ExperimentalFfmpegVideoRenderer.java" +cp "$DONOR/$MOD/src/main/java/androidx/media3/decoder/ffmpeg/ExperimentalFfmpegVideoDecoder.java" \ + "$MEDIA/$MOD/src/main/java/androidx/media3/decoder/ffmpeg/ExperimentalFfmpegVideoDecoder.java" +cp "$DONOR/$MOD/src/main/jni/ffmpeg_jni.cc" "$MEDIA/$MOD/src/main/jni/ffmpeg_jni.cc" +cp "$DONOR/$MOD/src/main/jni/CMakeLists.txt" "$MEDIA/$MOD/src/main/jni/CMakeLists.txt" + +JNI="$MEDIA/$MOD/src/main/jni" +NDK_VERSION="27.0.12077973" +NDK_PATH="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}/ndk/$NDK_VERSION" +if [[ ! -d "$NDK_PATH" ]]; then + printf "y\n" | "${ANDROID_SDK_ROOT:-${ANDROID_HOME}}/cmdline-tools/latest/bin/sdkmanager" "ndk;$NDK_VERSION" >/dev/null +fi +HOST_PLATFORM="linux-x86_64" +API=24 +TOOLCHAIN="$NDK_PATH/toolchains/llvm/prebuilt/$HOST_PLATFORM/bin" + +echo "==> Build FFmpeg 6.0 arm64 with audio + HEVC software decoders" +git clone -q --depth 1 --branch release/6.0 https://github.com/FFmpeg/FFmpeg.git "$JNI/ffmpeg" +pushd "$JNI/ffmpeg" >/dev/null +./configure \ + --target-os=android \ + --enable-cross-compile \ + --cross-prefix="$TOOLCHAIN/aarch64-linux-android${API}-" \ + --arch=aarch64 \ + --cpu=armv8-a \ + --cc="$TOOLCHAIN/aarch64-linux-android${API}-clang" \ + --cxx="$TOOLCHAIN/aarch64-linux-android${API}-clang++" \ + --ar="$TOOLCHAIN/llvm-ar" \ + --nm="$TOOLCHAIN/llvm-nm" \ + --ranlib="$TOOLCHAIN/llvm-ranlib" \ + --strip="$TOOLCHAIN/llvm-strip" \ + --libdir="android-libs/arm64-v8a" \ + --target-os=android \ + --disable-static --enable-shared \ + --disable-doc --disable-programs --disable-avdevice \ + --disable-everything \ + --enable-avcodec --enable-avutil --enable-swresample --enable-swscale \ + --disable-postproc --disable-avfilter --disable-symver --disable-v4l2-m2m --disable-vulkan \ + --enable-decoder=vorbis --enable-decoder=opus --enable-decoder=flac --enable-decoder=alac \ + --enable-decoder=pcm_mulaw --enable-decoder=pcm_alaw --enable-decoder=mp3 \ + --enable-decoder=amrnb --enable-decoder=amrwb --enable-decoder=aac \ + --enable-decoder=ac3 --enable-decoder=eac3 --enable-decoder=dca \ + --enable-decoder=mlp --enable-decoder=truehd \ + --enable-decoder=hevc +make -j2 +make install-libs +popd >/dev/null + +echo "==> Build libyuv arm64" +git clone -q --depth 1 https://chromium.googlesource.com/libyuv/libyuv "$JNI/libyuv" +# NDK 27/Clang 18 can pass libyuv's C SME probe but cannot compile the C++ +# __arm_new("za") implementation used by current rotate_sme.cc. SME is only +# an optimization; disable that object set and keep NEON/SVE paths. +sed -i 's/if (CAN_COMPILE_SME)/if (FALSE)/' "$JNI/libyuv/CMakeLists.txt" +mkdir -p "$JNI/libyuv/build-arm64-v8a" +pushd "$JNI/libyuv/build-arm64-v8a" >/dev/null +cmake .. \ + -DCMAKE_TOOLCHAIN_FILE="$NDK_PATH/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a \ + -DANDROID_PLATFORM=android-$API \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=ON \ + -DANDROID_STL=c++_shared \ + -DCAN_COMPILE_SME=FALSE \ + -DCMAKE_C_FLAGS="-DLIBYUV_DISABLE_SME" \ + -DCMAKE_CXX_FLAGS="-DLIBYUV_DISABLE_SME" +cmake --build . -j2 +popd >/dev/null +mkdir -p "$JNI/libyuv/android-libs/arm64-v8a" +cp "$JNI/libyuv/build-arm64-v8a/libyuv.so" "$JNI/libyuv/android-libs/arm64-v8a/libyuv.so" + +echo "==> Limit FFmpeg extension to the ABI actually built for this transition APK" +pushd "$MEDIA" >/dev/null +python3 - <<'PY' +from pathlib import Path +p = Path("libraries/decoder_ffmpeg/build.gradle.kts") +s = p.read_text() +if "abiFilters 'arm64-v8a'" not in s: + marker = "android {\n" + if marker not in s: + raise SystemExit("decoder_ffmpeg android block not found") + s = s.replace(marker, "android {\n defaultConfig {\n ndk { abiFilters += listOf(\"arm64-v8a\") }\n }\n", 1) +p.write_text(s) +PY + +echo "==> Build Media3 FFmpeg AAR against exact Just+ Media3 version" +chmod +x ./gradlew +./gradlew :lib-decoder-ffmpeg:assembleRelease --stacktrace +AAR="$(find libraries/decoder_ffmpeg -type f -name '*.aar' | grep -i release | head -n1)" +test -n "$AAR" +mkdir -p "$(dirname "$OUT")" +cp "$AAR" "$OUT" +popd >/dev/null + +echo "HEVC-capable FFmpeg extension: $OUT" diff --git a/scripts/fix-justplus-v015-gradle.sh b/scripts/fix-justplus-v015-gradle.sh new file mode 100644 index 0000000..a285fc3 --- /dev/null +++ b/scripts/fix-justplus-v015-gradle.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +BUILD="$(cd "$(dirname "$0")/.." && pwd)/.justplus-upstream/app/build.gradle" +test -f "$BUILD" + +python3 - "$BUILD" <<'PY' +from pathlib import Path +import sys +p = Path(sys.argv[1]) +s = p.read_text() +s = s.replace( + 'versionCode (project.findProperty("versionCodeOverride") ?: "1500000").toString().toInteger()', + 'versionCode 1500000' +) +s = s.replace( + 'versionName (project.findProperty("versionNameOverride") ?: "0.0.15").toString()', + 'versionName "0.0.15"' +) +p.write_text(s) +PY diff --git a/scripts/prepare-justplus.sh b/scripts/prepare-justplus.sh new file mode 100644 index 0000000..99f13c5 --- /dev/null +++ b/scripts/prepare-justplus.sh @@ -0,0 +1,301 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +UPSTREAM="$ROOT/.justplus-upstream" + +rm -rf "$UPSTREAM" +git clone --depth 1 --branch v1.2.0 https://github.com/just-plus-player/just-plus-player.git "$UPSTREAM" + +export DDD_ROOT="$ROOT" +python3 <<'PY' +from pathlib import Path +import os +import re + +root = Path(os.environ["DDD_ROOT"]) +up = root / ".justplus-upstream" + +# ---- Patch Just+ Gradle identity/signing/dependency ---- +build = up / "app/build.gradle" +s = build.read_text() +s = s.replace('applicationId "com.justplus.player"', 'applicationId "top.rootu.dddplayer"') +s = s.replace('minSdkVersion 23', 'minSdkVersion 24') +s = s.replace('versionCode tagVersionCode', 'versionCode (project.findProperty("versionCodeOverride") ?: "1500000").toString().toInteger()') +s = s.replace('versionName tagVersionName', 'versionName (project.findProperty("versionNameOverride") ?: "0.0.15").toString()') + +signing = ''' signingConfigs {\n release {\n def dddKeystore = rootProject.file("app/dddplayer-release.jks")\n if (dddKeystore.exists()) {\n storeFile dddKeystore\n storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")\n keyAlias System.getenv("ANDROID_KEY_ALIAS")\n keyPassword System.getenv("ANDROID_KEY_PASSWORD")\n }\n }\n }\n''' +if 'def dddKeystore' not in s: + s = s.replace(' buildTypes {\n', signing + ' buildTypes {\n', 1) +s = s.replace(' release {\n minifyEnabled false', + ' release {\n signingConfig signingConfigs.release\n minifyEnabled false', 1) +if "implementation project(path: ':legacy')" not in s: + s = s.replace('dependencies {\n', "dependencies {\n implementation project(path: ':legacy')\n", 1) +if "androidx.documentfile:documentfile" not in s: + s = s.replace('dependencies {\n', "dependencies {\n implementation \'androidx.documentfile:documentfile:1.1.0\'\n", 1) +if "media3-datasource-okhttp" not in s: + s = s.replace('dependencies {\n', "dependencies {\n implementation \"androidx.media3:media3-datasource-okhttp:1.11.0-beta01\"\n", 1) +build.write_text(s) + +# Visible identity: Android package/signature stay DDD-compatible, UI becomes Just Player. +strings = up / "app/src/main/res/values/strings.xml" +s = strings.read_text() +s = re.sub(r'(]*>).*?()', r'\1Just Player 2.1\2', s, count=1) +strings.write_text(s) + +# Keep self-update/links on the same repository used by installed DDD 0.0.14. +for p in (up / "app/src/main/java").rglob("*.java"): + text = p.read_text() + changed = text.replace("just-plus-player/just-plus-player", "ARST113/dddplayer2") + if changed != text: + p.write_text(changed) + +# Future self-updates need package-installer permission under the inherited DDD application id. +manifest = up / "app/src/main/AndroidManifest.xml" +s = manifest.read_text() +if 'android.permission.REQUEST_INSTALL_PACKAGES' not in s: + s = s.replace('', + '\n ', 1) +manifest.write_text(s) + +# ---- Patch Just+ PlayerActivity: DDD bridge hooks + clean control metadata + HEVC fallback ---- +pa = up / "app/src/main/java/com/brouken/player/PlayerActivity.java" +s = pa.read_text() + +interop_import = 'import top.rootu.dddplayer.compat.JustPlusBridgeInterop;\n' +if interop_import not in s: + marker = 'import java.util.concurrent.TimeUnit;\n' + if marker not in s: + raise SystemExit('PlayerActivity import marker not found') + s = s.replace(marker, marker + '\n' + interop_import, 1) + +# DDD control query/fragment metadata is for the integration layer and must never reach TorrServer/CDN. +create_marker = ' super.onCreate(savedInstanceState);\n' +if 'sanitizeDddLaunchIntent();' not in s: + if create_marker not in s: + raise SystemExit('PlayerActivity onCreate marker not found') + s = s.replace(create_marker, create_marker + ' sanitizeDddLaunchIntent();\n', 1) + +# Attach after Just+ has installed its own listeners and analytics hooks. +attach_marker = ' player.addAnalyticsListener(playbackInfoListener);\n' +if 'JustPlusBridgeInterop.attach(this, getIntent(), player);' not in s: + if attach_marker not in s: + raise SystemExit('PlayerActivity attach marker not found') + s = s.replace(attach_marker, + attach_marker + ' JustPlusBridgeInterop.attach(this, getIntent(), player);\n', 1) + +# DDD internal headers contain JSON/control information and make strict media endpoints reject the request. +headers_marker = ''' if ("User-Agent".equalsIgnoreCase(name)) {\n userAgent = value;\n } else {\n headers.put(name, value);\n }''' +headers_replacement = ''' if (name.regionMatches(true, 0, "X-Lampa-DDD-", 0, "X-Lampa-DDD-".length())) {\n continue;\n }\n if ("User-Agent".equalsIgnoreCase(name)) {\n userAgent = value;\n } else {\n headers.put(name, value);\n }''' +if 'name.regionMatches(true, 0, "X-Lampa-DDD-"' not in s: + if headers_marker not in s: + raise SystemExit('PlayerActivity headers marker not found') + s = s.replace(headers_marker, headers_replacement, 1) + +# Prefer Just+'s DV->HEVC recovery. If plain HEVC itself is what failed, hand the exact launch +# to the preserved DDD/libavcodec pipeline. This covers RExt/4:2:2/4:4:4 10-bit files such as +# hvc1.4.10.H120.9C.08 without changing the working AV1/HDR/DV paths. +dv_marker = ''' if (error.errorCode == PlaybackException.ERROR_CODE_DECODING_FAILED\n && error instanceof ExoPlaybackException\n && recoverByForcingHevcForDolbyVision(error,\n ((ExoPlaybackException) error).rendererFormat)) {\n return;\n }''' +legacy_hook = dv_marker + '''\n if (error instanceof ExoPlaybackException\n && recoverByLaunchingLegacyHevc(error, ((ExoPlaybackException) error).rendererFormat)) {\n return;\n }''' +if 'recoverByLaunchingLegacyHevc(error' not in s: + if dv_marker not in s: + raise SystemExit('PlayerActivity DV recovery marker not found') + s = s.replace(dv_marker, legacy_hook, 1) + +methods = r''' + /** + * Transition-only compatibility path. Normal Main/Main10 HEVC stays in Just+/MediaCodec. + * When the selected Android HEVC decoder fails, the same Intent is opened by the preserved + * DDD activity, whose libavcodec fallback handles RExt 10-bit formats unsupported by MediaCodec. + */ + private boolean recoverByLaunchingLegacyHevc(PlaybackException error, @Nullable Format failingFormat) { + if (failingFormat == null || !MimeTypes.VIDEO_H265.equals(failingFormat.sampleMimeType) + || !isDecoderFailure(error)) { + return false; + } + final Intent current = getIntent(); + if (current != null && current.getBooleanExtra(JustPlusBridgeInterop.EXTRA_FORCE_LEGACY, false)) { + return false; + } + final Intent legacy = current == null ? new Intent() : new Intent(current); + final String originalUri = legacy.getStringExtra(JustPlusBridgeInterop.EXTRA_ORIGINAL_URI); + if (originalUri != null && !originalUri.isEmpty()) { + legacy.setData(Uri.parse(originalUri)); + } + legacy.setClassName(getPackageName(), "com.brouken.player.LegacyHevcActivity"); + legacy.putExtra(JustPlusBridgeInterop.EXTRA_FORCE_LEGACY, true); + if (player != null) { + legacy.putExtra("position", player.getCurrentPosition()); + } + JustPlusBridgeInterop.detach(this, "hevc_legacy_fallback"); + startActivity(legacy); + finish(); + return true; + } + + /** Strip DDD query/fragment control metadata while preserving it for bridge parsing. */ + private void sanitizeDddLaunchIntent() { + final Intent launch = getIntent(); + if (launch == null || launch.getData() == null) { + return; + } + final Uri data = launch.getData(); + final String original = data.toString(); + boolean changed = false; + String cleanQuery = null; + final String encodedQuery = data.getEncodedQuery(); + if (encodedQuery != null && !encodedQuery.isEmpty()) { + final ArrayList kept = new ArrayList<>(); + for (String part : encodedQuery.split("&")) { + final String rawKey = part.contains("=") ? part.substring(0, part.indexOf('=')) : part; + final String key = Uri.decode(rawKey); + if (key != null && key.toLowerCase(Locale.ROOT).startsWith("ddd_")) { + changed = true; + } else { + kept.add(part); + } + } + cleanQuery = kept.isEmpty() ? null : TextUtils.join("&", kept); + } + final String fragment = data.getFragment(); + final boolean dddFragment = fragment != null && fragment.toLowerCase(Locale.ROOT).contains("ddd_"); + changed |= dddFragment; + if (!changed) { + return; + } + final Uri.Builder builder = data.buildUpon(); + builder.encodedQuery(cleanQuery); + if (dddFragment) { + builder.fragment(null); + } + launch.putExtra(JustPlusBridgeInterop.EXTRA_ORIGINAL_URI, original); + launch.setData(builder.build()); + } + +''' +static_marker = ' private static boolean isDecoderFailure(PlaybackException error) {' +if 'private boolean recoverByLaunchingLegacyHevc' not in s: + if static_marker not in s: + raise SystemExit('PlayerActivity decoder failure marker not found') + s = s.replace(static_marker, methods + static_marker, 1) + +# Stop bridge observers/local socket with the Activity. attach() handles player rebuilds separately. +destroy_marker = ''' super.onDestroy();''' +if 'JustPlusBridgeInterop.detach(this, "activity_destroyed");' not in s: + # There is a single Activity.onDestroy() super call in this source. + if destroy_marker not in s: + raise SystemExit('PlayerActivity onDestroy marker not found') + s = s.replace(destroy_marker, + ' JustPlusBridgeInterop.detach(this, "activity_destroyed");\n' + destroy_marker, 1) + +pa.write_text(s) + +# ---- Patch old DDD PlayerActivity into a compatibility router ---- +legacy_pa = root / "app/src/main/java/top/rootu/dddplayer/ui/PlayerActivity.kt" +s = legacy_pa.read_text() +router_marker = ' super.onCreate(savedInstanceState)\n' +router = ''' super.onCreate(savedInstanceState)\n\n // 0.0.15 keeps this component name so existing Lampa integrations continue to work.\n // Normal launches are forwarded to Just+; only decoder fallback explicitly stays here.\n if (!intent.getBooleanExtra("ddd_force_legacy", false)) {\n runCatching {\n val forward = Intent(intent).apply {\n setClassName(packageName, "com.brouken.player.PlayerActivity")\n }\n startActivity(forward)\n finish()\n return\n }.onFailure { Log.w("DDDPlayer/Compat", "Just+ router failed, staying on legacy player", it) }\n }\n''' +if 'Just+ router failed' not in s: + if router_marker not in s: + raise SystemExit('Legacy PlayerActivity onCreate marker not found') + s = s.replace(router_marker, router, 1) +legacy_pa.write_text(s) + + +# ---- Generate Just+-UI HEVC fallback Activity ---- +legacy_activity = up / "app/src/main/java/com/brouken/player/LegacyHevcActivity.java" +legacy_activity.write_text(r'''package com.brouken.player; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Looper; +import android.view.WindowManager; + +import androidx.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; + +import top.rootu.dddplayer.compat.JustPlusBridgeInterop; +import top.rootu.dddplayer.compat.LegacyHevcPlayer; + +/** + * Just+ controller surface backed by DDD/libavcodec for unsupported HEVC. + * The old DDD Activity remains only as the last-resort fallback. + */ +public final class LegacyHevcActivity extends Activity { + private LegacyHevcPlayer legacyPlayer; + private com.brouken.player.dtpv.DoubleTapPlayerView videoView; + + @Override + protected void onCreate(@Nullable Bundle state) { + super.onCreate(state); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + setContentView(R.layout.activity_player); + + videoView = findViewById(R.id.video_view); + final Intent intent = getIntent(); + Uri uri = intent.getData(); + final String original = intent.getStringExtra(JustPlusBridgeInterop.EXTRA_ORIGINAL_URI); + if (original != null && !original.isEmpty()) { + uri = Uri.parse(original); + } + if (uri == null) { + finish(); + return; + } + + final HashMap headers = new HashMap<>(); + final String[] flat = intent.getStringArrayExtra("headers"); + if (flat != null) { + for (int i = 0; i + 1 < flat.length; i += 2) { + final String name = flat[i]; + final String value = flat[i + 1]; + if (name == null || value == null) continue; + if (name.regionMatches(true, 0, "X-Lampa-DDD-", 0, "X-Lampa-DDD-".length())) continue; + headers.put(name, value); + } + } + + final CharSequence titleValue = intent.getCharSequenceExtra("title"); + final String title = titleValue == null ? null : titleValue.toString(); + final long position = Math.max(0L, intent.getLongExtra("position", 0L)); + + legacyPlayer = new LegacyHevcPlayer( + Looper.getMainLooper(), uri, headers, position, title); + videoView.setPlayer(legacyPlayer); + legacyPlayer.prepare(); + legacyPlayer.play(); + videoView.showController(); + } + + @Override + protected void onDestroy() { + if (videoView != null) videoView.setPlayer(null); + if (legacyPlayer != null) { + legacyPlayer.release(); + legacyPlayer = null; + } + super.onDestroy(); + } +} +''') + +# Register the internal fallback activity under the same Just+ theme. +manifest = up / "app/src/main/AndroidManifest.xml" +ms = manifest.read_text() +if 'android:name=".LegacyHevcActivity"' not in ms: + insert = ''' +''' + ms = ms.replace('