diff --git a/.vscode/launch.json b/.vscode/launch.json
index ee28073..0c54b15 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -77,7 +77,6 @@
"name": "Web",
"request": "launch",
"type": "dart",
- "deviceId": "chrome",
"args": [
"--web-port",
"9090",
@@ -87,7 +86,6 @@
"name": "Web (release mode)",
"request": "launch",
"type": "dart",
- "deviceId": "chrome",
"flutterMode": "release",
"args": [
"--web-port",
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index f7de74e..21691be 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -114,6 +114,23 @@
"group": {
"kind": "build",
}
+ },
+ {
+ "label": "Generate Pigeon Files",
+ "type": "shell",
+ "command": "powershell",
+ "args": [
+ "-Command",
+ "Get-ChildItem -Path pigeons/*.dart | ForEach-Object { dart run pigeon --input $_.FullName }"
+ ],
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "problemMatcher": [],
+ "options": {
+ "cwd": "${workspaceFolder}"
+ }
}
],
}
\ No newline at end of file
diff --git a/android/.gitignore b/android/.gitignore
index 55afd91..0050c9b 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -11,3 +11,5 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
+
+**/TestData.kt
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 2026b86..168a5aa 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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")
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ec9ea68..52293ec 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,42 +1,95 @@
-
-
-
-
-
-
-
+ xmlns:tools="http://schemas.android.com/tools"
+ package="nl.jknaapen.fladder">
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/MainActivity.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/MainActivity.kt
index 9726f5e..223023f 100644
--- a/android/app/src/main/kotlin/nl/jknaapen/fladder/MainActivity.kt
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/MainActivity.kt
@@ -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
+ private var videoPlayerCallback: ((Result) -> 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) -> 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)
}
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt
new file mode 100644
index 0000000..9a4fda2
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt
@@ -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()
+ }
+ }
+}
+
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt
new file mode 100644
index 0000000..2714d6c
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt
new file mode 100644
index 0000000..ffbf3fa
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt
@@ -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 {
+ return listOf(result)
+ }
+
+ fun wrapError(exception: Throwable): List {
+ 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).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,
+ val skipForward: Long,
+ val skipBackward: Long
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): PlayerSettings {
+ val skipTypes = pigeonVar_list[0] as Map
+ val skipForward = pigeonVar_list[1] as Long
+ val skipBackward = pigeonVar_list[2] as Long
+ return PlayerSettings(skipTypes, skipForward, skipBackward)
+ }
+ }
+ fun toList(): List {
+ 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)?.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 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(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.settings.PlayerSettingsPigeon.sendPlayerSettings$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val playerSettingsArg = args[0] as PlayerSettings
+ val wrapped: List = try {
+ api.sendPlayerSettings(playerSettingsArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ PlayerSettingsHelperPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt
new file mode 100644
index 0000000..d730a7d
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt
@@ -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 {
+ return listOf(result)
+ }
+
+ fun wrapError(exception: Throwable): List {
+ 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).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,
+ val defaultSubtrack: Long,
+ val subtitleTracks: List,
+ val trickPlayModel: TrickPlayModel? = null,
+ val chapters: List,
+ val segments: List,
+ val previousVideo: String? = null,
+ val nextVideo: String? = null,
+ val url: String
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): 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
+ val defaultSubtrack = pigeonVar_list[8] as Long
+ val subtitleTracks = pigeonVar_list[9] as List
+ val trickPlayModel = pigeonVar_list[10] as TrickPlayModel?
+ val chapters = pigeonVar_list[11] as List
+ val segments = pigeonVar_list[12] as List
+ 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 {
+ 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): 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 {
+ 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): 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 {
+ 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): 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 {
+ 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): 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 {
+ 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
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): 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
+ return TrickPlayModel(width, height, tileWidth, tileHeight, thumbnailCount, interval, images)
+ }
+ }
+ fun toList(): List {
+ 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): StartResult {
+ val resultValue = pigeonVar_list[0] as String?
+ return StartResult(resultValue)
+ }
+ }
+ fun toList(): List {
+ 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): 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 {
+ 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)?.let {
+ PlayableData.fromList(it)
+ }
+ }
+ 131.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ MediaSegment.fromList(it)
+ }
+ }
+ 132.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ AudioTrack.fromList(it)
+ }
+ }
+ 133.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ SubtitleTrack.fromList(it)
+ }
+ }
+ 134.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ Chapter.fromList(it)
+ }
+ }
+ 135.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ TrickPlayModel.fromList(it)
+ }
+ }
+ 136.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ StartResult.fromList(it)
+ }
+ }
+ 137.toByte() -> {
+ return (readValue(buffer) as? List)?.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) -> Unit)
+ fun disposeActivity()
+ fun isLeanBackEnabled(): Boolean
+
+ companion object {
+ /** The codec used by NativeVideoActivity. */
+ val codec: MessageCodec 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(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.launchActivity$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.launchActivity{ result: Result ->
+ 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(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.disposeActivity$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ api.disposeActivity()
+ listOf(null)
+ } catch (exception: Throwable) {
+ VideoPlayerHelperPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.isLeanBackEnabled$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = 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 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(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.sendPlayableModel$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val playableDataArg = args[0] as PlayableData
+ val wrapped: List = try {
+ listOf(api.sendPlayableModel(playableDataArg))
+ } catch (exception: Throwable) {
+ VideoPlayerHelperPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.open$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val urlArg = args[0] as String
+ val playArg = args[1] as Boolean
+ val wrapped: List = try {
+ api.open(urlArg, playArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ VideoPlayerHelperPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setLooping$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val loopingArg = args[0] as Boolean
+ val wrapped: List = try {
+ api.setLooping(loopingArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ VideoPlayerHelperPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setVolume$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val volumeArg = args[0] as Double
+ val wrapped: List = try {
+ api.setVolume(volumeArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ VideoPlayerHelperPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setPlaybackSpeed$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val speedArg = args[0] as Double
+ val wrapped: List = try {
+ api.setPlaybackSpeed(speedArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ VideoPlayerHelperPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.play$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ api.play()
+ listOf(null)
+ } catch (exception: Throwable) {
+ VideoPlayerHelperPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.pause$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ api.pause()
+ listOf(null)
+ } catch (exception: Throwable) {
+ VideoPlayerHelperPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.seekTo$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val positionArg = args[0] as Long
+ val wrapped: List = try {
+ api.seekTo(positionArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ VideoPlayerHelperPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.stop$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = 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 by lazy {
+ VideoPlayerHelperPigeonCodec()
+ }
+ }
+ fun onPlaybackStateChanged(stateArg: PlaybackState, callback: (Result) -> Unit)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(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 by lazy {
+ VideoPlayerHelperPigeonCodec()
+ }
+ }
+ fun loadNextVideo(callback: (Result) -> Unit)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadNextVideo$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(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)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadPreviousVideo$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(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)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onStop$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(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)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(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)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(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)))
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomIconButton.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomIconButton.kt
new file mode 100644
index 0000000..d7d9941
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomIconButton.kt
@@ -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()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt
new file mode 100644
index 0000000..5fc23f1
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt
@@ -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
+ )
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt
new file mode 100644
index 0000000..174bcea
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt
@@ -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
+ }
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt
new file mode 100644
index 0000000..ba6cf7b
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt
@@ -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
+ }
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/TrickplayOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/TrickplayOverlay.kt
new file mode 100644
index 0000000..a8544da
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/TrickplayOverlay.kt
@@ -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)
+
+@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()
+ val nextFrames = mutableListOf()
+
+ 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,
+) {
+ 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)
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoEndTime.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoEndTime.kt
new file mode 100644
index 0000000..40f8e6d
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoEndTime.kt
@@ -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),
+ )
+}
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt
new file mode 100644
index 0000000..22a94e6
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt
@@ -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,
+ showSubDialog: MutableState
+) {
+ 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",
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt
new file mode 100644
index 0000000..f6eb79f
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt
@@ -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 ?: "",
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/ChapterSelectionSheet.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/ChapterSelectionSheet.kt
new file mode 100644
index 0000000..71b6403
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/ChapterSelectionSheet.kt
@@ -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.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
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/CustomModalBottomSheet.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/CustomModalBottomSheet.kt
new file mode 100644
index 0000000..65a78af
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/CustomModalBottomSheet.kt
@@ -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()
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt
new file mode 100644
index 0000000..41be3ca
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt
@@ -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 ?: "",
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/TrackButton.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/TrackButton.kt
new file mode 100644
index 0000000..68a7923
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/TrackButton.kt
@@ -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()
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt
new file mode 100644
index 0000000..f505c60
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt
@@ -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 = 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()
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/PlayerSettingsObject.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/PlayerSettingsObject.kt
new file mode 100644
index 0000000..bfe2892
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/PlayerSettingsObject.kt
@@ -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 = 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
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt
new file mode 100644
index 0000000..71a14c8
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt
@@ -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(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>(listOf())
+ val exoSubTracks = mutableStateOf>(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
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt
new file mode 100644
index 0000000..1cbb755
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt
@@ -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 { 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()?.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)
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/FormatTime.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/FormatTime.kt
new file mode 100644
index 0000000..28ac1c4
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/FormatTime.kt
@@ -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)
+}
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ImmersiveSystemBars.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ImmersiveSystemBars.kt
new file mode 100644
index 0000000..03ff402
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ImmersiveSystemBars.kt
@@ -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 { }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/LeanBackCheck.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/LeanBackCheck.kt
new file mode 100644
index 0000000..d0d80b5
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/LeanBackCheck.kt
@@ -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
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/Modifiers.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/Modifiers.kt
new file mode 100644
index 0000000..9cb42e2
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/Modifiers.kt
@@ -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
+ )
+}
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ScaledContent.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ScaledContent.kt
new file mode 100644
index 0000000..fb5e07b
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ScaledContent.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TestPlaybackData.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TestPlaybackData.kt
new file mode 100644
index 0000000..44e2e30
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TestPlaybackData.kt
@@ -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",
+)
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TrackHelper.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TrackHelper.kt
new file mode 100644
index 0000000..e2f45b9
--- /dev/null
+++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TrackHelper.kt
@@ -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 {
+ val selector = trackSelector as? DefaultTrackSelector ?: return emptyList()
+ val mapped = selector.currentMappedTrackInfo ?: return emptyList()
+ val result = mutableListOf()
+
+ 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 {
+ val selector = trackSelector as? DefaultTrackSelector ?: return emptyList()
+ val mapped = selector.currentMappedTrackInfo ?: return emptyList()
+ val result = mutableListOf()
+
+ 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()
+ }
+}
diff --git a/android/app/src/main/res/drawable/app_banner.png b/android/app/src/main/res/drawable/app_banner.png
new file mode 100644
index 0000000..99028d3
Binary files /dev/null and b/android/app/src/main/res/drawable/app_banner.png differ
diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
new file mode 100644
index 0000000..daf289c
--- /dev/null
+++ b/android/build/reports/problems/problems-report.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Gradle Configuration Cache
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 5d6560a..02767eb 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/android/settings.gradle b/android/settings.gradle
index 2faf9ab..2a7a11c 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -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"
diff --git a/assets/marketing/banner.png b/assets/marketing/banner.png
index 3a46d20..472e9a0 100644
Binary files a/assets/marketing/banner.png and b/assets/marketing/banner.png differ
diff --git a/assets/marketing/banner2.afphoto b/assets/marketing/banner2.afphoto
index ced3e2e..50e9ca3 100644
Binary files a/assets/marketing/banner2.afphoto and b/assets/marketing/banner2.afphoto differ
diff --git a/lib/fake/fake_jellyfin_open_api.dart b/lib/fake/fake_jellyfin_open_api.dart
index a40d7e6..a4c11f9 100644
--- a/lib/fake/fake_jellyfin_open_api.dart
+++ b/lib/fake/fake_jellyfin_open_api.dart
@@ -679,6 +679,12 @@ class FakeJellyfinOpenApi extends JellyfinOpenApi {
),
);
}
+
+ @override
+ Future> brandingConfigurationGet() async => chopper.Response(
+ FakeHelper.fakeCorrectResponse,
+ const BrandingOptions(loginDisclaimer: "Test server"),
+ );
}
class FakeHelper {
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index fe09e62..8ae9b96 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -1171,6 +1171,7 @@
"phone": "Phone",
"tablet": "Tablet",
"desktop": "Desktop",
+ "television": "Television",
"layoutModeSingle": "Single",
"layoutModeDual": "Dual",
"copiedToClipboard": "Copied to clipboard",
@@ -1191,6 +1192,7 @@
"segmentActionSkip": "Skip",
"loading": "Loading",
"exitFladderTitle": "Exit Fladder",
+ "exitFladderDesc": "Are you sure you want to close Fladder?",
"castAndCrew": "Cast & Crew",
"guestActor": "{count, plural, other{Guest Actors} one{Guest Actor}}",
"@guestActor": {
@@ -1336,5 +1338,8 @@
"type": "double"
}
}
- }
+ },
+ "quickConnectPostFailed": "Failed to get quick connect code",
+ "quickConnectLoginUsingCode": "Using quick connect",
+ "quickConnectEnterCodeDescription": "Enter the code below to login"
}
\ No newline at end of file
diff --git a/lib/main.dart b/lib/main.dart
index c1b1b34..71b1291 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -29,6 +29,7 @@ import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/routes/auto_router.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/login/lock_screen.dart';
+import 'package:fladder/src/video_player_helper.g.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/application_info.dart';
@@ -36,6 +37,7 @@ import 'package:fladder/util/fladder_config.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/util/themes_data.dart';
+import 'package:fladder/widgets/media_query_scaler.dart';
bool get _isDesktop {
if (kIsWeb) return false;
@@ -86,13 +88,16 @@ void main(List args) async {
os: !kIsWeb ? defaultTargetPlatform.name.capitalize() : "${defaultTargetPlatform.name.capitalize()} Web",
);
+ // Check if running on android TV
+ final leanBackEnabled = !kIsWeb && Platform.isAndroid ? await NativeVideoActivity().isLeanBackEnabled() : false;
+
runApp(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWith((ref) => sharedPreferences),
applicationInfoProvider.overrideWith((ref) => applicationInfo),
crashLogProvider.overrideWith((ref) => crashProvider),
- argumentsStateProvider.overrideWith((ref) => ArgumentsModel.fromArguments(args)),
+ argumentsStateProvider.overrideWith((ref) => ArgumentsModel.fromArguments(args, leanBackEnabled)),
syncProvider.overrideWith((ref) => SyncNotifier(ref, applicationDirectory))
],
child: AdaptiveLayoutBuilder(
@@ -116,7 +121,9 @@ class _MainState extends ConsumerState with WindowListener, WidgetsBinding
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
- if (ref.read(lockScreenActiveProvider) || ref.read(userProvider) == null) {
+ if (ref.read(lockScreenActiveProvider) ||
+ ref.read(userProvider) == null ||
+ ref.read(videoPlayerProvider).lastState?.playing == true) {
dateTime = DateTime.now();
return;
}
@@ -248,11 +255,8 @@ class _MainState extends ConsumerState with WindowListener, WidgetsBinding
final language = ref.watch(clientSettingsProvider
.select((value) => value.selectedLocale ?? WidgetsBinding.instance.platformDispatcher.locale));
final scrollBehaviour = const MaterialScrollBehavior();
- return Shortcuts(
- shortcuts: {
- LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
- },
- child: DynamicColorBuilder(builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
+ return DynamicColorBuilder(
+ builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
final lightTheme = themeColor == null
? FladderTheme.theme(lightDynamic ?? FladderTheme.defaultScheme(Brightness.light), schemeVariant)
: FladderTheme.theme(themeColor.schemeLight, schemeVariant);
@@ -281,9 +285,12 @@ class _MainState extends ConsumerState with WindowListener, WidgetsBinding
}
return locale;
},
- builder: (context, child) => LocalizationContextWrapper(
- child: ScaffoldMessenger(child: child ?? Container()),
- currentLocale: language,
+ builder: (context, child) => MediaQueryScaler(
+ child: LocalizationContextWrapper(
+ child: ScaffoldMessenger(child: child ?? Container()),
+ currentLocale: language,
+ ),
+ enable: ref.read(argumentsStateProvider).leanBackMode,
),
debugShowCheckedModeBanner: false,
darkTheme: darkTheme.copyWith(
@@ -300,7 +307,7 @@ class _MainState extends ConsumerState with WindowListener, WidgetsBinding
routerConfig: autoRouter.config(),
),
);
- }),
+ },
);
}
}
diff --git a/lib/models/item_base_model.dart b/lib/models/item_base_model.dart
index 7c3b9fc..b7c9a08 100644
--- a/lib/models/item_base_model.dart
+++ b/lib/models/item_base_model.dart
@@ -164,7 +164,7 @@ class ItemBaseModel with ItemBaseModelMappable {
}
}
- Future navigateTo(BuildContext context, {WidgetRef? ref}) async {
+ Future navigateTo(BuildContext context, {WidgetRef? ref, Object? tag}) async {
switch (this) {
case FolderModel _:
case BoxSetModel _:
@@ -191,7 +191,7 @@ class ItemBaseModel with ItemBaseModelMappable {
case SeasonModel _:
case PersonModel _:
default:
- context.router.push(DetailsRoute(id: id, item: this));
+ context.router.push(DetailsRoute(id: id, item: this, tag: tag));
break;
}
}
diff --git a/lib/models/items/episode_model.dart b/lib/models/items/episode_model.dart
index 4ed4351..64cc76c 100644
--- a/lib/models/items/episode_model.dart
+++ b/lib/models/items/episode_model.dart
@@ -181,7 +181,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable {
playlistId: item.playlistItemId,
dateAired: item.premiereDate,
chapters: Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], ref),
- images: ImagesData.fromBaseItem(item, ref, getOriginalSize: true),
+ images: ImagesData.fromBaseItem(item, ref),
primaryRatio: item.primaryImageAspectRatio,
season: item.parentIndexNumber ?? 0,
episode: item.indexNumber ?? 0,
diff --git a/lib/models/items/images_models.dart b/lib/models/items/images_models.dart
index 47e546d..a9f957a 100644
--- a/lib/models/items/images_models.dart
+++ b/lib/models/items/images_models.dart
@@ -38,10 +38,9 @@ class ImagesData {
dto.BaseItemDto item,
Ref ref, {
Size backDrop = const Size(2000, 2000),
- Size logo = const Size(1000, 1000),
+ Size logo = const Size(500, 500),
Size primary = const Size(600, 600),
bool getOriginalSize = false,
- int quality = 95,
}) {
final itemid = item.id;
if (itemid == null) return null;
@@ -59,7 +58,6 @@ class ImagesData {
type: enums.ImageType.primary,
maxHeight: primary.height.toInt(),
maxWidth: primary.width.toInt(),
- quality: quality,
),
key: "${itemid}_primary_${item.imageTags?['Primary']}",
hash: item.imageBlurHashes?.primary?[item.imageTags?['Primary']] ?? "",
@@ -77,7 +75,6 @@ class ImagesData {
type: enums.ImageType.logo,
maxHeight: logo.height.toInt(),
maxWidth: logo.width.toInt(),
- quality: quality,
),
key: "${itemid}_logo_${item.imageTags?['Logo']}",
hash: item.imageBlurHashes?.logo?[item.imageTags?['Logo']] ?? "")
@@ -98,7 +95,6 @@ class ImagesData {
backdrop,
maxHeight: backDrop.height.toInt(),
maxWidth: backDrop.width.toInt(),
- quality: quality,
),
key: "${itemid}_backdrop_${index}_$backdrop",
hash: item.imageBlurHashes?.backdrop?[backdrop] ?? "",
@@ -116,9 +112,8 @@ class ImagesData {
dto.BaseItemDto item,
Ref ref, {
Size backDrop = const Size(2000, 2000),
- Size logo = const Size(1000, 1000),
+ Size logo = const Size(500, 500),
Size primary = const Size(600, 600),
- int quality = 95,
}) {
if (item.seriesId == null && item.parentId == null) return null;
@@ -132,7 +127,6 @@ class ImagesData {
type: enums.ImageType.primary,
maxHeight: primary.height.toInt(),
maxWidth: primary.width.toInt(),
- quality: quality,
),
key: "${item.seriesId}_primary_${item.seriesPrimaryImageTag ?? ""}",
hash: item.imageBlurHashes?.primary?[item.seriesPrimaryImageTag] ?? "")
@@ -144,7 +138,6 @@ class ImagesData {
type: enums.ImageType.logo,
maxHeight: logo.height.toInt(),
maxWidth: logo.width.toInt(),
- quality: quality,
),
key: "${item.seriesId}_logo_${item.parentLogoImageTag ?? ""}",
hash: item.imageBlurHashes?.logo?[item.parentLogoImageTag] ?? "")
@@ -161,7 +154,6 @@ class ImagesData {
backdrop,
maxHeight: backDrop.height.toInt(),
maxWidth: backDrop.width.toInt(),
- quality: quality,
),
key: "${itemId}_backdrop_${index}_$backdrop",
hash: item.imageBlurHashes?.backdrop?[backdrop] ?? "",
@@ -180,8 +172,7 @@ class ImagesData {
Ref ref, {
Size backDrop = const Size(2000, 2000),
Size logo = const Size(1000, 1000),
- Size primary = const Size(2000, 2000),
- int quality = 95,
+ Size primary = const Size(500, 500),
}) {
return ImagesData(
primary: (item.primaryImageTag != null && item.imageBlurHashes != null)
@@ -191,7 +182,6 @@ class ImagesData {
type: enums.ImageType.primary,
maxHeight: primary.height.toInt(),
maxWidth: primary.width.toInt(),
- quality: quality,
),
key: "${item.id ?? ""}_primary_${item.primaryImageTag ?? ''}",
hash: item.imageBlurHashes?.primary?[item.primaryImageTag] ?? '')
diff --git a/lib/models/items/movie_model.dart b/lib/models/items/movie_model.dart
index d550b43..0aebe4e 100644
--- a/lib/models/items/movie_model.dart
+++ b/lib/models/items/movie_model.dart
@@ -74,6 +74,11 @@ class MovieModel extends ItemStreamModel with MovieModelMappable {
@override
MediaStreamsModel? get streamModel => mediaStreams;
+ @override
+ String? label(BuildContext context) {
+ return name;
+ }
+
@override
bool get syncAble => true;
diff --git a/lib/models/items/person_model.dart b/lib/models/items/person_model.dart
index ebbaf2a..7cc1a12 100644
--- a/lib/models/items/person_model.dart
+++ b/lib/models/items/person_model.dart
@@ -1,15 +1,14 @@
-import 'package:fladder/models/items/images_models.dart';
-import 'package:fladder/models/items/overview_model.dart';
+import 'package:dart_mappable/dart_mappable.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart';
+import 'package:fladder/models/items/images_models.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/movie_model.dart';
+import 'package:fladder/models/items/overview_model.dart';
import 'package:fladder/models/items/series_model.dart';
-import 'package:dart_mappable/dart_mappable.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-
part 'person_model.mapper.dart';
@MappableClass()
diff --git a/lib/models/items/series_model.dart b/lib/models/items/series_model.dart
index 1a22ef9..5ab8dde 100644
--- a/lib/models/items/series_model.dart
+++ b/lib/models/items/series_model.dart
@@ -85,7 +85,7 @@ class SeriesModel extends ItemBaseModel with SeriesModelMappable {
userData: UserData.fromDto(item.userData),
parentId: item.parentId,
playlistId: item.playlistItemId,
- images: ImagesData.fromBaseItem(item, ref, getOriginalSize: true),
+ images: ImagesData.fromBaseItem(item, ref),
primaryRatio: item.primaryImageAspectRatio,
originalTitle: item.originalTitle ?? "",
sortName: item.sortName ?? "",
diff --git a/lib/models/login_screen_model.dart b/lib/models/login_screen_model.dart
index c607bbc..ac6fa07 100644
--- a/lib/models/login_screen_model.dart
+++ b/lib/models/login_screen_model.dart
@@ -1,26 +1,34 @@
-// ignore_for_file: public_member_api_docs, sort_constructors_first
+import 'package:freezed_annotation/freezed_annotation.dart';
+
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/credentials_model.dart';
-class LoginScreenModel {
- final List accounts;
- final CredentialsModel tempCredentials;
- final bool loading;
- LoginScreenModel({
- required this.accounts,
- required this.tempCredentials,
- required this.loading,
- });
+part 'login_screen_model.freezed.dart';
- LoginScreenModel copyWith({
- List? accounts,
- CredentialsModel? tempCredentials,
- bool? loading,
- }) {
- return LoginScreenModel(
- accounts: accounts ?? this.accounts,
- tempCredentials: tempCredentials ?? this.tempCredentials,
- loading: loading ?? this.loading,
- );
- }
+enum LoginScreenType {
+ users,
+ login,
+ code,
+}
+
+@Freezed(copyWith: true)
+abstract class LoginScreenModel with _$LoginScreenModel {
+ factory LoginScreenModel({
+ @Default([]) List accounts,
+ @Default(LoginScreenType.users) LoginScreenType screen,
+ ServerLoginModel? serverLoginModel,
+ String? errorMessage,
+ @Default(false) bool hasBaseUrl,
+ @Default(false) bool loading,
+ }) = _LoginScreenModel;
+}
+
+@Freezed(copyWith: true)
+abstract class ServerLoginModel with _$ServerLoginModel {
+ factory ServerLoginModel({
+ required CredentialsModel tempCredentials,
+ @Default([]) List accounts,
+ String? serverMessage,
+ @Default(false) bool hasQuickConnect,
+ }) = _ServerLoginModel;
}
diff --git a/lib/models/login_screen_model.freezed.dart b/lib/models/login_screen_model.freezed.dart
new file mode 100644
index 0000000..acf33cf
--- /dev/null
+++ b/lib/models/login_screen_model.freezed.dart
@@ -0,0 +1,774 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// coverage:ignore-file
+// ignore_for_file: type=lint
+// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
+
+part of 'login_screen_model.dart';
+
+// **************************************************************************
+// FreezedGenerator
+// **************************************************************************
+
+// dart format off
+T _$identity(T value) => value;
+
+/// @nodoc
+mixin _$LoginScreenModel {
+ List get accounts;
+ LoginScreenType get screen;
+ ServerLoginModel? get serverLoginModel;
+ String? get errorMessage;
+ bool get hasBaseUrl;
+ bool get loading;
+
+ /// Create a copy of LoginScreenModel
+ /// with the given fields replaced by the non-null parameter values.
+ @JsonKey(includeFromJson: false, includeToJson: false)
+ @pragma('vm:prefer-inline')
+ $LoginScreenModelCopyWith get copyWith =>
+ _$LoginScreenModelCopyWithImpl(
+ this as LoginScreenModel, _$identity);
+
+ @override
+ String toString() {
+ return 'LoginScreenModel(accounts: $accounts, screen: $screen, serverLoginModel: $serverLoginModel, errorMessage: $errorMessage, hasBaseUrl: $hasBaseUrl, loading: $loading)';
+ }
+}
+
+/// @nodoc
+abstract mixin class $LoginScreenModelCopyWith<$Res> {
+ factory $LoginScreenModelCopyWith(
+ LoginScreenModel value, $Res Function(LoginScreenModel) _then) =
+ _$LoginScreenModelCopyWithImpl;
+ @useResult
+ $Res call(
+ {List accounts,
+ LoginScreenType screen,
+ ServerLoginModel? serverLoginModel,
+ String? errorMessage,
+ bool hasBaseUrl,
+ bool loading});
+
+ $ServerLoginModelCopyWith<$Res>? get serverLoginModel;
+}
+
+/// @nodoc
+class _$LoginScreenModelCopyWithImpl<$Res>
+ implements $LoginScreenModelCopyWith<$Res> {
+ _$LoginScreenModelCopyWithImpl(this._self, this._then);
+
+ final LoginScreenModel _self;
+ final $Res Function(LoginScreenModel) _then;
+
+ /// Create a copy of LoginScreenModel
+ /// with the given fields replaced by the non-null parameter values.
+ @pragma('vm:prefer-inline')
+ @override
+ $Res call({
+ Object? accounts = null,
+ Object? screen = null,
+ Object? serverLoginModel = freezed,
+ Object? errorMessage = freezed,
+ Object? hasBaseUrl = null,
+ Object? loading = null,
+ }) {
+ return _then(_self.copyWith(
+ accounts: null == accounts
+ ? _self.accounts
+ : accounts // ignore: cast_nullable_to_non_nullable
+ as List,
+ screen: null == screen
+ ? _self.screen
+ : screen // ignore: cast_nullable_to_non_nullable
+ as LoginScreenType,
+ serverLoginModel: freezed == serverLoginModel
+ ? _self.serverLoginModel
+ : serverLoginModel // ignore: cast_nullable_to_non_nullable
+ as ServerLoginModel?,
+ errorMessage: freezed == errorMessage
+ ? _self.errorMessage
+ : errorMessage // ignore: cast_nullable_to_non_nullable
+ as String?,
+ hasBaseUrl: null == hasBaseUrl
+ ? _self.hasBaseUrl
+ : hasBaseUrl // ignore: cast_nullable_to_non_nullable
+ as bool,
+ loading: null == loading
+ ? _self.loading
+ : loading // ignore: cast_nullable_to_non_nullable
+ as bool,
+ ));
+ }
+
+ /// Create a copy of LoginScreenModel
+ /// with the given fields replaced by the non-null parameter values.
+ @override
+ @pragma('vm:prefer-inline')
+ $ServerLoginModelCopyWith<$Res>? get serverLoginModel {
+ if (_self.serverLoginModel == null) {
+ return null;
+ }
+
+ return $ServerLoginModelCopyWith<$Res>(_self.serverLoginModel!, (value) {
+ return _then(_self.copyWith(serverLoginModel: value));
+ });
+ }
+}
+
+/// Adds pattern-matching-related methods to [LoginScreenModel].
+extension LoginScreenModelPatterns on LoginScreenModel {
+ /// A variant of `map` that fallback to returning `orElse`.
+ ///
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case final Subclass value:
+ /// return ...;
+ /// case _:
+ /// return orElse();
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult maybeMap(
+ TResult Function(_LoginScreenModel value)? $default, {
+ required TResult orElse(),
+ }) {
+ final _that = this;
+ switch (_that) {
+ case _LoginScreenModel() when $default != null:
+ return $default(_that);
+ case _:
+ return orElse();
+ }
+ }
+
+ /// A `switch`-like method, using callbacks.
+ ///
+ /// Callbacks receives the raw object, upcasted.
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case final Subclass value:
+ /// return ...;
+ /// case final Subclass2 value:
+ /// return ...;
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult map(
+ TResult Function(_LoginScreenModel value) $default,
+ ) {
+ final _that = this;
+ switch (_that) {
+ case _LoginScreenModel():
+ return $default(_that);
+ case _:
+ throw StateError('Unexpected subclass');
+ }
+ }
+
+ /// A variant of `map` that fallback to returning `null`.
+ ///
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case final Subclass value:
+ /// return ...;
+ /// case _:
+ /// return null;
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult? mapOrNull(
+ TResult? Function(_LoginScreenModel value)? $default,
+ ) {
+ final _that = this;
+ switch (_that) {
+ case _LoginScreenModel() when $default != null:
+ return $default(_that);
+ case _:
+ return null;
+ }
+ }
+
+ /// A variant of `when` that fallback to an `orElse` callback.
+ ///
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case Subclass(:final field):
+ /// return ...;
+ /// case _:
+ /// return orElse();
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult maybeWhen(
+ TResult Function(
+ List accounts,
+ LoginScreenType screen,
+ ServerLoginModel? serverLoginModel,
+ String? errorMessage,
+ bool hasBaseUrl,
+ bool loading)?
+ $default, {
+ required TResult orElse(),
+ }) {
+ final _that = this;
+ switch (_that) {
+ case _LoginScreenModel() when $default != null:
+ return $default(_that.accounts, _that.screen, _that.serverLoginModel,
+ _that.errorMessage, _that.hasBaseUrl, _that.loading);
+ case _:
+ return orElse();
+ }
+ }
+
+ /// A `switch`-like method, using callbacks.
+ ///
+ /// As opposed to `map`, this offers destructuring.
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case Subclass(:final field):
+ /// return ...;
+ /// case Subclass2(:final field2):
+ /// return ...;
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult when(
+ TResult Function(
+ List accounts,
+ LoginScreenType screen,
+ ServerLoginModel? serverLoginModel,
+ String? errorMessage,
+ bool hasBaseUrl,
+ bool loading)
+ $default,
+ ) {
+ final _that = this;
+ switch (_that) {
+ case _LoginScreenModel():
+ return $default(_that.accounts, _that.screen, _that.serverLoginModel,
+ _that.errorMessage, _that.hasBaseUrl, _that.loading);
+ case _:
+ throw StateError('Unexpected subclass');
+ }
+ }
+
+ /// A variant of `when` that fallback to returning `null`
+ ///
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case Subclass(:final field):
+ /// return ...;
+ /// case _:
+ /// return null;
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult? whenOrNull(
+ TResult? Function(
+ List accounts,
+ LoginScreenType screen,
+ ServerLoginModel? serverLoginModel,
+ String? errorMessage,
+ bool hasBaseUrl,
+ bool loading)?
+ $default,
+ ) {
+ final _that = this;
+ switch (_that) {
+ case _LoginScreenModel() when $default != null:
+ return $default(_that.accounts, _that.screen, _that.serverLoginModel,
+ _that.errorMessage, _that.hasBaseUrl, _that.loading);
+ case _:
+ return null;
+ }
+ }
+}
+
+/// @nodoc
+
+class _LoginScreenModel implements LoginScreenModel {
+ _LoginScreenModel(
+ {final List accounts = const [],
+ this.screen = LoginScreenType.users,
+ this.serverLoginModel,
+ this.errorMessage,
+ this.hasBaseUrl = false,
+ this.loading = false})
+ : _accounts = accounts;
+
+ final List _accounts;
+ @override
+ @JsonKey()
+ List get accounts {
+ if (_accounts is EqualUnmodifiableListView) return _accounts;
+ // ignore: implicit_dynamic_type
+ return EqualUnmodifiableListView(_accounts);
+ }
+
+ @override
+ @JsonKey()
+ final LoginScreenType screen;
+ @override
+ final ServerLoginModel? serverLoginModel;
+ @override
+ final String? errorMessage;
+ @override
+ @JsonKey()
+ final bool hasBaseUrl;
+ @override
+ @JsonKey()
+ final bool loading;
+
+ /// Create a copy of LoginScreenModel
+ /// with the given fields replaced by the non-null parameter values.
+ @override
+ @JsonKey(includeFromJson: false, includeToJson: false)
+ @pragma('vm:prefer-inline')
+ _$LoginScreenModelCopyWith<_LoginScreenModel> get copyWith =>
+ __$LoginScreenModelCopyWithImpl<_LoginScreenModel>(this, _$identity);
+
+ @override
+ String toString() {
+ return 'LoginScreenModel(accounts: $accounts, screen: $screen, serverLoginModel: $serverLoginModel, errorMessage: $errorMessage, hasBaseUrl: $hasBaseUrl, loading: $loading)';
+ }
+}
+
+/// @nodoc
+abstract mixin class _$LoginScreenModelCopyWith<$Res>
+ implements $LoginScreenModelCopyWith<$Res> {
+ factory _$LoginScreenModelCopyWith(
+ _LoginScreenModel value, $Res Function(_LoginScreenModel) _then) =
+ __$LoginScreenModelCopyWithImpl;
+ @override
+ @useResult
+ $Res call(
+ {List accounts,
+ LoginScreenType screen,
+ ServerLoginModel? serverLoginModel,
+ String? errorMessage,
+ bool hasBaseUrl,
+ bool loading});
+
+ @override
+ $ServerLoginModelCopyWith<$Res>? get serverLoginModel;
+}
+
+/// @nodoc
+class __$LoginScreenModelCopyWithImpl<$Res>
+ implements _$LoginScreenModelCopyWith<$Res> {
+ __$LoginScreenModelCopyWithImpl(this._self, this._then);
+
+ final _LoginScreenModel _self;
+ final $Res Function(_LoginScreenModel) _then;
+
+ /// Create a copy of LoginScreenModel
+ /// with the given fields replaced by the non-null parameter values.
+ @override
+ @pragma('vm:prefer-inline')
+ $Res call({
+ Object? accounts = null,
+ Object? screen = null,
+ Object? serverLoginModel = freezed,
+ Object? errorMessage = freezed,
+ Object? hasBaseUrl = null,
+ Object? loading = null,
+ }) {
+ return _then(_LoginScreenModel(
+ accounts: null == accounts
+ ? _self._accounts
+ : accounts // ignore: cast_nullable_to_non_nullable
+ as List,
+ screen: null == screen
+ ? _self.screen
+ : screen // ignore: cast_nullable_to_non_nullable
+ as LoginScreenType,
+ serverLoginModel: freezed == serverLoginModel
+ ? _self.serverLoginModel
+ : serverLoginModel // ignore: cast_nullable_to_non_nullable
+ as ServerLoginModel?,
+ errorMessage: freezed == errorMessage
+ ? _self.errorMessage
+ : errorMessage // ignore: cast_nullable_to_non_nullable
+ as String?,
+ hasBaseUrl: null == hasBaseUrl
+ ? _self.hasBaseUrl
+ : hasBaseUrl // ignore: cast_nullable_to_non_nullable
+ as bool,
+ loading: null == loading
+ ? _self.loading
+ : loading // ignore: cast_nullable_to_non_nullable
+ as bool,
+ ));
+ }
+
+ /// Create a copy of LoginScreenModel
+ /// with the given fields replaced by the non-null parameter values.
+ @override
+ @pragma('vm:prefer-inline')
+ $ServerLoginModelCopyWith<$Res>? get serverLoginModel {
+ if (_self.serverLoginModel == null) {
+ return null;
+ }
+
+ return $ServerLoginModelCopyWith<$Res>(_self.serverLoginModel!, (value) {
+ return _then(_self.copyWith(serverLoginModel: value));
+ });
+ }
+}
+
+/// @nodoc
+mixin _$ServerLoginModel {
+ CredentialsModel get tempCredentials;
+ List get accounts;
+ String? get serverMessage;
+ bool get hasQuickConnect;
+
+ /// Create a copy of ServerLoginModel
+ /// with the given fields replaced by the non-null parameter values.
+ @JsonKey(includeFromJson: false, includeToJson: false)
+ @pragma('vm:prefer-inline')
+ $ServerLoginModelCopyWith get copyWith =>
+ _$ServerLoginModelCopyWithImpl(
+ this as ServerLoginModel, _$identity);
+
+ @override
+ String toString() {
+ return 'ServerLoginModel(tempCredentials: $tempCredentials, accounts: $accounts, serverMessage: $serverMessage, hasQuickConnect: $hasQuickConnect)';
+ }
+}
+
+/// @nodoc
+abstract mixin class $ServerLoginModelCopyWith<$Res> {
+ factory $ServerLoginModelCopyWith(
+ ServerLoginModel value, $Res Function(ServerLoginModel) _then) =
+ _$ServerLoginModelCopyWithImpl;
+ @useResult
+ $Res call(
+ {CredentialsModel tempCredentials,
+ List accounts,
+ String? serverMessage,
+ bool hasQuickConnect});
+}
+
+/// @nodoc
+class _$ServerLoginModelCopyWithImpl<$Res>
+ implements $ServerLoginModelCopyWith<$Res> {
+ _$ServerLoginModelCopyWithImpl(this._self, this._then);
+
+ final ServerLoginModel _self;
+ final $Res Function(ServerLoginModel) _then;
+
+ /// Create a copy of ServerLoginModel
+ /// with the given fields replaced by the non-null parameter values.
+ @pragma('vm:prefer-inline')
+ @override
+ $Res call({
+ Object? tempCredentials = null,
+ Object? accounts = null,
+ Object? serverMessage = freezed,
+ Object? hasQuickConnect = null,
+ }) {
+ return _then(_self.copyWith(
+ tempCredentials: null == tempCredentials
+ ? _self.tempCredentials
+ : tempCredentials // ignore: cast_nullable_to_non_nullable
+ as CredentialsModel,
+ accounts: null == accounts
+ ? _self.accounts
+ : accounts // ignore: cast_nullable_to_non_nullable
+ as List,
+ serverMessage: freezed == serverMessage
+ ? _self.serverMessage
+ : serverMessage // ignore: cast_nullable_to_non_nullable
+ as String?,
+ hasQuickConnect: null == hasQuickConnect
+ ? _self.hasQuickConnect
+ : hasQuickConnect // ignore: cast_nullable_to_non_nullable
+ as bool,
+ ));
+ }
+}
+
+/// Adds pattern-matching-related methods to [ServerLoginModel].
+extension ServerLoginModelPatterns on ServerLoginModel {
+ /// A variant of `map` that fallback to returning `orElse`.
+ ///
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case final Subclass value:
+ /// return ...;
+ /// case _:
+ /// return orElse();
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult maybeMap(
+ TResult Function(_ServerLoginModel value)? $default, {
+ required TResult orElse(),
+ }) {
+ final _that = this;
+ switch (_that) {
+ case _ServerLoginModel() when $default != null:
+ return $default(_that);
+ case _:
+ return orElse();
+ }
+ }
+
+ /// A `switch`-like method, using callbacks.
+ ///
+ /// Callbacks receives the raw object, upcasted.
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case final Subclass value:
+ /// return ...;
+ /// case final Subclass2 value:
+ /// return ...;
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult map(
+ TResult Function(_ServerLoginModel value) $default,
+ ) {
+ final _that = this;
+ switch (_that) {
+ case _ServerLoginModel():
+ return $default(_that);
+ case _:
+ throw StateError('Unexpected subclass');
+ }
+ }
+
+ /// A variant of `map` that fallback to returning `null`.
+ ///
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case final Subclass value:
+ /// return ...;
+ /// case _:
+ /// return null;
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult? mapOrNull(
+ TResult? Function(_ServerLoginModel value)? $default,
+ ) {
+ final _that = this;
+ switch (_that) {
+ case _ServerLoginModel() when $default != null:
+ return $default(_that);
+ case _:
+ return null;
+ }
+ }
+
+ /// A variant of `when` that fallback to an `orElse` callback.
+ ///
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case Subclass(:final field):
+ /// return ...;
+ /// case _:
+ /// return orElse();
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult maybeWhen(
+ TResult Function(
+ CredentialsModel tempCredentials,
+ List accounts,
+ String? serverMessage,
+ bool hasQuickConnect)?
+ $default, {
+ required TResult orElse(),
+ }) {
+ final _that = this;
+ switch (_that) {
+ case _ServerLoginModel() when $default != null:
+ return $default(_that.tempCredentials, _that.accounts,
+ _that.serverMessage, _that.hasQuickConnect);
+ case _:
+ return orElse();
+ }
+ }
+
+ /// A `switch`-like method, using callbacks.
+ ///
+ /// As opposed to `map`, this offers destructuring.
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case Subclass(:final field):
+ /// return ...;
+ /// case Subclass2(:final field2):
+ /// return ...;
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult when(
+ TResult Function(
+ CredentialsModel tempCredentials,
+ List accounts,
+ String? serverMessage,
+ bool hasQuickConnect)
+ $default,
+ ) {
+ final _that = this;
+ switch (_that) {
+ case _ServerLoginModel():
+ return $default(_that.tempCredentials, _that.accounts,
+ _that.serverMessage, _that.hasQuickConnect);
+ case _:
+ throw StateError('Unexpected subclass');
+ }
+ }
+
+ /// A variant of `when` that fallback to returning `null`
+ ///
+ /// It is equivalent to doing:
+ /// ```dart
+ /// switch (sealedClass) {
+ /// case Subclass(:final field):
+ /// return ...;
+ /// case _:
+ /// return null;
+ /// }
+ /// ```
+
+ @optionalTypeArgs
+ TResult? whenOrNull(
+ TResult? Function(
+ CredentialsModel tempCredentials,
+ List accounts,
+ String? serverMessage,
+ bool hasQuickConnect)?
+ $default,
+ ) {
+ final _that = this;
+ switch (_that) {
+ case _ServerLoginModel() when $default != null:
+ return $default(_that.tempCredentials, _that.accounts,
+ _that.serverMessage, _that.hasQuickConnect);
+ case _:
+ return null;
+ }
+ }
+}
+
+/// @nodoc
+
+class _ServerLoginModel implements ServerLoginModel {
+ _ServerLoginModel(
+ {required this.tempCredentials,
+ final List accounts = const [],
+ this.serverMessage,
+ this.hasQuickConnect = false})
+ : _accounts = accounts;
+
+ @override
+ final CredentialsModel tempCredentials;
+ final List _accounts;
+ @override
+ @JsonKey()
+ List get accounts {
+ if (_accounts is EqualUnmodifiableListView) return _accounts;
+ // ignore: implicit_dynamic_type
+ return EqualUnmodifiableListView(_accounts);
+ }
+
+ @override
+ final String? serverMessage;
+ @override
+ @JsonKey()
+ final bool hasQuickConnect;
+
+ /// Create a copy of ServerLoginModel
+ /// with the given fields replaced by the non-null parameter values.
+ @override
+ @JsonKey(includeFromJson: false, includeToJson: false)
+ @pragma('vm:prefer-inline')
+ _$ServerLoginModelCopyWith<_ServerLoginModel> get copyWith =>
+ __$ServerLoginModelCopyWithImpl<_ServerLoginModel>(this, _$identity);
+
+ @override
+ String toString() {
+ return 'ServerLoginModel(tempCredentials: $tempCredentials, accounts: $accounts, serverMessage: $serverMessage, hasQuickConnect: $hasQuickConnect)';
+ }
+}
+
+/// @nodoc
+abstract mixin class _$ServerLoginModelCopyWith<$Res>
+ implements $ServerLoginModelCopyWith<$Res> {
+ factory _$ServerLoginModelCopyWith(
+ _ServerLoginModel value, $Res Function(_ServerLoginModel) _then) =
+ __$ServerLoginModelCopyWithImpl;
+ @override
+ @useResult
+ $Res call(
+ {CredentialsModel tempCredentials,
+ List accounts,
+ String? serverMessage,
+ bool hasQuickConnect});
+}
+
+/// @nodoc
+class __$ServerLoginModelCopyWithImpl<$Res>
+ implements _$ServerLoginModelCopyWith<$Res> {
+ __$ServerLoginModelCopyWithImpl(this._self, this._then);
+
+ final _ServerLoginModel _self;
+ final $Res Function(_ServerLoginModel) _then;
+
+ /// Create a copy of ServerLoginModel
+ /// with the given fields replaced by the non-null parameter values.
+ @override
+ @pragma('vm:prefer-inline')
+ $Res call({
+ Object? tempCredentials = null,
+ Object? accounts = null,
+ Object? serverMessage = freezed,
+ Object? hasQuickConnect = null,
+ }) {
+ return _then(_ServerLoginModel(
+ tempCredentials: null == tempCredentials
+ ? _self.tempCredentials
+ : tempCredentials // ignore: cast_nullable_to_non_nullable
+ as CredentialsModel,
+ accounts: null == accounts
+ ? _self._accounts
+ : accounts // ignore: cast_nullable_to_non_nullable
+ as List,
+ serverMessage: freezed == serverMessage
+ ? _self.serverMessage
+ : serverMessage // ignore: cast_nullable_to_non_nullable
+ as String?,
+ hasQuickConnect: null == hasQuickConnect
+ ? _self.hasQuickConnect
+ : hasQuickConnect // ignore: cast_nullable_to_non_nullable
+ as bool,
+ ));
+ }
+}
+
+// dart format on
diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart
index 33ec571..7f17379 100644
--- a/lib/models/playback/playback_model.dart
+++ b/lib/models/playback/playback_model.dart
@@ -137,7 +137,7 @@ class PlaybackModelHelper {
oldModel: currentModel,
);
if (newModel == null) return null;
- ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, startPosition: Duration.zero);
+ ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, Duration.zero);
return newModel;
}
@@ -502,7 +502,7 @@ class PlaybackModelHelper {
}
if (newModel == null) return;
if (newModel.runtimeType != playbackModel.runtimeType || newModel is TranscodePlaybackModel) {
- ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, startPosition: currentPosition);
+ ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, currentPosition);
}
}
}
diff --git a/lib/models/settings/arguments_model.dart b/lib/models/settings/arguments_model.dart
index 2331357..7dcaac2 100644
--- a/lib/models/settings/arguments_model.dart
+++ b/lib/models/settings/arguments_model.dart
@@ -8,12 +8,14 @@ abstract class ArgumentsModel with _$ArgumentsModel {
factory ArgumentsModel({
@Default(false) bool htpcMode,
+ @Default(false) bool leanBackMode,
}) = _ArgumentsModel;
- factory ArgumentsModel.fromArguments(List arguments) {
+ factory ArgumentsModel.fromArguments(List arguments, bool leanBackEnabled) {
arguments = arguments.map((e) => e.trim()).toList();
return ArgumentsModel(
- htpcMode: arguments.contains('--htpc'),
+ htpcMode: arguments.contains('--htpc') || leanBackEnabled,
+ leanBackMode: leanBackEnabled,
);
}
}
diff --git a/lib/models/settings/arguments_model.freezed.dart b/lib/models/settings/arguments_model.freezed.dart
index 2e81426..333a23e 100644
--- a/lib/models/settings/arguments_model.freezed.dart
+++ b/lib/models/settings/arguments_model.freezed.dart
@@ -15,10 +15,11 @@ T _$identity(T value) => value;
/// @nodoc
mixin _$ArgumentsModel {
bool get htpcMode;
+ bool get leanBackMode;
@override
String toString() {
- return 'ArgumentsModel(htpcMode: $htpcMode)';
+ return 'ArgumentsModel(htpcMode: $htpcMode, leanBackMode: $leanBackMode)';
}
}
@@ -115,13 +116,13 @@ extension ArgumentsModelPatterns on ArgumentsModel {
@optionalTypeArgs
TResult maybeWhen(
- TResult Function(bool htpcMode)? $default, {
+ TResult Function(bool htpcMode, bool leanBackMode)? $default, {
required TResult orElse(),
}) {
final _that = this;
switch (_that) {
case _ArgumentsModel() when $default != null:
- return $default(_that.htpcMode);
+ return $default(_that.htpcMode, _that.leanBackMode);
case _:
return orElse();
}
@@ -142,12 +143,12 @@ extension ArgumentsModelPatterns on ArgumentsModel {
@optionalTypeArgs
TResult when(
- TResult Function(bool htpcMode) $default,
+ TResult Function(bool htpcMode, bool leanBackMode) $default,
) {
final _that = this;
switch (_that) {
case _ArgumentsModel():
- return $default(_that.htpcMode);
+ return $default(_that.htpcMode, _that.leanBackMode);
case _:
throw StateError('Unexpected subclass');
}
@@ -167,12 +168,12 @@ extension ArgumentsModelPatterns on ArgumentsModel {
@optionalTypeArgs
TResult? whenOrNull(
- TResult? Function(bool htpcMode)? $default,
+ TResult? Function(bool htpcMode, bool leanBackMode)? $default,
) {
final _that = this;
switch (_that) {
case _ArgumentsModel() when $default != null:
- return $default(_that.htpcMode);
+ return $default(_that.htpcMode, _that.leanBackMode);
case _:
return null;
}
@@ -182,15 +183,19 @@ extension ArgumentsModelPatterns on ArgumentsModel {
/// @nodoc
class _ArgumentsModel extends ArgumentsModel {
- _ArgumentsModel({this.htpcMode = false}) : super._();
+ _ArgumentsModel({this.htpcMode = false, this.leanBackMode = false})
+ : super._();
@override
@JsonKey()
final bool htpcMode;
+ @override
+ @JsonKey()
+ final bool leanBackMode;
@override
String toString() {
- return 'ArgumentsModel(htpcMode: $htpcMode)';
+ return 'ArgumentsModel(htpcMode: $htpcMode, leanBackMode: $leanBackMode)';
}
}
diff --git a/lib/models/settings/home_settings_model.dart b/lib/models/settings/home_settings_model.dart
index 0f2de4b..a061c5f 100644
--- a/lib/models/settings/home_settings_model.dart
+++ b/lib/models/settings/home_settings_model.dart
@@ -40,7 +40,8 @@ T selectAvailableOrSmaller(T value, Set availableOptions, List allOptio
enum HomeBanner {
hide,
carousel,
- banner;
+ banner,
+ detailedBanner;
const HomeBanner();
@@ -48,6 +49,7 @@ enum HomeBanner {
HomeBanner.hide => context.localized.hide,
HomeBanner.carousel => context.localized.homeBannerCarousel,
HomeBanner.banner => context.localized.homeBannerSlideshow,
+ HomeBanner.detailedBanner => 'Detailed banner'
};
}
diff --git a/lib/models/settings/home_settings_model.g.dart b/lib/models/settings/home_settings_model.g.dart
index 6307c72..8cb95ce 100644
--- a/lib/models/settings/home_settings_model.g.dart
+++ b/lib/models/settings/home_settings_model.g.dart
@@ -47,12 +47,14 @@ const _$ViewSizeEnumMap = {
ViewSize.phone: 'phone',
ViewSize.tablet: 'tablet',
ViewSize.desktop: 'desktop',
+ ViewSize.television: 'television',
};
const _$HomeBannerEnumMap = {
HomeBanner.hide: 'hide',
HomeBanner.carousel: 'carousel',
HomeBanner.banner: 'banner',
+ HomeBanner.detailedBanner: 'detailedBanner',
};
const _$HomeCarouselSettingsEnumMap = {
diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart
index a735ba1..50ff903 100644
--- a/lib/models/settings/video_player_settings.dart
+++ b/lib/models/settings/video_player_settings.dart
@@ -127,11 +127,17 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
enum PlayerOptions {
libMDK,
- libMPV;
+ libMPV,
+ nativePlayer;
const PlayerOptions();
- static Iterable get available => kIsWeb ? {PlayerOptions.libMPV} : PlayerOptions.values;
+ static Iterable get available => kIsWeb
+ ? {PlayerOptions.libMPV}
+ : switch (defaultTargetPlatform) {
+ TargetPlatform.android => PlayerOptions.values,
+ _ => {PlayerOptions.libMDK, PlayerOptions.libMPV},
+ };
static PlayerOptions get platformDefaults {
if (kIsWeb) return PlayerOptions.libMPV;
@@ -143,6 +149,7 @@ enum PlayerOptions {
String label(BuildContext context) => switch (this) {
PlayerOptions.libMDK => "MDK",
PlayerOptions.libMPV => "MPV",
+ PlayerOptions.nativePlayer => "Native",
};
}
diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart
index 096d0bc..ec6da4c 100644
--- a/lib/models/settings/video_player_settings.g.dart
+++ b/lib/models/settings/video_player_settings.g.dart
@@ -82,6 +82,7 @@ const _$BoxFitEnumMap = {
const _$PlayerOptionsEnumMap = {
PlayerOptions.libMDK: 'libMDK',
PlayerOptions.libMPV: 'libMPV',
+ PlayerOptions.nativePlayer: 'nativePlayer',
};
const _$DeviceOrientationEnumMap = {
diff --git a/lib/providers/api_provider.dart b/lib/providers/api_provider.dart
index 777360f..c6ef052 100644
--- a/lib/providers/api_provider.dart
+++ b/lib/providers/api_provider.dart
@@ -37,10 +37,14 @@ class JellyRequest implements Interceptor {
FutureOr> intercept(Chain chain) async {
final connectivityNotifier = ref.read(connectivityStatusProvider.notifier);
try {
- final serverUrl = Uri.parse(ref.read(userProvider)?.server ?? ref.read(authProvider).tempCredentials.server);
+ final serverUrl = Uri.parse(
+ ref.read(userProvider)?.server ?? ref.read(authProvider).serverLoginModel?.tempCredentials.server ?? "");
//Use current logged in user otherwise use the authprovider
- var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).tempCredentials;
+ var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).serverLoginModel?.tempCredentials;
+
+ if (loginModel == null) throw UnimplementedError();
+
var headers = loginModel.header(ref);
final Response response = await chain.proceed(
applyHeaders(
diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart
index ff5cd87..3be9feb 100644
--- a/lib/providers/auth_provider.dart
+++ b/lib/providers/auth_provider.dart
@@ -1,6 +1,9 @@
+import 'package:flutter/material.dart';
+
import 'package:chopper/chopper.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/credentials_model.dart';
import 'package:fladder/models/login_screen_model.dart';
@@ -13,44 +16,133 @@ import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/views_provider.dart';
+import 'package:fladder/screens/login/lock_screen.dart';
+import 'package:fladder/screens/shared/fladder_snackbar.dart';
+import 'package:fladder/util/fladder_config.dart';
+import 'package:fladder/util/localization_helper.dart';
+import 'package:fladder/util/string_extensions.dart';
final authProvider = StateNotifierProvider((ref) {
return AuthNotifier(ref);
});
class AuthNotifier extends StateNotifier {
- AuthNotifier(this.ref)
- : super(
- LoginScreenModel(
- accounts: [],
- tempCredentials: CredentialsModel.createNewCredentials(),
- loading: false,
- ),
- );
+ AuthNotifier(this.ref) : super(LoginScreenModel());
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
- Future>?> getPublicUsers() async {
- try {
- var response = await api.usersPublicGet(state.tempCredentials);
- if (response.isSuccessful && response.body != null) {
- var models = response.body ?? [];
+ BuildContext? context;
- return response.copyWith(body: models.toList());
+ Future initModel(BuildContext newContext) async {
+ context ??= newContext;
+ ref.read(userProvider.notifier).clear();
+ final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts();
+ ref.read(lockScreenActiveProvider.notifier).update((state) => true);
+ if (FladderConfig.baseUrl != null) {
+ final url = FladderConfig.baseUrl;
+ state = state.copyWith(
+ hasBaseUrl: true,
+ );
+ if (url != null) {
+ await setServer(url);
}
- return response.copyWith(body: []);
+ }
+ state = state.copyWith(
+ accounts: currentAccounts,
+ screen: currentAccounts.isEmpty ? LoginScreenType.login : LoginScreenType.users,
+ );
+ }
+
+ Future _fetchServerInfo(String url) async {
+ try {
+ final newCredentials = CredentialsModel.createNewCredentials().copyWith(server: url.rtrim('/'));
+ final newLoginModel = ServerLoginModel(tempCredentials: newCredentials);
+ state = state.copyWith(
+ serverLoginModel: newLoginModel,
+ loading: true,
+ );
+ final publicUsers = (await getPublicUsers())?.body ?? [];
+ final quickConnectStatus = (await api.quickConnectEnabled()).body ?? false;
+ final branding = await api.getBranding();
+ final serverResponse = await api.systemInfoPublicGet();
+ state = state.copyWith(
+ screen: quickConnectStatus ? LoginScreenType.code : LoginScreenType.login,
+ serverLoginModel: newLoginModel.copyWith(
+ tempCredentials: newCredentials.copyWith(
+ serverName: serverResponse.body?.serverName,
+ serverId: serverResponse.body?.id,
+ ),
+ accounts: publicUsers,
+ hasQuickConnect: quickConnectStatus,
+ serverMessage: branding.body?.loginDisclaimer,
+ ),
+ loading: false,
+ );
} catch (e) {
- return null;
+ state = state.copyWith(
+ errorMessage: context?.localized.invalidUrl,
+ loading: false,
+ );
+ if (context != null) {
+ fladderSnackbar(context!, title: context!.localized.unableToConnectHost);
+ }
}
}
+ String? _parseUrl(String url) {
+ if (url.isEmpty) {
+ return null;
+ }
+ if (!Uri.parse(url).isAbsolute) {
+ return context?.localized.invalidUrl;
+ }
+
+ if (!url.startsWith('https://') && !url.startsWith('http://')) {
+ return context?.localized.invalidUrlDesc;
+ }
+ return null;
+ }
+
+ Future>?> getPublicUsers() async {
+ try {
+ state = state.copyWith(loading: true);
+ final credentials = state.serverLoginModel?.tempCredentials;
+ if (credentials == null) return null;
+ var response = await api.usersPublicGet(credentials);
+ if (response.isSuccessful && response.body != null) {
+ var models = response.body ?? [];
+ return response.copyWith(body: models.toList());
+ }
+ state = state.copyWith(
+ serverLoginModel: state.serverLoginModel?.copyWith(
+ accounts: response.body ?? [],
+ ),
+ );
+ return response.copyWith(body: []);
+ } catch (e) {
+ return null;
+ } finally {
+ state = state.copyWith(loading: false);
+ }
+ }
+
+ Future?> authenticateUsingSecret(String secret) async {
+ clearAllProviders();
+ var response = await api.quickConnectAuthenticate(secret);
+ return _createAccountModel(response);
+ }
+
Future?> authenticateByName(String userName, String password) async {
- state = state.copyWith(loading: true);
clearAllProviders();
var response = await api.usersAuthenticateByNamePost(userName: userName, password: password);
- CredentialsModel credentials = state.tempCredentials;
+ return _createAccountModel(response);
+ }
+
+ Future?> _createAccountModel(Response response) async {
+ CredentialsModel? credentials = state.serverLoginModel?.tempCredentials;
+ if (credentials == null) return null;
if (response.isSuccessful && (response.body?.accessToken?.isNotEmpty ?? false)) {
var serverResponse = await api.systemInfoPublicGet();
credentials = credentials.copyWith(
@@ -68,16 +160,21 @@ class AuthNotifier extends StateNotifier {
);
ref.read(sharedUtilityProvider).addAccount(newUser);
ref.read(userProvider.notifier).userState = newUser;
- state = state.copyWith(loading: false);
+ final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts();
+
+ state = state.copyWith(
+ serverLoginModel: null,
+ accounts: currentAccounts,
+ );
+
return Response(response.base, newUser);
}
- state = state.copyWith(loading: false);
return Response(response.base, null);
}
Future logOutUser() async {
final currentUser = ref.read(userProvider);
- state = state.copyWith(tempCredentials: CredentialsModel.createNewCredentials());
+ state = state.copyWith(serverLoginModel: null);
await ref.read(sharedUtilityProvider).removeAccount(currentUser);
clearAllProviders();
return null;
@@ -95,10 +192,17 @@ class AuthNotifier extends StateNotifier {
ref.read(libraryScreenProvider.notifier).clear();
}
- void setServer(String server) {
+ Future setServer(String server) async {
+ final url = (state.hasBaseUrl ? FladderConfig.baseUrl : server);
+ if (url == null || server.isEmpty) return;
+ final isUrlValid = _parseUrl(url);
state = state.copyWith(
- tempCredentials: state.tempCredentials.copyWith(server: server),
+ errorMessage: isUrlValid,
+ serverLoginModel: null,
);
+ if (isUrlValid == null) {
+ await _fetchServerInfo(url);
+ }
}
List getSavedAccounts() {
@@ -113,4 +217,27 @@ class AuthNotifier extends StateNotifier {
accounts.insert(newIndex, original);
ref.read(sharedUtilityProvider).saveAccounts(accounts);
}
+
+ void addNewUser() {
+ state = state.copyWith(
+ screen: LoginScreenType.login,
+ );
+ }
+
+ void goUserSelect() {
+ state = state.copyWith(
+ serverLoginModel: state.hasBaseUrl ? state.serverLoginModel : null,
+ screen: LoginScreenType.users,
+ );
+ }
+
+ void tryParseUrl(String server) {
+ if (server.isNotEmpty && state.errorMessage != null) {
+ final url = server;
+ final isUrlValid = _parseUrl(url);
+ state = state.copyWith(
+ errorMessage: isUrlValid,
+ );
+ }
+ }
}
diff --git a/lib/providers/dashboard_provider.dart b/lib/providers/dashboard_provider.dart
index 1c8ca7d..2d78a63 100644
--- a/lib/providers/dashboard_provider.dart
+++ b/lib/providers/dashboard_provider.dart
@@ -26,9 +26,16 @@ class DashboardNotifier extends StateNotifier {
final viewTypes =
ref.read(viewsProvider.select((value) => value.dashboardViews)).map((e) => e.collectionType).toSet().toList();
+ final imagesToFetch = {
+ ImageType.logo,
+ ImageType.primary,
+ ImageType.backdrop,
+ ImageType.banner,
+ }.toList();
+
if (viewTypes.containsAny([CollectionType.movies, CollectionType.tvshows])) {
final resumeVideoResponse = await api.usersUserIdItemsResumeGet(
- limit: 16,
+ enableImageTypes: imagesToFetch,
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
@@ -36,6 +43,8 @@ class DashboardNotifier extends StateNotifier {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
+ ItemFields.overview,
+ ItemFields.genres,
],
mediaTypes: [MediaType.video],
enableTotalRecordCount: false,
@@ -48,7 +57,7 @@ class DashboardNotifier extends StateNotifier {
if (viewTypes.contains(CollectionType.music)) {
final resumeAudioResponse = await api.usersUserIdItemsResumeGet(
- limit: 16,
+ enableImageTypes: imagesToFetch,
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
@@ -56,6 +65,8 @@ class DashboardNotifier extends StateNotifier {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
+ ItemFields.overview,
+ ItemFields.genres,
],
mediaTypes: [MediaType.audio],
enableTotalRecordCount: false,
@@ -68,7 +79,7 @@ class DashboardNotifier extends StateNotifier {
if (viewTypes.contains(CollectionType.books)) {
final resumeBookResponse = await api.usersUserIdItemsResumeGet(
- limit: 16,
+ enableImageTypes: imagesToFetch,
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
@@ -76,6 +87,8 @@ class DashboardNotifier extends StateNotifier {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
+ ItemFields.overview,
+ ItemFields.genres,
],
mediaTypes: [MediaType.book],
enableTotalRecordCount: false,
@@ -87,7 +100,6 @@ class DashboardNotifier extends StateNotifier {
}
final nextResponse = await api.showsNextUpGet(
- limit: 16,
nextUpDateCutoff: DateTime.now().subtract(
ref.read(clientSettingsProvider.select((value) => value.nextUpDateCutoff ?? const Duration(days: 28)))),
fields: [
@@ -97,6 +109,8 @@ class DashboardNotifier extends StateNotifier {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
+ ItemFields.overview,
+ ItemFields.genres,
],
);
diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart
index 8b22e7f..401cba9 100644
--- a/lib/providers/image_provider.dart
+++ b/lib/providers/image_provider.dart
@@ -6,7 +6,7 @@ import 'package:fladder/providers/user_provider.dart';
const _defaultHeight = 576;
const _defaultWidth = 384;
-const _defaultQuality = 96;
+const _defaultQuality = 90;
final imageUtilityProvider = Provider((ref) {
return ImageNotifier(ref: ref);
@@ -19,7 +19,7 @@ class ImageNotifier {
});
String get currentServerUrl {
- return ref.read(userProvider)?.server ?? ref.read(authProvider).tempCredentials.server;
+ return ref.read(userProvider)?.server ?? ref.read(authProvider).serverLoginModel?.tempCredentials.server ?? "";
}
String getUserImageUrl(String id) {
diff --git a/lib/providers/lock_screen_provider.dart b/lib/providers/lock_screen_provider.dart
new file mode 100644
index 0000000..e69de29
diff --git a/lib/providers/service_provider.dart b/lib/providers/service_provider.dart
index 22823ba..f2f73b0 100644
--- a/lib/providers/service_provider.dart
+++ b/lib/providers/service_provider.dart
@@ -77,7 +77,7 @@ class JellyService {
final JellyfinOpenApi _api;
JellyfinOpenApi get api {
- var authServer = ref.read(authProvider).tempCredentials.server;
+ var authServer = ref.read(authProvider).serverLoginModel?.tempCredentials.server ?? "";
var currentServer = ref.read(userProvider)?.credentials.server;
if ((authServer.isNotEmpty ? authServer : currentServer) == FakeHelper.fakeTestServerUrl) {
return FakeJellyfinOpenApi();
@@ -1126,6 +1126,8 @@ class JellyService {
Future> quickConnectEnabled() async => api.quickConnectEnabledGet();
+ Future> getBranding() async => api.brandingConfigurationGet();
+
Future> deleteItem(String itemId) => api.itemsItemIdDelete(itemId: itemId);
Future _updateUserConfiguration(UserConfiguration newUserConfiguration) async {
@@ -1161,6 +1163,22 @@ class JellyService {
);
return _updateUserConfiguration(updated);
}
+
+ Future> quickConnectInitiate() async {
+ return api.quickConnectInitiatePost();
+ }
+
+ Future> quickConnectConnectGet({
+ String? secret,
+ }) async {
+ return api.quickConnectConnectGet(secret: secret);
+ }
+
+ Future> quickConnectAuthenticate(String secret) async {
+ return api.usersAuthenticateWithQuickConnectPost(
+ body: QuickConnectDto(secret: secret),
+ );
+ }
}
extension ParsedMap on Map {
diff --git a/lib/providers/settings/video_player_settings_provider.dart b/lib/providers/settings/video_player_settings_provider.dart
index bb66947..33aa724 100644
--- a/lib/providers/settings/video_player_settings_provider.dart
+++ b/lib/providers/settings/video_player_settings_provider.dart
@@ -5,10 +5,13 @@ import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:screen_brightness/screen_brightness.dart';
+import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/settings/key_combinations.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/shared_provider.dart';
+import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
+import 'package:fladder/src/player_settings_helper.g.dart' as pigeon;
final videoPlayerSettingsProvider =
StateNotifierProvider((ref) {
@@ -30,6 +33,30 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier MapEntry(
+ switch (key) {
+ MediaSegmentType.unknown => pigeon.SegmentType.intro,
+ MediaSegmentType.commercial => pigeon.SegmentType.commercial,
+ MediaSegmentType.preview => pigeon.SegmentType.preview,
+ MediaSegmentType.recap => pigeon.SegmentType.recap,
+ MediaSegmentType.outro => pigeon.SegmentType.outro,
+ MediaSegmentType.intro => pigeon.SegmentType.intro,
+ },
+ switch (value) {
+ SegmentSkip.none => pigeon.SegmentSkip.none,
+ SegmentSkip.askToSkip => pigeon.SegmentSkip.ask,
+ SegmentSkip.skip => pigeon.SegmentSkip.skip,
+ },
+ ),
+ ),
+ skipBackward: (userData?.userSettings?.skipBackDuration ?? const Duration(seconds: 15)).inMilliseconds,
+ skipForward: (userData?.userSettings?.skipForwardDuration ?? const Duration(seconds: 30)).inMilliseconds,
+ ),
+ );
}
void setScreenBrightness(double? value) async {
diff --git a/lib/providers/shared_provider.dart b/lib/providers/shared_provider.dart
index e3a6bac..480c1ae 100644
--- a/lib/providers/shared_provider.dart
+++ b/lib/providers/shared_provider.dart
@@ -55,10 +55,30 @@ class SharedUtility {
}
Future addAccount(AccountModel account) async {
- return await saveAccounts(getAccounts()
- ..add(account.copyWith(
- lastUsed: DateTime.now(),
- )));
+ final newAccount = account.copyWith(
+ lastUsed: DateTime.now(),
+ );
+
+ List accounts = getAccounts().toList();
+ if (accounts.any((element) => element.sameIdentity(newAccount))) {
+ accounts = accounts
+ .map(
+ (e) => e.sameIdentity(newAccount)
+ ? e.copyWith(
+ credentials: newAccount.credentials,
+ lastUsed: newAccount.lastUsed,
+ )
+ : e,
+ )
+ .toList();
+ } else {
+ accounts = [
+ ...accounts,
+ newAccount,
+ ];
+ }
+
+ return await saveAccounts(accounts);
}
Future removeAccount(AccountModel? account) async {
diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart
index a077035..3e58ecf 100644
--- a/lib/providers/video_player_provider.dart
+++ b/lib/providers/video_player_provider.dart
@@ -1,5 +1,7 @@
import 'dart:async';
+import 'package:flutter/material.dart';
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/media_playback_model.dart';
@@ -77,6 +79,8 @@ class VideoPlayerNotifier extends StateNotifier {
mediaState.update(
(state) => state.playing == event ? state : state.copyWith(playing: event),
);
+ final currentState = playbackState;
+ ref.read(playBackModel)?.updatePlaybackPosition(currentState.position, playbackState.playing, ref);
}
Future updatePosition(Duration event) async {
@@ -105,7 +109,7 @@ class VideoPlayerNotifier extends StateNotifier {
}
}
- Future loadPlaybackItem(PlaybackModel model, {Duration? startPosition}) async {
+ Future loadPlaybackItem(PlaybackModel model, Duration startPosition) async {
await state.stop();
mediaState
.update((state) => state.copyWith(state: VideoPlayerState.fullScreen, buffering: true, errorPlaying: false));
@@ -114,13 +118,13 @@ class VideoPlayerNotifier extends StateNotifier {
PlaybackModel? newPlaybackModel = model;
if (media != null) {
- await state.open(media.url, false);
+ await state.loadVideo(model, startPosition, false);
await state.setVolume(ref.read(videoPlayerSettingsProvider).volume);
state.stateStream?.takeWhile((event) => event.buffering == true).listen(
null,
onDone: () async {
- final start = startPosition ?? await model.startDuration();
- if (start != null) {
+ final start = startPosition;
+ if (start != Duration.zero) {
await state.seek(start);
}
await state.setAudioTrack(null, model);
@@ -138,4 +142,6 @@ class VideoPlayerNotifier extends StateNotifier {
mediaState.update((state) => state.copyWith(errorPlaying: true));
return false;
}
+
+ Future openPlayer(BuildContext context) async => state.openPlayer(context);
}
diff --git a/lib/providers/views_provider.dart b/lib/providers/views_provider.dart
index c04e5a0..9d29f62 100644
--- a/lib/providers/views_provider.dart
+++ b/lib/providers/views_provider.dart
@@ -65,6 +65,7 @@ class ViewsNotifier extends StateNotifier {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
+ ItemFields.overview,
],
);
return e.copyWith(recentlyAdded: recents.body?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList());
diff --git a/lib/routes/auto_router.dart b/lib/routes/auto_router.dart
index f975458..444d143 100644
--- a/lib/routes/auto_router.dart
+++ b/lib/routes/auto_router.dart
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/login/lock_screen.dart';
+import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart';
const settingsPageRoute = "settings";
@@ -53,18 +54,22 @@ final List homeRoutes = [
page: DashboardRoute.page,
initial: true,
path: 'dashboard',
+ maintainState: false,
),
AutoRoute(
page: FavouritesRoute.page,
path: 'favourites',
+ maintainState: false,
),
AutoRoute(
page: SyncedRoute.page,
path: 'synced',
+ maintainState: false,
),
AutoRoute(
page: LibraryRoute.page,
path: 'libraries',
+ maintainState: false,
),
];
@@ -76,7 +81,7 @@ final List detailsRoutes = [
final List _defaultRoutes = [
AutoRoute(page: SplashRoute.page, path: '/splash'),
- AutoRoute(page: LoginRoute.page, path: '/login'),
+ AutoRoute(page: LoginRoute.page, path: '/login', maintainState: false),
];
final List _settingsChildren = [
@@ -117,6 +122,8 @@ class AuthGuard extends AutoRouteGuard {
if (ref.read(userProvider) != null ||
resolver.routeName == const LoginRoute().routeName ||
resolver.routeName == SplashRoute().routeName) {
+ // We assume the last main focus is no longer active after navigating
+ lastMainFocus = null;
return resolver.next(true);
}
@@ -127,5 +134,9 @@ class AuthGuard extends AutoRouteGuard {
router.replace(const LoginRoute());
}
}));
+
+ // We assume the last main focus is no longer active after navigating
+ lastMainFocus = null;
+ return;
}
}
diff --git a/lib/routes/auto_router.gr.dart b/lib/routes/auto_router.gr.dart
index 3ee4e9c..b331868 100644
--- a/lib/routes/auto_router.gr.dart
+++ b/lib/routes/auto_router.gr.dart
@@ -93,11 +93,12 @@ class DetailsRoute extends _i18.PageRouteInfo {
DetailsRoute({
String id = '',
_i19.ItemBaseModel? item,
+ Object? tag,
_i20.Key? key,
List<_i18.PageRouteInfo>? children,
}) : super(
DetailsRoute.name,
- args: DetailsRouteArgs(id: id, item: item, key: key),
+ args: DetailsRouteArgs(id: id, item: item, tag: tag, key: key),
rawQueryParams: {'id': id},
initialChildren: children,
);
@@ -111,34 +112,44 @@ class DetailsRoute extends _i18.PageRouteInfo {
final args = data.argsAs(
orElse: () => DetailsRouteArgs(id: queryParams.getString('id', '')),
);
- return _i4.DetailsScreen(id: args.id, item: args.item, key: args.key);
+ return _i4.DetailsScreen(
+ id: args.id,
+ item: args.item,
+ tag: args.tag,
+ key: args.key,
+ );
},
);
}
class DetailsRouteArgs {
- const DetailsRouteArgs({this.id = '', this.item, this.key});
+ const DetailsRouteArgs({this.id = '', this.item, this.tag, this.key});
final String id;
final _i19.ItemBaseModel? item;
+ final Object? tag;
+
final _i20.Key? key;
@override
String toString() {
- return 'DetailsRouteArgs{id: $id, item: $item, key: $key}';
+ return 'DetailsRouteArgs{id: $id, item: $item, tag: $tag, key: $key}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DetailsRouteArgs) return false;
- return id == other.id && item == other.item && key == other.key;
+ return id == other.id &&
+ item == other.item &&
+ tag == other.tag &&
+ key == other.key;
}
@override
- int get hashCode => id.hashCode ^ item.hashCode ^ key.hashCode;
+ int get hashCode => id.hashCode ^ item.hashCode ^ tag.hashCode ^ key.hashCode;
}
/// generated route for
diff --git a/lib/routes/nested_details_screen.dart b/lib/routes/nested_details_screen.dart
index d037e3d..79c7204 100644
--- a/lib/routes/nested_details_screen.dart
+++ b/lib/routes/nested_details_screen.dart
@@ -13,7 +13,13 @@ import 'package:fladder/util/fladder_image.dart';
class DetailsScreen extends ConsumerStatefulWidget {
final String id;
final ItemBaseModel? item;
- const DetailsScreen({@QueryParam() this.id = '', this.item, super.key});
+ final Object? tag;
+ const DetailsScreen({
+ @QueryParam() this.id = '',
+ this.item,
+ this.tag,
+ super.key,
+ });
@override
ConsumerState createState() => _DetailsScreenState();
@@ -66,7 +72,7 @@ class _DetailsScreenState extends ConsumerState {
key: Key(widget.id),
children: [
Hero(
- tag: widget.id,
+ tag: widget.tag ?? UniqueKey(),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 1.0),
diff --git a/lib/screens/book_viewer/book_viewer_settings.dart b/lib/screens/book_viewer/book_viewer_settings.dart
index 38b6301..df69435 100644
--- a/lib/screens/book_viewer/book_viewer_settings.dart
+++ b/lib/screens/book_viewer/book_viewer_settings.dart
@@ -1,11 +1,14 @@
+import 'package:flutter/material.dart';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
+import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_side_sheet.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
Future showBookViewerSettings(
BuildContext context,
@@ -80,10 +83,9 @@ class BookViewerSettingsScreen extends ConsumerWidget {
label: const Text("Read direction"),
current: settings.readDirection.name.toUpperCaseSplit(),
itemBuilder: (context) => ReadDirection.values
- .map((value) => PopupMenuItem(
- value: value,
- child: Text(value.name.toUpperCaseSplit()),
- onTap: () => ref
+ .map((value) => ItemActionButton(
+ label: Text(value.name.toUpperCaseSplit()),
+ action: () => ref
.read(bookViewerSettingsProvider.notifier)
.update((state) => state.copyWith(readDirection: value)),
))
@@ -102,10 +104,9 @@ class BookViewerSettingsScreen extends ConsumerWidget {
label: const Text("Init zoom"),
current: settings.initZoomState.name.toUpperCaseSplit(),
itemBuilder: (context) => InitZoomState.values
- .map((value) => PopupMenuItem(
- value: value,
- child: Text(value.name.toUpperCaseSplit()),
- onTap: () => ref
+ .map((value) => ItemActionButton(
+ label: Text(value.name.toUpperCaseSplit()),
+ action: () => ref
.read(bookViewerSettingsProvider.notifier)
.update((state) => state.copyWith(initZoomState: value)),
))
diff --git a/lib/screens/crash_screen/crash_screen.dart b/lib/screens/crash_screen/crash_screen.dart
index 8f7a39c..a512a7e 100644
--- a/lib/screens/crash_screen/crash_screen.dart
+++ b/lib/screens/crash_screen/crash_screen.dart
@@ -9,6 +9,7 @@ import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
+import 'package:fladder/widgets/shared/item_actions.dart';
final _selectedWarningProvider = StateProvider((ref) => null);
@@ -41,16 +42,14 @@ class CrashScreen extends ConsumerWidget {
EnumBox(
current: selectedType == null ? context.localized.all : selectedType.name.capitalize(),
itemBuilder: (context) => [
- PopupMenuItem(
- value: null,
- child: Text(context.localized.all),
- onTap: () => ref.read(_selectedWarningProvider.notifier).update((state) => null),
+ ItemActionButton(
+ label: Text(context.localized.all),
+ action: () => ref.read(_selectedWarningProvider.notifier).update((state) => null),
),
...ErrorType.values.map(
- (entry) => PopupMenuItem(
- value: entry,
- child: Text(entry.name.capitalize()),
- onTap: () => ref.read(_selectedWarningProvider.notifier).update((state) => entry),
+ (entry) => ItemActionButton(
+ label: Text(entry.name.capitalize()),
+ action: () => ref.read(_selectedWarningProvider.notifier).update((state) => entry),
),
)
],
diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart
index a6bffc9..75b4002 100644
--- a/lib/screens/dashboard/dashboard_screen.dart
+++ b/lib/screens/dashboard/dashboard_screen.dart
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
+import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
@@ -23,6 +24,7 @@ import 'package:fladder/screens/shared/media/poster_row.dart';
import 'package:fladder/screens/shared/nested_scaffold.dart';
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
+import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/sliver_list_padding.dart';
@@ -45,6 +47,10 @@ class _DashboardScreenState extends ConsumerState {
late final Timer _timer;
final GlobalKey _refreshIndicatorKey = GlobalKey();
+ final textController = TextEditingController();
+
+ ItemBaseModel? selectedPoster;
+
@override
void initState() {
super.initState();
@@ -70,6 +76,7 @@ class _DashboardScreenState extends ConsumerState {
@override
Widget build(BuildContext context) {
final padding = AdaptiveLayout.adaptivePadding(context);
+ final bannerType = ref.watch(homeSettingsProvider.select((value) => value.homeBanner));
final dashboardData = ref.watch(dashboardProvider);
final views = ref.watch(viewsProvider);
@@ -87,10 +94,15 @@ class _DashboardScreenState extends ConsumerState {
HomeCarouselSettings.cont => allResume,
};
+ final viewSize = AdaptiveLayout.viewSizeOf(context);
+
return MediaQuery.removeViewInsets(
context: context,
child: NestedScaffold(
- background: BackgroundImage(items: [...homeCarouselItems, ...dashboardData.nextUp, ...allResume]),
+ background: BackgroundImage(
+ items: selectedPoster != null
+ ? [selectedPoster!]
+ : [...homeCarouselItems, ...dashboardData.nextUp, ...allResume]),
body: PullToRefresh(
refreshKey: _refreshIndicatorKey,
displacement: 80 + MediaQuery.of(context).viewPadding.top,
@@ -101,8 +113,8 @@ class _DashboardScreenState extends ConsumerState {
controller: AdaptiveLayout.scrollOf(context, HomeTabs.dashboard),
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
- const DefaultSliverTopBadding(),
- if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone)
+ if (bannerType != HomeBanner.detailedBanner) const DefaultSliverTopBadding(),
+ if (viewSize == ViewSize.phone)
NestedSliverAppBar(
route: LibrarySearchRoute(),
parent: context,
@@ -114,7 +126,16 @@ class _DashboardScreenState extends ConsumerState {
context,
horizontalPadding: 0,
),
- child: HomeBannerWidget(posters: homeCarouselItems),
+ child: HomeBannerWidget(
+ posters: homeCarouselItems,
+ onSelect: (selected) {
+ // if (selectedPoster != selected) {
+ // setState(() {
+ // selectedPoster = selected;
+ // });
+ // }
+ },
+ ),
),
),
},
@@ -130,80 +151,84 @@ class _DashboardScreenState extends ConsumerState {
...[
if (resumeVideo.isNotEmpty &&
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
- SliverToBoxAdapter(
- child: PosterRow(
- contentPadding: padding,
- label: context.localized.dashboardContinueWatching,
- posters: resumeVideo,
- ),
+ PosterRow(
+ contentPadding: padding,
+ label: context.localized.dashboardContinueWatching,
+ posters: resumeVideo,
),
if (resumeAudio.isNotEmpty &&
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
- SliverToBoxAdapter(
- child: PosterRow(
- contentPadding: padding,
- label: context.localized.dashboardContinueListening,
- posters: resumeAudio,
- ),
+ PosterRow(
+ contentPadding: padding,
+ label: context.localized.dashboardContinueListening,
+ posters: resumeAudio,
),
if (resumeBooks.isNotEmpty &&
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
- SliverToBoxAdapter(
- child: PosterRow(
- contentPadding: padding,
- label: context.localized.dashboardContinueReading,
- posters: resumeBooks,
- ),
+ PosterRow(
+ contentPadding: padding,
+ label: context.localized.dashboardContinueReading,
+ posters: resumeBooks,
),
if (dashboardData.nextUp.isNotEmpty &&
(homeSettings.nextUp == HomeNextUp.nextUp || homeSettings.nextUp == HomeNextUp.separate))
- SliverToBoxAdapter(
- child: PosterRow(
- contentPadding: padding,
- label: context.localized.nextUp,
- posters: dashboardData.nextUp,
- ),
+ PosterRow(
+ contentPadding: padding,
+ label: context.localized.nextUp,
+ posters: dashboardData.nextUp,
),
if ([...allResume, ...dashboardData.nextUp].isNotEmpty && homeSettings.nextUp == HomeNextUp.combined)
- SliverToBoxAdapter(
- child: PosterRow(
- contentPadding: padding,
- label: context.localized.dashboardContinue,
- posters: [...allResume, ...dashboardData.nextUp],
+ PosterRow(
+ contentPadding: padding,
+ label: context.localized.dashboardContinue,
+ posters: [...allResume, ...dashboardData.nextUp],
+ ),
+ ...views.dashboardViews.where((element) => element.recentlyAdded.isNotEmpty).map(
+ (view) => PosterRow(
+ contentPadding: padding,
+ label: context.localized.dashboardRecentlyAdded(view.name),
+ collectionAspectRatio: view.collectionType.aspectRatio,
+ onLabelClick: () => context.router.push(
+ LibrarySearchRoute(
+ viewModelId: view.id,
+ types: switch (view.collectionType) {
+ CollectionType.tvshows => {
+ FladderItemType.episode: true,
+ },
+ _ => {},
+ },
+ sortingOptions: switch (view.collectionType) {
+ CollectionType.books ||
+ CollectionType.boxsets ||
+ CollectionType.folders ||
+ CollectionType.music =>
+ SortingOptions.dateLastContentAdded,
+ _ => SortingOptions.dateAdded,
+ },
+ sortOrder: SortingOrder.descending,
+ recursive: true,
+ ),
+ ),
+ posters: view.recentlyAdded,
+ ),
+ ),
+ ]
+ .nonNulls
+ .toList()
+ .mapIndexed(
+ (index, child) => SliverToBoxAdapter(
+ child: FocusProvider(
+ autoFocus: bannerType != HomeBanner.detailedBanner ? index == 0 : false,
+ child: child,
+ ),
+ ),
+ )
+ .toList()
+ .addInBetween(
+ const SliverToBoxAdapter(
+ child: SizedBox(height: 16),
),
),
- ...views.dashboardViews
- .where((element) => element.recentlyAdded.isNotEmpty)
- .map((view) => SliverToBoxAdapter(
- child: PosterRow(
- contentPadding: padding,
- label: context.localized.dashboardRecentlyAdded(view.name),
- collectionAspectRatio: view.collectionType.aspectRatio,
- onLabelClick: () => context.router.push(
- LibrarySearchRoute(
- viewModelId: view.id,
- types: switch (view.collectionType) {
- CollectionType.tvshows => {
- FladderItemType.episode: true,
- },
- _ => {},
- },
- sortingOptions: switch (view.collectionType) {
- CollectionType.books ||
- CollectionType.boxsets ||
- CollectionType.folders ||
- CollectionType.music =>
- SortingOptions.dateLastContentAdded,
- _ => SortingOptions.dateAdded,
- },
- sortOrder: SortingOrder.descending,
- recursive: true,
- ),
- ),
- posters: view.recentlyAdded,
- ),
- )),
- ].nonNulls.toList().addInBetween(const SliverToBoxAdapter(child: SizedBox(height: 16))),
const DefautlSliverBottomPadding(),
],
),
diff --git a/lib/screens/dashboard/home_banner_widget.dart b/lib/screens/dashboard/home_banner_widget.dart
index db10520..e569b80 100644
--- a/lib/screens/dashboard/home_banner_widget.dart
+++ b/lib/screens/dashboard/home_banner_widget.dart
@@ -5,17 +5,26 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/providers/settings/home_settings_provider.dart';
+import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/screens/shared/media/carousel_banner.dart';
+import 'package:fladder/screens/shared/media/detailed_banner.dart';
import 'package:fladder/screens/shared/media/media_banner.dart';
class HomeBannerWidget extends ConsumerWidget {
final List posters;
- const HomeBannerWidget({required this.posters, super.key});
+ final Function(ItemBaseModel selected) onSelect;
+
+ const HomeBannerWidget({
+ required this.posters,
+ required this.onSelect,
+ super.key,
+ });
@override
Widget build(BuildContext context, WidgetRef ref) {
final bannerType = ref.watch(homeSettingsProvider.select((value) => value.homeBanner));
final maxHeight = (MediaQuery.sizeOf(context).shortestSide * 0.6).clamp(125.0, 375.0);
+
return switch (bannerType) {
HomeBanner.carousel => Column(
mainAxisSize: MainAxisSize.min,
@@ -34,6 +43,12 @@ class HomeBannerWidget extends ConsumerWidget {
maxHeight: maxHeight,
),
),
+ HomeBanner.detailedBanner => AnimatedFadeSize(
+ child: DetailedBanner(
+ posters: posters,
+ onSelect: onSelect,
+ ),
+ ),
_ => const SizedBox.shrink(),
};
}
diff --git a/lib/screens/details_screens/components/media_stream_information.dart b/lib/screens/details_screens/components/media_stream_information.dart
index dd8ee9c..036f43e 100644
--- a/lib/screens/details_screens/components/media_stream_information.dart
+++ b/lib/screens/details_screens/components/media_stream_information.dart
@@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/screens/details_screens/components/label_title_item.dart';
import 'package:fladder/util/localization_helper.dart';
+import 'package:fladder/widgets/shared/enum_selection.dart';
+import 'package:fladder/widgets/shared/item_actions.dart';
class MediaStreamInformation extends ConsumerWidget {
final MediaStreamsModel mediaStream;
@@ -30,15 +32,13 @@ class MediaStreamInformation extends ConsumerWidget {
label: Text(context.localized.version),
current: mediaStream.currentVersionStream?.name ?? "",
itemBuilder: (context) => mediaStream.versionStreams
- .map((e) => PopupMenuItem(
- value: e,
- padding: EdgeInsets.zero,
- child: textWidget(
+ .map((e) => ItemActionButton(
+ selected: mediaStream.currentVersionStream == e,
+ label: textWidget(
context,
- selected: mediaStream.currentVersionStream == e,
label: e.name,
),
- onTap: () => onVersionIndexChanged?.call(e.index),
+ action: () => onVersionIndexChanged?.call(e.index),
))
.toList(),
),
@@ -48,10 +48,8 @@ class MediaStreamInformation extends ConsumerWidget {
current: (mediaStream.videoStreams.first).prettyName,
itemBuilder: (context) => mediaStream.videoStreams
.map(
- (e) => PopupMenuItem(
- value: e,
- padding: EdgeInsets.zero,
- child: Text(e.prettyName),
+ (e) => ItemActionButton(
+ label: Text(e.prettyName),
),
)
.toList(),
@@ -62,12 +60,13 @@ class MediaStreamInformation extends ConsumerWidget {
current: mediaStream.currentAudioStream?.displayTitle ?? "",
itemBuilder: (context) => [AudioStreamModel.no(), ...mediaStream.audioStreams]
.map(
- (e) => PopupMenuItem(
- value: e,
- padding: EdgeInsets.zero,
- child: textWidget(context,
- selected: mediaStream.currentAudioStream?.index == e.index, label: e.displayTitle),
- onTap: () => onAudioIndexChanged?.call(e.index),
+ (e) => ItemActionButton(
+ selected: mediaStream.currentAudioStream?.index == e.index,
+ label: textWidget(
+ context,
+ label: e.displayTitle,
+ ),
+ action: () => onAudioIndexChanged?.call(e.index),
),
)
.toList(),
@@ -78,12 +77,13 @@ class MediaStreamInformation extends ConsumerWidget {
current: mediaStream.currentSubStream?.displayTitle ?? "",
itemBuilder: (context) => [SubStreamModel.no(), ...mediaStream.subStreams]
.map(
- (e) => PopupMenuItem(
- value: e,
- padding: EdgeInsets.zero,
- child: textWidget(context,
- selected: mediaStream.currentSubStream?.index == e.index, label: e.displayTitle),
- onTap: () => onSubIndexChanged?.call(e.index),
+ (e) => ItemActionButton(
+ selected: mediaStream.currentSubStream?.index == e.index,
+ label: textWidget(
+ context,
+ label: e.displayTitle,
+ ),
+ action: () => onSubIndexChanged?.call(e.index),
),
)
.toList(),
@@ -92,22 +92,12 @@ class MediaStreamInformation extends ConsumerWidget {
);
}
- Widget textWidget(BuildContext context, {required bool selected, required String label}) {
- return Container(
- height: kMinInteractiveDimension,
- width: double.maxFinite,
- color: selected ? Theme.of(context).colorScheme.primary : null,
- alignment: Alignment.centerLeft,
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8),
- child: Text(
- label,
- style: Theme.of(context).textTheme.labelLarge?.copyWith(
- color: selected ? Theme.of(context).colorScheme.onPrimary : null,
- fontWeight: FontWeight.bold,
- ),
- ),
- ),
+ Widget textWidget(BuildContext context, {required String label}) {
+ return Text(
+ label,
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
);
}
}
@@ -115,7 +105,7 @@ class MediaStreamInformation extends ConsumerWidget {
class _StreamOptionSelect extends StatelessWidget {
final Text label;
final String current;
- final List> Function(BuildContext context) itemBuilder;
+ final List Function(BuildContext context) itemBuilder;
const _StreamOptionSelect({
required this.label,
required this.current,
@@ -124,47 +114,14 @@ class _StreamOptionSelect