feat: Android TV support (#503)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-09-28 21:07:49 +02:00 committed by GitHub
parent 7ab8c015b9
commit c299492d6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
168 changed files with 12019 additions and 3073 deletions

2
android/.gitignore vendored
View file

@ -11,3 +11,5 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
**/TestData.kt

View file

@ -1,6 +1,7 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "org.jetbrains.kotlin.plugin.compose"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
@ -8,7 +9,7 @@ plugins {
def keystoreProperties = new Properties()
def keystorePropertiesFile = file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
def localProperties = new Properties()
@ -30,6 +31,21 @@ if (flutterVersionName == null) {
}
android {
packagingOptions {
jniLibs {
pickFirsts += [
"lib/x86_64/libc++_shared.so",
"lib/arm64-v8a/libc++_shared.so",
"lib/armeabi-v7a/libc++_shared.so",
"lib/x86/libc++_shared.so",
"lib/x86_64/libass.so",
"lib/arm64-v8a/libass.so",
"lib/armeabi-v7a/libass.so",
"lib/x86/libass.so",
]
}
}
namespace = "nl.jknaapen.fladder"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@ -39,8 +55,12 @@ android {
targetCompatibility = JavaVersion.VERSION_1_8
}
buildFeatures {
compose = true
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
jvmTarget = "1.8"
}
defaultConfig {
@ -51,25 +71,29 @@ android {
versionName = flutter.versionName
}
signingConfigs {
release {
storeFile file('keystore.jks')
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storePassword keystoreProperties['storePassword']
composeOptions {
kotlinCompilerExtensionVersion '1.6.4'
}
signingConfigs {
release {
storeFile file('keystore.jks')
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storePassword keystoreProperties['storePassword']
}
}
}
buildTypes {
release {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
signingConfig signingConfigs.release
}
}
signingConfig signingConfigs.release
}
}
flavorDimensions "default"
productFlavors {
productFlavors {
development {
dimension "default"
applicationIdSuffix ".dev"
@ -84,3 +108,29 @@ android {
flutter {
source = "../.."
}
dependencies {
def composeBom = platform('androidx.compose:compose-bom:2025.09.00')
implementation composeBom
androidTestImplementation composeBom
implementation('androidx.compose.material3:material3')
implementation('androidx.compose.ui:ui-tooling-preview')
debugImplementation('androidx.compose.ui:ui-tooling')
implementation('androidx.activity:activity-compose:1.11.0')
// Media3 (ExoPlayer)
def media3_version = "1.8.0+1"
implementation("androidx.media3:media3-exoplayer:$media3_version")
implementation("androidx.media3:media3-session:$media3_version")
implementation("androidx.media3:media3-ui:$media3_version")
implementation("androidx.media3:media3-exoplayer-dash:$media3_version")
implementation("androidx.media3:media3-exoplayer-hls:$media3_version")
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:$media3_version")
implementation("io.github.peerless2012:ass-media:0.3.0-rc03")
//UI
implementation("io.github.rabehx:iconsax-compose:0.0.3")
implementation("io.coil-kt.coil3:coil-compose:3.3.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0")
}

View file

@ -1,42 +1,95 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" package="nl.jknaapen.fladder">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
xmlns:tools="http://schemas.android.com/tools"
package="nl.jknaapen.fladder">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application android:label="Fladder" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true" android:enableOnBackInvokedCallback="true">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<application
android:label="Fladder"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:banner="@drawable/app_banner"
android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<service android:name="androidx.work.impl.foreground.SystemForegroundService" android:foregroundServiceType="dataSync" tools:node="merge" />
<service android:name="com.ryanheise.audioservice.AudioService" android:foregroundServiceType="mediaPlayback" android:exported="true" tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<!-- Launcher for phones/tablets -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Don't delete the meta-data below.
<!-- Launcher for Android TV -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".VideoPlayerActivity"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:supportsPictureInPicture="true"
android:windowSoftInputMode="adjustResize"
android:exported="true" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver" android:exported="true" tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -1,7 +1,77 @@
package nl.jknaapen.fladder
import io.flutter.embedding.android.FlutterFragmentActivity
import com.ryanheise.audioservice.AudioServiceFragmentActivity;
import NativeVideoActivity
import PlayerSettingsPigeon
import StartResult
import VideoPlayerApi
import VideoPlayerControlsCallback
import VideoPlayerListenerCallback
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import com.ryanheise.audioservice.AudioServiceFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import nl.jknaapen.fladder.objects.PlayerSettingsObject
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.leanBackEnabled
class MainActivity: AudioServiceFragmentActivity () {
class MainActivity : AudioServiceFragmentActivity(), NativeVideoActivity {
private lateinit var videoPlayerLauncher: ActivityResultLauncher<Intent>
private var videoPlayerCallback: ((Result<StartResult>) -> Unit)? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val videoPlayerHost = VideoPlayerObject
NativeVideoActivity.setUp(
flutterEngine.dartExecutor.binaryMessenger,
this
)
VideoPlayerApi.setUp(
flutterEngine.dartExecutor.binaryMessenger,
videoPlayerHost.implementation
)
videoPlayerHost.videoPlayerListener =
VideoPlayerListenerCallback(flutterEngine.dartExecutor.binaryMessenger)
videoPlayerHost.videoPlayerControls =
VideoPlayerControlsCallback(flutterEngine.dartExecutor.binaryMessenger)
PlayerSettingsPigeon.setUp(
flutterEngine.dartExecutor.binaryMessenger,
api = PlayerSettingsObject
)
videoPlayerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val callback = videoPlayerCallback
videoPlayerCallback = null
val startResult = if (result.resultCode == RESULT_OK) {
StartResult(resultValue = result.data?.getStringExtra("result") ?: "Finished")
} else {
StartResult(resultValue = "Cancelled")
}
callback?.invoke(Result.success(startResult))
}
}
override fun launchActivity(callback: (Result<StartResult>) -> Unit) {
try {
videoPlayerCallback = callback
val intent = Intent(this, VideoPlayerActivity::class.java)
videoPlayerLauncher.launch(intent)
} catch (e: Exception) {
e.printStackTrace()
callback(Result.failure(e))
}
}
override fun disposeActivity() {
VideoPlayerObject.currentActivity?.finish()
}
override fun isLeanBackEnabled(): Boolean = leanBackEnabled(applicationContext)
}

View file

@ -0,0 +1,26 @@
package nl.jknaapen.fladder
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF3B82F6)
)
@Composable
fun VideoPlayerTheme(
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = DarkColorScheme,
) {
CompositionLocalProvider {
content()
}
}
}

View file

@ -0,0 +1,54 @@
package nl.jknaapen.fladder
import android.graphics.PixelFormat
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.media3.common.util.UnstableApi
import nl.jknaapen.fladder.composables.controls.CustomVideoControls
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.player.ExoPlayer
import nl.jknaapen.fladder.utility.ScaledContent
import nl.jknaapen.fladder.utility.leanBackEnabled
class VideoPlayerActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
VideoPlayerObject.currentActivity = this
window.setFlags(
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
)
window.setFormat(PixelFormat.TRANSLUCENT)
setContent {
VideoPlayerTheme {
VideoPlayerScreen()
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(UnstableApi::class)
@Composable
fun VideoPlayerScreen(
) {
val leanBackEnabled = leanBackEnabled(LocalContext.current)
ExoPlayer { player ->
ScaledContent(if (leanBackEnabled) 0.50f else 1f) {
CustomVideoControls(player)
}
}
}

View file

@ -0,0 +1,200 @@
// Autogenerated from Pigeon (v26.0.1), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object PlayerSettingsHelperPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
return a.contentEquals(b)
}
if (a is Array<*> && b is Array<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is List<*> && b is List<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size && a.all {
(b as Map<Any?, Any?>).containsKey(it.key) &&
deepEquals(it.value, b[it.key])
}
}
return a == b
}
}
enum class SegmentType(val raw: Int) {
COMMERCIAL(0),
PREVIEW(1),
RECAP(2),
INTRO(3),
OUTRO(4);
companion object {
fun ofRaw(raw: Int): SegmentType? {
return values().firstOrNull { it.raw == raw }
}
}
}
enum class SegmentSkip(val raw: Int) {
ASK(0),
SKIP(1),
NONE(2);
companion object {
fun ofRaw(raw: Int): SegmentSkip? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class PlayerSettings (
val skipTypes: Map<SegmentType, SegmentSkip>,
val skipForward: Long,
val skipBackward: Long
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): PlayerSettings {
val skipTypes = pigeonVar_list[0] as Map<SegmentType, SegmentSkip>
val skipForward = pigeonVar_list[1] as Long
val skipBackward = pigeonVar_list[2] as Long
return PlayerSettings(skipTypes, skipForward, skipBackward)
}
}
fun toList(): List<Any?> {
return listOf(
skipTypes,
skipForward,
skipBackward,
)
}
override fun equals(other: Any?): Boolean {
if (other !is PlayerSettings) {
return false
}
if (this === other) {
return true
}
return PlayerSettingsHelperPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class PlayerSettingsHelperPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
SegmentType.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as Long?)?.let {
SegmentSkip.ofRaw(it.toInt())
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlayerSettings.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is SegmentType -> {
stream.write(129)
writeValue(stream, value.raw)
}
is SegmentSkip -> {
stream.write(130)
writeValue(stream, value.raw)
}
is PlayerSettings -> {
stream.write(131)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PlayerSettingsPigeon {
fun sendPlayerSettings(playerSettings: PlayerSettings)
companion object {
/** The codec used by PlayerSettingsPigeon. */
val codec: MessageCodec<Any?> by lazy {
PlayerSettingsHelperPigeonCodec()
}
/** Sets up an instance of `PlayerSettingsPigeon` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: PlayerSettingsPigeon?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.settings.PlayerSettingsPigeon.sendPlayerSettings$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val playerSettingsArg = args[0] as PlayerSettings
val wrapped: List<Any?> = try {
api.sendPlayerSettings(playerSettingsArg)
listOf(null)
} catch (exception: Throwable) {
PlayerSettingsHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View file

@ -0,0 +1,911 @@
// Autogenerated from Pigeon (v26.0.1), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object VideoPlayerHelperPigeonUtils {
fun createConnectionError(channelName: String): FlutterError {
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
return a.contentEquals(b)
}
if (a is Array<*> && b is Array<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is List<*> && b is List<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size && a.all {
(b as Map<Any?, Any?>).containsKey(it.key) &&
deepEquals(it.value, b[it.key])
}
}
return a == b
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
enum class MediaSegmentType(val raw: Int) {
COMMERCIAL(0),
PREVIEW(1),
RECAP(2),
INTRO(3),
OUTRO(4);
companion object {
fun ofRaw(raw: Int): MediaSegmentType? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class PlayableData (
val id: String,
val title: String,
val subTitle: String? = null,
val logoUrl: String? = null,
val description: String,
val startPosition: Long,
val defaultAudioTrack: Long,
val audioTracks: List<AudioTrack>,
val defaultSubtrack: Long,
val subtitleTracks: List<SubtitleTrack>,
val trickPlayModel: TrickPlayModel? = null,
val chapters: List<Chapter>,
val segments: List<MediaSegment>,
val previousVideo: String? = null,
val nextVideo: String? = null,
val url: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): PlayableData {
val id = pigeonVar_list[0] as String
val title = pigeonVar_list[1] as String
val subTitle = pigeonVar_list[2] as String?
val logoUrl = pigeonVar_list[3] as String?
val description = pigeonVar_list[4] as String
val startPosition = pigeonVar_list[5] as Long
val defaultAudioTrack = pigeonVar_list[6] as Long
val audioTracks = pigeonVar_list[7] as List<AudioTrack>
val defaultSubtrack = pigeonVar_list[8] as Long
val subtitleTracks = pigeonVar_list[9] as List<SubtitleTrack>
val trickPlayModel = pigeonVar_list[10] as TrickPlayModel?
val chapters = pigeonVar_list[11] as List<Chapter>
val segments = pigeonVar_list[12] as List<MediaSegment>
val previousVideo = pigeonVar_list[13] as String?
val nextVideo = pigeonVar_list[14] as String?
val url = pigeonVar_list[15] as String
return PlayableData(id, title, subTitle, logoUrl, description, startPosition, defaultAudioTrack, audioTracks, defaultSubtrack, subtitleTracks, trickPlayModel, chapters, segments, previousVideo, nextVideo, url)
}
}
fun toList(): List<Any?> {
return listOf(
id,
title,
subTitle,
logoUrl,
description,
startPosition,
defaultAudioTrack,
audioTracks,
defaultSubtrack,
subtitleTracks,
trickPlayModel,
chapters,
segments,
previousVideo,
nextVideo,
url,
)
}
override fun equals(other: Any?): Boolean {
if (other !is PlayableData) {
return false
}
if (this === other) {
return true
}
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class MediaSegment (
val type: MediaSegmentType,
val name: String,
val start: Long,
val end: Long
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): MediaSegment {
val type = pigeonVar_list[0] as MediaSegmentType
val name = pigeonVar_list[1] as String
val start = pigeonVar_list[2] as Long
val end = pigeonVar_list[3] as Long
return MediaSegment(type, name, start, end)
}
}
fun toList(): List<Any?> {
return listOf(
type,
name,
start,
end,
)
}
override fun equals(other: Any?): Boolean {
if (other !is MediaSegment) {
return false
}
if (this === other) {
return true
}
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class AudioTrack (
val name: String,
val languageCode: String,
val codec: String,
val index: Long,
val external: Boolean,
val url: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): AudioTrack {
val name = pigeonVar_list[0] as String
val languageCode = pigeonVar_list[1] as String
val codec = pigeonVar_list[2] as String
val index = pigeonVar_list[3] as Long
val external = pigeonVar_list[4] as Boolean
val url = pigeonVar_list[5] as String?
return AudioTrack(name, languageCode, codec, index, external, url)
}
}
fun toList(): List<Any?> {
return listOf(
name,
languageCode,
codec,
index,
external,
url,
)
}
override fun equals(other: Any?): Boolean {
if (other !is AudioTrack) {
return false
}
if (this === other) {
return true
}
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class SubtitleTrack (
val name: String,
val languageCode: String,
val codec: String,
val index: Long,
val external: Boolean,
val url: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SubtitleTrack {
val name = pigeonVar_list[0] as String
val languageCode = pigeonVar_list[1] as String
val codec = pigeonVar_list[2] as String
val index = pigeonVar_list[3] as Long
val external = pigeonVar_list[4] as Boolean
val url = pigeonVar_list[5] as String?
return SubtitleTrack(name, languageCode, codec, index, external, url)
}
}
fun toList(): List<Any?> {
return listOf(
name,
languageCode,
codec,
index,
external,
url,
)
}
override fun equals(other: Any?): Boolean {
if (other !is SubtitleTrack) {
return false
}
if (this === other) {
return true
}
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class Chapter (
val name: String,
val url: String,
val time: Long
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): Chapter {
val name = pigeonVar_list[0] as String
val url = pigeonVar_list[1] as String
val time = pigeonVar_list[2] as Long
return Chapter(name, url, time)
}
}
fun toList(): List<Any?> {
return listOf(
name,
url,
time,
)
}
override fun equals(other: Any?): Boolean {
if (other !is Chapter) {
return false
}
if (this === other) {
return true
}
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class TrickPlayModel (
val width: Long,
val height: Long,
val tileWidth: Long,
val tileHeight: Long,
val thumbnailCount: Long,
val interval: Long,
val images: List<String>
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): TrickPlayModel {
val width = pigeonVar_list[0] as Long
val height = pigeonVar_list[1] as Long
val tileWidth = pigeonVar_list[2] as Long
val tileHeight = pigeonVar_list[3] as Long
val thumbnailCount = pigeonVar_list[4] as Long
val interval = pigeonVar_list[5] as Long
val images = pigeonVar_list[6] as List<String>
return TrickPlayModel(width, height, tileWidth, tileHeight, thumbnailCount, interval, images)
}
}
fun toList(): List<Any?> {
return listOf(
width,
height,
tileWidth,
tileHeight,
thumbnailCount,
interval,
images,
)
}
override fun equals(other: Any?): Boolean {
if (other !is TrickPlayModel) {
return false
}
if (this === other) {
return true
}
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class StartResult (
val resultValue: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): StartResult {
val resultValue = pigeonVar_list[0] as String?
return StartResult(resultValue)
}
}
fun toList(): List<Any?> {
return listOf(
resultValue,
)
}
override fun equals(other: Any?): Boolean {
if (other !is StartResult) {
return false
}
if (this === other) {
return true
}
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class PlaybackState (
val position: Long,
val buffered: Long,
val duration: Long,
val playing: Boolean,
val buffering: Boolean,
val completed: Boolean,
val failed: Boolean
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): PlaybackState {
val position = pigeonVar_list[0] as Long
val buffered = pigeonVar_list[1] as Long
val duration = pigeonVar_list[2] as Long
val playing = pigeonVar_list[3] as Boolean
val buffering = pigeonVar_list[4] as Boolean
val completed = pigeonVar_list[5] as Boolean
val failed = pigeonVar_list[6] as Boolean
return PlaybackState(position, buffered, duration, playing, buffering, completed, failed)
}
}
fun toList(): List<Any?> {
return listOf(
position,
buffered,
duration,
playing,
buffering,
completed,
failed,
)
}
override fun equals(other: Any?): Boolean {
if (other !is PlaybackState) {
return false
}
if (this === other) {
return true
}
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
MediaSegmentType.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlayableData.fromList(it)
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
MediaSegment.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
AudioTrack.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SubtitleTrack.fromList(it)
}
}
134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
Chapter.fromList(it)
}
}
135.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TrickPlayModel.fromList(it)
}
}
136.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
StartResult.fromList(it)
}
}
137.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlaybackState.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is MediaSegmentType -> {
stream.write(129)
writeValue(stream, value.raw)
}
is PlayableData -> {
stream.write(130)
writeValue(stream, value.toList())
}
is MediaSegment -> {
stream.write(131)
writeValue(stream, value.toList())
}
is AudioTrack -> {
stream.write(132)
writeValue(stream, value.toList())
}
is SubtitleTrack -> {
stream.write(133)
writeValue(stream, value.toList())
}
is Chapter -> {
stream.write(134)
writeValue(stream, value.toList())
}
is TrickPlayModel -> {
stream.write(135)
writeValue(stream, value.toList())
}
is StartResult -> {
stream.write(136)
writeValue(stream, value.toList())
}
is PlaybackState -> {
stream.write(137)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NativeVideoActivity {
fun launchActivity(callback: (Result<StartResult>) -> Unit)
fun disposeActivity()
fun isLeanBackEnabled(): Boolean
companion object {
/** The codec used by NativeVideoActivity. */
val codec: MessageCodec<Any?> by lazy {
VideoPlayerHelperPigeonCodec()
}
/** Sets up an instance of `NativeVideoActivity` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: NativeVideoActivity?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.launchActivity$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.launchActivity{ result: Result<StartResult> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(VideoPlayerHelperPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(VideoPlayerHelperPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.disposeActivity$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.disposeActivity()
listOf(null)
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.isLeanBackEnabled$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.isLeanBackEnabled())
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface VideoPlayerApi {
fun sendPlayableModel(playableData: PlayableData): Boolean
fun open(url: String, play: Boolean)
fun setLooping(looping: Boolean)
/** Sets the volume, with 0.0 being muted and 1.0 being full volume. */
fun setVolume(volume: Double)
/** Sets the playback speed as a multiple of normal speed. */
fun setPlaybackSpeed(speed: Double)
fun play()
/** Pauses playback if the video is currently playing. */
fun pause()
/** Seeks to the given playback position, in milliseconds. */
fun seekTo(position: Long)
fun stop()
companion object {
/** The codec used by VideoPlayerApi. */
val codec: MessageCodec<Any?> by lazy {
VideoPlayerHelperPigeonCodec()
}
/** Sets up an instance of `VideoPlayerApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: VideoPlayerApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.sendPlayableModel$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val playableDataArg = args[0] as PlayableData
val wrapped: List<Any?> = try {
listOf(api.sendPlayableModel(playableDataArg))
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.open$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val urlArg = args[0] as String
val playArg = args[1] as Boolean
val wrapped: List<Any?> = try {
api.open(urlArg, playArg)
listOf(null)
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setLooping$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val loopingArg = args[0] as Boolean
val wrapped: List<Any?> = try {
api.setLooping(loopingArg)
listOf(null)
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setVolume$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val volumeArg = args[0] as Double
val wrapped: List<Any?> = try {
api.setVolume(volumeArg)
listOf(null)
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setPlaybackSpeed$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val speedArg = args[0] as Double
val wrapped: List<Any?> = try {
api.setPlaybackSpeed(speedArg)
listOf(null)
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.play$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.play()
listOf(null)
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.pause$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.pause()
listOf(null)
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.seekTo$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val positionArg = args[0] as Long
val wrapped: List<Any?> = try {
api.seekTo(positionArg)
listOf(null)
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.stop$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.stop()
listOf(null)
} catch (exception: Throwable) {
VideoPlayerHelperPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
class VideoPlayerListenerCallback(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
companion object {
/** The codec used by VideoPlayerListenerCallback. */
val codec: MessageCodec<Any?> by lazy {
VideoPlayerHelperPigeonCodec()
}
}
fun onPlaybackStateChanged(stateArg: PlaybackState, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(stateArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
}
}
}
}
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
class VideoPlayerControlsCallback(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
companion object {
/** The codec used by VideoPlayerControlsCallback. */
val codec: MessageCodec<Any?> by lazy {
VideoPlayerHelperPigeonCodec()
}
}
fun loadNextVideo(callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadNextVideo$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(null) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
}
}
}
fun loadPreviousVideo(callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadPreviousVideo$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(null) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
}
}
}
fun onStop(callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onStop$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(null) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
}
}
}
fun swapSubtitleTrack(valueArg: Long, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(valueArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
}
}
}
fun swapAudioTrack(valueArg: Long, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(valueArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
}
}
}
}

View file

@ -0,0 +1,89 @@
package nl.jknaapen.fladder.composables.controls
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import nl.jknaapen.fladder.utility.conditional
import nl.jknaapen.fladder.utility.highlightOnFocus
@Composable
internal fun CustomIconButton(
modifier: Modifier = Modifier,
onClick: () -> Unit,
enabled: Boolean = true,
enableFocusIndicator: Boolean = true,
enableScaledFocus: Boolean = false,
backgroundColor: Color = Color.Transparent,
foreGroundColor: Color = Color.White,
backgroundFocusedColor: Color = Color.Transparent,
foreGroundFocusedColor: Color = Color.White,
icon: @Composable () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
var isFocused by remember { mutableStateOf(false) }
val currentContentColor by remember {
derivedStateOf {
if (isFocused) {
foreGroundFocusedColor
} else {
foreGroundColor
}
}
}
val currentBackgroundColor by remember {
derivedStateOf {
if (isFocused) {
backgroundFocusedColor
} else {
backgroundColor
}
}
}
Box(
modifier = modifier
.wrapContentSize() // parent expands to fit children
.conditional(enableScaledFocus) {
scale(if (isFocused) 1.05f else 1f)
}
.conditional(enableFocusIndicator) {
highlightOnFocus()
}
.background(currentBackgroundColor, shape = RoundedCornerShape(8.dp))
.onFocusChanged { isFocused = it.isFocused }
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = null,
onClick = onClick
)
.alpha(if (enabled) 1f else 0.5f),
contentAlignment = Alignment.Center
) {
CompositionLocalProvider(LocalContentColor provides currentContentColor) {
Box(modifier = Modifier.padding(8.dp)) {
icon()
}
}
}
}

View file

@ -0,0 +1,51 @@
package nl.jknaapen.fladder.composables.controls
import PlayableData
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
@Composable
fun ItemHeader(state: PlayableData?) {
val title = state?.title
val logoUrl = state?.logoUrl
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.CenterStart
) {
if (!logoUrl.isNullOrBlank()) {
AsyncImage(
model = logoUrl,
contentDescription = title ?: "logo",
alignment = Alignment.CenterStart,
modifier = Modifier
.heightIn(max = 100.dp)
.widthIn(max = 200.dp)
)
} else {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineMedium.copy(
color = Color.White,
fontWeight = FontWeight.Bold
)
)
}
}
}
}

View file

@ -0,0 +1,423 @@
package nl.jknaapen.fladder.composables.controls
import MediaSegment
import MediaSegmentType
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.Key.Companion.Back
import androidx.compose.ui.input.key.Key.Companion.ButtonSelect
import androidx.compose.ui.input.key.Key.Companion.DirectionCenter
import androidx.compose.ui.input.key.Key.Companion.DirectionLeft
import androidx.compose.ui.input.key.Key.Companion.DirectionRight
import androidx.compose.ui.input.key.Key.Companion.Enter
import androidx.compose.ui.input.key.Key.Companion.Escape
import androidx.compose.ui.input.key.Key.Companion.Spacebar
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
import androidx.media3.exoplayer.ExoPlayer
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.formatTime
import kotlin.math.max
import kotlin.math.min
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@RequiresApi(Build.VERSION_CODES.O)
@Composable
internal fun ProgressBar(
modifier: Modifier = Modifier,
player: ExoPlayer,
bottomControlFocusRequester: FocusRequester,
onUserInteraction: () -> Unit = {}
) {
val position by VideoPlayerObject.position.collectAsState(0L)
val duration by VideoPlayerObject.duration.collectAsState(0L)
var tempPosition by remember { mutableLongStateOf(position) }
var scrubbingTimeLine by remember { mutableStateOf(false) }
val playableData by VideoPlayerObject.implementation.playbackData.collectAsState(null)
val currentPosition by remember {
derivedStateOf {
if (scrubbingTimeLine) {
tempPosition
} else {
position
}
}
}
Column {
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState(null)
if (scrubbingTimeLine)
FilmstripTrickPlayOverlay(
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
.align(alignment = Alignment.CenterHorizontally),
currentPosition = tempPosition.milliseconds,
trickPlayModel = playbackData?.trickPlayModel
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
val subTitle = playableData?.subTitle
subTitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyLarge.copy(color = Color.White),
)
}
VideoEndTime()
}
Row(
horizontalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterHorizontally
),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth()
) {
Text(
formatTime(currentPosition),
color = Color.White,
style = MaterialTheme.typography.labelMedium
)
SimpleProgressBar(
player,
bottomControlFocusRequester,
onUserInteraction,
tempPosition,
scrubbingTimeLine,
onTempPosChanged = {
tempPosition = it
},
onScrubbingChanged = {
scrubbingTimeLine = it
}
)
Text(
"-" + formatTime(
(duration - currentPosition).fastCoerceIn(
minimumValue = 0L,
maximumValue = duration
)
),
color = Color.White,
style = MaterialTheme.typography.labelMedium
)
}
}
}
@Composable
internal fun RowScope.SimpleProgressBar(
player: ExoPlayer,
playFocusRequester: FocusRequester,
onUserInteraction: () -> Unit,
tempPosition: Long,
scrubbingTimeLine: Boolean,
onTempPosChanged: (Long) -> Unit = {},
onScrubbingChanged: (Boolean) -> Unit = {}
) {
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState()
var width by remember { mutableIntStateOf(0) }
val position by VideoPlayerObject.position.collectAsState(0L)
val duration by VideoPlayerObject.duration.collectAsState(0L)
val slideBarShape = RoundedCornerShape(size = 8.dp)
var thumbFocused by remember { mutableStateOf(false) }
var internalTempPosition by remember { mutableLongStateOf(0L) }
val progress by remember(scrubbingTimeLine, tempPosition, position) {
derivedStateOf {
if (scrubbingTimeLine) {
tempPosition.toFloat() / duration.toFloat()
} else {
position.toFloat() / duration.toFloat()
}
}
}
Box(
modifier = Modifier
.weight(1f)
.onGloballyPositioned(
onGloballyPositioned = {
width = it.size.width
}
)
.heightIn(min = 26.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
onUserInteraction()
val clickRelativeOffset = offset.x / width.toFloat()
val newPosition = duration.milliseconds * clickRelativeOffset.toDouble()
player.seekTo(newPosition.toLong(DurationUnit.MILLISECONDS))
}
}
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
onScrubbingChanged(true)
onUserInteraction()
onTempPosChanged(player.currentPosition)
},
onDrag = { change, dragAmount ->
onUserInteraction()
change.consume()
val relative = change.position.x / size.width.toFloat()
internalTempPosition = (duration.milliseconds * relative.toDouble())
.toLong(DurationUnit.MILLISECONDS)
onTempPosChanged(
internalTempPosition
)
},
onDragEnd = {
onScrubbingChanged(false)
player.seekTo(internalTempPosition)
},
onDragCancel = {
onScrubbingChanged(false)
}
)
},
contentAlignment = Alignment.CenterStart,
) {
Box(
modifier = Modifier
.focusable(enabled = false)
.fillMaxWidth()
.height(12.dp)
.background(
color = Color.Black.copy(alpha = 0.15f),
shape = slideBarShape
),
) {
Box(
modifier = Modifier
.focusable(enabled = false)
.fillMaxHeight()
.fillMaxWidth(progress)
.padding(end = 9.dp)
.background(
color = Color.White.copy(alpha = 0.75f),
shape = slideBarShape
)
)
val density = LocalDensity.current
val mediaSegments = playbackData?.segments
if (width > 0 && duration.toDuration(DurationUnit.MILLISECONDS) > Duration.ZERO) {
mediaSegments?.forEach { segment ->
val segStartMs = max(
0.0,
segment.start.toDuration(DurationUnit.MILLISECONDS)
.toDouble(DurationUnit.MILLISECONDS)
)
val segEndMs = max(
segStartMs,
segment.end.toDuration(DurationUnit.MILLISECONDS)
.toDouble(DurationUnit.MILLISECONDS)
)
val durMs = duration.toDouble().coerceAtLeast(1.0)
if (segStartMs >= durMs) return@forEach
val startPx = (width * (segStartMs / durMs)).toFloat()
val segPx =
(width * ((segEndMs - segStartMs) / durMs)).toFloat().coerceAtLeast(1f)
val segDp = with(density) { segPx.toDp() }
Box(
modifier = Modifier
.focusable(enabled = false)
.graphicsLayer {
translationX = startPx
translationY = 16.dp.toPx()
}
.width(segDp)
.height(6.dp)
.background(
color = segment.color.copy(alpha = 0.75f),
shape = RoundedCornerShape(8.dp)
)
)
}
}
//Generate chapter dots
val chapters = playbackData?.chapters ?: listOf()
chapters.forEach { chapter ->
val chapterDuration = chapter.time.toDuration(DurationUnit.SECONDS)
.toDouble(DurationUnit.SECONDS)
val isAfterCurrentPositon = chapterDuration > position.toDouble()
val segStartMs = max(
0.0,
chapterDuration
)
val durMs = duration.toDouble().coerceAtLeast(1.0)
val startPx = (width * (segStartMs / durMs)).toFloat()
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(horizontal = 2.dp)
.focusable(enabled = false)
.graphicsLayer {
translationX = startPx
}
.size(6.dp)
.background(
color = (if (isAfterCurrentPositon) Color.White else Color.Black).copy(
alpha = 0.45f
),
shape = CircleShape
)
)
}
}
//Thumb
Box(
modifier = Modifier
.onFocusChanged { state: FocusState ->
thumbFocused = state.isFocused
if (!state.isFocused) {
onScrubbingChanged(false)
} else {
onTempPosChanged(position)
}
}
.focusable(enabled = true)
.onKeyEvent { keyEvent: KeyEvent ->
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
onUserInteraction()
when (keyEvent.key) {
Key.DirectionDown -> {
playFocusRequester.requestFocus()
onScrubbingChanged(false)
true
}
DirectionLeft -> {
if (!scrubbingTimeLine) {
onTempPosChanged(position)
onScrubbingChanged(true)
player.pause()
}
val newPos = max(0L, tempPosition - 3000L)
onTempPosChanged(newPos)
true
}
DirectionRight -> {
if (!scrubbingTimeLine) {
onTempPosChanged(position)
onScrubbingChanged(true)
player.pause()
}
val newPos = min(player.duration.takeIf { it > 0 } ?: 1L,
tempPosition + 3000L)
onTempPosChanged(newPos)
true
}
Enter, Spacebar, ButtonSelect, DirectionCenter -> {
if (scrubbingTimeLine) {
player.seekTo(tempPosition)
player.play()
onScrubbingChanged(false)
true
} else false
}
Escape, Back -> {
if (scrubbingTimeLine) {
onScrubbingChanged(false)
player.play()
true
}
false
}
else -> false
}
}
.graphicsLayer {
translationX = (width * progress) - 4.dp.toPx()
}
.background(
color = Color.White,
shape = CircleShape,
)
.width(8.dp)
.height(if (thumbFocused) 21.dp else 8.dp)
)
}
}
val MediaSegment.color: Color
get() = when (this.type) {
MediaSegmentType.COMMERCIAL -> Color.Magenta
MediaSegmentType.PREVIEW -> Color(255, 128, 0)
MediaSegmentType.RECAP -> Color(135, 206, 250)
MediaSegmentType.OUTRO -> Color.Yellow
MediaSegmentType.INTRO -> Color.Green
}

View file

@ -0,0 +1,180 @@
package nl.jknaapen.fladder.composables.controls
import MediaSegment
import MediaSegmentType
import SegmentSkip
import SegmentType
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import nl.jknaapen.fladder.objects.PlayerSettingsObject
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.defaultSelected
import nl.jknaapen.fladder.utility.leanBackEnabled
import kotlin.time.Duration.Companion.milliseconds
@RequiresApi(Build.VERSION_CODES.O)
@Composable
internal fun BoxScope.SegmentSkipOverlay(
modifier: Modifier = Modifier,
) {
val isAndroidTV = leanBackEnabled(LocalContext.current)
val focusRequester = remember { FocusRequester() }
val state by VideoPlayerObject.implementation.playbackData.collectAsState()
val position by VideoPlayerObject.position.collectAsState(0L)
val segments = state?.segments ?: emptyList()
val player = VideoPlayerObject.implementation.player
val skipMap by PlayerSettingsObject.skipMap.collectAsState(mapOf())
var isFocused by remember { mutableStateOf(false) }
LaunchedEffect(segments, skipMap) { }
if (segments.isEmpty() || player == null) return
val activeSegment = segments.firstOrNull { it.start <= position && it.end >= position }
val segmentType = activeSegment?.type?.toSegment
val skip = skipMap[segmentType]
fun skipSegment(segment: MediaSegment) {
player.seekTo(segment.end + 250.milliseconds.inWholeMilliseconds)
}
LaunchedEffect(activeSegment, position, skipMap) {
if (skipMap.isEmpty()) return@LaunchedEffect
if (activeSegment != null) {
if (skip == SegmentSkip.SKIP) {
skipSegment(activeSegment)
}
}
}
LaunchedEffect(activeSegment) {
if (activeSegment != null) {
focusRequester.captureFocus()
}
}
val shape = RoundedCornerShape(8.dp)
AnimatedVisibility(
activeSegment != null && skip == SegmentSkip.ASK,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.safeContentPadding()
) {
Box {
FilledTonalButton(
modifier = modifier
.align(alignment = Alignment.CenterEnd)
.focusRequester(focusRequester)
.onFocusChanged { state ->
isFocused = state.isFocused
}
.border(
width = 2.dp,
color = if (isFocused) Color.White.copy(alpha = 0.4f) else Color.Transparent,
shape = shape,
)
.defaultSelected(true),
contentPadding = PaddingValues(horizontal = 12.dp),
shape = shape,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = Color.White.copy(alpha = 0.75f),
contentColor = Color.Black,
),
onClick = {
activeSegment?.let {
player.seekTo(it.end)
}
}
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isAndroidTV) {
Box(
modifier = Modifier
.size(24.dp)
.background(
color = Color.Black.copy(alpha = 0.15f),
shape = CircleShape,
)
.border(
width = 1.5.dp,
color = Color.Black.copy(alpha = 0.15f),
shape = CircleShape,
)
) {
Box(
modifier = Modifier
.padding(7.dp)
.fillMaxSize()
.background(
color = Color.White,
shape = CircleShape,
)
) {
}
}
}
activeSegment?.let {
Text(
"Skip ${it.name.lowercase()}",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
)
}
}
}
}
}
}
private val MediaSegmentType.toSegment: SegmentType
get() = when (this) {
MediaSegmentType.COMMERCIAL -> SegmentType.COMMERCIAL
MediaSegmentType.PREVIEW -> SegmentType.PREVIEW
MediaSegmentType.RECAP -> SegmentType.RECAP
MediaSegmentType.INTRO -> SegmentType.INTRO
MediaSegmentType.OUTRO -> SegmentType.OUTRO
}

View file

@ -0,0 +1,195 @@
package nl.jknaapen.fladder.composables.controls
import TrickPlayModel
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import coil3.compose.AsyncImagePainter
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import coil3.toBitmap
import kotlin.time.Duration
private data class ThumbnailData(val tileUrl: String, val offset: Pair<Double, Double>)
@Composable
fun FilmstripTrickPlayOverlay(
modifier: Modifier = Modifier,
currentPosition: Duration,
trickPlayModel: TrickPlayModel?,
thumbnailsToShowOnEachSide: Int = 2,
) {
if (trickPlayModel == null) {
return
}
val uniqueThumbnails = remember(currentPosition, trickPlayModel, thumbnailsToShowOnEachSide) {
val currentFrameIndex = (currentPosition.inWholeMilliseconds / trickPlayModel.interval)
.toInt()
.coerceIn(0, (trickPlayModel.thumbnailCount - 1).toInt())
val currentFrame = trickPlayModel.getThumbnailDetailsForIndex(currentFrameIndex)
if (currentFrame == null) return@remember emptyList()
val foundThumbnails = mutableSetOf(currentFrame)
val previousFrames = mutableListOf<ThumbnailData>()
val nextFrames = mutableListOf<ThumbnailData>()
var searchIndex = currentFrameIndex - 1
while (previousFrames.size < thumbnailsToShowOnEachSide && searchIndex >= 0) {
trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let {
if (foundThumbnails.add(it)) previousFrames.add(it)
}
searchIndex--
}
searchIndex = currentFrameIndex + 1
while (nextFrames.size < thumbnailsToShowOnEachSide && searchIndex < trickPlayModel.thumbnailCount) {
trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let {
if (foundThumbnails.add(it)) nextFrames.add(it)
}
searchIndex++
}
if (previousFrames.size < thumbnailsToShowOnEachSide) {
var extraNeeded = thumbnailsToShowOnEachSide - previousFrames.size
while (extraNeeded > 0 && searchIndex < trickPlayModel.thumbnailCount) {
trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let {
if (foundThumbnails.add(it)) {
nextFrames.add(it)
extraNeeded--
}
}
searchIndex++
}
} else if (nextFrames.size < thumbnailsToShowOnEachSide) {
var extraNeeded = thumbnailsToShowOnEachSide - nextFrames.size
searchIndex = currentFrameIndex - previousFrames.size - 1
while (extraNeeded > 0 && searchIndex >= 0) {
trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let {
if (foundThumbnails.add(it)) {
previousFrames.add(it)
extraNeeded--
}
}
searchIndex--
}
}
previousFrames.reversed() + currentFrame + nextFrames
}
val currentFrameData = trickPlayModel.getThumbnailDetailsForIndex(
(currentPosition.inWholeMilliseconds / trickPlayModel.interval).toInt()
)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
uniqueThumbnails.forEach { thumbnailData ->
val isCenter = thumbnailData == currentFrameData
val scale = if (isCenter) 1.2f else 1.0f
val alpha = if (isCenter) 1.0f else 0.7f
Thumbnail(
modifier = Modifier
.weight(1f)
.zIndex(if (isCenter) 1f else 0f)
.fillMaxHeight()
.padding(horizontal = 4.dp)
.padding(bottom = 8.dp)
.scale(scale)
.graphicsLayer {
this.alpha = alpha
},
trickPlayModel = trickPlayModel,
tileUrl = thumbnailData.tileUrl,
offset = thumbnailData.offset
)
}
}
}
@Composable
private fun Thumbnail(
modifier: Modifier = Modifier,
trickPlayModel: TrickPlayModel,
tileUrl: String,
offset: Pair<Double, Double>,
) {
val context = LocalContext.current
val painter = rememberAsyncImagePainter(
ImageRequest.Builder(context)
.data(tileUrl)
.build()
)
val imageState by painter.state.collectAsState()
Box(
modifier = modifier
.aspectRatio(16f / 9f)
.clip(
shape = RoundedCornerShape(12.dp)
)
) {
when (val state = imageState) {
is AsyncImagePainter.State.Success -> {
val imageBitmap = state.result.image.toBitmap().asImageBitmap()
Canvas(modifier = Modifier.matchParentSize()) {
val (offsetX, offsetY) = offset
drawImage(
image = imageBitmap,
srcOffset = IntOffset(offsetX.toInt(), offsetY.toInt()),
srcSize = IntSize(
trickPlayModel.width.toInt(),
trickPlayModel.height.toInt()
),
dstSize = IntSize(size.width.toInt(), size.height.toInt())
)
}
}
else -> return@Box
}
}
}
val TrickPlayModel.imagesPerTile: Int
get() = (tileWidth * tileHeight).toInt()
private fun TrickPlayModel.getThumbnailDetailsForIndex(index: Int): ThumbnailData? {
val safeIndex = index.coerceIn(0, (thumbnailCount - 1).toInt())
val indexOfTile = (safeIndex / imagesPerTile).coerceIn(0, images.size - 1)
val tileUrl = images.getOrNull(indexOfTile) ?: return null
val tileIndex = safeIndex % imagesPerTile
val column = tileIndex % tileWidth
val row = tileIndex / tileWidth
val offset = Pair(
(width * column).toDouble(),
(height * row).toDouble()
)
return ThumbnailData(tileUrl, offset)
}

View file

@ -0,0 +1,44 @@
package nl.jknaapen.fladder.composables.controls
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import nl.jknaapen.fladder.objects.VideoPlayerObject
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.toJavaInstant
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalTime::class)
@Composable
fun VideoEndTime() {
val startInstant = remember { Clock.System.now() }
val durationMs by VideoPlayerObject.duration.collectAsState(initial = 0L)
val zone = ZoneId.systemDefault()
val javaInstant = remember(startInstant) { startInstant.toJavaInstant() }
val endJavaInstant = remember(javaInstant, durationMs) {
javaInstant.plusMillis(durationMs)
}
val endZoned = remember(endJavaInstant, zone) {
endJavaInstant.atZone(zone)
}
val formatter = DateTimeFormatter.ofPattern("hh:mm a")
val formattedEnd = remember(endZoned, formatter) {
endZoned.format(formatter)
}
Text(
text = "ends at $formattedEnd",
style = MaterialTheme.typography.bodyLarge.copy(color = Color.White),
)
}

View file

@ -0,0 +1,448 @@
package nl.jknaapen.fladder.composables.controls
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import io.github.rabehx.iconsax.Iconsax
import io.github.rabehx.iconsax.filled.AudioSquare
import io.github.rabehx.iconsax.filled.Backward
import io.github.rabehx.iconsax.filled.Check
import io.github.rabehx.iconsax.filled.Forward
import io.github.rabehx.iconsax.filled.PauseCircle
import io.github.rabehx.iconsax.filled.PlayCircle
import io.github.rabehx.iconsax.filled.Subtitle
import io.github.rabehx.iconsax.outline.CloseSquare
import io.github.rabehx.iconsax.outline.Refresh
import kotlinx.coroutines.delay
import nl.jknaapen.fladder.composables.dialogs.AudioPicker
import nl.jknaapen.fladder.composables.dialogs.ChapterSelectionSheet
import nl.jknaapen.fladder.composables.dialogs.SubtitlePicker
import nl.jknaapen.fladder.objects.PlayerSettingsObject
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.ImmersiveSystemBars
import nl.jknaapen.fladder.utility.defaultSelected
import nl.jknaapen.fladder.utility.leanBackEnabled
import nl.jknaapen.fladder.utility.visible
import kotlin.time.Duration.Companion.seconds
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(UnstableApi::class)
@Composable
fun CustomVideoControls(
exoPlayer: ExoPlayer,
) {
var showControls by remember { mutableStateOf(false) }
val lastInteraction = remember { mutableLongStateOf(System.currentTimeMillis()) }
val showAudioDialog = remember { mutableStateOf(false) }
val showSubDialog = remember { mutableStateOf(false) }
var showChapterDialog by remember { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val activity = LocalActivity.current
val buffering by VideoPlayerObject.buffering.collectAsState(true)
val playing by VideoPlayerObject.playing.collectAsState(false)
ImmersiveSystemBars(isImmersive = !showControls)
BackHandler(
enabled = showControls
) {
showControls = false
}
// Restart the hide timer whenever `lastInteraction` changes.
LaunchedEffect(lastInteraction.longValue) {
delay(5.seconds)
showControls = false
}
val bottomControlFocusRequester = remember { FocusRequester() }
fun updateLastInteraction() {
showControls = true
lastInteraction.longValue = System.currentTimeMillis()
}
Box(
modifier = Modifier
.fillMaxSize()
.focusable(enabled = false)
.onFocusChanged { focusState ->
if (focusState.hasFocus) {
bottomControlFocusRequester.requestFocus()
}
}
.onKeyEvent { keyEvent: KeyEvent ->
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
if (!showControls) {
bottomControlFocusRequester.requestFocus()
}
updateLastInteraction()
return@onKeyEvent false
}
.clickable(
indication = null,
interactionSource = interactionSource,
) {
showControls = !showControls
if (showControls) lastInteraction.longValue = System.currentTimeMillis()
},
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier.visible(
visible = showControls,
)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.background(
brush = Brush.linearGradient(
colors = listOf(
Color.Black.copy(alpha = 0.85f),
Color.Black.copy(alpha = 0f),
),
start = Offset(0f, 0f),
end = Offset(0f, Float.POSITIVE_INFINITY)
),
)
.safeContentPadding(),
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start)
) {
Column(
modifier = Modifier.weight(1f),
) {
val state by VideoPlayerObject.implementation.playbackData.collectAsState(
null
)
state?.let {
ItemHeader(it)
}
}
if (!leanBackEnabled(LocalContext.current)) {
IconButton(
{
activity?.finish()
}
) {
Icon(
Iconsax.Outline.CloseSquare,
modifier = Modifier
.size(48.dp)
.focusable(false),
contentDescription = "Close icon",
tint = Color.White,
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Progress Bar
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.displayCutoutPadding(),
) {
ProgressBar(
modifier = Modifier,
exoPlayer, bottomControlFocusRequester, ::updateLastInteraction
)
}
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(
Color.Black.copy(alpha = 0f),
Color.Black.copy(alpha = 0.85f),
),
start = Offset(0f, 0f),
end = Offset(0f, Float.POSITIVE_INFINITY)
),
)
.displayCutoutPadding()
.padding(horizontal = 16.dp)
.padding(top = 8.dp, bottom = 16.dp)
) {
LeftButtons(
openChapterSelection = {
showChapterDialog = true
}
)
PlaybackButtons(exoPlayer, bottomControlFocusRequester)
RightButtons(showAudioDialog, showSubDialog)
}
}
}
SegmentSkipOverlay()
if (buffering && !playing) {
CircularProgressIndicator(
modifier = Modifier
.align(alignment = Alignment.Center)
)
}
}
if (showAudioDialog.value) {
AudioPicker(
player = exoPlayer,
onDismissRequest = {
showAudioDialog.value = false
}
)
}
if (showSubDialog.value) {
SubtitlePicker(
player = exoPlayer,
onDismissRequest = {
showSubDialog.value = false
}
)
}
if (showChapterDialog) {
ChapterSelectionSheet(
onSelected = {
exoPlayer.seekTo(it.time)
showChapterDialog = false
},
onDismiss = {
showChapterDialog = false
}
)
}
}
// Control Buttons
@Composable
fun PlaybackButtons(
player: ExoPlayer,
bottomControlFocusRequester: FocusRequester,
) {
val state by VideoPlayerObject.videoPlayerState.collectAsState(null)
val forwardSpeed by PlayerSettingsObject.forwardSpeed.collectAsState(30.seconds)
val backwardSpeed by PlayerSettingsObject.backwardSpeed.collectAsState(15.seconds)
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState(null)
val nextVideo = playbackData?.nextVideo
val previousVideo = playbackData?.previousVideo
val isPlaying = state?.playing ?: false
Row(
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 6.dp)
.wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterHorizontally
),
verticalAlignment = Alignment.CenterVertically,
) {
CustomIconButton(
onClick = { VideoPlayerObject.videoPlayerControls?.loadPreviousVideo {} },
enabled = previousVideo != null,
) {
Icon(
Iconsax.Filled.Backward,
modifier = Modifier.size(32.dp),
contentDescription = previousVideo,
tint = Color.White
)
}
CustomIconButton(
onClick = {
player.seekTo(
player.currentPosition - backwardSpeed.inWholeMilliseconds
)
},
) {
Box(
modifier = Modifier
.wrapContentSize(),
contentAlignment = Alignment.Center,
) {
Icon(
Iconsax.Outline.Refresh,
contentDescription = "Forward",
modifier = Modifier
.size(48.dp),
)
Text("-${backwardSpeed.inWholeSeconds}")
}
}
CustomIconButton(
modifier = Modifier
.focusRequester(bottomControlFocusRequester)
.defaultSelected(true),
enableScaledFocus = true,
onClick = {
if (player.isPlaying) player.pause() else player.play()
},
) {
Icon(
if (isPlaying) Iconsax.Filled.PauseCircle else Iconsax.Filled.PlayCircle,
modifier = Modifier.size(55.dp),
contentDescription = if (isPlaying) "Pause" else "Play",
)
}
CustomIconButton(
onClick = {
player.seekTo(
player.currentPosition + forwardSpeed.inWholeMilliseconds
)
},
) {
Box(
modifier = Modifier
.wrapContentSize(),
contentAlignment = Alignment.Center,
) {
Icon(
Iconsax.Outline.Refresh,
contentDescription = "Forward",
modifier = Modifier
.size(48.dp)
.scale(scaleX = -1f, scaleY = 1f),
)
Text(forwardSpeed.inWholeSeconds.toString())
}
}
CustomIconButton(
onClick = { VideoPlayerObject.videoPlayerControls?.loadNextVideo {} },
enabled = nextVideo != null,
) {
Icon(
Iconsax.Filled.Forward,
modifier = Modifier.size(32.dp),
contentDescription = nextVideo,
)
}
}
}
@Composable
internal fun RowScope.LeftButtons(
openChapterSelection: () -> Unit,
) {
val chapters by VideoPlayerObject.chapters.collectAsState(emptyList())
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start
) {
CustomIconButton(
onClick = openChapterSelection,
enabled = chapters?.isNotEmpty() == true
) {
Icon(
Iconsax.Filled.Check,
modifier = Modifier.size(32.dp),
contentDescription = "Show chapters",
)
}
}
}
@Composable
internal fun RowScope.RightButtons(
showAudioDialog: MutableState<Boolean>,
showSubDialog: MutableState<Boolean>
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End
) {
CustomIconButton(
onClick = {
showAudioDialog.value = true
},
) {
Icon(
Iconsax.Filled.AudioSquare,
modifier = Modifier.size(32.dp),
contentDescription = "Audio Track",
)
}
CustomIconButton(
onClick = {
showSubDialog.value = true
},
) {
Icon(
Iconsax.Filled.Subtitle,
modifier = Modifier.size(32.dp),
contentDescription = "Subtitles",
)
}
}
}

View file

@ -0,0 +1,92 @@
package nl.jknaapen.fladder.composables.dialogs
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.clearAudioTrack
import nl.jknaapen.fladder.utility.defaultSelected
import nl.jknaapen.fladder.utility.setInternalAudioTrack
@OptIn(UnstableApi::class)
@Composable
fun AudioPicker(
player: ExoPlayer,
onDismissRequest: () -> Unit,
) {
val selectedIndex by VideoPlayerObject.currentAudioTrackIndex.collectAsState()
val audioTracks by VideoPlayerObject.audioTracks.collectAsState(listOf())
val internalAudioTracks by VideoPlayerObject.exoAudioTracks
val listState = rememberLazyListState()
LaunchedEffect(selectedIndex) {
listState.scrollToItem(
audioTracks.indexOfFirst { it.index == selectedIndex.toLong() }
)
}
CustomModalBottomSheet(
onDismissRequest,
maxWidth = 600.dp,
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
item {
TrackButton(
modifier = Modifier
.fillMaxWidth()
.defaultSelected(-1 == selectedIndex),
onClick = {
VideoPlayerObject.setAudioTrackIndex(-1)
player.clearAudioTrack()
},
selected = -1 == selectedIndex
) {
Text(
text = "Off",
)
}
}
internalAudioTracks.forEachIndexed { index, track ->
val serverTrack = audioTracks.elementAtOrNull(index + 1)
val selected = serverTrack?.index == selectedIndex.toLong()
item {
TrackButton(
modifier = Modifier
.fillMaxWidth()
.defaultSelected(selected),
onClick = {
serverTrack?.index?.let {
VideoPlayerObject.setAudioTrackIndex(it.toInt())
}
player.setInternalAudioTrack(track)
},
selected = selected
) {
Text(
text = serverTrack?.name ?: "",
)
}
}
}
}
}
}

View file

@ -0,0 +1,156 @@
package nl.jknaapen.fladder.composables.dialogs
import Chapter
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.defaultSelected
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ChapterSelectionSheet(
onSelected: (Chapter) -> Unit,
onDismiss: () -> Unit
) {
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState()
val chapters = playbackData?.chapters ?: listOf()
val currentPosition by VideoPlayerObject.position.collectAsState(0L)
var currentChapter: Chapter? by remember {
mutableStateOf(
chapters[chapters.indexOfCurrent(
currentPosition
)]
)
}
val lazyListState = rememberLazyListState()
LaunchedEffect(Unit) {
lazyListState.animateScrollToItem(
chapters.indexOfCurrent(currentPosition)
)
}
CustomModalBottomSheet(
onDismiss,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.wrapContentHeight(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Chapters",
style = MaterialTheme.typography.titleLarge,
color = Color.White
)
LazyRow(
state = lazyListState,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
chapters.forEachIndexed { index, chapter ->
val selectedChapter = currentChapter == chapter
val isCurrentChapter = chapters.indexOfCurrent(currentPosition) == index
item {
Column(
modifier = Modifier
.background(
color = Color.Black.copy(alpha = 0.75f),
shape = RoundedCornerShape(8.dp)
)
.border(
width = 2.dp,
color = Color.White.copy(alpha = if (selectedChapter) 1f else 0f),
shape = RoundedCornerShape(8.dp)
)
.defaultSelected(index == 0)
.onFocusChanged {
if (it.isFocused) {
currentChapter = chapter
}
}
.clickable(
onClick = {
onSelected(chapter)
}
)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterVertically
),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterHorizontally
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
chapter.name,
style = MaterialTheme.typography.bodyLarge,
color = Color.White
)
}
AsyncImage(
model = chapter.url,
modifier = Modifier
.clip(
shape = RoundedCornerShape(24.dp)
)
.heightIn(min = 125.dp, max = 150.dp)
.border(
width = 2.dp,
color = Color.White.copy(alpha = if (isCurrentChapter) 1f else 0f),
shape = RoundedCornerShape(24.dp)
),
contentDescription = ""
)
}
}
}
}
}
}
}
private fun List<Chapter>.indexOfCurrent(currentPosition: Long): Int {
return this.indexOfFirst { chapter ->
val nextChapterTime =
this.getOrNull(this.indexOf(chapter) + 1)?.time ?: Long.MAX_VALUE
currentPosition >= chapter.time && currentPosition < nextChapterTime
}
}

View file

@ -0,0 +1,63 @@
package nl.jknaapen.fladder.composables.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CustomModalBottomSheet(
onDismissRequest: () -> Unit,
maxWidth: Dp = LocalConfiguration.current.screenWidthDp.dp,
content: @Composable () -> Unit,
) {
val modalBottomSheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
ModalBottomSheet(
onDismissRequest,
dragHandle = null,
sheetState = modalBottomSheetState,
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
containerColor = Color.Transparent,
sheetMaxWidth = maxWidth,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.systemBarsPadding()
.displayCutoutPadding()
.background(
brush = Brush.linearGradient(
colors = listOf(
Color.Black.copy(alpha = 0f),
Color.Black.copy(alpha = 0.8f),
),
start = Offset(0f, 0f),
end = Offset(0f, Float.POSITIVE_INFINITY)
)
)
.navigationBarsPadding()
) {
content()
}
}
}

View file

@ -0,0 +1,110 @@
package nl.jknaapen.fladder.composables.dialogs
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.clearSubtitleTrack
import nl.jknaapen.fladder.utility.defaultSelected
import nl.jknaapen.fladder.utility.setInternalSubtitleTrack
@OptIn(UnstableApi::class)
@Composable
fun SubtitlePicker(
player: ExoPlayer,
onDismissRequest: () -> Unit,
) {
val selectedIndex by VideoPlayerObject.currentSubtitleTrackIndex.collectAsState()
val subTitles by VideoPlayerObject.subtitleTracks.collectAsState(listOf())
val internalSubTracks by VideoPlayerObject.exoSubTracks
val listState = rememberLazyListState()
LaunchedEffect(selectedIndex) {
listState.scrollToItem(
subTitles.indexOfFirst { it.index == selectedIndex.toLong() }
)
}
CustomModalBottomSheet(
onDismissRequest,
maxWidth = 600.dp,
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
item {
TrackButton(
modifier = Modifier
.fillMaxWidth()
.defaultSelected(-1 == selectedIndex),
onClick = {
VideoPlayerObject.setSubtitleTrackIndex(-1)
player.clearSubtitleTrack()
},
selected = -1 == selectedIndex
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterVertically
)
) {
Text(
text = "Off",
)
}
}
}
internalSubTracks.forEachIndexed { index, subtitle ->
val serverSub = subTitles.elementAtOrNull(index + 1)
val selected = serverSub?.index == selectedIndex.toLong()
item {
TrackButton(
modifier = Modifier
.fillMaxWidth()
.defaultSelected(selected),
onClick = {
serverSub?.index?.let {
VideoPlayerObject.setSubtitleTrackIndex(it.toInt())
}
player.setInternalSubtitleTrack(subtitle)
},
selected = selected,
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(
8.dp,
alignment = Alignment.CenterVertically
)
) {
Text(
text = serverSub?.name ?: "",
)
}
}
}
}
}
}
}

View file

@ -0,0 +1,52 @@
package nl.jknaapen.fladder.composables.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
internal fun TrackButton(
modifier: Modifier = Modifier,
onClick: () -> Unit,
selected: Boolean = false,
content: @Composable () -> Unit,
) {
val backgroundColor = if (selected) Color.White else Color.Black
val textColor = if (selected) Color.Black else Color.White
val textStyle =
MaterialTheme.typography.bodyLarge.copy(color = textColor, fontWeight = FontWeight.Bold)
val interactionSource = remember { MutableInteractionSource() }
TextButton(
modifier = modifier
.background(
color = backgroundColor.copy(alpha = 0.65f),
shape = RoundedCornerShape(12.dp),
)
.padding(12.dp)
.clickable(
onClick = onClick,
interactionSource = interactionSource,
indication = null,
),
onClick = onClick,
interactionSource = interactionSource,
) {
CompositionLocalProvider(LocalTextStyle provides textStyle) {
content()
}
}
}

View file

@ -0,0 +1,149 @@
package nl.jknaapen.fladder.messengers
import PlayableData
import VideoPlayerApi
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.flow.MutableStateFlow
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.clearAudioTrack
import nl.jknaapen.fladder.utility.clearSubtitleTrack
import nl.jknaapen.fladder.utility.enableSubtitles
import nl.jknaapen.fladder.utility.getAudioTracks
import nl.jknaapen.fladder.utility.getSubtitleTracks
import nl.jknaapen.fladder.utility.setInternalAudioTrack
import nl.jknaapen.fladder.utility.setInternalSubtitleTrack
class VideoPlayerImplementation(
) : VideoPlayerApi {
var player: ExoPlayer? = null
val playbackData: MutableStateFlow<PlayableData?> = MutableStateFlow(null)
override fun sendPlayableModel(playableData: PlayableData): Boolean {
try {
println("Send playable data")
playbackData.value = playableData
return true
} catch (e: Exception) {
println("Error loading data $e")
return false
}
}
override fun open(url: String, play: Boolean) {
try {
player?.stop()
player?.clearMediaItems()
playbackData.value?.let {
VideoPlayerObject.setAudioTrackIndex(it.defaultAudioTrack.toInt(), true)
VideoPlayerObject.setSubtitleTrackIndex(it.defaultSubtrack.toInt(), true)
}
val startPosition = playbackData.value?.startPosition ?: 0L
println("Loading video in native $url")
val subTitles = playbackData.value?.subtitleTracks ?: listOf()
val mediaItem = MediaItem.Builder().apply {
setUri(url)
setTag(playbackData.value?.title)
setMediaId(playbackData.value?.id ?: "")
setSubtitleConfigurations(
subTitles.filter { it.external && it.url?.isNotEmpty() == true }.map { sub ->
MediaItem.SubtitleConfiguration.Builder(sub.url!!.toUri())
.setMimeType(guessSubtitleMimeType(sub.url))
.setLanguage(sub.languageCode)
.setLabel(sub.name)
.build()
}
)
}.build()
player?.setMediaItem(mediaItem, startPosition)
player?.prepare()
player?.playWhenReady = play
} catch (e: Exception) {
println("Error playing video $e")
}
}
override fun setLooping(looping: Boolean) {
player?.repeatMode = if (looping) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
}
override fun setVolume(volume: Double) {
player?.volume = volume.toFloat()
}
override fun setPlaybackSpeed(speed: Double) {
player?.setPlaybackSpeed(speed.toFloat())
}
override fun play() {
player?.play()
}
override fun pause() {
player?.pause()
}
override fun seekTo(position: Long) {
player?.seekTo(position)
}
override fun stop() {
player?.stop()
}
fun init(exoPlayer: ExoPlayer?) {
player = exoPlayer
//exoPlayer initializes after the playbackData is set for the first load
playbackData.value?.let {
sendPlayableModel(it)
VideoPlayerObject.setAudioTrackIndex(it.defaultAudioTrack.toInt(), true)
VideoPlayerObject.setSubtitleTrackIndex(it.defaultSubtrack.toInt(), true)
open(it.url, true)
}
}
}
fun guessSubtitleMimeType(fileName: String): String = when {
fileName.contains(".srt", ignoreCase = true) -> MimeTypes.APPLICATION_SUBRIP
fileName.contains(".vtt", ignoreCase = true) -> MimeTypes.TEXT_VTT
fileName.contains(".ass", ignoreCase = true) -> MimeTypes.TEXT_SSA
else -> MimeTypes.APPLICATION_SUBRIP
}
fun ExoPlayer.properlySetSubAndAudioTracks(playableData: PlayableData) {
try {
val currentSubIndex = playableData.defaultSubtrack
val indexOfSubtitleTrack =
playableData.subtitleTracks.indexOfFirst { it.index == currentSubIndex }
val internalSubTracks = this.getSubtitleTracks()
val wantedSubIndex = indexOfSubtitleTrack - 1
if (wantedSubIndex < 0) {
clearSubtitleTrack()
} else {
enableSubtitles()
setInternalSubtitleTrack(internalSubTracks[wantedSubIndex])
}
val currentAudioIndex = playableData.defaultAudioTrack
val indexOfAudioTrack =
playableData.audioTracks.indexOfFirst { it.index == currentAudioIndex }
val internalAudioTracks = this.getAudioTracks()
val wantedAudioIndex = indexOfAudioTrack - 1
if (wantedAudioIndex < 0) {
clearAudioTrack()
} else {
clearAudioTrack(false)
setInternalAudioTrack(internalAudioTracks[wantedAudioIndex])
}
} catch (e: Exception) {
e.printStackTrace()
}
}

View file

@ -0,0 +1,27 @@
package nl.jknaapen.fladder.objects
import PlayerSettings
import PlayerSettingsPigeon
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlin.time.DurationUnit
import kotlin.time.toDuration
object PlayerSettingsObject : PlayerSettingsPigeon {
val settings: MutableStateFlow<PlayerSettings?> = MutableStateFlow(null)
val skipMap = settings.map { it?.skipTypes ?: mapOf() }
val forwardSpeed =
settings.map {
(it?.skipForward ?: 1L).toDuration(DurationUnit.MILLISECONDS)
}
val backwardSpeed = settings.map {
(it?.skipBackward ?: 1L).toDuration(
DurationUnit.MILLISECONDS
)
}
override fun sendPlayerSettings(playerSettings: PlayerSettings) {
settings.value = playerSettings
}
}

View file

@ -0,0 +1,61 @@
package nl.jknaapen.fladder.objects
import PlaybackState
import VideoPlayerControlsCallback
import VideoPlayerListenerCallback
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import nl.jknaapen.fladder.VideoPlayerActivity
import nl.jknaapen.fladder.messengers.VideoPlayerImplementation
import nl.jknaapen.fladder.utility.InternalTrack
object VideoPlayerObject {
val implementation: VideoPlayerImplementation = VideoPlayerImplementation()
private var _currentState = MutableStateFlow<PlaybackState?>(null)
val videoPlayerState = _currentState.map { it }
val buffering = _currentState.map { it?.buffering ?: true }
val position = _currentState.map { it?.position ?: 0L }
val duration = _currentState.map { it?.duration ?: 0L }
val playing = _currentState.map { it?.playing ?: false }
val chapters = implementation.playbackData.map { it?.chapters }
val currentSubtitleTrackIndex =
MutableStateFlow((implementation.playbackData.value?.defaultSubtrack ?: -1).toInt())
val currentAudioTrackIndex =
MutableStateFlow((implementation.playbackData.value?.defaultAudioTrack ?: -1).toInt())
val exoAudioTracks = mutableStateOf<List<InternalTrack>>(listOf())
val exoSubTracks = mutableStateOf<List<InternalTrack>>(listOf())
fun setSubtitleTrackIndex(value: Int, init: Boolean = false) {
currentSubtitleTrackIndex.value = value
if (!init) {
videoPlayerControls?.swapSubtitleTrack(value.toLong(), callback = {})
}
}
fun setAudioTrackIndex(value: Int, init: Boolean = false) {
currentAudioTrackIndex.value = value
if (!init) {
videoPlayerControls?.swapAudioTrack(value.toLong(), callback = {})
}
}
val subtitleTracks = implementation.playbackData.map { it?.subtitleTracks ?: listOf() }
val audioTracks = implementation.playbackData.map { it?.audioTracks ?: listOf() }
fun setPlaybackState(state: PlaybackState) {
_currentState.value = state
videoPlayerListener?.onPlaybackStateChanged(
state, callback = {}
)
}
var videoPlayerListener: VideoPlayerListenerCallback? = null
var videoPlayerControls: VideoPlayerControlsCallback? = null
var currentActivity: VideoPlayerActivity? = null
}

View file

@ -0,0 +1,232 @@
package nl.jknaapen.fladder.player
import PlaybackState
import android.app.ActivityManager
import android.content.Context
import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.getSystemService
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.TsExtractor
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
import io.github.peerless2012.ass.media.kt.buildWithAssSupport
import io.github.peerless2012.ass.media.type.AssRenderType
import nl.jknaapen.fladder.messengers.properlySetSubAndAudioTracks
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.getAudioTracks
import nl.jknaapen.fladder.utility.getSubtitleTracks
import java.io.File
val LocalPlayer = compositionLocalOf<ExoPlayer?> { null }
@OptIn(UnstableApi::class)
@Composable
internal fun ExoPlayer(
controls: @Composable (
player: ExoPlayer,
) -> Unit,
) {
val videoHost = VideoPlayerObject
val context = LocalContext.current
var initialized = false
val extractorsFactory = DefaultExtractorsFactory().apply {
val isLowRamDevice = context.getSystemService<ActivityManager>()?.isLowRamDevice == true
setTsExtractorTimestampSearchBytes(
when (isLowRamDevice) {
true -> TsExtractor.TS_PACKET_SIZE * 1800
false -> TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
}
)
setConstantBitrateSeekingEnabled(true)
setConstantBitrateSeekingAlwaysEnabled(true)
}
val videoCache = remember { VideoCache.buildCacheDataSourceFactory(context) }
val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
val renderersFactory = DefaultRenderersFactory(context)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
.setEnableDecoderFallback(true)
val exoPlayer = remember {
ExoPlayer.Builder(context, renderersFactory)
.setTrackSelector(DefaultTrackSelector(context).apply {
setParameters(buildUponParameters().apply {
setAudioOffloadPreferences(
TrackSelectionParameters.AudioOffloadPreferences.DEFAULT.buildUpon().apply {
setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
}.build()
)
setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
setTunnelingEnabled(true)
})
})
.setMediaSourceFactory(
DefaultMediaSourceFactory(
videoCache,
extractorsFactory
),
)
.setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.setPauseAtEndOfMediaItems(true)
.setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT)
.buildWithAssSupport(context, AssRenderType.LEGACY)
}
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
videoHost.setPlaybackState(
PlaybackState(
position = exoPlayer.currentPosition,
buffered = exoPlayer.bufferedPosition,
duration = exoPlayer.duration,
playing = exoPlayer.isPlaying,
buffering = playbackState == Player.STATE_BUFFERING,
completed = playbackState == Player.STATE_ENDED,
failed = playbackState == Player.STATE_IDLE
)
)
}
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
videoHost.setPlaybackState(
PlaybackState(
position = exoPlayer.currentPosition,
buffered = exoPlayer.bufferedPosition,
duration = exoPlayer.duration,
playing = exoPlayer.isPlaying,
buffering = exoPlayer.playbackState == Player.STATE_BUFFERING,
completed = exoPlayer.playbackState == Player.STATE_ENDED,
failed = exoPlayer.playbackState == Player.STATE_IDLE
)
)
}
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
if (!initialized) {
initialized = true
VideoPlayerObject.implementation.playbackData.value?.let {
exoPlayer.properlySetSubAndAudioTracks(it)
}
VideoPlayerObject.exoSubTracks.value = exoPlayer.getSubtitleTracks()
VideoPlayerObject.exoAudioTracks.value = exoPlayer.getAudioTracks()
}
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
}
}
DisposableEffect(Unit) {
VideoPlayerObject.implementation.init(exoPlayer)
onDispose {
videoHost.videoPlayerControls?.onStop(callback = {})
VideoPlayerObject.implementation.init(null)
exoPlayer.release()
}
}
Box(
modifier = Modifier
.background(Color.Black)
.fillMaxSize()
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
PlayerView(it).apply {
player = exoPlayer
useController = false
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
subtitleView?.apply {
setStyle(
CaptionStyleCompat(
android.graphics.Color.WHITE,
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT,
CaptionStyleCompat.EDGE_TYPE_OUTLINE,
android.graphics.Color.BLACK,
null
)
)
}
}
},
)
CompositionLocalProvider(LocalPlayer provides exoPlayer) {
controls(exoPlayer)
}
}
}
@UnstableApi
object VideoCache {
private const val CACHE_SIZE: Long = 150L * 1024L * 1024L // 150 MB
@Volatile
private var cache: SimpleCache? = null
fun getCache(context: Context): SimpleCache {
return cache ?: synchronized(this) {
cache ?: SimpleCache(
File(context.cacheDir, "video_cache"),
LeastRecentlyUsedCacheEvictor(CACHE_SIZE)
).also { cache = it }
}
}
fun buildCacheDataSourceFactory(context: Context): DataSource.Factory {
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
val upstreamFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
return CacheDataSource.Factory()
.setCache(getCache(context))
.setUpstreamDataSourceFactory(upstreamFactory)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
}

View file

@ -0,0 +1,11 @@
package nl.jknaapen.fladder.utility
fun formatTime(ms: Long): String {
if (ms < 0) {
return "0:00"
}
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "%d:%02d".format(minutes, seconds)
}

View file

@ -0,0 +1,35 @@
package nl.jknaapen.fladder.utility
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
@Composable
fun ImmersiveSystemBars(isImmersive: Boolean) {
val view = LocalView.current
LaunchedEffect(view) {
val activity = view.context as? Activity
val window = activity?.window
if (window != null) {
WindowCompat.setDecorFitsSystemWindows(window, false)
}
}
DisposableEffect(view, isImmersive) {
val activity = view.context as? Activity
val window = activity?.window
val controller = window?.let { WindowInsetsControllerCompat(it, view) }
if (isImmersive) {
controller?.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
} else {
controller?.show(androidx.core.view.WindowInsetsCompat.Type.systemBars())
}
onDispose { }
}
}

View file

@ -0,0 +1,14 @@
package nl.jknaapen.fladder.utility
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.annotation.RequiresApi
@RequiresApi(Build.VERSION_CODES.O)
fun leanBackEnabled(context: Context): Boolean {
val pm = context.packageManager
val leanBackEnabled = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
val leanBackOnly = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK_ONLY)
return leanBackEnabled || leanBackOnly
}

View file

@ -0,0 +1,95 @@
package nl.jknaapen.fladder.utility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Adds a subtle background when focused is true. Use this to visually mark the focused/selected
* element in D-pad / keyboard navigation.
*/
fun Modifier.highlightOnFocus(
color: Color = Color.White.copy(alpha = 0.85f),
width: Dp = 1.5.dp,
shape: Shape = RoundedCornerShape(16.dp)
): Modifier = composed {
var hasFocus by remember { mutableStateOf(false) }
val highlightModifier = remember {
Modifier
.clip(RoundedCornerShape(8.dp))
.background(
color = color.copy(alpha = 0.25f),
shape = shape,
)
.border(
width = width,
color = color.copy(alpha = 0.5f),
shape = shape
)
}
this
.onFocusChanged { focusState ->
hasFocus = focusState.hasFocus
}
.then(if (hasFocus) highlightModifier else Modifier)
}
/**
* Requests focus on first composition when [defaultSelected] is true.
* Returns a modifier with a focus requester attached so it can be combined with focusable()/onKeyEvent.
*/
@Composable
fun Modifier.defaultSelected(defaultSelected: Boolean): Modifier {
val requester = remember { FocusRequester() }
LaunchedEffect(defaultSelected) {
if (defaultSelected) requester.requestFocus()
}
return this.focusRequester(requester)
}
/**
* Conditional if modifier
*/
@Composable
fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): Modifier {
return if (condition) {
then(modifier(Modifier))
} else {
this
}
}
@Composable
fun Modifier.visible(
visible: Boolean,
): Modifier {
val alphaAnimated by animateFloatAsState(if (visible) 1f else 0f)
return this
.graphicsLayer {
alpha = alphaAnimated
}
.then(
if (!visible) Modifier.pointerInput(Unit) {} else Modifier
)
}

View file

@ -0,0 +1,21 @@
package nl.jknaapen.fladder.utility
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
@Composable
fun ScaledContent(
scale: Float,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(
density = density.density * scale,
)
) {
content()
}
}

View file

@ -0,0 +1,94 @@
package nl.jknaapen.fladder.utility
import AudioTrack
import Chapter
import PlayableData
import SubtitleTrack
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
val testPlaybackData = PlayableData(
id = "8lsf8234l99sdf923lsd8f23j98j",
title = "Big buck bunny",
subTitle = "Episode 1x2",
startPosition = 0,
description = "Short description of the movie that is being watched",
defaultSubtrack = 1,
defaultAudioTrack = 1,
subtitleTracks = listOf(
SubtitleTrack(
name = "English",
languageCode = "EN",
codec = "SRT",
index = 1,
external = false,
),
SubtitleTrack(
name = "Dutch",
languageCode = "NL",
codec = "SRT",
index = 2,
external = false,
),
SubtitleTrack(
name = "Japanese",
languageCode = "JP",
codec = "srt",
index = 3,
url = "https://gist.githubusercontent.com/matibzurovski/d690d5c14acbaa399e7f0829f9d6888e/raw/63578ca30e7430be1fa4942d4d8dd599f78151c7/example.srt",
external = true,
),
),
audioTracks = listOf(
AudioTrack(
name = "English",
languageCode = "EN",
codec = "AC3",
index = 1,
external = false,
),
AudioTrack(
name = "Dutch",
languageCode = "NL",
codec = "SRT",
index = 2,
external = false,
),
),
chapters = listOf(
Chapter(
name = "Chapter 1",
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
time = 5.seconds.toLong(DurationUnit.MILLISECONDS),
),
Chapter(
name = "Chapter 2",
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
time = 10.seconds.toLong(DurationUnit.MILLISECONDS),
),
Chapter(
name = "Chapter 3",
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
time = 15.seconds.toLong(DurationUnit.MILLISECONDS),
),
Chapter(
name = "Chapter 4",
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
time = 20.seconds.toLong(DurationUnit.MILLISECONDS),
),
Chapter(
name = "Chapter 5",
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
time = 25.seconds.toLong(DurationUnit.MILLISECONDS),
),
Chapter(
name = "Chapter 6",
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
time = 30.seconds.toLong(DurationUnit.MILLISECONDS),
)
),
nextVideo = null,
previousVideo = "Previous episode name",
segments = listOf(),
url = "https://github.com/ietf-wg-cellar/matroska-test-files/raw/refs/heads/master/test_files/test5.mkv",
)

View file

@ -0,0 +1,157 @@
package nl.jknaapen.fladder.utility
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
data class InternalTrack(
val rendererIndex: Int,
val groupIndex: Int,
val trackIndex: Int,
val label: String
)
@OptIn(UnstableApi::class)
fun ExoPlayer.getAudioTracks(): List<InternalTrack> {
val selector = trackSelector as? DefaultTrackSelector ?: return emptyList()
val mapped = selector.currentMappedTrackInfo ?: return emptyList()
val result = mutableListOf<InternalTrack>()
for (rendererIndex in 0 until mapped.rendererCount) {
if (mapped.getRendererType(rendererIndex) != C.TRACK_TYPE_AUDIO) continue
val groups = mapped.getTrackGroups(rendererIndex)
for (groupIndex in 0 until groups.length) {
val group = groups[groupIndex]
for (trackIndex in 0 until group.length) {
val format = group.getFormat(trackIndex)
result.add(
InternalTrack(
rendererIndex = rendererIndex,
groupIndex = groupIndex,
trackIndex = trackIndex,
label = format.label ?: format.language ?: "Audiotrack: $trackIndex",
)
)
}
}
}
return result
}
@OptIn(UnstableApi::class)
fun ExoPlayer.setInternalAudioTrack(audioTrack: InternalTrack) {
try {
val selector = trackSelector as? DefaultTrackSelector ?: return
val mapped = selector.currentMappedTrackInfo ?: return
val groups = mapped.getTrackGroups(audioTrack.rendererIndex)
if (audioTrack.groupIndex >= groups.length) return
val group = groups[audioTrack.groupIndex]
val override = TrackSelectionOverride(group, audioTrack.trackIndex)
selector.setParameters(
selector.buildUponParameters()
.setRendererDisabled(audioTrack.rendererIndex, false)
.build()
)
this.trackSelectionParameters = this.trackSelectionParameters
.buildUpon()
.setOverrideForType(override)
.build()
} catch (e: Exception) {
e.printStackTrace()
}
}
@OptIn(UnstableApi::class)
fun ExoPlayer.clearAudioTrack(disable: Boolean = true) {
val selector = trackSelector as? DefaultTrackSelector ?: return
selector.setParameters(
selector.buildUponParameters()
.setRendererDisabled(C.TRACK_TYPE_AUDIO, disable)
.build()
)
}
@OptIn(UnstableApi::class)
fun ExoPlayer.getSubtitleTracks(): List<InternalTrack> {
val selector = trackSelector as? DefaultTrackSelector ?: return emptyList()
val mapped = selector.currentMappedTrackInfo ?: return emptyList()
val result = mutableListOf<InternalTrack>()
for (rendererIndex in 0 until mapped.rendererCount) {
if (mapped.getRendererType(rendererIndex) != C.TRACK_TYPE_TEXT) continue
val groups = mapped.getTrackGroups(rendererIndex)
for (groupIndex in 0 until groups.length) {
val group = groups[groupIndex]
for (trackIndex in 0 until group.length) {
val format = group.getFormat(trackIndex)
result.add(
InternalTrack(
rendererIndex = rendererIndex,
groupIndex = groupIndex,
trackIndex = trackIndex,
label = format.label ?: format.language ?: "Subtitletrack: $trackIndex",
)
)
}
}
}
return result
}
@OptIn(UnstableApi::class)
fun ExoPlayer.clearSubtitleTrack() {
val selector = trackSelector as? DefaultTrackSelector ?: return
val newParams = selector.buildUponParameters()
.setRendererDisabled(C.TRACK_TYPE_TEXT, false) // keep text renderer active
.setPreferredTextLanguage(null) // don't auto-pick a language
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) // < disables selection of *any* text track
.build()
selector.setParameters(newParams)
}
@OptIn(UnstableApi::class)
fun ExoPlayer.enableSubtitles(language: String? = null) {
val selector = trackSelector as? DefaultTrackSelector ?: return
val newParams = selector.buildUponParameters()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) // allow text again
.setPreferredTextLanguage(language) // optional: auto-pick by language
.build()
selector.setParameters(newParams)
}
@OptIn(UnstableApi::class)
fun ExoPlayer.setInternalSubtitleTrack(subtitleTrack: InternalTrack) {
try {
enableSubtitles()
val selector = trackSelector as? DefaultTrackSelector ?: return
val mapped = selector.currentMappedTrackInfo ?: return
val groups = mapped.getTrackGroups(subtitleTrack.rendererIndex)
if (subtitleTrack.groupIndex >= groups.length) return
val group = groups[subtitleTrack.groupIndex]
val override = TrackSelectionOverride(group, subtitleTrack.trackIndex)
selector.setParameters(
selector.buildUponParameters()
.setRendererDisabled(subtitleTrack.rendererIndex, false)
.build()
)
// Apply override (replaces other text overrides)
this.trackSelectionParameters = this.trackSelectionParameters
.buildUpon()
.setOverrideForType(override)
.build()
} catch (e: Exception) {
e.printStackTrace()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip

View file

@ -13,13 +13,17 @@ pluginManagement {
google()
mavenCentral()
gradlePluginPortal()
maven {
url "https://repo.jellyfin.org/releases/client/android"
}
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.4.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.0" apply false
id "com.android.application" version '8.12.2' apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
id "org.jetbrains.kotlin.plugin.compose" version "2.1.0" apply false
}
include ":app"