mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-07 13:38:13 -08:00
feat: Android TV support (#503)
Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
parent
7ab8c015b9
commit
c299492d6d
168 changed files with 12019 additions and 3073 deletions
2
android/.gitignore
vendored
2
android/.gitignore
vendored
|
|
@ -11,3 +11,5 @@ GeneratedPluginRegistrant.java
|
|||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
|
||||
**/TestData.kt
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
26
android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt
Normal file
26
android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable/app_banner.png
Normal file
BIN
android/app/src/main/res/drawable/app_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
663
android/build/reports/problems/problems-report.html
Normal file
663
android/build/reports/problems/problems-report.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue