Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions .github/workflows/build-android-apks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
name: Build Android APKs

on:
push:
branches:
- develop
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: write

jobs:
build-android-apks:
name: Build Android APKs
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4.1.1

- name: Fetch Flutter version
run: |
FLUTTER_VERSION=$(jq -r '.flutter' .fvmrc)
echo "FLUTTER_VERSION=${FLUTTER_VERSION}" >> "$GITHUB_ENV"
shell: bash

- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: zulu
java-version: "17"
cache: gradle
check-latest: true

- name: Prepare release signing
env:
ENCODED_STRING: ${{ secrets.KEYSTORE_BASE_64 }}
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEYSTORE_ALIAS: ${{ secrets.RELEASE_KEYSTORE_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: |
if [[ -z "$ENCODED_STRING" || -z "$RELEASE_KEYSTORE_PASSWORD" || -z "$RELEASE_KEYSTORE_ALIAS" || -z "$RELEASE_KEY_PASSWORD" ]]; then
echo "Missing one or more signing secrets."
echo "Required secrets: KEYSTORE_BASE_64, RELEASE_KEYSTORE_PASSWORD, RELEASE_KEYSTORE_ALIAS, RELEASE_KEY_PASSWORD"
exit 1
fi

echo "$ENCODED_STRING" | base64 -d > android/app/keystore.jks
echo "SIGNING_SOURCE=repository signing secrets" >> "$GITHUB_ENV"

cat > android/app/key.properties <<EOF
storePassword=$RELEASE_KEYSTORE_PASSWORD
keyPassword=$RELEASE_KEY_PASSWORD
keyAlias=$RELEASE_KEYSTORE_ALIAS
EOF

- name: Set up Flutter
uses: subosito/flutter-action@v2.19.0
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:"
cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:"

- name: Get dependencies
run: flutter pub get

- name: Build split release APKs
run: flutter build apk --release --split-per-abi --build-number=${{ github.run_number }} --flavor production

- name: Collect requested APKs
run: |
mkdir -p build/app/outputs/android_artifacts
cp build/app/outputs/flutter-apk/app-arm64-v8a-production-release.apk build/app/outputs/android_artifacts/fladder-arm64-v8a.apk
cp build/app/outputs/flutter-apk/app-armeabi-v7a-production-release.apk build/app/outputs/android_artifacts/fladder-armeabi-v7a.apk

- name: Upload arm64-v8a APK
uses: actions/upload-artifact@v4
with:
name: fladder-arm64-v8a-apk
path: build/app/outputs/android_artifacts/fladder-arm64-v8a.apk

- name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v4
with:
name: fladder-armeabi-v7a-apk
path: build/app/outputs/android_artifacts/fladder-armeabi-v7a.apk

- name: Create commit release
env:
GH_TOKEN: ${{ github.token }}
run: |
SHORT_SHA="${GITHUB_SHA::7}"
TAG_NAME="apk-${SHORT_SHA}-${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
RELEASE_NAME="Fladder APK ${SHORT_SHA} #${GITHUB_RUN_NUMBER}"

gh release create "$TAG_NAME" \
build/app/outputs/android_artifacts/fladder-arm64-v8a.apk \
build/app/outputs/android_artifacts/fladder-armeabi-v7a.apk \
--target "$GITHUB_SHA" \
--title "$RELEASE_NAME" \
--notes "Automated Android APK build for commit ${GITHUB_SHA} from ${GITHUB_REF_NAME}. Build number: ${GITHUB_RUN_NUMBER}. Signing: ${SIGNING_SOURCE}." \
--prerelease
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ app.*.map.json
**/android/**/GeneratedPluginRegistrant.java
**/android/key.properties
*.jks
*.jks.base64
local.properties
**/.cxx/

Expand Down
2 changes: 2 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ dependencies {
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:$media3_version")
implementation("io.github.peerless2012:ass-media:0.3.0")

testImplementation("junit:junit:4.13.2")

//UI
implementation("io.github.rabehx:iconsax-compose:0.0.5")
implementation("io.coil-kt.coil3:coil-compose:3.3.0")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ enum class SegmentSkip(val raw: Int) {
/** Generated class from Pigeon that represents data sent in messages. */
data class PlayerSettings (
val enableTunneling: Boolean,
val ignoreHdr10Plus: Boolean,
val skipTypes: Map<SegmentType, SegmentSkip>,
val themeColor: Long? = null,
val skipForward: Long,
Expand All @@ -164,21 +165,23 @@ data class PlayerSettings (
companion object {
fun fromList(pigeonVar_list: List<Any?>): PlayerSettings {
val enableTunneling = pigeonVar_list[0] as Boolean
val skipTypes = pigeonVar_list[1] as Map<SegmentType, SegmentSkip>
val themeColor = pigeonVar_list[2] as Long?
val skipForward = pigeonVar_list[3] as Long
val skipBackward = pigeonVar_list[4] as Long
val autoNextType = pigeonVar_list[5] as AutoNextType
val acceptedOrientations = pigeonVar_list[6] as List<PlayerOrientations>
val fillScreen = pigeonVar_list[7] as Boolean
val videoFit = pigeonVar_list[8] as VideoPlayerFit
val screensaver = pigeonVar_list[9] as Screensaver
return PlayerSettings(enableTunneling, skipTypes, themeColor, skipForward, skipBackward, autoNextType, acceptedOrientations, fillScreen, videoFit, screensaver)
val ignoreHdr10Plus = pigeonVar_list[1] as Boolean
val skipTypes = pigeonVar_list[2] as Map<SegmentType, SegmentSkip>
val themeColor = pigeonVar_list[3] as Long?
val skipForward = pigeonVar_list[4] as Long
val skipBackward = pigeonVar_list[5] as Long
val autoNextType = pigeonVar_list[6] as AutoNextType
val acceptedOrientations = pigeonVar_list[7] as List<PlayerOrientations>
val fillScreen = pigeonVar_list[8] as Boolean
val videoFit = pigeonVar_list[9] as VideoPlayerFit
val screensaver = pigeonVar_list[10] as Screensaver
return PlayerSettings(enableTunneling, ignoreHdr10Plus, skipTypes, themeColor, skipForward, skipBackward, autoNextType, acceptedOrientations, fillScreen, videoFit, screensaver)
}
}
fun toList(): List<Any?> {
return listOf(
enableTunneling,
ignoreHdr10Plus,
skipTypes,
themeColor,
skipForward,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ internal fun ExoPlayer(
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()

val renderersFactory = DefaultRenderersFactory(context)
val renderersFactory = (if (PlayerSettingsObject.settings.value?.ignoreHdr10Plus == true) {
StripHDR10PlusRenderersFactory(context)
} else {
DefaultRenderersFactory(context)
})
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
.setEnableDecoderFallback(true)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package nl.jknaapen.fladder.player

import java.nio.ByteBuffer

/**
* In-place sanitizer for HEVC Annex B buffers carrying both Dolby Vision and HDR10+
* dynamic metadata. Some Android TV chipsets fail when a native DV decoder also
* receives in-band HDR10+ SEI, so keep only the dynamic metadata for the selected
* decode path.
*/
object StripHDR10PlusBitstreamSanitizer {
private const val NAL_TYPE_PREFIX_SEI = 39
private const val NAL_TYPE_SUFFIX_SEI = 40
private const val NAL_TYPE_UNSPEC62 = 62
private const val NAL_TYPE_UNSPEC63 = 63

private const val SEI_PAYLOAD_TYPE_ITU_T_T35 = 4

fun sanitize(data: ByteBuffer, stripHdr10PlusSei: Boolean, stripDvRpu: Boolean) {
val startPos = data.position()
val limit = data.limit()
var writePos = startPos
var nalStartIndex = -1
var startCodeLen = 0

var i = startPos
while (i <= limit) {
val atEnd = i == limit
var foundStartCode = false
var nextStartCodeLen = 0
if (!atEnd && i + 2 < limit && data.get(i).toInt() == 0 && data.get(i + 1).toInt() == 0) {
if (data.get(i + 2).toInt() == 1) {
foundStartCode = true
nextStartCodeLen = 3
} else if (data.get(i + 2).toInt() == 0 && i + 3 < limit && data.get(i + 3).toInt() == 1) {
foundStartCode = true
nextStartCodeLen = 4
}
}

if (foundStartCode || atEnd) {
if (nalStartIndex >= 0) {
val nalDataStart = nalStartIndex + startCodeLen
val nalEnd = i
var strip = false

if (nalEnd - nalDataStart >= 2) {
val nalUnitType = (data.get(nalDataStart).toInt() and 0x7E) shr 1
strip = when (nalUnitType) {
NAL_TYPE_UNSPEC62, NAL_TYPE_UNSPEC63 -> stripDvRpu
NAL_TYPE_PREFIX_SEI, NAL_TYPE_SUFFIX_SEI ->
stripHdr10PlusSei && isHdr10PlusSeiNalUnit(data, nalDataStart + 2, nalEnd)
else -> false
}
}

if (!strip) {
if (writePos != nalStartIndex) {
for (j in nalStartIndex until nalEnd) {
data.put(writePos++, data.get(j))
}
} else {
writePos = nalEnd
}
}
}
nalStartIndex = i
startCodeLen = nextStartCodeLen
i += if (nextStartCodeLen > 0) nextStartCodeLen else 1
} else {
i++
}
}

data.limit(writePos)
data.position(startPos)
}

private fun isHdr10PlusSeiNalUnit(data: ByteBuffer, rbspStart: Int, nalEnd: Int): Boolean {
var pos = rbspStart
if (pos >= nalEnd) return false

var payloadType = 0
while (pos < nalEnd) {
val value = data.get(pos++).toInt() and 0xFF
payloadType += value
if (value != 0xFF) break
}

var payloadSize = 0
while (pos < nalEnd) {
val value = data.get(pos++).toInt() and 0xFF
payloadSize += value
if (value != 0xFF) break
}

if (payloadType != SEI_PAYLOAD_TYPE_ITU_T_T35 || payloadSize < 7 || pos + 7 > nalEnd) {
return false
}

val countryCode = data.get(pos).toInt() and 0xFF
val providerCode = ((data.get(pos + 1).toInt() and 0xFF) shl 8) or (data.get(pos + 2).toInt() and 0xFF)
val orientedCode = ((data.get(pos + 3).toInt() and 0xFF) shl 8) or (data.get(pos + 4).toInt() and 0xFF)
val appIdentifier = data.get(pos + 5).toInt() and 0xFF
val appVersion = data.get(pos + 6).toInt() and 0xFF

return countryCode == 0xB5 &&
providerCode == 0x003C &&
orientedCode == 0x0001 &&
appIdentifier == 4 &&
(appVersion == 0 || appVersion == 1)
}
}
Loading
Loading