mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-07 13:38:13 -08:00
feat: Android TV support (#503)
Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
parent
7ab8c015b9
commit
c299492d6d
168 changed files with 12019 additions and 3073 deletions
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -77,7 +77,6 @@
|
||||||
"name": "Web",
|
"name": "Web",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart",
|
"type": "dart",
|
||||||
"deviceId": "chrome",
|
|
||||||
"args": [
|
"args": [
|
||||||
"--web-port",
|
"--web-port",
|
||||||
"9090",
|
"9090",
|
||||||
|
|
@ -87,7 +86,6 @@
|
||||||
"name": "Web (release mode)",
|
"name": "Web (release mode)",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart",
|
"type": "dart",
|
||||||
"deviceId": "chrome",
|
|
||||||
"flutterMode": "release",
|
"flutterMode": "release",
|
||||||
"args": [
|
"args": [
|
||||||
"--web-port",
|
"--web-port",
|
||||||
|
|
|
||||||
17
.vscode/tasks.json
vendored
17
.vscode/tasks.json
vendored
|
|
@ -114,6 +114,23 @@
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "build",
|
"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}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
2
android/.gitignore
vendored
2
android/.gitignore
vendored
|
|
@ -11,3 +11,5 @@ GeneratedPluginRegistrant.java
|
||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
|
||||||
|
**/TestData.kt
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
plugins {
|
plugins {
|
||||||
id "com.android.application"
|
id "com.android.application"
|
||||||
id "kotlin-android"
|
id "kotlin-android"
|
||||||
|
id "org.jetbrains.kotlin.plugin.compose"
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +31,21 @@ if (flutterVersionName == null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
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"
|
namespace = "nl.jknaapen.fladder"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
@ -39,8 +55,12 @@ android {
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_1_8
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
|
@ -51,6 +71,10 @@ android {
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion '1.6.4'
|
||||||
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
release {
|
release {
|
||||||
storeFile file('keystore.jks')
|
storeFile file('keystore.jks')
|
||||||
|
|
@ -84,3 +108,29 @@ android {
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
def composeBom = platform('androidx.compose:compose-bom:2025.09.00')
|
||||||
|
implementation composeBom
|
||||||
|
androidTestImplementation composeBom
|
||||||
|
implementation('androidx.compose.material3:material3')
|
||||||
|
implementation('androidx.compose.ui:ui-tooling-preview')
|
||||||
|
debugImplementation('androidx.compose.ui:ui-tooling')
|
||||||
|
implementation('androidx.activity:activity-compose:1.11.0')
|
||||||
|
|
||||||
|
// Media3 (ExoPlayer)
|
||||||
|
def media3_version = "1.8.0+1"
|
||||||
|
implementation("androidx.media3:media3-exoplayer:$media3_version")
|
||||||
|
implementation("androidx.media3:media3-session:$media3_version")
|
||||||
|
implementation("androidx.media3:media3-ui:$media3_version")
|
||||||
|
|
||||||
|
implementation("androidx.media3:media3-exoplayer-dash:$media3_version")
|
||||||
|
implementation("androidx.media3:media3-exoplayer-hls:$media3_version")
|
||||||
|
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:$media3_version")
|
||||||
|
implementation("io.github.peerless2012:ass-media:0.3.0-rc03")
|
||||||
|
|
||||||
|
//UI
|
||||||
|
implementation("io.github.rabehx:iconsax-compose:0.0.3")
|
||||||
|
implementation("io.coil-kt.coil3:coil-compose:3.3.0")
|
||||||
|
implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools" package="nl.jknaapen.fladder">
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
package="nl.jknaapen.fladder">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
|
@ -8,22 +10,68 @@
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.touchscreen"
|
||||||
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.software.leanback"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<application android:label="Fladder" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true" android:enableOnBackInvokedCallback="true">
|
<application
|
||||||
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
|
android:label="Fladder"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:banner="@drawable/app_banner"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
to determine the Window background behind the Flutter UI. -->
|
to determine the Window background behind the Flutter UI. -->
|
||||||
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" />
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme" />
|
||||||
|
|
||||||
|
<!-- Launcher for phones/tablets -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Launcher for Android TV -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service android:name="androidx.work.impl.foreground.SystemForegroundService" android:foregroundServiceType="dataSync" tools:node="merge" />
|
<activity
|
||||||
<service android:name="com.ryanheise.audioservice.AudioService" android:foregroundServiceType="mediaPlayback" android:exported="true" tools:ignore="Instantiatable">
|
android:name=".VideoPlayerActivity"
|
||||||
|
android:theme="@style/Theme.AppCompat.NoActionBar"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:supportsPictureInPicture="true"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:exported="true" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
tools:node="merge" />
|
||||||
|
<service
|
||||||
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
|
android:foregroundServiceType="mediaPlayback"
|
||||||
|
android:exported="true"
|
||||||
|
tools:ignore="Instantiatable">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
@ -31,9 +79,14 @@
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data android:name="flutterEmbedding" android:value="2" />
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
|
||||||
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver" android:exported="true" tools:ignore="Instantiatable">
|
<receiver
|
||||||
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
|
android:exported="true"
|
||||||
|
tools:ignore="Instantiatable">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,77 @@
|
||||||
package nl.jknaapen.fladder
|
package nl.jknaapen.fladder
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import NativeVideoActivity
|
||||||
import com.ryanheise.audioservice.AudioServiceFragmentActivity;
|
import PlayerSettingsPigeon
|
||||||
|
import StartResult
|
||||||
|
import VideoPlayerApi
|
||||||
|
import VideoPlayerControlsCallback
|
||||||
|
import VideoPlayerListenerCallback
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import com.ryanheise.audioservice.AudioServiceFragmentActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import nl.jknaapen.fladder.objects.PlayerSettingsObject
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import nl.jknaapen.fladder.utility.leanBackEnabled
|
||||||
|
|
||||||
class MainActivity: AudioServiceFragmentActivity () {
|
class MainActivity : AudioServiceFragmentActivity(), NativeVideoActivity {
|
||||||
|
private lateinit var videoPlayerLauncher: ActivityResultLauncher<Intent>
|
||||||
|
private var videoPlayerCallback: ((Result<StartResult>) -> Unit)? = null
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
val videoPlayerHost = VideoPlayerObject
|
||||||
|
NativeVideoActivity.setUp(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
this
|
||||||
|
)
|
||||||
|
VideoPlayerApi.setUp(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
videoPlayerHost.implementation
|
||||||
|
)
|
||||||
|
videoPlayerHost.videoPlayerListener =
|
||||||
|
VideoPlayerListenerCallback(flutterEngine.dartExecutor.binaryMessenger)
|
||||||
|
|
||||||
|
videoPlayerHost.videoPlayerControls =
|
||||||
|
VideoPlayerControlsCallback(flutterEngine.dartExecutor.binaryMessenger)
|
||||||
|
|
||||||
|
PlayerSettingsPigeon.setUp(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
api = PlayerSettingsObject
|
||||||
|
)
|
||||||
|
|
||||||
|
videoPlayerLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
val callback = videoPlayerCallback
|
||||||
|
videoPlayerCallback = null
|
||||||
|
|
||||||
|
val startResult = if (result.resultCode == RESULT_OK) {
|
||||||
|
StartResult(resultValue = result.data?.getStringExtra("result") ?: "Finished")
|
||||||
|
} else {
|
||||||
|
StartResult(resultValue = "Cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
callback?.invoke(Result.success(startResult))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun launchActivity(callback: (Result<StartResult>) -> Unit) {
|
||||||
|
try {
|
||||||
|
videoPlayerCallback = callback
|
||||||
|
val intent = Intent(this, VideoPlayerActivity::class.java)
|
||||||
|
videoPlayerLauncher.launch(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
callback(Result.failure(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disposeActivity() {
|
||||||
|
VideoPlayerObject.currentActivity?.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isLeanBackEnabled(): Boolean = leanBackEnabled(applicationContext)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt
Normal file
26
android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package nl.jknaapen.fladder
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Color(0xFF3B82F6)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoPlayerTheme(
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = DarkColorScheme,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package nl.jknaapen.fladder
|
||||||
|
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import nl.jknaapen.fladder.composables.controls.CustomVideoControls
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import nl.jknaapen.fladder.player.ExoPlayer
|
||||||
|
import nl.jknaapen.fladder.utility.ScaledContent
|
||||||
|
import nl.jknaapen.fladder.utility.leanBackEnabled
|
||||||
|
|
||||||
|
class VideoPlayerActivity : ComponentActivity() {
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
VideoPlayerObject.currentActivity = this
|
||||||
|
|
||||||
|
window.setFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
|
||||||
|
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
|
||||||
|
)
|
||||||
|
|
||||||
|
window.setFormat(PixelFormat.TRANSLUCENT)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
VideoPlayerTheme {
|
||||||
|
VideoPlayerScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun VideoPlayerScreen(
|
||||||
|
) {
|
||||||
|
val leanBackEnabled = leanBackEnabled(LocalContext.current)
|
||||||
|
ExoPlayer { player ->
|
||||||
|
ScaledContent(if (leanBackEnabled) 0.50f else 1f) {
|
||||||
|
CustomVideoControls(player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
// Autogenerated from Pigeon (v26.0.1), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.flutter.plugin.common.BasicMessageChannel
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MessageCodec
|
||||||
|
import io.flutter.plugin.common.StandardMethodCodec
|
||||||
|
import io.flutter.plugin.common.StandardMessageCodec
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
private object PlayerSettingsHelperPigeonUtils {
|
||||||
|
|
||||||
|
fun wrapResult(result: Any?): List<Any?> {
|
||||||
|
return listOf(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wrapError(exception: Throwable): List<Any?> {
|
||||||
|
return if (exception is FlutterError) {
|
||||||
|
listOf(
|
||||||
|
exception.code,
|
||||||
|
exception.message,
|
||||||
|
exception.details
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
exception.javaClass.simpleName,
|
||||||
|
exception.toString(),
|
||||||
|
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||||
|
if (a is ByteArray && b is ByteArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is IntArray && b is IntArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is LongArray && b is LongArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is DoubleArray && b is DoubleArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is Array<*> && b is Array<*>) {
|
||||||
|
return a.size == b.size &&
|
||||||
|
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||||
|
}
|
||||||
|
if (a is List<*> && b is List<*>) {
|
||||||
|
return a.size == b.size &&
|
||||||
|
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||||
|
}
|
||||||
|
if (a is Map<*, *> && b is Map<*, *>) {
|
||||||
|
return a.size == b.size && a.all {
|
||||||
|
(b as Map<Any?, Any?>).containsKey(it.key) &&
|
||||||
|
deepEquals(it.value, b[it.key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SegmentType(val raw: Int) {
|
||||||
|
COMMERCIAL(0),
|
||||||
|
PREVIEW(1),
|
||||||
|
RECAP(2),
|
||||||
|
INTRO(3),
|
||||||
|
OUTRO(4);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun ofRaw(raw: Int): SegmentType? {
|
||||||
|
return values().firstOrNull { it.raw == raw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SegmentSkip(val raw: Int) {
|
||||||
|
ASK(0),
|
||||||
|
SKIP(1),
|
||||||
|
NONE(2);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun ofRaw(raw: Int): SegmentSkip? {
|
||||||
|
return values().firstOrNull { it.raw == raw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class PlayerSettings (
|
||||||
|
val skipTypes: Map<SegmentType, SegmentSkip>,
|
||||||
|
val skipForward: Long,
|
||||||
|
val skipBackward: Long
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): PlayerSettings {
|
||||||
|
val skipTypes = pigeonVar_list[0] as Map<SegmentType, SegmentSkip>
|
||||||
|
val skipForward = pigeonVar_list[1] as Long
|
||||||
|
val skipBackward = pigeonVar_list[2] as Long
|
||||||
|
return PlayerSettings(skipTypes, skipForward, skipBackward)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
skipTypes,
|
||||||
|
skipForward,
|
||||||
|
skipBackward,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is PlayerSettings) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return PlayerSettingsHelperPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
private open class PlayerSettingsHelperPigeonCodec : StandardMessageCodec() {
|
||||||
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
|
return when (type) {
|
||||||
|
129.toByte() -> {
|
||||||
|
return (readValue(buffer) as Long?)?.let {
|
||||||
|
SegmentType.ofRaw(it.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
130.toByte() -> {
|
||||||
|
return (readValue(buffer) as Long?)?.let {
|
||||||
|
SegmentSkip.ofRaw(it.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
131.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
PlayerSettings.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> super.readValueOfType(type, buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||||
|
when (value) {
|
||||||
|
is SegmentType -> {
|
||||||
|
stream.write(129)
|
||||||
|
writeValue(stream, value.raw)
|
||||||
|
}
|
||||||
|
is SegmentSkip -> {
|
||||||
|
stream.write(130)
|
||||||
|
writeValue(stream, value.raw)
|
||||||
|
}
|
||||||
|
is PlayerSettings -> {
|
||||||
|
stream.write(131)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
else -> super.writeValue(stream, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
|
interface PlayerSettingsPigeon {
|
||||||
|
fun sendPlayerSettings(playerSettings: PlayerSettings)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The codec used by PlayerSettingsPigeon. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
PlayerSettingsHelperPigeonCodec()
|
||||||
|
}
|
||||||
|
/** Sets up an instance of `PlayerSettingsPigeon` to handle messages through the `binaryMessenger`. */
|
||||||
|
@JvmOverloads
|
||||||
|
fun setUp(binaryMessenger: BinaryMessenger, api: PlayerSettingsPigeon?, messageChannelSuffix: String = "") {
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.settings.PlayerSettingsPigeon.sendPlayerSettings$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val playerSettingsArg = args[0] as PlayerSettings
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.sendPlayerSettings(playerSettingsArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
PlayerSettingsHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,911 @@
|
||||||
|
// Autogenerated from Pigeon (v26.0.1), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.flutter.plugin.common.BasicMessageChannel
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MessageCodec
|
||||||
|
import io.flutter.plugin.common.StandardMethodCodec
|
||||||
|
import io.flutter.plugin.common.StandardMessageCodec
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
private object VideoPlayerHelperPigeonUtils {
|
||||||
|
|
||||||
|
fun createConnectionError(channelName: String): FlutterError {
|
||||||
|
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
|
||||||
|
|
||||||
|
fun wrapResult(result: Any?): List<Any?> {
|
||||||
|
return listOf(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wrapError(exception: Throwable): List<Any?> {
|
||||||
|
return if (exception is FlutterError) {
|
||||||
|
listOf(
|
||||||
|
exception.code,
|
||||||
|
exception.message,
|
||||||
|
exception.details
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
exception.javaClass.simpleName,
|
||||||
|
exception.toString(),
|
||||||
|
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||||
|
if (a is ByteArray && b is ByteArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is IntArray && b is IntArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is LongArray && b is LongArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is DoubleArray && b is DoubleArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is Array<*> && b is Array<*>) {
|
||||||
|
return a.size == b.size &&
|
||||||
|
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||||
|
}
|
||||||
|
if (a is List<*> && b is List<*>) {
|
||||||
|
return a.size == b.size &&
|
||||||
|
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||||
|
}
|
||||||
|
if (a is Map<*, *> && b is Map<*, *>) {
|
||||||
|
return a.size == b.size && a.all {
|
||||||
|
(b as Map<Any?, Any?>).containsKey(it.key) &&
|
||||||
|
deepEquals(it.value, b[it.key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||||
|
* @property code The error code.
|
||||||
|
* @property message The error message.
|
||||||
|
* @property details The error details. Must be a datatype supported by the api codec.
|
||||||
|
*/
|
||||||
|
class FlutterError (
|
||||||
|
val code: String,
|
||||||
|
override val message: String? = null,
|
||||||
|
val details: Any? = null
|
||||||
|
) : Throwable()
|
||||||
|
|
||||||
|
enum class MediaSegmentType(val raw: Int) {
|
||||||
|
COMMERCIAL(0),
|
||||||
|
PREVIEW(1),
|
||||||
|
RECAP(2),
|
||||||
|
INTRO(3),
|
||||||
|
OUTRO(4);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun ofRaw(raw: Int): MediaSegmentType? {
|
||||||
|
return values().firstOrNull { it.raw == raw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class PlayableData (
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subTitle: String? = null,
|
||||||
|
val logoUrl: String? = null,
|
||||||
|
val description: String,
|
||||||
|
val startPosition: Long,
|
||||||
|
val defaultAudioTrack: Long,
|
||||||
|
val audioTracks: List<AudioTrack>,
|
||||||
|
val defaultSubtrack: Long,
|
||||||
|
val subtitleTracks: List<SubtitleTrack>,
|
||||||
|
val trickPlayModel: TrickPlayModel? = null,
|
||||||
|
val chapters: List<Chapter>,
|
||||||
|
val segments: List<MediaSegment>,
|
||||||
|
val previousVideo: String? = null,
|
||||||
|
val nextVideo: String? = null,
|
||||||
|
val url: String
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): PlayableData {
|
||||||
|
val id = pigeonVar_list[0] as String
|
||||||
|
val title = pigeonVar_list[1] as String
|
||||||
|
val subTitle = pigeonVar_list[2] as String?
|
||||||
|
val logoUrl = pigeonVar_list[3] as String?
|
||||||
|
val description = pigeonVar_list[4] as String
|
||||||
|
val startPosition = pigeonVar_list[5] as Long
|
||||||
|
val defaultAudioTrack = pigeonVar_list[6] as Long
|
||||||
|
val audioTracks = pigeonVar_list[7] as List<AudioTrack>
|
||||||
|
val defaultSubtrack = pigeonVar_list[8] as Long
|
||||||
|
val subtitleTracks = pigeonVar_list[9] as List<SubtitleTrack>
|
||||||
|
val trickPlayModel = pigeonVar_list[10] as TrickPlayModel?
|
||||||
|
val chapters = pigeonVar_list[11] as List<Chapter>
|
||||||
|
val segments = pigeonVar_list[12] as List<MediaSegment>
|
||||||
|
val previousVideo = pigeonVar_list[13] as String?
|
||||||
|
val nextVideo = pigeonVar_list[14] as String?
|
||||||
|
val url = pigeonVar_list[15] as String
|
||||||
|
return PlayableData(id, title, subTitle, logoUrl, description, startPosition, defaultAudioTrack, audioTracks, defaultSubtrack, subtitleTracks, trickPlayModel, chapters, segments, previousVideo, nextVideo, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
subTitle,
|
||||||
|
logoUrl,
|
||||||
|
description,
|
||||||
|
startPosition,
|
||||||
|
defaultAudioTrack,
|
||||||
|
audioTracks,
|
||||||
|
defaultSubtrack,
|
||||||
|
subtitleTracks,
|
||||||
|
trickPlayModel,
|
||||||
|
chapters,
|
||||||
|
segments,
|
||||||
|
previousVideo,
|
||||||
|
nextVideo,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is PlayableData) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class MediaSegment (
|
||||||
|
val type: MediaSegmentType,
|
||||||
|
val name: String,
|
||||||
|
val start: Long,
|
||||||
|
val end: Long
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): MediaSegment {
|
||||||
|
val type = pigeonVar_list[0] as MediaSegmentType
|
||||||
|
val name = pigeonVar_list[1] as String
|
||||||
|
val start = pigeonVar_list[2] as Long
|
||||||
|
val end = pigeonVar_list[3] as Long
|
||||||
|
return MediaSegment(type, name, start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is MediaSegment) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class AudioTrack (
|
||||||
|
val name: String,
|
||||||
|
val languageCode: String,
|
||||||
|
val codec: String,
|
||||||
|
val index: Long,
|
||||||
|
val external: Boolean,
|
||||||
|
val url: String? = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): AudioTrack {
|
||||||
|
val name = pigeonVar_list[0] as String
|
||||||
|
val languageCode = pigeonVar_list[1] as String
|
||||||
|
val codec = pigeonVar_list[2] as String
|
||||||
|
val index = pigeonVar_list[3] as Long
|
||||||
|
val external = pigeonVar_list[4] as Boolean
|
||||||
|
val url = pigeonVar_list[5] as String?
|
||||||
|
return AudioTrack(name, languageCode, codec, index, external, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
name,
|
||||||
|
languageCode,
|
||||||
|
codec,
|
||||||
|
index,
|
||||||
|
external,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is AudioTrack) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class SubtitleTrack (
|
||||||
|
val name: String,
|
||||||
|
val languageCode: String,
|
||||||
|
val codec: String,
|
||||||
|
val index: Long,
|
||||||
|
val external: Boolean,
|
||||||
|
val url: String? = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): SubtitleTrack {
|
||||||
|
val name = pigeonVar_list[0] as String
|
||||||
|
val languageCode = pigeonVar_list[1] as String
|
||||||
|
val codec = pigeonVar_list[2] as String
|
||||||
|
val index = pigeonVar_list[3] as Long
|
||||||
|
val external = pigeonVar_list[4] as Boolean
|
||||||
|
val url = pigeonVar_list[5] as String?
|
||||||
|
return SubtitleTrack(name, languageCode, codec, index, external, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
name,
|
||||||
|
languageCode,
|
||||||
|
codec,
|
||||||
|
index,
|
||||||
|
external,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is SubtitleTrack) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class Chapter (
|
||||||
|
val name: String,
|
||||||
|
val url: String,
|
||||||
|
val time: Long
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): Chapter {
|
||||||
|
val name = pigeonVar_list[0] as String
|
||||||
|
val url = pigeonVar_list[1] as String
|
||||||
|
val time = pigeonVar_list[2] as Long
|
||||||
|
return Chapter(name, url, time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
time,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is Chapter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class TrickPlayModel (
|
||||||
|
val width: Long,
|
||||||
|
val height: Long,
|
||||||
|
val tileWidth: Long,
|
||||||
|
val tileHeight: Long,
|
||||||
|
val thumbnailCount: Long,
|
||||||
|
val interval: Long,
|
||||||
|
val images: List<String>
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): TrickPlayModel {
|
||||||
|
val width = pigeonVar_list[0] as Long
|
||||||
|
val height = pigeonVar_list[1] as Long
|
||||||
|
val tileWidth = pigeonVar_list[2] as Long
|
||||||
|
val tileHeight = pigeonVar_list[3] as Long
|
||||||
|
val thumbnailCount = pigeonVar_list[4] as Long
|
||||||
|
val interval = pigeonVar_list[5] as Long
|
||||||
|
val images = pigeonVar_list[6] as List<String>
|
||||||
|
return TrickPlayModel(width, height, tileWidth, tileHeight, thumbnailCount, interval, images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
tileWidth,
|
||||||
|
tileHeight,
|
||||||
|
thumbnailCount,
|
||||||
|
interval,
|
||||||
|
images,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is TrickPlayModel) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class StartResult (
|
||||||
|
val resultValue: String? = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): StartResult {
|
||||||
|
val resultValue = pigeonVar_list[0] as String?
|
||||||
|
return StartResult(resultValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
resultValue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is StartResult) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class PlaybackState (
|
||||||
|
val position: Long,
|
||||||
|
val buffered: Long,
|
||||||
|
val duration: Long,
|
||||||
|
val playing: Boolean,
|
||||||
|
val buffering: Boolean,
|
||||||
|
val completed: Boolean,
|
||||||
|
val failed: Boolean
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): PlaybackState {
|
||||||
|
val position = pigeonVar_list[0] as Long
|
||||||
|
val buffered = pigeonVar_list[1] as Long
|
||||||
|
val duration = pigeonVar_list[2] as Long
|
||||||
|
val playing = pigeonVar_list[3] as Boolean
|
||||||
|
val buffering = pigeonVar_list[4] as Boolean
|
||||||
|
val completed = pigeonVar_list[5] as Boolean
|
||||||
|
val failed = pigeonVar_list[6] as Boolean
|
||||||
|
return PlaybackState(position, buffered, duration, playing, buffering, completed, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
position,
|
||||||
|
buffered,
|
||||||
|
duration,
|
||||||
|
playing,
|
||||||
|
buffering,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is PlaybackState) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() {
|
||||||
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
|
return when (type) {
|
||||||
|
129.toByte() -> {
|
||||||
|
return (readValue(buffer) as Long?)?.let {
|
||||||
|
MediaSegmentType.ofRaw(it.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
130.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
PlayableData.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
131.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
MediaSegment.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
132.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
AudioTrack.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
133.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
SubtitleTrack.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
134.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
Chapter.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
135.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
TrickPlayModel.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
136.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
StartResult.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
137.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
PlaybackState.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> super.readValueOfType(type, buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||||
|
when (value) {
|
||||||
|
is MediaSegmentType -> {
|
||||||
|
stream.write(129)
|
||||||
|
writeValue(stream, value.raw)
|
||||||
|
}
|
||||||
|
is PlayableData -> {
|
||||||
|
stream.write(130)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
is MediaSegment -> {
|
||||||
|
stream.write(131)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
is AudioTrack -> {
|
||||||
|
stream.write(132)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
is SubtitleTrack -> {
|
||||||
|
stream.write(133)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
is Chapter -> {
|
||||||
|
stream.write(134)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
is TrickPlayModel -> {
|
||||||
|
stream.write(135)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
is StartResult -> {
|
||||||
|
stream.write(136)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
is PlaybackState -> {
|
||||||
|
stream.write(137)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
else -> super.writeValue(stream, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
|
interface NativeVideoActivity {
|
||||||
|
fun launchActivity(callback: (Result<StartResult>) -> Unit)
|
||||||
|
fun disposeActivity()
|
||||||
|
fun isLeanBackEnabled(): Boolean
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The codec used by NativeVideoActivity. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
VideoPlayerHelperPigeonCodec()
|
||||||
|
}
|
||||||
|
/** Sets up an instance of `NativeVideoActivity` to handle messages through the `binaryMessenger`. */
|
||||||
|
@JvmOverloads
|
||||||
|
fun setUp(binaryMessenger: BinaryMessenger, api: NativeVideoActivity?, messageChannelSuffix: String = "") {
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.launchActivity$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
api.launchActivity{ result: Result<StartResult> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(VideoPlayerHelperPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(VideoPlayerHelperPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.disposeActivity$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.disposeActivity()
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.isLeanBackEnabled$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.isLeanBackEnabled())
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
|
interface VideoPlayerApi {
|
||||||
|
fun sendPlayableModel(playableData: PlayableData): Boolean
|
||||||
|
fun open(url: String, play: Boolean)
|
||||||
|
fun setLooping(looping: Boolean)
|
||||||
|
/** Sets the volume, with 0.0 being muted and 1.0 being full volume. */
|
||||||
|
fun setVolume(volume: Double)
|
||||||
|
/** Sets the playback speed as a multiple of normal speed. */
|
||||||
|
fun setPlaybackSpeed(speed: Double)
|
||||||
|
fun play()
|
||||||
|
/** Pauses playback if the video is currently playing. */
|
||||||
|
fun pause()
|
||||||
|
/** Seeks to the given playback position, in milliseconds. */
|
||||||
|
fun seekTo(position: Long)
|
||||||
|
fun stop()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The codec used by VideoPlayerApi. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
VideoPlayerHelperPigeonCodec()
|
||||||
|
}
|
||||||
|
/** Sets up an instance of `VideoPlayerApi` to handle messages through the `binaryMessenger`. */
|
||||||
|
@JvmOverloads
|
||||||
|
fun setUp(binaryMessenger: BinaryMessenger, api: VideoPlayerApi?, messageChannelSuffix: String = "") {
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.sendPlayableModel$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val playableDataArg = args[0] as PlayableData
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.sendPlayableModel(playableDataArg))
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.open$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val urlArg = args[0] as String
|
||||||
|
val playArg = args[1] as Boolean
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.open(urlArg, playArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setLooping$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val loopingArg = args[0] as Boolean
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.setLooping(loopingArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setVolume$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val volumeArg = args[0] as Double
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.setVolume(volumeArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setPlaybackSpeed$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val speedArg = args[0] as Double
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.setPlaybackSpeed(speedArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.play$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.play()
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.pause$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.pause()
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.seekTo$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val positionArg = args[0] as Long
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.seekTo(positionArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.stop$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.stop()
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
VideoPlayerHelperPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
|
||||||
|
class VideoPlayerListenerCallback(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
|
||||||
|
companion object {
|
||||||
|
/** The codec used by VideoPlayerListenerCallback. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
VideoPlayerHelperPigeonCodec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onPlaybackStateChanged(stateArg: PlaybackState, callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(listOf(stateArg)) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
|
||||||
|
class VideoPlayerControlsCallback(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
|
||||||
|
companion object {
|
||||||
|
/** The codec used by VideoPlayerControlsCallback. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
VideoPlayerHelperPigeonCodec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun loadNextVideo(callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadNextVideo$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(null) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun loadPreviousVideo(callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadPreviousVideo$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(null) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onStop(callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onStop$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(null) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun swapSubtitleTrack(valueArg: Long, callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(listOf(valueArg)) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun swapAudioTrack(valueArg: Long, callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(listOf(valueArg)) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package nl.jknaapen.fladder.composables.controls
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import nl.jknaapen.fladder.utility.conditional
|
||||||
|
import nl.jknaapen.fladder.utility.highlightOnFocus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun CustomIconButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
enableFocusIndicator: Boolean = true,
|
||||||
|
enableScaledFocus: Boolean = false,
|
||||||
|
backgroundColor: Color = Color.Transparent,
|
||||||
|
foreGroundColor: Color = Color.White,
|
||||||
|
backgroundFocusedColor: Color = Color.Transparent,
|
||||||
|
foreGroundFocusedColor: Color = Color.White,
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
val currentContentColor by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
if (isFocused) {
|
||||||
|
foreGroundFocusedColor
|
||||||
|
} else {
|
||||||
|
foreGroundColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentBackgroundColor by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
if (isFocused) {
|
||||||
|
backgroundFocusedColor
|
||||||
|
} else {
|
||||||
|
backgroundColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.wrapContentSize() // parent expands to fit children
|
||||||
|
.conditional(enableScaledFocus) {
|
||||||
|
scale(if (isFocused) 1.05f else 1f)
|
||||||
|
}
|
||||||
|
.conditional(enableFocusIndicator) {
|
||||||
|
highlightOnFocus()
|
||||||
|
}
|
||||||
|
.background(currentBackgroundColor, shape = RoundedCornerShape(8.dp))
|
||||||
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
|
.clickable(
|
||||||
|
enabled = enabled,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
.alpha(if (enabled) 1f else 0.5f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides currentContentColor) {
|
||||||
|
Box(modifier = Modifier.padding(8.dp)) {
|
||||||
|
icon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package nl.jknaapen.fladder.composables.controls
|
||||||
|
|
||||||
|
import PlayableData
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ItemHeader(state: PlayableData?) {
|
||||||
|
val title = state?.title
|
||||||
|
val logoUrl = state?.logoUrl
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
if (!logoUrl.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = logoUrl,
|
||||||
|
contentDescription = title ?: "logo",
|
||||||
|
alignment = Alignment.CenterStart,
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 100.dp)
|
||||||
|
.widthIn(max = 200.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
title?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,423 @@
|
||||||
|
package nl.jknaapen.fladder.composables.controls
|
||||||
|
|
||||||
|
import MediaSegment
|
||||||
|
import MediaSegmentType
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.FocusState
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.Key.Companion.Back
|
||||||
|
import androidx.compose.ui.input.key.Key.Companion.ButtonSelect
|
||||||
|
import androidx.compose.ui.input.key.Key.Companion.DirectionCenter
|
||||||
|
import androidx.compose.ui.input.key.Key.Companion.DirectionLeft
|
||||||
|
import androidx.compose.ui.input.key.Key.Companion.DirectionRight
|
||||||
|
import androidx.compose.ui.input.key.Key.Companion.Enter
|
||||||
|
import androidx.compose.ui.input.key.Key.Companion.Escape
|
||||||
|
import androidx.compose.ui.input.key.Key.Companion.Spacebar
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.fastCoerceIn
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import nl.jknaapen.fladder.utility.formatTime
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
import kotlin.time.toDuration
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@Composable
|
||||||
|
internal fun ProgressBar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
player: ExoPlayer,
|
||||||
|
bottomControlFocusRequester: FocusRequester,
|
||||||
|
onUserInteraction: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val position by VideoPlayerObject.position.collectAsState(0L)
|
||||||
|
val duration by VideoPlayerObject.duration.collectAsState(0L)
|
||||||
|
|
||||||
|
var tempPosition by remember { mutableLongStateOf(position) }
|
||||||
|
var scrubbingTimeLine by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val playableData by VideoPlayerObject.implementation.playbackData.collectAsState(null)
|
||||||
|
|
||||||
|
val currentPosition by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
if (scrubbingTimeLine) {
|
||||||
|
tempPosition
|
||||||
|
} else {
|
||||||
|
position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState(null)
|
||||||
|
if (scrubbingTimeLine)
|
||||||
|
FilmstripTrickPlayOverlay(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(125.dp)
|
||||||
|
.align(alignment = Alignment.CenterHorizontally),
|
||||||
|
currentPosition = tempPosition.milliseconds,
|
||||||
|
trickPlayModel = playbackData?.trickPlayModel
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
val subTitle = playableData?.subTitle
|
||||||
|
subTitle?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(color = Color.White),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
VideoEndTime()
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(
|
||||||
|
8.dp,
|
||||||
|
alignment = Alignment.CenterHorizontally
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
formatTime(currentPosition),
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
SimpleProgressBar(
|
||||||
|
player,
|
||||||
|
bottomControlFocusRequester,
|
||||||
|
onUserInteraction,
|
||||||
|
tempPosition,
|
||||||
|
scrubbingTimeLine,
|
||||||
|
onTempPosChanged = {
|
||||||
|
tempPosition = it
|
||||||
|
},
|
||||||
|
onScrubbingChanged = {
|
||||||
|
scrubbingTimeLine = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"-" + formatTime(
|
||||||
|
(duration - currentPosition).fastCoerceIn(
|
||||||
|
minimumValue = 0L,
|
||||||
|
maximumValue = duration
|
||||||
|
)
|
||||||
|
),
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RowScope.SimpleProgressBar(
|
||||||
|
player: ExoPlayer,
|
||||||
|
playFocusRequester: FocusRequester,
|
||||||
|
onUserInteraction: () -> Unit,
|
||||||
|
tempPosition: Long,
|
||||||
|
scrubbingTimeLine: Boolean,
|
||||||
|
onTempPosChanged: (Long) -> Unit = {},
|
||||||
|
onScrubbingChanged: (Boolean) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState()
|
||||||
|
|
||||||
|
var width by remember { mutableIntStateOf(0) }
|
||||||
|
val position by VideoPlayerObject.position.collectAsState(0L)
|
||||||
|
val duration by VideoPlayerObject.duration.collectAsState(0L)
|
||||||
|
|
||||||
|
val slideBarShape = RoundedCornerShape(size = 8.dp)
|
||||||
|
|
||||||
|
var thumbFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var internalTempPosition by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
val progress by remember(scrubbingTimeLine, tempPosition, position) {
|
||||||
|
derivedStateOf {
|
||||||
|
if (scrubbingTimeLine) {
|
||||||
|
tempPosition.toFloat() / duration.toFloat()
|
||||||
|
} else {
|
||||||
|
position.toFloat() / duration.toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onGloballyPositioned(
|
||||||
|
onGloballyPositioned = {
|
||||||
|
width = it.size.width
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.heightIn(min = 26.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
onUserInteraction()
|
||||||
|
val clickRelativeOffset = offset.x / width.toFloat()
|
||||||
|
val newPosition = duration.milliseconds * clickRelativeOffset.toDouble()
|
||||||
|
player.seekTo(newPosition.toLong(DurationUnit.MILLISECONDS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { offset ->
|
||||||
|
onScrubbingChanged(true)
|
||||||
|
onUserInteraction()
|
||||||
|
onTempPosChanged(player.currentPosition)
|
||||||
|
},
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
onUserInteraction()
|
||||||
|
change.consume()
|
||||||
|
val relative = change.position.x / size.width.toFloat()
|
||||||
|
internalTempPosition = (duration.milliseconds * relative.toDouble())
|
||||||
|
.toLong(DurationUnit.MILLISECONDS)
|
||||||
|
onTempPosChanged(
|
||||||
|
internalTempPosition
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
onScrubbingChanged(false)
|
||||||
|
player.seekTo(internalTempPosition)
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
onScrubbingChanged(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.focusable(enabled = false)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(12.dp)
|
||||||
|
.background(
|
||||||
|
color = Color.Black.copy(alpha = 0.15f),
|
||||||
|
shape = slideBarShape
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.focusable(enabled = false)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(progress)
|
||||||
|
.padding(end = 9.dp)
|
||||||
|
.background(
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
shape = slideBarShape
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val mediaSegments = playbackData?.segments
|
||||||
|
if (width > 0 && duration.toDuration(DurationUnit.MILLISECONDS) > Duration.ZERO) {
|
||||||
|
mediaSegments?.forEach { segment ->
|
||||||
|
val segStartMs = max(
|
||||||
|
0.0,
|
||||||
|
segment.start.toDuration(DurationUnit.MILLISECONDS)
|
||||||
|
.toDouble(DurationUnit.MILLISECONDS)
|
||||||
|
)
|
||||||
|
val segEndMs = max(
|
||||||
|
segStartMs,
|
||||||
|
segment.end.toDuration(DurationUnit.MILLISECONDS)
|
||||||
|
.toDouble(DurationUnit.MILLISECONDS)
|
||||||
|
)
|
||||||
|
val durMs = duration.toDouble().coerceAtLeast(1.0)
|
||||||
|
|
||||||
|
if (segStartMs >= durMs) return@forEach
|
||||||
|
|
||||||
|
val startPx = (width * (segStartMs / durMs)).toFloat()
|
||||||
|
val segPx =
|
||||||
|
(width * ((segEndMs - segStartMs) / durMs)).toFloat().coerceAtLeast(1f)
|
||||||
|
|
||||||
|
val segDp = with(density) { segPx.toDp() }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.focusable(enabled = false)
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX = startPx
|
||||||
|
translationY = 16.dp.toPx()
|
||||||
|
}
|
||||||
|
.width(segDp)
|
||||||
|
.height(6.dp)
|
||||||
|
.background(
|
||||||
|
color = segment.color.copy(alpha = 0.75f),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Generate chapter dots
|
||||||
|
val chapters = playbackData?.chapters ?: listOf()
|
||||||
|
chapters.forEach { chapter ->
|
||||||
|
val chapterDuration = chapter.time.toDuration(DurationUnit.SECONDS)
|
||||||
|
.toDouble(DurationUnit.SECONDS)
|
||||||
|
val isAfterCurrentPositon = chapterDuration > position.toDouble()
|
||||||
|
val segStartMs = max(
|
||||||
|
0.0,
|
||||||
|
chapterDuration
|
||||||
|
)
|
||||||
|
|
||||||
|
val durMs = duration.toDouble().coerceAtLeast(1.0)
|
||||||
|
val startPx = (width * (segStartMs / durMs)).toFloat()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.focusable(enabled = false)
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX = startPx
|
||||||
|
}
|
||||||
|
.size(6.dp)
|
||||||
|
.background(
|
||||||
|
color = (if (isAfterCurrentPositon) Color.White else Color.Black).copy(
|
||||||
|
alpha = 0.45f
|
||||||
|
),
|
||||||
|
shape = CircleShape
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Thumb
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.onFocusChanged { state: FocusState ->
|
||||||
|
thumbFocused = state.isFocused
|
||||||
|
if (!state.isFocused) {
|
||||||
|
onScrubbingChanged(false)
|
||||||
|
} else {
|
||||||
|
onTempPosChanged(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusable(enabled = true)
|
||||||
|
.onKeyEvent { keyEvent: KeyEvent ->
|
||||||
|
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||||
|
|
||||||
|
onUserInteraction()
|
||||||
|
|
||||||
|
when (keyEvent.key) {
|
||||||
|
Key.DirectionDown -> {
|
||||||
|
playFocusRequester.requestFocus()
|
||||||
|
onScrubbingChanged(false)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectionLeft -> {
|
||||||
|
if (!scrubbingTimeLine) {
|
||||||
|
onTempPosChanged(position)
|
||||||
|
onScrubbingChanged(true)
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
val newPos = max(0L, tempPosition - 3000L)
|
||||||
|
onTempPosChanged(newPos)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectionRight -> {
|
||||||
|
if (!scrubbingTimeLine) {
|
||||||
|
onTempPosChanged(position)
|
||||||
|
onScrubbingChanged(true)
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
val newPos = min(player.duration.takeIf { it > 0 } ?: 1L,
|
||||||
|
tempPosition + 3000L)
|
||||||
|
onTempPosChanged(newPos)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
Enter, Spacebar, ButtonSelect, DirectionCenter -> {
|
||||||
|
if (scrubbingTimeLine) {
|
||||||
|
player.seekTo(tempPosition)
|
||||||
|
player.play()
|
||||||
|
onScrubbingChanged(false)
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
|
||||||
|
Escape, Back -> {
|
||||||
|
if (scrubbingTimeLine) {
|
||||||
|
onScrubbingChanged(false)
|
||||||
|
player.play()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX = (width * progress) - 4.dp.toPx()
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
color = Color.White,
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.width(8.dp)
|
||||||
|
.height(if (thumbFocused) 21.dp else 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MediaSegment.color: Color
|
||||||
|
get() = when (this.type) {
|
||||||
|
MediaSegmentType.COMMERCIAL -> Color.Magenta
|
||||||
|
MediaSegmentType.PREVIEW -> Color(255, 128, 0)
|
||||||
|
MediaSegmentType.RECAP -> Color(135, 206, 250)
|
||||||
|
MediaSegmentType.OUTRO -> Color.Yellow
|
||||||
|
MediaSegmentType.INTRO -> Color.Green
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
package nl.jknaapen.fladder.composables.controls
|
||||||
|
|
||||||
|
import MediaSegment
|
||||||
|
import MediaSegmentType
|
||||||
|
import SegmentSkip
|
||||||
|
import SegmentType
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeContentPadding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import nl.jknaapen.fladder.objects.PlayerSettingsObject
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import nl.jknaapen.fladder.utility.defaultSelected
|
||||||
|
import nl.jknaapen.fladder.utility.leanBackEnabled
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@Composable
|
||||||
|
internal fun BoxScope.SegmentSkipOverlay(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val isAndroidTV = leanBackEnabled(LocalContext.current)
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
val state by VideoPlayerObject.implementation.playbackData.collectAsState()
|
||||||
|
val position by VideoPlayerObject.position.collectAsState(0L)
|
||||||
|
|
||||||
|
val segments = state?.segments ?: emptyList()
|
||||||
|
val player = VideoPlayerObject.implementation.player
|
||||||
|
val skipMap by PlayerSettingsObject.skipMap.collectAsState(mapOf())
|
||||||
|
|
||||||
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(segments, skipMap) { }
|
||||||
|
|
||||||
|
if (segments.isEmpty() || player == null) return
|
||||||
|
|
||||||
|
val activeSegment = segments.firstOrNull { it.start <= position && it.end >= position }
|
||||||
|
|
||||||
|
val segmentType = activeSegment?.type?.toSegment
|
||||||
|
val skip = skipMap[segmentType]
|
||||||
|
|
||||||
|
fun skipSegment(segment: MediaSegment) {
|
||||||
|
player.seekTo(segment.end + 250.milliseconds.inWholeMilliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(activeSegment, position, skipMap) {
|
||||||
|
if (skipMap.isEmpty()) return@LaunchedEffect
|
||||||
|
if (activeSegment != null) {
|
||||||
|
if (skip == SegmentSkip.SKIP) {
|
||||||
|
skipSegment(activeSegment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(activeSegment) {
|
||||||
|
if (activeSegment != null) {
|
||||||
|
focusRequester.captureFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val shape = RoundedCornerShape(8.dp)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
activeSegment != null && skip == SegmentSkip.ASK,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
.safeContentPadding()
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
FilledTonalButton(
|
||||||
|
modifier = modifier
|
||||||
|
.align(alignment = Alignment.CenterEnd)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusChanged { state ->
|
||||||
|
isFocused = state.isFocused
|
||||||
|
}
|
||||||
|
.border(
|
||||||
|
width = 2.dp,
|
||||||
|
color = if (isFocused) Color.White.copy(alpha = 0.4f) else Color.Transparent,
|
||||||
|
shape = shape,
|
||||||
|
)
|
||||||
|
.defaultSelected(true),
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
|
shape = shape,
|
||||||
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
|
containerColor = Color.White.copy(alpha = 0.75f),
|
||||||
|
contentColor = Color.Black,
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
activeSegment?.let {
|
||||||
|
player.seekTo(it.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (isAndroidTV) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.background(
|
||||||
|
color = Color.Black.copy(alpha = 0.15f),
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
width = 1.5.dp,
|
||||||
|
color = Color.Black.copy(alpha = 0.15f),
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(7.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
color = Color.White,
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeSegment?.let {
|
||||||
|
Text(
|
||||||
|
"Skip ${it.name.lowercase()}",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MediaSegmentType.toSegment: SegmentType
|
||||||
|
get() = when (this) {
|
||||||
|
MediaSegmentType.COMMERCIAL -> SegmentType.COMMERCIAL
|
||||||
|
MediaSegmentType.PREVIEW -> SegmentType.PREVIEW
|
||||||
|
MediaSegmentType.RECAP -> SegmentType.RECAP
|
||||||
|
MediaSegmentType.INTRO -> SegmentType.INTRO
|
||||||
|
MediaSegmentType.OUTRO -> SegmentType.OUTRO
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
package nl.jknaapen.fladder.composables.controls
|
||||||
|
|
||||||
|
import TrickPlayModel
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
|
import coil3.compose.rememberAsyncImagePainter
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.toBitmap
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
private data class ThumbnailData(val tileUrl: String, val offset: Pair<Double, Double>)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FilmstripTrickPlayOverlay(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
currentPosition: Duration,
|
||||||
|
trickPlayModel: TrickPlayModel?,
|
||||||
|
thumbnailsToShowOnEachSide: Int = 2,
|
||||||
|
) {
|
||||||
|
if (trickPlayModel == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uniqueThumbnails = remember(currentPosition, trickPlayModel, thumbnailsToShowOnEachSide) {
|
||||||
|
val currentFrameIndex = (currentPosition.inWholeMilliseconds / trickPlayModel.interval)
|
||||||
|
.toInt()
|
||||||
|
.coerceIn(0, (trickPlayModel.thumbnailCount - 1).toInt())
|
||||||
|
|
||||||
|
val currentFrame = trickPlayModel.getThumbnailDetailsForIndex(currentFrameIndex)
|
||||||
|
if (currentFrame == null) return@remember emptyList()
|
||||||
|
|
||||||
|
val foundThumbnails = mutableSetOf(currentFrame)
|
||||||
|
val previousFrames = mutableListOf<ThumbnailData>()
|
||||||
|
val nextFrames = mutableListOf<ThumbnailData>()
|
||||||
|
|
||||||
|
var searchIndex = currentFrameIndex - 1
|
||||||
|
while (previousFrames.size < thumbnailsToShowOnEachSide && searchIndex >= 0) {
|
||||||
|
trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let {
|
||||||
|
if (foundThumbnails.add(it)) previousFrames.add(it)
|
||||||
|
}
|
||||||
|
searchIndex--
|
||||||
|
}
|
||||||
|
|
||||||
|
searchIndex = currentFrameIndex + 1
|
||||||
|
while (nextFrames.size < thumbnailsToShowOnEachSide && searchIndex < trickPlayModel.thumbnailCount) {
|
||||||
|
trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let {
|
||||||
|
if (foundThumbnails.add(it)) nextFrames.add(it)
|
||||||
|
}
|
||||||
|
searchIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousFrames.size < thumbnailsToShowOnEachSide) {
|
||||||
|
var extraNeeded = thumbnailsToShowOnEachSide - previousFrames.size
|
||||||
|
while (extraNeeded > 0 && searchIndex < trickPlayModel.thumbnailCount) {
|
||||||
|
trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let {
|
||||||
|
if (foundThumbnails.add(it)) {
|
||||||
|
nextFrames.add(it)
|
||||||
|
extraNeeded--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchIndex++
|
||||||
|
}
|
||||||
|
} else if (nextFrames.size < thumbnailsToShowOnEachSide) {
|
||||||
|
var extraNeeded = thumbnailsToShowOnEachSide - nextFrames.size
|
||||||
|
searchIndex = currentFrameIndex - previousFrames.size - 1
|
||||||
|
while (extraNeeded > 0 && searchIndex >= 0) {
|
||||||
|
trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let {
|
||||||
|
if (foundThumbnails.add(it)) {
|
||||||
|
previousFrames.add(it)
|
||||||
|
extraNeeded--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchIndex--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousFrames.reversed() + currentFrame + nextFrames
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentFrameData = trickPlayModel.getThumbnailDetailsForIndex(
|
||||||
|
(currentPosition.inWholeMilliseconds / trickPlayModel.interval).toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
uniqueThumbnails.forEach { thumbnailData ->
|
||||||
|
val isCenter = thumbnailData == currentFrameData
|
||||||
|
val scale = if (isCenter) 1.2f else 1.0f
|
||||||
|
val alpha = if (isCenter) 1.0f else 0.7f
|
||||||
|
|
||||||
|
Thumbnail(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.zIndex(if (isCenter) 1f else 0f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
.scale(scale)
|
||||||
|
.graphicsLayer {
|
||||||
|
this.alpha = alpha
|
||||||
|
},
|
||||||
|
trickPlayModel = trickPlayModel,
|
||||||
|
tileUrl = thumbnailData.tileUrl,
|
||||||
|
offset = thumbnailData.offset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Thumbnail(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
trickPlayModel: TrickPlayModel,
|
||||||
|
tileUrl: String,
|
||||||
|
offset: Pair<Double, Double>,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val painter = rememberAsyncImagePainter(
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(tileUrl)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
val imageState by painter.state.collectAsState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
.clip(
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
when (val state = imageState) {
|
||||||
|
is AsyncImagePainter.State.Success -> {
|
||||||
|
val imageBitmap = state.result.image.toBitmap().asImageBitmap()
|
||||||
|
Canvas(modifier = Modifier.matchParentSize()) {
|
||||||
|
val (offsetX, offsetY) = offset
|
||||||
|
drawImage(
|
||||||
|
image = imageBitmap,
|
||||||
|
srcOffset = IntOffset(offsetX.toInt(), offsetY.toInt()),
|
||||||
|
srcSize = IntSize(
|
||||||
|
trickPlayModel.width.toInt(),
|
||||||
|
trickPlayModel.height.toInt()
|
||||||
|
),
|
||||||
|
dstSize = IntSize(size.width.toInt(), size.height.toInt())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> return@Box
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val TrickPlayModel.imagesPerTile: Int
|
||||||
|
get() = (tileWidth * tileHeight).toInt()
|
||||||
|
|
||||||
|
private fun TrickPlayModel.getThumbnailDetailsForIndex(index: Int): ThumbnailData? {
|
||||||
|
val safeIndex = index.coerceIn(0, (thumbnailCount - 1).toInt())
|
||||||
|
val indexOfTile = (safeIndex / imagesPerTile).coerceIn(0, images.size - 1)
|
||||||
|
val tileUrl = images.getOrNull(indexOfTile) ?: return null
|
||||||
|
|
||||||
|
val tileIndex = safeIndex % imagesPerTile
|
||||||
|
val column = tileIndex % tileWidth
|
||||||
|
val row = tileIndex / tileWidth
|
||||||
|
val offset = Pair(
|
||||||
|
(width * column).toDouble(),
|
||||||
|
(height * row).toDouble()
|
||||||
|
)
|
||||||
|
|
||||||
|
return ThumbnailData(tileUrl, offset)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package nl.jknaapen.fladder.composables.controls
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
@Composable
|
||||||
|
fun VideoEndTime() {
|
||||||
|
val startInstant = remember { Clock.System.now() }
|
||||||
|
val durationMs by VideoPlayerObject.duration.collectAsState(initial = 0L)
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
|
||||||
|
val javaInstant = remember(startInstant) { startInstant.toJavaInstant() }
|
||||||
|
val endJavaInstant = remember(javaInstant, durationMs) {
|
||||||
|
javaInstant.plusMillis(durationMs)
|
||||||
|
}
|
||||||
|
val endZoned = remember(endJavaInstant, zone) {
|
||||||
|
endJavaInstant.atZone(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("hh:mm a")
|
||||||
|
val formattedEnd = remember(endZoned, formatter) {
|
||||||
|
endZoned.format(formatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "ends at $formattedEnd",
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(color = Color.White),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,448 @@
|
||||||
|
package nl.jknaapen.fladder.composables.controls
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeContentPadding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import io.github.rabehx.iconsax.Iconsax
|
||||||
|
import io.github.rabehx.iconsax.filled.AudioSquare
|
||||||
|
import io.github.rabehx.iconsax.filled.Backward
|
||||||
|
import io.github.rabehx.iconsax.filled.Check
|
||||||
|
import io.github.rabehx.iconsax.filled.Forward
|
||||||
|
import io.github.rabehx.iconsax.filled.PauseCircle
|
||||||
|
import io.github.rabehx.iconsax.filled.PlayCircle
|
||||||
|
import io.github.rabehx.iconsax.filled.Subtitle
|
||||||
|
import io.github.rabehx.iconsax.outline.CloseSquare
|
||||||
|
import io.github.rabehx.iconsax.outline.Refresh
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import nl.jknaapen.fladder.composables.dialogs.AudioPicker
|
||||||
|
import nl.jknaapen.fladder.composables.dialogs.ChapterSelectionSheet
|
||||||
|
import nl.jknaapen.fladder.composables.dialogs.SubtitlePicker
|
||||||
|
import nl.jknaapen.fladder.objects.PlayerSettingsObject
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import nl.jknaapen.fladder.utility.ImmersiveSystemBars
|
||||||
|
import nl.jknaapen.fladder.utility.defaultSelected
|
||||||
|
import nl.jknaapen.fladder.utility.leanBackEnabled
|
||||||
|
import nl.jknaapen.fladder.utility.visible
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CustomVideoControls(
|
||||||
|
exoPlayer: ExoPlayer,
|
||||||
|
) {
|
||||||
|
var showControls by remember { mutableStateOf(false) }
|
||||||
|
val lastInteraction = remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||||
|
|
||||||
|
val showAudioDialog = remember { mutableStateOf(false) }
|
||||||
|
val showSubDialog = remember { mutableStateOf(false) }
|
||||||
|
var showChapterDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
|
||||||
|
val buffering by VideoPlayerObject.buffering.collectAsState(true)
|
||||||
|
val playing by VideoPlayerObject.playing.collectAsState(false)
|
||||||
|
|
||||||
|
ImmersiveSystemBars(isImmersive = !showControls)
|
||||||
|
|
||||||
|
BackHandler(
|
||||||
|
enabled = showControls
|
||||||
|
) {
|
||||||
|
showControls = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart the hide timer whenever `lastInteraction` changes.
|
||||||
|
LaunchedEffect(lastInteraction.longValue) {
|
||||||
|
delay(5.seconds)
|
||||||
|
showControls = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val bottomControlFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
fun updateLastInteraction() {
|
||||||
|
showControls = true
|
||||||
|
lastInteraction.longValue = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusable(enabled = false)
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
if (focusState.hasFocus) {
|
||||||
|
bottomControlFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onKeyEvent { keyEvent: KeyEvent ->
|
||||||
|
if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||||
|
if (!showControls) {
|
||||||
|
bottomControlFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
updateLastInteraction()
|
||||||
|
return@onKeyEvent false
|
||||||
|
}
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
) {
|
||||||
|
showControls = !showControls
|
||||||
|
if (showControls) lastInteraction.longValue = System.currentTimeMillis()
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.BottomCenter
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.visible(
|
||||||
|
visible = showControls,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Black.copy(alpha = 0.85f),
|
||||||
|
Color.Black.copy(alpha = 0f),
|
||||||
|
),
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(0f, Float.POSITIVE_INFINITY)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.safeContentPadding(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
val state by VideoPlayerObject.implementation.playbackData.collectAsState(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
state?.let {
|
||||||
|
ItemHeader(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!leanBackEnabled(LocalContext.current)) {
|
||||||
|
IconButton(
|
||||||
|
{
|
||||||
|
activity?.finish()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.Outline.CloseSquare,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.focusable(false),
|
||||||
|
contentDescription = "Close icon",
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
// Progress Bar
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.displayCutoutPadding(),
|
||||||
|
) {
|
||||||
|
ProgressBar(
|
||||||
|
modifier = Modifier,
|
||||||
|
exoPlayer, bottomControlFocusRequester, ::updateLastInteraction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Black.copy(alpha = 0f),
|
||||||
|
Color.Black.copy(alpha = 0.85f),
|
||||||
|
),
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(0f, Float.POSITIVE_INFINITY)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.displayCutoutPadding()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 8.dp, bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
LeftButtons(
|
||||||
|
openChapterSelection = {
|
||||||
|
showChapterDialog = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
PlaybackButtons(exoPlayer, bottomControlFocusRequester)
|
||||||
|
RightButtons(showAudioDialog, showSubDialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SegmentSkipOverlay()
|
||||||
|
if (buffering && !playing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(alignment = Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAudioDialog.value) {
|
||||||
|
AudioPicker(
|
||||||
|
player = exoPlayer,
|
||||||
|
onDismissRequest = {
|
||||||
|
showAudioDialog.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSubDialog.value) {
|
||||||
|
SubtitlePicker(
|
||||||
|
player = exoPlayer,
|
||||||
|
onDismissRequest = {
|
||||||
|
showSubDialog.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showChapterDialog) {
|
||||||
|
ChapterSelectionSheet(
|
||||||
|
onSelected = {
|
||||||
|
exoPlayer.seekTo(it.time)
|
||||||
|
showChapterDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
showChapterDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control Buttons
|
||||||
|
@Composable
|
||||||
|
fun PlaybackButtons(
|
||||||
|
player: ExoPlayer,
|
||||||
|
bottomControlFocusRequester: FocusRequester,
|
||||||
|
) {
|
||||||
|
val state by VideoPlayerObject.videoPlayerState.collectAsState(null)
|
||||||
|
|
||||||
|
val forwardSpeed by PlayerSettingsObject.forwardSpeed.collectAsState(30.seconds)
|
||||||
|
val backwardSpeed by PlayerSettingsObject.backwardSpeed.collectAsState(15.seconds)
|
||||||
|
|
||||||
|
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState(null)
|
||||||
|
val nextVideo = playbackData?.nextVideo
|
||||||
|
val previousVideo = playbackData?.previousVideo
|
||||||
|
|
||||||
|
val isPlaying = state?.playing ?: false
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 4.dp, vertical = 6.dp)
|
||||||
|
.wrapContentWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(
|
||||||
|
8.dp,
|
||||||
|
alignment = Alignment.CenterHorizontally
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = { VideoPlayerObject.videoPlayerControls?.loadPreviousVideo {} },
|
||||||
|
enabled = previousVideo != null,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.Filled.Backward,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
contentDescription = previousVideo,
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = {
|
||||||
|
player.seekTo(
|
||||||
|
player.currentPosition - backwardSpeed.inWholeMilliseconds
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.Outline.Refresh,
|
||||||
|
contentDescription = "Forward",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp),
|
||||||
|
)
|
||||||
|
Text("-${backwardSpeed.inWholeSeconds}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CustomIconButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(bottomControlFocusRequester)
|
||||||
|
.defaultSelected(true),
|
||||||
|
enableScaledFocus = true,
|
||||||
|
onClick = {
|
||||||
|
if (player.isPlaying) player.pause() else player.play()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (isPlaying) Iconsax.Filled.PauseCircle else Iconsax.Filled.PlayCircle,
|
||||||
|
modifier = Modifier.size(55.dp),
|
||||||
|
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = {
|
||||||
|
player.seekTo(
|
||||||
|
player.currentPosition + forwardSpeed.inWholeMilliseconds
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.Outline.Refresh,
|
||||||
|
contentDescription = "Forward",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.scale(scaleX = -1f, scaleY = 1f),
|
||||||
|
)
|
||||||
|
Text(forwardSpeed.inWholeSeconds.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = { VideoPlayerObject.videoPlayerControls?.loadNextVideo {} },
|
||||||
|
enabled = nextVideo != null,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.Filled.Forward,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
contentDescription = nextVideo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RowScope.LeftButtons(
|
||||||
|
openChapterSelection: () -> Unit,
|
||||||
|
) {
|
||||||
|
val chapters by VideoPlayerObject.chapters.collectAsState(emptyList())
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.Start
|
||||||
|
) {
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = openChapterSelection,
|
||||||
|
enabled = chapters?.isNotEmpty() == true
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.Filled.Check,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
contentDescription = "Show chapters",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RowScope.RightButtons(
|
||||||
|
showAudioDialog: MutableState<Boolean>,
|
||||||
|
showSubDialog: MutableState<Boolean>
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = {
|
||||||
|
showAudioDialog.value = true
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.Filled.AudioSquare,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
contentDescription = "Audio Track",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = {
|
||||||
|
showSubDialog.value = true
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.Filled.Subtitle,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
contentDescription = "Subtitles",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
package nl.jknaapen.fladder.composables.dialogs
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import nl.jknaapen.fladder.utility.clearAudioTrack
|
||||||
|
import nl.jknaapen.fladder.utility.defaultSelected
|
||||||
|
import nl.jknaapen.fladder.utility.setInternalAudioTrack
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AudioPicker(
|
||||||
|
player: ExoPlayer,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedIndex by VideoPlayerObject.currentAudioTrackIndex.collectAsState()
|
||||||
|
val audioTracks by VideoPlayerObject.audioTracks.collectAsState(listOf())
|
||||||
|
val internalAudioTracks by VideoPlayerObject.exoAudioTracks
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(selectedIndex) {
|
||||||
|
listState.scrollToItem(
|
||||||
|
audioTracks.indexOfFirst { it.index == selectedIndex.toLong() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomModalBottomSheet(
|
||||||
|
onDismissRequest,
|
||||||
|
maxWidth = 600.dp,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
TrackButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.defaultSelected(-1 == selectedIndex),
|
||||||
|
onClick = {
|
||||||
|
VideoPlayerObject.setAudioTrackIndex(-1)
|
||||||
|
player.clearAudioTrack()
|
||||||
|
},
|
||||||
|
selected = -1 == selectedIndex
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Off",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internalAudioTracks.forEachIndexed { index, track ->
|
||||||
|
val serverTrack = audioTracks.elementAtOrNull(index + 1)
|
||||||
|
val selected = serverTrack?.index == selectedIndex.toLong()
|
||||||
|
item {
|
||||||
|
TrackButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.defaultSelected(selected),
|
||||||
|
onClick = {
|
||||||
|
serverTrack?.index?.let {
|
||||||
|
VideoPlayerObject.setAudioTrackIndex(it.toInt())
|
||||||
|
}
|
||||||
|
player.setInternalAudioTrack(track)
|
||||||
|
},
|
||||||
|
selected = selected
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = serverTrack?.name ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
package nl.jknaapen.fladder.composables.dialogs
|
||||||
|
|
||||||
|
import Chapter
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import nl.jknaapen.fladder.utility.defaultSelected
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun ChapterSelectionSheet(
|
||||||
|
onSelected: (Chapter) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState()
|
||||||
|
val chapters = playbackData?.chapters ?: listOf()
|
||||||
|
val currentPosition by VideoPlayerObject.position.collectAsState(0L)
|
||||||
|
|
||||||
|
var currentChapter: Chapter? by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
chapters[chapters.indexOfCurrent(
|
||||||
|
currentPosition
|
||||||
|
)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
lazyListState.animateScrollToItem(
|
||||||
|
chapters.indexOfCurrent(currentPosition)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomModalBottomSheet(
|
||||||
|
onDismiss,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Chapters",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
state = lazyListState,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
chapters.forEachIndexed { index, chapter ->
|
||||||
|
val selectedChapter = currentChapter == chapter
|
||||||
|
val isCurrentChapter = chapters.indexOfCurrent(currentPosition) == index
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
color = Color.Black.copy(alpha = 0.75f),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
width = 2.dp,
|
||||||
|
color = Color.White.copy(alpha = if (selectedChapter) 1f else 0f),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.defaultSelected(index == 0)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
currentChapter = chapter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
onSelected(chapter)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(
|
||||||
|
8.dp,
|
||||||
|
alignment = Alignment.CenterVertically
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(
|
||||||
|
8.dp,
|
||||||
|
alignment = Alignment.CenterHorizontally
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
chapter.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AsyncImage(
|
||||||
|
model = chapter.url,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(
|
||||||
|
shape = RoundedCornerShape(24.dp)
|
||||||
|
)
|
||||||
|
.heightIn(min = 125.dp, max = 150.dp)
|
||||||
|
.border(
|
||||||
|
width = 2.dp,
|
||||||
|
color = Color.White.copy(alpha = if (isCurrentChapter) 1f else 0f),
|
||||||
|
shape = RoundedCornerShape(24.dp)
|
||||||
|
),
|
||||||
|
contentDescription = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Chapter>.indexOfCurrent(currentPosition: Long): Int {
|
||||||
|
return this.indexOfFirst { chapter ->
|
||||||
|
val nextChapterTime =
|
||||||
|
this.getOrNull(this.indexOf(chapter) + 1)?.time ?: Long.MAX_VALUE
|
||||||
|
currentPosition >= chapter.time && currentPosition < nextChapterTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package nl.jknaapen.fladder.composables.dialogs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun CustomModalBottomSheet(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
maxWidth: Dp = LocalConfiguration.current.screenWidthDp.dp,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val modalBottomSheetState = rememberModalBottomSheetState(
|
||||||
|
skipPartiallyExpanded = true
|
||||||
|
)
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest,
|
||||||
|
dragHandle = null,
|
||||||
|
sheetState = modalBottomSheetState,
|
||||||
|
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
sheetMaxWidth = maxWidth,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.systemBarsPadding()
|
||||||
|
.displayCutoutPadding()
|
||||||
|
.background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Black.copy(alpha = 0f),
|
||||||
|
Color.Black.copy(alpha = 0.8f),
|
||||||
|
),
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(0f, Float.POSITIVE_INFINITY)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
package nl.jknaapen.fladder.composables.dialogs
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import nl.jknaapen.fladder.utility.clearSubtitleTrack
|
||||||
|
import nl.jknaapen.fladder.utility.defaultSelected
|
||||||
|
import nl.jknaapen.fladder.utility.setInternalSubtitleTrack
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SubtitlePicker(
|
||||||
|
player: ExoPlayer,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedIndex by VideoPlayerObject.currentSubtitleTrackIndex.collectAsState()
|
||||||
|
val subTitles by VideoPlayerObject.subtitleTracks.collectAsState(listOf())
|
||||||
|
val internalSubTracks by VideoPlayerObject.exoSubTracks
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(selectedIndex) {
|
||||||
|
listState.scrollToItem(
|
||||||
|
subTitles.indexOfFirst { it.index == selectedIndex.toLong() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomModalBottomSheet(
|
||||||
|
onDismissRequest,
|
||||||
|
maxWidth = 600.dp,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
TrackButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.defaultSelected(-1 == selectedIndex),
|
||||||
|
onClick = {
|
||||||
|
VideoPlayerObject.setSubtitleTrackIndex(-1)
|
||||||
|
player.clearSubtitleTrack()
|
||||||
|
},
|
||||||
|
selected = -1 == selectedIndex
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(
|
||||||
|
8.dp,
|
||||||
|
alignment = Alignment.CenterVertically
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Off",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internalSubTracks.forEachIndexed { index, subtitle ->
|
||||||
|
val serverSub = subTitles.elementAtOrNull(index + 1)
|
||||||
|
val selected = serverSub?.index == selectedIndex.toLong()
|
||||||
|
item {
|
||||||
|
TrackButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.defaultSelected(selected),
|
||||||
|
onClick = {
|
||||||
|
serverSub?.index?.let {
|
||||||
|
VideoPlayerObject.setSubtitleTrackIndex(it.toInt())
|
||||||
|
}
|
||||||
|
player.setInternalSubtitleTrack(subtitle)
|
||||||
|
},
|
||||||
|
selected = selected,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(
|
||||||
|
8.dp,
|
||||||
|
alignment = Alignment.CenterVertically
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = serverSub?.name ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package nl.jknaapen.fladder.composables.dialogs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TrackButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
selected: Boolean = false,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val backgroundColor = if (selected) Color.White else Color.Black
|
||||||
|
val textColor = if (selected) Color.Black else Color.White
|
||||||
|
val textStyle =
|
||||||
|
MaterialTheme.typography.bodyLarge.copy(color = textColor, fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
modifier = modifier
|
||||||
|
.background(
|
||||||
|
color = backgroundColor.copy(alpha = 0.65f),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
)
|
||||||
|
.padding(12.dp)
|
||||||
|
.clickable(
|
||||||
|
onClick = onClick,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = null,
|
||||||
|
),
|
||||||
|
onClick = onClick,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalTextStyle provides textStyle) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
package nl.jknaapen.fladder.messengers
|
||||||
|
|
||||||
|
import PlayableData
|
||||||
|
import VideoPlayerApi
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import nl.jknaapen.fladder.utility.clearAudioTrack
|
||||||
|
import nl.jknaapen.fladder.utility.clearSubtitleTrack
|
||||||
|
import nl.jknaapen.fladder.utility.enableSubtitles
|
||||||
|
import nl.jknaapen.fladder.utility.getAudioTracks
|
||||||
|
import nl.jknaapen.fladder.utility.getSubtitleTracks
|
||||||
|
import nl.jknaapen.fladder.utility.setInternalAudioTrack
|
||||||
|
import nl.jknaapen.fladder.utility.setInternalSubtitleTrack
|
||||||
|
|
||||||
|
class VideoPlayerImplementation(
|
||||||
|
) : VideoPlayerApi {
|
||||||
|
var player: ExoPlayer? = null
|
||||||
|
val playbackData: MutableStateFlow<PlayableData?> = MutableStateFlow(null)
|
||||||
|
|
||||||
|
override fun sendPlayableModel(playableData: PlayableData): Boolean {
|
||||||
|
try {
|
||||||
|
println("Send playable data")
|
||||||
|
playbackData.value = playableData
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Error loading data $e")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun open(url: String, play: Boolean) {
|
||||||
|
try {
|
||||||
|
player?.stop()
|
||||||
|
player?.clearMediaItems()
|
||||||
|
|
||||||
|
playbackData.value?.let {
|
||||||
|
VideoPlayerObject.setAudioTrackIndex(it.defaultAudioTrack.toInt(), true)
|
||||||
|
VideoPlayerObject.setSubtitleTrackIndex(it.defaultSubtrack.toInt(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val startPosition = playbackData.value?.startPosition ?: 0L
|
||||||
|
println("Loading video in native $url")
|
||||||
|
val subTitles = playbackData.value?.subtitleTracks ?: listOf()
|
||||||
|
val mediaItem = MediaItem.Builder().apply {
|
||||||
|
setUri(url)
|
||||||
|
setTag(playbackData.value?.title)
|
||||||
|
setMediaId(playbackData.value?.id ?: "")
|
||||||
|
setSubtitleConfigurations(
|
||||||
|
subTitles.filter { it.external && it.url?.isNotEmpty() == true }.map { sub ->
|
||||||
|
MediaItem.SubtitleConfiguration.Builder(sub.url!!.toUri())
|
||||||
|
.setMimeType(guessSubtitleMimeType(sub.url))
|
||||||
|
.setLanguage(sub.languageCode)
|
||||||
|
.setLabel(sub.name)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
player?.setMediaItem(mediaItem, startPosition)
|
||||||
|
player?.prepare()
|
||||||
|
player?.playWhenReady = play
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Error playing video $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setLooping(looping: Boolean) {
|
||||||
|
player?.repeatMode = if (looping) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setVolume(volume: Double) {
|
||||||
|
player?.volume = volume.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Double) {
|
||||||
|
player?.setPlaybackSpeed(speed.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun play() {
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause() {
|
||||||
|
player?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekTo(position: Long) {
|
||||||
|
player?.seekTo(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
player?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun init(exoPlayer: ExoPlayer?) {
|
||||||
|
player = exoPlayer
|
||||||
|
//exoPlayer initializes after the playbackData is set for the first load
|
||||||
|
playbackData.value?.let {
|
||||||
|
sendPlayableModel(it)
|
||||||
|
VideoPlayerObject.setAudioTrackIndex(it.defaultAudioTrack.toInt(), true)
|
||||||
|
VideoPlayerObject.setSubtitleTrackIndex(it.defaultSubtrack.toInt(), true)
|
||||||
|
open(it.url, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun guessSubtitleMimeType(fileName: String): String = when {
|
||||||
|
fileName.contains(".srt", ignoreCase = true) -> MimeTypes.APPLICATION_SUBRIP
|
||||||
|
fileName.contains(".vtt", ignoreCase = true) -> MimeTypes.TEXT_VTT
|
||||||
|
fileName.contains(".ass", ignoreCase = true) -> MimeTypes.TEXT_SSA
|
||||||
|
else -> MimeTypes.APPLICATION_SUBRIP
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ExoPlayer.properlySetSubAndAudioTracks(playableData: PlayableData) {
|
||||||
|
try {
|
||||||
|
val currentSubIndex = playableData.defaultSubtrack
|
||||||
|
val indexOfSubtitleTrack =
|
||||||
|
playableData.subtitleTracks.indexOfFirst { it.index == currentSubIndex }
|
||||||
|
val internalSubTracks = this.getSubtitleTracks()
|
||||||
|
|
||||||
|
val wantedSubIndex = indexOfSubtitleTrack - 1
|
||||||
|
if (wantedSubIndex < 0) {
|
||||||
|
clearSubtitleTrack()
|
||||||
|
} else {
|
||||||
|
enableSubtitles()
|
||||||
|
setInternalSubtitleTrack(internalSubTracks[wantedSubIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentAudioIndex = playableData.defaultAudioTrack
|
||||||
|
val indexOfAudioTrack =
|
||||||
|
playableData.audioTracks.indexOfFirst { it.index == currentAudioIndex }
|
||||||
|
val internalAudioTracks = this.getAudioTracks()
|
||||||
|
|
||||||
|
val wantedAudioIndex = indexOfAudioTrack - 1
|
||||||
|
if (wantedAudioIndex < 0) {
|
||||||
|
clearAudioTrack()
|
||||||
|
} else {
|
||||||
|
clearAudioTrack(false)
|
||||||
|
setInternalAudioTrack(internalAudioTracks[wantedAudioIndex])
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package nl.jknaapen.fladder.objects
|
||||||
|
|
||||||
|
import PlayerSettings
|
||||||
|
import PlayerSettingsPigeon
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
import kotlin.time.toDuration
|
||||||
|
|
||||||
|
object PlayerSettingsObject : PlayerSettingsPigeon {
|
||||||
|
val settings: MutableStateFlow<PlayerSettings?> = MutableStateFlow(null)
|
||||||
|
val skipMap = settings.map { it?.skipTypes ?: mapOf() }
|
||||||
|
|
||||||
|
val forwardSpeed =
|
||||||
|
settings.map {
|
||||||
|
(it?.skipForward ?: 1L).toDuration(DurationUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
|
val backwardSpeed = settings.map {
|
||||||
|
(it?.skipBackward ?: 1L).toDuration(
|
||||||
|
DurationUnit.MILLISECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sendPlayerSettings(playerSettings: PlayerSettings) {
|
||||||
|
settings.value = playerSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package nl.jknaapen.fladder.objects
|
||||||
|
|
||||||
|
import PlaybackState
|
||||||
|
import VideoPlayerControlsCallback
|
||||||
|
import VideoPlayerListenerCallback
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import nl.jknaapen.fladder.VideoPlayerActivity
|
||||||
|
import nl.jknaapen.fladder.messengers.VideoPlayerImplementation
|
||||||
|
import nl.jknaapen.fladder.utility.InternalTrack
|
||||||
|
|
||||||
|
object VideoPlayerObject {
|
||||||
|
val implementation: VideoPlayerImplementation = VideoPlayerImplementation()
|
||||||
|
private var _currentState = MutableStateFlow<PlaybackState?>(null)
|
||||||
|
|
||||||
|
val videoPlayerState = _currentState.map { it }
|
||||||
|
|
||||||
|
val buffering = _currentState.map { it?.buffering ?: true }
|
||||||
|
val position = _currentState.map { it?.position ?: 0L }
|
||||||
|
val duration = _currentState.map { it?.duration ?: 0L }
|
||||||
|
val playing = _currentState.map { it?.playing ?: false }
|
||||||
|
|
||||||
|
val chapters = implementation.playbackData.map { it?.chapters }
|
||||||
|
|
||||||
|
val currentSubtitleTrackIndex =
|
||||||
|
MutableStateFlow((implementation.playbackData.value?.defaultSubtrack ?: -1).toInt())
|
||||||
|
val currentAudioTrackIndex =
|
||||||
|
MutableStateFlow((implementation.playbackData.value?.defaultAudioTrack ?: -1).toInt())
|
||||||
|
|
||||||
|
val exoAudioTracks = mutableStateOf<List<InternalTrack>>(listOf())
|
||||||
|
val exoSubTracks = mutableStateOf<List<InternalTrack>>(listOf())
|
||||||
|
|
||||||
|
fun setSubtitleTrackIndex(value: Int, init: Boolean = false) {
|
||||||
|
currentSubtitleTrackIndex.value = value
|
||||||
|
if (!init) {
|
||||||
|
videoPlayerControls?.swapSubtitleTrack(value.toLong(), callback = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAudioTrackIndex(value: Int, init: Boolean = false) {
|
||||||
|
currentAudioTrackIndex.value = value
|
||||||
|
if (!init) {
|
||||||
|
videoPlayerControls?.swapAudioTrack(value.toLong(), callback = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val subtitleTracks = implementation.playbackData.map { it?.subtitleTracks ?: listOf() }
|
||||||
|
val audioTracks = implementation.playbackData.map { it?.audioTracks ?: listOf() }
|
||||||
|
|
||||||
|
fun setPlaybackState(state: PlaybackState) {
|
||||||
|
_currentState.value = state
|
||||||
|
videoPlayerListener?.onPlaybackStateChanged(
|
||||||
|
state, callback = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var videoPlayerListener: VideoPlayerListenerCallback? = null
|
||||||
|
var videoPlayerControls: VideoPlayerControlsCallback? = null
|
||||||
|
var currentActivity: VideoPlayerActivity? = null
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
package nl.jknaapen.fladder.player
|
||||||
|
|
||||||
|
import PlaybackState
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionParameters
|
||||||
|
import androidx.media3.common.Tracks
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.DataSource
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.datasource.DefaultHttpDataSource
|
||||||
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
|
import androidx.media3.extractor.ts.TsExtractor
|
||||||
|
import androidx.media3.ui.CaptionStyleCompat
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import io.github.peerless2012.ass.media.kt.buildWithAssSupport
|
||||||
|
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||||
|
import nl.jknaapen.fladder.messengers.properlySetSubAndAudioTracks
|
||||||
|
import nl.jknaapen.fladder.objects.VideoPlayerObject
|
||||||
|
import nl.jknaapen.fladder.utility.getAudioTracks
|
||||||
|
import nl.jknaapen.fladder.utility.getSubtitleTracks
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
val LocalPlayer = compositionLocalOf<ExoPlayer?> { null }
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun ExoPlayer(
|
||||||
|
controls: @Composable (
|
||||||
|
player: ExoPlayer,
|
||||||
|
) -> Unit,
|
||||||
|
) {
|
||||||
|
val videoHost = VideoPlayerObject
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var initialized = false
|
||||||
|
|
||||||
|
val extractorsFactory = DefaultExtractorsFactory().apply {
|
||||||
|
val isLowRamDevice = context.getSystemService<ActivityManager>()?.isLowRamDevice == true
|
||||||
|
setTsExtractorTimestampSearchBytes(
|
||||||
|
when (isLowRamDevice) {
|
||||||
|
true -> TsExtractor.TS_PACKET_SIZE * 1800
|
||||||
|
false -> TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
|
||||||
|
}
|
||||||
|
)
|
||||||
|
setConstantBitrateSeekingEnabled(true)
|
||||||
|
setConstantBitrateSeekingAlwaysEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val videoCache = remember { VideoCache.buildCacheDataSourceFactory(context) }
|
||||||
|
|
||||||
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val renderersFactory = DefaultRenderersFactory(context)
|
||||||
|
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||||
|
.setEnableDecoderFallback(true)
|
||||||
|
|
||||||
|
val exoPlayer = remember {
|
||||||
|
ExoPlayer.Builder(context, renderersFactory)
|
||||||
|
.setTrackSelector(DefaultTrackSelector(context).apply {
|
||||||
|
setParameters(buildUponParameters().apply {
|
||||||
|
setAudioOffloadPreferences(
|
||||||
|
TrackSelectionParameters.AudioOffloadPreferences.DEFAULT.buildUpon().apply {
|
||||||
|
setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
|
||||||
|
}.build()
|
||||||
|
)
|
||||||
|
setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
|
||||||
|
setTunnelingEnabled(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.setMediaSourceFactory(
|
||||||
|
DefaultMediaSourceFactory(
|
||||||
|
videoCache,
|
||||||
|
extractorsFactory
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setAudioAttributes(audioAttributes, true)
|
||||||
|
.setHandleAudioBecomingNoisy(true)
|
||||||
|
.setPauseAtEndOfMediaItems(true)
|
||||||
|
.setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT)
|
||||||
|
.buildWithAssSupport(context, AssRenderType.LEGACY)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(exoPlayer) {
|
||||||
|
val listener = object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
videoHost.setPlaybackState(
|
||||||
|
PlaybackState(
|
||||||
|
position = exoPlayer.currentPosition,
|
||||||
|
buffered = exoPlayer.bufferedPosition,
|
||||||
|
duration = exoPlayer.duration,
|
||||||
|
playing = exoPlayer.isPlaying,
|
||||||
|
buffering = playbackState == Player.STATE_BUFFERING,
|
||||||
|
completed = playbackState == Player.STATE_ENDED,
|
||||||
|
failed = playbackState == Player.STATE_IDLE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
|
super.onEvents(player, events)
|
||||||
|
videoHost.setPlaybackState(
|
||||||
|
PlaybackState(
|
||||||
|
position = exoPlayer.currentPosition,
|
||||||
|
buffered = exoPlayer.bufferedPosition,
|
||||||
|
duration = exoPlayer.duration,
|
||||||
|
playing = exoPlayer.isPlaying,
|
||||||
|
buffering = exoPlayer.playbackState == Player.STATE_BUFFERING,
|
||||||
|
completed = exoPlayer.playbackState == Player.STATE_ENDED,
|
||||||
|
failed = exoPlayer.playbackState == Player.STATE_IDLE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
|
super.onTracksChanged(tracks)
|
||||||
|
if (!initialized) {
|
||||||
|
initialized = true
|
||||||
|
VideoPlayerObject.implementation.playbackData.value?.let {
|
||||||
|
exoPlayer.properlySetSubAndAudioTracks(it)
|
||||||
|
}
|
||||||
|
VideoPlayerObject.exoSubTracks.value = exoPlayer.getSubtitleTracks()
|
||||||
|
VideoPlayerObject.exoAudioTracks.value = exoPlayer.getAudioTracks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exoPlayer.addListener(listener)
|
||||||
|
onDispose {
|
||||||
|
exoPlayer.removeListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
VideoPlayerObject.implementation.init(exoPlayer)
|
||||||
|
onDispose {
|
||||||
|
videoHost.videoPlayerControls?.onStop(callback = {})
|
||||||
|
VideoPlayerObject.implementation.init(null)
|
||||||
|
exoPlayer.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(Color.Black)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = {
|
||||||
|
PlayerView(it).apply {
|
||||||
|
player = exoPlayer
|
||||||
|
useController = false
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
subtitleView?.apply {
|
||||||
|
setStyle(
|
||||||
|
CaptionStyleCompat(
|
||||||
|
android.graphics.Color.WHITE,
|
||||||
|
android.graphics.Color.TRANSPARENT,
|
||||||
|
android.graphics.Color.TRANSPARENT,
|
||||||
|
CaptionStyleCompat.EDGE_TYPE_OUTLINE,
|
||||||
|
android.graphics.Color.BLACK,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
CompositionLocalProvider(LocalPlayer provides exoPlayer) {
|
||||||
|
controls(exoPlayer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
object VideoCache {
|
||||||
|
private const val CACHE_SIZE: Long = 150L * 1024L * 1024L // 150 MB
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cache: SimpleCache? = null
|
||||||
|
|
||||||
|
fun getCache(context: Context): SimpleCache {
|
||||||
|
return cache ?: synchronized(this) {
|
||||||
|
cache ?: SimpleCache(
|
||||||
|
File(context.cacheDir, "video_cache"),
|
||||||
|
LeastRecentlyUsedCacheEvictor(CACHE_SIZE)
|
||||||
|
).also { cache = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildCacheDataSourceFactory(context: Context): DataSource.Factory {
|
||||||
|
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
|
val upstreamFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
|
||||||
|
|
||||||
|
return CacheDataSource.Factory()
|
||||||
|
.setCache(getCache(context))
|
||||||
|
.setUpstreamDataSourceFactory(upstreamFactory)
|
||||||
|
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package nl.jknaapen.fladder.utility
|
||||||
|
|
||||||
|
fun formatTime(ms: Long): String {
|
||||||
|
if (ms < 0) {
|
||||||
|
return "0:00"
|
||||||
|
}
|
||||||
|
val totalSeconds = ms / 1000
|
||||||
|
val minutes = totalSeconds / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
return "%d:%02d".format(minutes, seconds)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package nl.jknaapen.fladder.utility
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImmersiveSystemBars(isImmersive: Boolean) {
|
||||||
|
val view = LocalView.current
|
||||||
|
LaunchedEffect(view) {
|
||||||
|
val activity = view.context as? Activity
|
||||||
|
val window = activity?.window
|
||||||
|
if (window != null) {
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(view, isImmersive) {
|
||||||
|
val activity = view.context as? Activity
|
||||||
|
val window = activity?.window
|
||||||
|
val controller = window?.let { WindowInsetsControllerCompat(it, view) }
|
||||||
|
|
||||||
|
if (isImmersive) {
|
||||||
|
controller?.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
|
||||||
|
} else {
|
||||||
|
controller?.show(androidx.core.view.WindowInsetsCompat.Type.systemBars())
|
||||||
|
}
|
||||||
|
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package nl.jknaapen.fladder.utility
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
fun leanBackEnabled(context: Context): Boolean {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val leanBackEnabled = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
|
||||||
|
val leanBackOnly = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK_ONLY)
|
||||||
|
return leanBackEnabled || leanBackOnly
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package nl.jknaapen.fladder.utility
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a subtle background when focused is true. Use this to visually mark the focused/selected
|
||||||
|
* element in D-pad / keyboard navigation.
|
||||||
|
*/
|
||||||
|
fun Modifier.highlightOnFocus(
|
||||||
|
color: Color = Color.White.copy(alpha = 0.85f),
|
||||||
|
width: Dp = 1.5.dp,
|
||||||
|
shape: Shape = RoundedCornerShape(16.dp)
|
||||||
|
): Modifier = composed {
|
||||||
|
var hasFocus by remember { mutableStateOf(false) }
|
||||||
|
val highlightModifier = remember {
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
color = color.copy(alpha = 0.25f),
|
||||||
|
shape = shape,
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
width = width,
|
||||||
|
color = color.copy(alpha = 0.5f),
|
||||||
|
shape = shape
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
hasFocus = focusState.hasFocus
|
||||||
|
}
|
||||||
|
.then(if (hasFocus) highlightModifier else Modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests focus on first composition when [defaultSelected] is true.
|
||||||
|
* Returns a modifier with a focus requester attached so it can be combined with focusable()/onKeyEvent.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.defaultSelected(defaultSelected: Boolean): Modifier {
|
||||||
|
val requester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(defaultSelected) {
|
||||||
|
if (defaultSelected) requester.requestFocus()
|
||||||
|
}
|
||||||
|
return this.focusRequester(requester)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conditional if modifier
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): Modifier {
|
||||||
|
return if (condition) {
|
||||||
|
then(modifier(Modifier))
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Modifier.visible(
|
||||||
|
visible: Boolean,
|
||||||
|
): Modifier {
|
||||||
|
val alphaAnimated by animateFloatAsState(if (visible) 1f else 0f)
|
||||||
|
return this
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = alphaAnimated
|
||||||
|
}
|
||||||
|
.then(
|
||||||
|
if (!visible) Modifier.pointerInput(Unit) {} else Modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package nl.jknaapen.fladder.utility
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScaledContent(
|
||||||
|
scale: Float,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalDensity provides Density(
|
||||||
|
density = density.density * scale,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
package nl.jknaapen.fladder.utility
|
||||||
|
|
||||||
|
import AudioTrack
|
||||||
|
import Chapter
|
||||||
|
import PlayableData
|
||||||
|
import SubtitleTrack
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
|
||||||
|
val testPlaybackData = PlayableData(
|
||||||
|
id = "8lsf8234l99sdf923lsd8f23j98j",
|
||||||
|
title = "Big buck bunny",
|
||||||
|
subTitle = "Episode 1x2",
|
||||||
|
startPosition = 0,
|
||||||
|
description = "Short description of the movie that is being watched",
|
||||||
|
defaultSubtrack = 1,
|
||||||
|
defaultAudioTrack = 1,
|
||||||
|
subtitleTracks = listOf(
|
||||||
|
SubtitleTrack(
|
||||||
|
name = "English",
|
||||||
|
languageCode = "EN",
|
||||||
|
codec = "SRT",
|
||||||
|
index = 1,
|
||||||
|
external = false,
|
||||||
|
),
|
||||||
|
SubtitleTrack(
|
||||||
|
name = "Dutch",
|
||||||
|
languageCode = "NL",
|
||||||
|
codec = "SRT",
|
||||||
|
index = 2,
|
||||||
|
external = false,
|
||||||
|
),
|
||||||
|
SubtitleTrack(
|
||||||
|
name = "Japanese",
|
||||||
|
languageCode = "JP",
|
||||||
|
codec = "srt",
|
||||||
|
index = 3,
|
||||||
|
url = "https://gist.githubusercontent.com/matibzurovski/d690d5c14acbaa399e7f0829f9d6888e/raw/63578ca30e7430be1fa4942d4d8dd599f78151c7/example.srt",
|
||||||
|
external = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
audioTracks = listOf(
|
||||||
|
AudioTrack(
|
||||||
|
name = "English",
|
||||||
|
languageCode = "EN",
|
||||||
|
codec = "AC3",
|
||||||
|
index = 1,
|
||||||
|
external = false,
|
||||||
|
),
|
||||||
|
AudioTrack(
|
||||||
|
name = "Dutch",
|
||||||
|
languageCode = "NL",
|
||||||
|
codec = "SRT",
|
||||||
|
index = 2,
|
||||||
|
external = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
chapters = listOf(
|
||||||
|
Chapter(
|
||||||
|
name = "Chapter 1",
|
||||||
|
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
|
||||||
|
time = 5.seconds.toLong(DurationUnit.MILLISECONDS),
|
||||||
|
),
|
||||||
|
Chapter(
|
||||||
|
name = "Chapter 2",
|
||||||
|
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
|
||||||
|
time = 10.seconds.toLong(DurationUnit.MILLISECONDS),
|
||||||
|
),
|
||||||
|
Chapter(
|
||||||
|
name = "Chapter 3",
|
||||||
|
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
|
||||||
|
time = 15.seconds.toLong(DurationUnit.MILLISECONDS),
|
||||||
|
),
|
||||||
|
Chapter(
|
||||||
|
name = "Chapter 4",
|
||||||
|
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
|
||||||
|
time = 20.seconds.toLong(DurationUnit.MILLISECONDS),
|
||||||
|
),
|
||||||
|
Chapter(
|
||||||
|
name = "Chapter 5",
|
||||||
|
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
|
||||||
|
time = 25.seconds.toLong(DurationUnit.MILLISECONDS),
|
||||||
|
),
|
||||||
|
Chapter(
|
||||||
|
name = "Chapter 6",
|
||||||
|
url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c",
|
||||||
|
time = 30.seconds.toLong(DurationUnit.MILLISECONDS),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
nextVideo = null,
|
||||||
|
previousVideo = "Previous episode name",
|
||||||
|
segments = listOf(),
|
||||||
|
url = "https://github.com/ietf-wg-cellar/matroska-test-files/raw/refs/heads/master/test_files/test5.mkv",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
package nl.jknaapen.fladder.utility
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
|
|
||||||
|
data class InternalTrack(
|
||||||
|
val rendererIndex: Int,
|
||||||
|
val groupIndex: Int,
|
||||||
|
val trackIndex: Int,
|
||||||
|
val label: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun ExoPlayer.getAudioTracks(): List<InternalTrack> {
|
||||||
|
val selector = trackSelector as? DefaultTrackSelector ?: return emptyList()
|
||||||
|
val mapped = selector.currentMappedTrackInfo ?: return emptyList()
|
||||||
|
val result = mutableListOf<InternalTrack>()
|
||||||
|
|
||||||
|
for (rendererIndex in 0 until mapped.rendererCount) {
|
||||||
|
if (mapped.getRendererType(rendererIndex) != C.TRACK_TYPE_AUDIO) continue
|
||||||
|
|
||||||
|
val groups = mapped.getTrackGroups(rendererIndex)
|
||||||
|
for (groupIndex in 0 until groups.length) {
|
||||||
|
val group = groups[groupIndex]
|
||||||
|
for (trackIndex in 0 until group.length) {
|
||||||
|
val format = group.getFormat(trackIndex)
|
||||||
|
result.add(
|
||||||
|
InternalTrack(
|
||||||
|
rendererIndex = rendererIndex,
|
||||||
|
groupIndex = groupIndex,
|
||||||
|
trackIndex = trackIndex,
|
||||||
|
label = format.label ?: format.language ?: "Audiotrack: $trackIndex",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun ExoPlayer.setInternalAudioTrack(audioTrack: InternalTrack) {
|
||||||
|
try {
|
||||||
|
val selector = trackSelector as? DefaultTrackSelector ?: return
|
||||||
|
val mapped = selector.currentMappedTrackInfo ?: return
|
||||||
|
val groups = mapped.getTrackGroups(audioTrack.rendererIndex)
|
||||||
|
if (audioTrack.groupIndex >= groups.length) return
|
||||||
|
|
||||||
|
val group = groups[audioTrack.groupIndex]
|
||||||
|
val override = TrackSelectionOverride(group, audioTrack.trackIndex)
|
||||||
|
|
||||||
|
selector.setParameters(
|
||||||
|
selector.buildUponParameters()
|
||||||
|
.setRendererDisabled(audioTrack.rendererIndex, false)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
this.trackSelectionParameters = this.trackSelectionParameters
|
||||||
|
.buildUpon()
|
||||||
|
.setOverrideForType(override)
|
||||||
|
.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun ExoPlayer.clearAudioTrack(disable: Boolean = true) {
|
||||||
|
val selector = trackSelector as? DefaultTrackSelector ?: return
|
||||||
|
selector.setParameters(
|
||||||
|
selector.buildUponParameters()
|
||||||
|
.setRendererDisabled(C.TRACK_TYPE_AUDIO, disable)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun ExoPlayer.getSubtitleTracks(): List<InternalTrack> {
|
||||||
|
val selector = trackSelector as? DefaultTrackSelector ?: return emptyList()
|
||||||
|
val mapped = selector.currentMappedTrackInfo ?: return emptyList()
|
||||||
|
val result = mutableListOf<InternalTrack>()
|
||||||
|
|
||||||
|
for (rendererIndex in 0 until mapped.rendererCount) {
|
||||||
|
if (mapped.getRendererType(rendererIndex) != C.TRACK_TYPE_TEXT) continue
|
||||||
|
|
||||||
|
val groups = mapped.getTrackGroups(rendererIndex)
|
||||||
|
for (groupIndex in 0 until groups.length) {
|
||||||
|
val group = groups[groupIndex]
|
||||||
|
for (trackIndex in 0 until group.length) {
|
||||||
|
val format = group.getFormat(trackIndex)
|
||||||
|
result.add(
|
||||||
|
InternalTrack(
|
||||||
|
rendererIndex = rendererIndex,
|
||||||
|
groupIndex = groupIndex,
|
||||||
|
trackIndex = trackIndex,
|
||||||
|
label = format.label ?: format.language ?: "Subtitletrack: $trackIndex",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun ExoPlayer.clearSubtitleTrack() {
|
||||||
|
val selector = trackSelector as? DefaultTrackSelector ?: return
|
||||||
|
val newParams = selector.buildUponParameters()
|
||||||
|
.setRendererDisabled(C.TRACK_TYPE_TEXT, false) // keep text renderer active
|
||||||
|
.setPreferredTextLanguage(null) // don't auto-pick a language
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) // <– disables selection of *any* text track
|
||||||
|
.build()
|
||||||
|
selector.setParameters(newParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun ExoPlayer.enableSubtitles(language: String? = null) {
|
||||||
|
val selector = trackSelector as? DefaultTrackSelector ?: return
|
||||||
|
val newParams = selector.buildUponParameters()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) // allow text again
|
||||||
|
.setPreferredTextLanguage(language) // optional: auto-pick by language
|
||||||
|
.build()
|
||||||
|
selector.setParameters(newParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun ExoPlayer.setInternalSubtitleTrack(subtitleTrack: InternalTrack) {
|
||||||
|
try {
|
||||||
|
enableSubtitles()
|
||||||
|
val selector = trackSelector as? DefaultTrackSelector ?: return
|
||||||
|
val mapped = selector.currentMappedTrackInfo ?: return
|
||||||
|
val groups = mapped.getTrackGroups(subtitleTrack.rendererIndex)
|
||||||
|
if (subtitleTrack.groupIndex >= groups.length) return
|
||||||
|
|
||||||
|
val group = groups[subtitleTrack.groupIndex]
|
||||||
|
val override = TrackSelectionOverride(group, subtitleTrack.trackIndex)
|
||||||
|
|
||||||
|
selector.setParameters(
|
||||||
|
selector.buildUponParameters()
|
||||||
|
.setRendererDisabled(subtitleTrack.rendererIndex, false)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply override (replaces other text overrides)
|
||||||
|
this.trackSelectionParameters = this.trackSelectionParameters
|
||||||
|
.buildUpon()
|
||||||
|
.setOverrideForType(override)
|
||||||
|
.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
android/app/src/main/res/drawable/app_banner.png
Normal file
BIN
android/app/src/main/res/drawable/app_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
663
android/build/reports/problems/problems-report.html
Normal file
663
android/build/reports/problems/problems-report.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,17 @@ pluginManagement {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
|
maven {
|
||||||
|
url "https://repo.jellyfin.org/releases/client/android"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
id "com.android.application" version "8.4.0" apply false
|
id "com.android.application" version '8.12.2' apply false
|
||||||
id "org.jetbrains.kotlin.android" version "1.9.0" 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"
|
include ":app"
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 387 KiB After Width: | Height: | Size: 581 KiB |
Binary file not shown.
|
|
@ -679,6 +679,12 @@ class FakeJellyfinOpenApi extends JellyfinOpenApi {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<chopper.Response<BrandingOptions>> brandingConfigurationGet() async => chopper.Response(
|
||||||
|
FakeHelper.fakeCorrectResponse,
|
||||||
|
const BrandingOptions(loginDisclaimer: "Test server"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeHelper {
|
class FakeHelper {
|
||||||
|
|
|
||||||
|
|
@ -1171,6 +1171,7 @@
|
||||||
"phone": "Phone",
|
"phone": "Phone",
|
||||||
"tablet": "Tablet",
|
"tablet": "Tablet",
|
||||||
"desktop": "Desktop",
|
"desktop": "Desktop",
|
||||||
|
"television": "Television",
|
||||||
"layoutModeSingle": "Single",
|
"layoutModeSingle": "Single",
|
||||||
"layoutModeDual": "Dual",
|
"layoutModeDual": "Dual",
|
||||||
"copiedToClipboard": "Copied to clipboard",
|
"copiedToClipboard": "Copied to clipboard",
|
||||||
|
|
@ -1191,6 +1192,7 @@
|
||||||
"segmentActionSkip": "Skip",
|
"segmentActionSkip": "Skip",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"exitFladderTitle": "Exit Fladder",
|
"exitFladderTitle": "Exit Fladder",
|
||||||
|
"exitFladderDesc": "Are you sure you want to close Fladder?",
|
||||||
"castAndCrew": "Cast & Crew",
|
"castAndCrew": "Cast & Crew",
|
||||||
"guestActor": "{count, plural, other{Guest Actors} one{Guest Actor}}",
|
"guestActor": "{count, plural, other{Guest Actors} one{Guest Actor}}",
|
||||||
"@guestActor": {
|
"@guestActor": {
|
||||||
|
|
@ -1336,5 +1338,8 @@
|
||||||
"type": "double"
|
"type": "double"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"quickConnectPostFailed": "Failed to get quick connect code",
|
||||||
|
"quickConnectLoginUsingCode": "Using quick connect",
|
||||||
|
"quickConnectEnterCodeDescription": "Enter the code below to login"
|
||||||
}
|
}
|
||||||
|
|
@ -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.dart';
|
||||||
import 'package:fladder/routes/auto_router.gr.dart';
|
import 'package:fladder/routes/auto_router.gr.dart';
|
||||||
import 'package:fladder/screens/login/lock_screen.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/theme.dart';
|
||||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||||
import 'package:fladder/util/application_info.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/localization_helper.dart';
|
||||||
import 'package:fladder/util/string_extensions.dart';
|
import 'package:fladder/util/string_extensions.dart';
|
||||||
import 'package:fladder/util/themes_data.dart';
|
import 'package:fladder/util/themes_data.dart';
|
||||||
|
import 'package:fladder/widgets/media_query_scaler.dart';
|
||||||
|
|
||||||
bool get _isDesktop {
|
bool get _isDesktop {
|
||||||
if (kIsWeb) return false;
|
if (kIsWeb) return false;
|
||||||
|
|
@ -86,13 +88,16 @@ void main(List<String> args) async {
|
||||||
os: !kIsWeb ? defaultTargetPlatform.name.capitalize() : "${defaultTargetPlatform.name.capitalize()} Web",
|
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(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
sharedPreferencesProvider.overrideWith((ref) => sharedPreferences),
|
sharedPreferencesProvider.overrideWith((ref) => sharedPreferences),
|
||||||
applicationInfoProvider.overrideWith((ref) => applicationInfo),
|
applicationInfoProvider.overrideWith((ref) => applicationInfo),
|
||||||
crashLogProvider.overrideWith((ref) => crashProvider),
|
crashLogProvider.overrideWith((ref) => crashProvider),
|
||||||
argumentsStateProvider.overrideWith((ref) => ArgumentsModel.fromArguments(args)),
|
argumentsStateProvider.overrideWith((ref) => ArgumentsModel.fromArguments(args, leanBackEnabled)),
|
||||||
syncProvider.overrideWith((ref) => SyncNotifier(ref, applicationDirectory))
|
syncProvider.overrideWith((ref) => SyncNotifier(ref, applicationDirectory))
|
||||||
],
|
],
|
||||||
child: AdaptiveLayoutBuilder(
|
child: AdaptiveLayoutBuilder(
|
||||||
|
|
@ -116,7 +121,9 @@ class _MainState extends ConsumerState<Main> with WindowListener, WidgetsBinding
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) async {
|
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();
|
dateTime = DateTime.now();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -248,11 +255,8 @@ class _MainState extends ConsumerState<Main> with WindowListener, WidgetsBinding
|
||||||
final language = ref.watch(clientSettingsProvider
|
final language = ref.watch(clientSettingsProvider
|
||||||
.select((value) => value.selectedLocale ?? WidgetsBinding.instance.platformDispatcher.locale));
|
.select((value) => value.selectedLocale ?? WidgetsBinding.instance.platformDispatcher.locale));
|
||||||
final scrollBehaviour = const MaterialScrollBehavior();
|
final scrollBehaviour = const MaterialScrollBehavior();
|
||||||
return Shortcuts(
|
return DynamicColorBuilder(
|
||||||
shortcuts: <LogicalKeySet, Intent>{
|
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
||||||
LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
|
|
||||||
},
|
|
||||||
child: DynamicColorBuilder(builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
|
||||||
final lightTheme = themeColor == null
|
final lightTheme = themeColor == null
|
||||||
? FladderTheme.theme(lightDynamic ?? FladderTheme.defaultScheme(Brightness.light), schemeVariant)
|
? FladderTheme.theme(lightDynamic ?? FladderTheme.defaultScheme(Brightness.light), schemeVariant)
|
||||||
: FladderTheme.theme(themeColor.schemeLight, schemeVariant);
|
: FladderTheme.theme(themeColor.schemeLight, schemeVariant);
|
||||||
|
|
@ -281,10 +285,13 @@ class _MainState extends ConsumerState<Main> with WindowListener, WidgetsBinding
|
||||||
}
|
}
|
||||||
return locale;
|
return locale;
|
||||||
},
|
},
|
||||||
builder: (context, child) => LocalizationContextWrapper(
|
builder: (context, child) => MediaQueryScaler(
|
||||||
|
child: LocalizationContextWrapper(
|
||||||
child: ScaffoldMessenger(child: child ?? Container()),
|
child: ScaffoldMessenger(child: child ?? Container()),
|
||||||
currentLocale: language,
|
currentLocale: language,
|
||||||
),
|
),
|
||||||
|
enable: ref.read(argumentsStateProvider).leanBackMode,
|
||||||
|
),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
darkTheme: darkTheme.copyWith(
|
darkTheme: darkTheme.copyWith(
|
||||||
scaffoldBackgroundColor: amoledOverwrite,
|
scaffoldBackgroundColor: amoledOverwrite,
|
||||||
|
|
@ -300,7 +307,7 @@ class _MainState extends ConsumerState<Main> with WindowListener, WidgetsBinding
|
||||||
routerConfig: autoRouter.config(),
|
routerConfig: autoRouter.config(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ class ItemBaseModel with ItemBaseModelMappable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> navigateTo(BuildContext context, {WidgetRef? ref}) async {
|
Future<void> navigateTo(BuildContext context, {WidgetRef? ref, Object? tag}) async {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case FolderModel _:
|
case FolderModel _:
|
||||||
case BoxSetModel _:
|
case BoxSetModel _:
|
||||||
|
|
@ -191,7 +191,7 @@ class ItemBaseModel with ItemBaseModelMappable {
|
||||||
case SeasonModel _:
|
case SeasonModel _:
|
||||||
case PersonModel _:
|
case PersonModel _:
|
||||||
default:
|
default:
|
||||||
context.router.push(DetailsRoute(id: id, item: this));
|
context.router.push(DetailsRoute(id: id, item: this, tag: tag));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable {
|
||||||
playlistId: item.playlistItemId,
|
playlistId: item.playlistItemId,
|
||||||
dateAired: item.premiereDate,
|
dateAired: item.premiereDate,
|
||||||
chapters: Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], ref),
|
chapters: Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], ref),
|
||||||
images: ImagesData.fromBaseItem(item, ref, getOriginalSize: true),
|
images: ImagesData.fromBaseItem(item, ref),
|
||||||
primaryRatio: item.primaryImageAspectRatio,
|
primaryRatio: item.primaryImageAspectRatio,
|
||||||
season: item.parentIndexNumber ?? 0,
|
season: item.parentIndexNumber ?? 0,
|
||||||
episode: item.indexNumber ?? 0,
|
episode: item.indexNumber ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,9 @@ class ImagesData {
|
||||||
dto.BaseItemDto item,
|
dto.BaseItemDto item,
|
||||||
Ref ref, {
|
Ref ref, {
|
||||||
Size backDrop = const Size(2000, 2000),
|
Size backDrop = const Size(2000, 2000),
|
||||||
Size logo = const Size(1000, 1000),
|
Size logo = const Size(500, 500),
|
||||||
Size primary = const Size(600, 600),
|
Size primary = const Size(600, 600),
|
||||||
bool getOriginalSize = false,
|
bool getOriginalSize = false,
|
||||||
int quality = 95,
|
|
||||||
}) {
|
}) {
|
||||||
final itemid = item.id;
|
final itemid = item.id;
|
||||||
if (itemid == null) return null;
|
if (itemid == null) return null;
|
||||||
|
|
@ -59,7 +58,6 @@ class ImagesData {
|
||||||
type: enums.ImageType.primary,
|
type: enums.ImageType.primary,
|
||||||
maxHeight: primary.height.toInt(),
|
maxHeight: primary.height.toInt(),
|
||||||
maxWidth: primary.width.toInt(),
|
maxWidth: primary.width.toInt(),
|
||||||
quality: quality,
|
|
||||||
),
|
),
|
||||||
key: "${itemid}_primary_${item.imageTags?['Primary']}",
|
key: "${itemid}_primary_${item.imageTags?['Primary']}",
|
||||||
hash: item.imageBlurHashes?.primary?[item.imageTags?['Primary']] ?? "",
|
hash: item.imageBlurHashes?.primary?[item.imageTags?['Primary']] ?? "",
|
||||||
|
|
@ -77,7 +75,6 @@ class ImagesData {
|
||||||
type: enums.ImageType.logo,
|
type: enums.ImageType.logo,
|
||||||
maxHeight: logo.height.toInt(),
|
maxHeight: logo.height.toInt(),
|
||||||
maxWidth: logo.width.toInt(),
|
maxWidth: logo.width.toInt(),
|
||||||
quality: quality,
|
|
||||||
),
|
),
|
||||||
key: "${itemid}_logo_${item.imageTags?['Logo']}",
|
key: "${itemid}_logo_${item.imageTags?['Logo']}",
|
||||||
hash: item.imageBlurHashes?.logo?[item.imageTags?['Logo']] ?? "")
|
hash: item.imageBlurHashes?.logo?[item.imageTags?['Logo']] ?? "")
|
||||||
|
|
@ -98,7 +95,6 @@ class ImagesData {
|
||||||
backdrop,
|
backdrop,
|
||||||
maxHeight: backDrop.height.toInt(),
|
maxHeight: backDrop.height.toInt(),
|
||||||
maxWidth: backDrop.width.toInt(),
|
maxWidth: backDrop.width.toInt(),
|
||||||
quality: quality,
|
|
||||||
),
|
),
|
||||||
key: "${itemid}_backdrop_${index}_$backdrop",
|
key: "${itemid}_backdrop_${index}_$backdrop",
|
||||||
hash: item.imageBlurHashes?.backdrop?[backdrop] ?? "",
|
hash: item.imageBlurHashes?.backdrop?[backdrop] ?? "",
|
||||||
|
|
@ -116,9 +112,8 @@ class ImagesData {
|
||||||
dto.BaseItemDto item,
|
dto.BaseItemDto item,
|
||||||
Ref ref, {
|
Ref ref, {
|
||||||
Size backDrop = const Size(2000, 2000),
|
Size backDrop = const Size(2000, 2000),
|
||||||
Size logo = const Size(1000, 1000),
|
Size logo = const Size(500, 500),
|
||||||
Size primary = const Size(600, 600),
|
Size primary = const Size(600, 600),
|
||||||
int quality = 95,
|
|
||||||
}) {
|
}) {
|
||||||
if (item.seriesId == null && item.parentId == null) return null;
|
if (item.seriesId == null && item.parentId == null) return null;
|
||||||
|
|
||||||
|
|
@ -132,7 +127,6 @@ class ImagesData {
|
||||||
type: enums.ImageType.primary,
|
type: enums.ImageType.primary,
|
||||||
maxHeight: primary.height.toInt(),
|
maxHeight: primary.height.toInt(),
|
||||||
maxWidth: primary.width.toInt(),
|
maxWidth: primary.width.toInt(),
|
||||||
quality: quality,
|
|
||||||
),
|
),
|
||||||
key: "${item.seriesId}_primary_${item.seriesPrimaryImageTag ?? ""}",
|
key: "${item.seriesId}_primary_${item.seriesPrimaryImageTag ?? ""}",
|
||||||
hash: item.imageBlurHashes?.primary?[item.seriesPrimaryImageTag] ?? "")
|
hash: item.imageBlurHashes?.primary?[item.seriesPrimaryImageTag] ?? "")
|
||||||
|
|
@ -144,7 +138,6 @@ class ImagesData {
|
||||||
type: enums.ImageType.logo,
|
type: enums.ImageType.logo,
|
||||||
maxHeight: logo.height.toInt(),
|
maxHeight: logo.height.toInt(),
|
||||||
maxWidth: logo.width.toInt(),
|
maxWidth: logo.width.toInt(),
|
||||||
quality: quality,
|
|
||||||
),
|
),
|
||||||
key: "${item.seriesId}_logo_${item.parentLogoImageTag ?? ""}",
|
key: "${item.seriesId}_logo_${item.parentLogoImageTag ?? ""}",
|
||||||
hash: item.imageBlurHashes?.logo?[item.parentLogoImageTag] ?? "")
|
hash: item.imageBlurHashes?.logo?[item.parentLogoImageTag] ?? "")
|
||||||
|
|
@ -161,7 +154,6 @@ class ImagesData {
|
||||||
backdrop,
|
backdrop,
|
||||||
maxHeight: backDrop.height.toInt(),
|
maxHeight: backDrop.height.toInt(),
|
||||||
maxWidth: backDrop.width.toInt(),
|
maxWidth: backDrop.width.toInt(),
|
||||||
quality: quality,
|
|
||||||
),
|
),
|
||||||
key: "${itemId}_backdrop_${index}_$backdrop",
|
key: "${itemId}_backdrop_${index}_$backdrop",
|
||||||
hash: item.imageBlurHashes?.backdrop?[backdrop] ?? "",
|
hash: item.imageBlurHashes?.backdrop?[backdrop] ?? "",
|
||||||
|
|
@ -180,8 +172,7 @@ class ImagesData {
|
||||||
Ref ref, {
|
Ref ref, {
|
||||||
Size backDrop = const Size(2000, 2000),
|
Size backDrop = const Size(2000, 2000),
|
||||||
Size logo = const Size(1000, 1000),
|
Size logo = const Size(1000, 1000),
|
||||||
Size primary = const Size(2000, 2000),
|
Size primary = const Size(500, 500),
|
||||||
int quality = 95,
|
|
||||||
}) {
|
}) {
|
||||||
return ImagesData(
|
return ImagesData(
|
||||||
primary: (item.primaryImageTag != null && item.imageBlurHashes != null)
|
primary: (item.primaryImageTag != null && item.imageBlurHashes != null)
|
||||||
|
|
@ -191,7 +182,6 @@ class ImagesData {
|
||||||
type: enums.ImageType.primary,
|
type: enums.ImageType.primary,
|
||||||
maxHeight: primary.height.toInt(),
|
maxHeight: primary.height.toInt(),
|
||||||
maxWidth: primary.width.toInt(),
|
maxWidth: primary.width.toInt(),
|
||||||
quality: quality,
|
|
||||||
),
|
),
|
||||||
key: "${item.id ?? ""}_primary_${item.primaryImageTag ?? ''}",
|
key: "${item.id ?? ""}_primary_${item.primaryImageTag ?? ''}",
|
||||||
hash: item.imageBlurHashes?.primary?[item.primaryImageTag] ?? '')
|
hash: item.imageBlurHashes?.primary?[item.primaryImageTag] ?? '')
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,11 @@ class MovieModel extends ItemStreamModel with MovieModelMappable {
|
||||||
@override
|
@override
|
||||||
MediaStreamsModel? get streamModel => mediaStreams;
|
MediaStreamsModel? get streamModel => mediaStreams;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? label(BuildContext context) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get syncAble => true;
|
bool get syncAble => true;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import 'package:fladder/models/items/images_models.dart';
|
import 'package:dart_mappable/dart_mappable.dart';
|
||||||
import 'package:fladder/models/items/overview_model.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
||||||
import 'package:fladder/models/item_base_model.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/item_shared_models.dart';
|
||||||
import 'package:fladder/models/items/movie_model.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: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';
|
part 'person_model.mapper.dart';
|
||||||
|
|
||||||
@MappableClass()
|
@MappableClass()
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ class SeriesModel extends ItemBaseModel with SeriesModelMappable {
|
||||||
userData: UserData.fromDto(item.userData),
|
userData: UserData.fromDto(item.userData),
|
||||||
parentId: item.parentId,
|
parentId: item.parentId,
|
||||||
playlistId: item.playlistItemId,
|
playlistId: item.playlistItemId,
|
||||||
images: ImagesData.fromBaseItem(item, ref, getOriginalSize: true),
|
images: ImagesData.fromBaseItem(item, ref),
|
||||||
primaryRatio: item.primaryImageAspectRatio,
|
primaryRatio: item.primaryImageAspectRatio,
|
||||||
originalTitle: item.originalTitle ?? "",
|
originalTitle: item.originalTitle ?? "",
|
||||||
sortName: item.sortName ?? "",
|
sortName: item.sortName ?? "",
|
||||||
|
|
|
||||||
|
|
@ -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/account_model.dart';
|
||||||
import 'package:fladder/models/credentials_model.dart';
|
import 'package:fladder/models/credentials_model.dart';
|
||||||
|
|
||||||
class LoginScreenModel {
|
part 'login_screen_model.freezed.dart';
|
||||||
final List<AccountModel> accounts;
|
|
||||||
final CredentialsModel tempCredentials;
|
|
||||||
final bool loading;
|
|
||||||
LoginScreenModel({
|
|
||||||
required this.accounts,
|
|
||||||
required this.tempCredentials,
|
|
||||||
required this.loading,
|
|
||||||
});
|
|
||||||
|
|
||||||
LoginScreenModel copyWith({
|
enum LoginScreenType {
|
||||||
List<AccountModel>? accounts,
|
users,
|
||||||
CredentialsModel? tempCredentials,
|
login,
|
||||||
bool? loading,
|
code,
|
||||||
}) {
|
|
||||||
return LoginScreenModel(
|
|
||||||
accounts: accounts ?? this.accounts,
|
|
||||||
tempCredentials: tempCredentials ?? this.tempCredentials,
|
|
||||||
loading: loading ?? this.loading,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Freezed(copyWith: true)
|
||||||
|
abstract class LoginScreenModel with _$LoginScreenModel {
|
||||||
|
factory LoginScreenModel({
|
||||||
|
@Default([]) List<AccountModel> 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<AccountModel> accounts,
|
||||||
|
String? serverMessage,
|
||||||
|
@Default(false) bool hasQuickConnect,
|
||||||
|
}) = _ServerLoginModel;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
774
lib/models/login_screen_model.freezed.dart
Normal file
774
lib/models/login_screen_model.freezed.dart
Normal file
|
|
@ -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>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$LoginScreenModel {
|
||||||
|
List<AccountModel> 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<LoginScreenModel> get copyWith =>
|
||||||
|
_$LoginScreenModelCopyWithImpl<LoginScreenModel>(
|
||||||
|
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<AccountModel> 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<AccountModel>,
|
||||||
|
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 extends Object?>(
|
||||||
|
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 extends Object?>(
|
||||||
|
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 extends Object?>(
|
||||||
|
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 extends Object?>(
|
||||||
|
TResult Function(
|
||||||
|
List<AccountModel> 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 extends Object?>(
|
||||||
|
TResult Function(
|
||||||
|
List<AccountModel> 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 extends Object?>(
|
||||||
|
TResult? Function(
|
||||||
|
List<AccountModel> 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<AccountModel> accounts = const [],
|
||||||
|
this.screen = LoginScreenType.users,
|
||||||
|
this.serverLoginModel,
|
||||||
|
this.errorMessage,
|
||||||
|
this.hasBaseUrl = false,
|
||||||
|
this.loading = false})
|
||||||
|
: _accounts = accounts;
|
||||||
|
|
||||||
|
final List<AccountModel> _accounts;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
List<AccountModel> 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<AccountModel> 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<AccountModel>,
|
||||||
|
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<AccountModel> 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<ServerLoginModel> get copyWith =>
|
||||||
|
_$ServerLoginModelCopyWithImpl<ServerLoginModel>(
|
||||||
|
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<AccountModel> 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<AccountModel>,
|
||||||
|
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 extends Object?>(
|
||||||
|
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 extends Object?>(
|
||||||
|
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 extends Object?>(
|
||||||
|
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 extends Object?>(
|
||||||
|
TResult Function(
|
||||||
|
CredentialsModel tempCredentials,
|
||||||
|
List<AccountModel> 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 extends Object?>(
|
||||||
|
TResult Function(
|
||||||
|
CredentialsModel tempCredentials,
|
||||||
|
List<AccountModel> 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 extends Object?>(
|
||||||
|
TResult? Function(
|
||||||
|
CredentialsModel tempCredentials,
|
||||||
|
List<AccountModel> 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<AccountModel> accounts = const [],
|
||||||
|
this.serverMessage,
|
||||||
|
this.hasQuickConnect = false})
|
||||||
|
: _accounts = accounts;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final CredentialsModel tempCredentials;
|
||||||
|
final List<AccountModel> _accounts;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
List<AccountModel> 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<AccountModel> 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<AccountModel>,
|
||||||
|
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
|
||||||
|
|
@ -137,7 +137,7 @@ class PlaybackModelHelper {
|
||||||
oldModel: currentModel,
|
oldModel: currentModel,
|
||||||
);
|
);
|
||||||
if (newModel == null) return null;
|
if (newModel == null) return null;
|
||||||
ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, startPosition: Duration.zero);
|
ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, Duration.zero);
|
||||||
return newModel;
|
return newModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -502,7 +502,7 @@ class PlaybackModelHelper {
|
||||||
}
|
}
|
||||||
if (newModel == null) return;
|
if (newModel == null) return;
|
||||||
if (newModel.runtimeType != playbackModel.runtimeType || newModel is TranscodePlaybackModel) {
|
if (newModel.runtimeType != playbackModel.runtimeType || newModel is TranscodePlaybackModel) {
|
||||||
ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, startPosition: currentPosition);
|
ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, currentPosition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ abstract class ArgumentsModel with _$ArgumentsModel {
|
||||||
|
|
||||||
factory ArgumentsModel({
|
factory ArgumentsModel({
|
||||||
@Default(false) bool htpcMode,
|
@Default(false) bool htpcMode,
|
||||||
|
@Default(false) bool leanBackMode,
|
||||||
}) = _ArgumentsModel;
|
}) = _ArgumentsModel;
|
||||||
|
|
||||||
factory ArgumentsModel.fromArguments(List<String> arguments) {
|
factory ArgumentsModel.fromArguments(List<String> arguments, bool leanBackEnabled) {
|
||||||
arguments = arguments.map((e) => e.trim()).toList();
|
arguments = arguments.map((e) => e.trim()).toList();
|
||||||
return ArgumentsModel(
|
return ArgumentsModel(
|
||||||
htpcMode: arguments.contains('--htpc'),
|
htpcMode: arguments.contains('--htpc') || leanBackEnabled,
|
||||||
|
leanBackMode: leanBackEnabled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ T _$identity<T>(T value) => value;
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$ArgumentsModel {
|
mixin _$ArgumentsModel {
|
||||||
bool get htpcMode;
|
bool get htpcMode;
|
||||||
|
bool get leanBackMode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'ArgumentsModel(htpcMode: $htpcMode)';
|
return 'ArgumentsModel(htpcMode: $htpcMode, leanBackMode: $leanBackMode)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,13 +116,13 @@ extension ArgumentsModelPatterns on ArgumentsModel {
|
||||||
|
|
||||||
@optionalTypeArgs
|
@optionalTypeArgs
|
||||||
TResult maybeWhen<TResult extends Object?>(
|
TResult maybeWhen<TResult extends Object?>(
|
||||||
TResult Function(bool htpcMode)? $default, {
|
TResult Function(bool htpcMode, bool leanBackMode)? $default, {
|
||||||
required TResult orElse(),
|
required TResult orElse(),
|
||||||
}) {
|
}) {
|
||||||
final _that = this;
|
final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _ArgumentsModel() when $default != null:
|
case _ArgumentsModel() when $default != null:
|
||||||
return $default(_that.htpcMode);
|
return $default(_that.htpcMode, _that.leanBackMode);
|
||||||
case _:
|
case _:
|
||||||
return orElse();
|
return orElse();
|
||||||
}
|
}
|
||||||
|
|
@ -142,12 +143,12 @@ extension ArgumentsModelPatterns on ArgumentsModel {
|
||||||
|
|
||||||
@optionalTypeArgs
|
@optionalTypeArgs
|
||||||
TResult when<TResult extends Object?>(
|
TResult when<TResult extends Object?>(
|
||||||
TResult Function(bool htpcMode) $default,
|
TResult Function(bool htpcMode, bool leanBackMode) $default,
|
||||||
) {
|
) {
|
||||||
final _that = this;
|
final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _ArgumentsModel():
|
case _ArgumentsModel():
|
||||||
return $default(_that.htpcMode);
|
return $default(_that.htpcMode, _that.leanBackMode);
|
||||||
case _:
|
case _:
|
||||||
throw StateError('Unexpected subclass');
|
throw StateError('Unexpected subclass');
|
||||||
}
|
}
|
||||||
|
|
@ -167,12 +168,12 @@ extension ArgumentsModelPatterns on ArgumentsModel {
|
||||||
|
|
||||||
@optionalTypeArgs
|
@optionalTypeArgs
|
||||||
TResult? whenOrNull<TResult extends Object?>(
|
TResult? whenOrNull<TResult extends Object?>(
|
||||||
TResult? Function(bool htpcMode)? $default,
|
TResult? Function(bool htpcMode, bool leanBackMode)? $default,
|
||||||
) {
|
) {
|
||||||
final _that = this;
|
final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _ArgumentsModel() when $default != null:
|
case _ArgumentsModel() when $default != null:
|
||||||
return $default(_that.htpcMode);
|
return $default(_that.htpcMode, _that.leanBackMode);
|
||||||
case _:
|
case _:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -182,15 +183,19 @@ extension ArgumentsModelPatterns on ArgumentsModel {
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
|
|
||||||
class _ArgumentsModel extends ArgumentsModel {
|
class _ArgumentsModel extends ArgumentsModel {
|
||||||
_ArgumentsModel({this.htpcMode = false}) : super._();
|
_ArgumentsModel({this.htpcMode = false, this.leanBackMode = false})
|
||||||
|
: super._();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@JsonKey()
|
@JsonKey()
|
||||||
final bool htpcMode;
|
final bool htpcMode;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool leanBackMode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'ArgumentsModel(htpcMode: $htpcMode)';
|
return 'ArgumentsModel(htpcMode: $htpcMode, leanBackMode: $leanBackMode)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@ T selectAvailableOrSmaller<T>(T value, Set<T> availableOptions, List<T> allOptio
|
||||||
enum HomeBanner {
|
enum HomeBanner {
|
||||||
hide,
|
hide,
|
||||||
carousel,
|
carousel,
|
||||||
banner;
|
banner,
|
||||||
|
detailedBanner;
|
||||||
|
|
||||||
const HomeBanner();
|
const HomeBanner();
|
||||||
|
|
||||||
|
|
@ -48,6 +49,7 @@ enum HomeBanner {
|
||||||
HomeBanner.hide => context.localized.hide,
|
HomeBanner.hide => context.localized.hide,
|
||||||
HomeBanner.carousel => context.localized.homeBannerCarousel,
|
HomeBanner.carousel => context.localized.homeBannerCarousel,
|
||||||
HomeBanner.banner => context.localized.homeBannerSlideshow,
|
HomeBanner.banner => context.localized.homeBannerSlideshow,
|
||||||
|
HomeBanner.detailedBanner => 'Detailed banner'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,14 @@ const _$ViewSizeEnumMap = {
|
||||||
ViewSize.phone: 'phone',
|
ViewSize.phone: 'phone',
|
||||||
ViewSize.tablet: 'tablet',
|
ViewSize.tablet: 'tablet',
|
||||||
ViewSize.desktop: 'desktop',
|
ViewSize.desktop: 'desktop',
|
||||||
|
ViewSize.television: 'television',
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$HomeBannerEnumMap = {
|
const _$HomeBannerEnumMap = {
|
||||||
HomeBanner.hide: 'hide',
|
HomeBanner.hide: 'hide',
|
||||||
HomeBanner.carousel: 'carousel',
|
HomeBanner.carousel: 'carousel',
|
||||||
HomeBanner.banner: 'banner',
|
HomeBanner.banner: 'banner',
|
||||||
|
HomeBanner.detailedBanner: 'detailedBanner',
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$HomeCarouselSettingsEnumMap = {
|
const _$HomeCarouselSettingsEnumMap = {
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,17 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
|
||||||
|
|
||||||
enum PlayerOptions {
|
enum PlayerOptions {
|
||||||
libMDK,
|
libMDK,
|
||||||
libMPV;
|
libMPV,
|
||||||
|
nativePlayer;
|
||||||
|
|
||||||
const PlayerOptions();
|
const PlayerOptions();
|
||||||
|
|
||||||
static Iterable<PlayerOptions> get available => kIsWeb ? {PlayerOptions.libMPV} : PlayerOptions.values;
|
static Iterable<PlayerOptions> get available => kIsWeb
|
||||||
|
? {PlayerOptions.libMPV}
|
||||||
|
: switch (defaultTargetPlatform) {
|
||||||
|
TargetPlatform.android => PlayerOptions.values,
|
||||||
|
_ => {PlayerOptions.libMDK, PlayerOptions.libMPV},
|
||||||
|
};
|
||||||
|
|
||||||
static PlayerOptions get platformDefaults {
|
static PlayerOptions get platformDefaults {
|
||||||
if (kIsWeb) return PlayerOptions.libMPV;
|
if (kIsWeb) return PlayerOptions.libMPV;
|
||||||
|
|
@ -143,6 +149,7 @@ enum PlayerOptions {
|
||||||
String label(BuildContext context) => switch (this) {
|
String label(BuildContext context) => switch (this) {
|
||||||
PlayerOptions.libMDK => "MDK",
|
PlayerOptions.libMDK => "MDK",
|
||||||
PlayerOptions.libMPV => "MPV",
|
PlayerOptions.libMPV => "MPV",
|
||||||
|
PlayerOptions.nativePlayer => "Native",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ const _$BoxFitEnumMap = {
|
||||||
const _$PlayerOptionsEnumMap = {
|
const _$PlayerOptionsEnumMap = {
|
||||||
PlayerOptions.libMDK: 'libMDK',
|
PlayerOptions.libMDK: 'libMDK',
|
||||||
PlayerOptions.libMPV: 'libMPV',
|
PlayerOptions.libMPV: 'libMPV',
|
||||||
|
PlayerOptions.nativePlayer: 'nativePlayer',
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$DeviceOrientationEnumMap = {
|
const _$DeviceOrientationEnumMap = {
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,14 @@ class JellyRequest implements Interceptor {
|
||||||
FutureOr<Response<BodyType>> intercept<BodyType>(Chain<BodyType> chain) async {
|
FutureOr<Response<BodyType>> intercept<BodyType>(Chain<BodyType> chain) async {
|
||||||
final connectivityNotifier = ref.read(connectivityStatusProvider.notifier);
|
final connectivityNotifier = ref.read(connectivityStatusProvider.notifier);
|
||||||
try {
|
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
|
//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);
|
var headers = loginModel.header(ref);
|
||||||
final Response<BodyType> response = await chain.proceed(
|
final Response<BodyType> response = await chain.proceed(
|
||||||
applyHeaders(
|
applyHeaders(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:chopper/chopper.dart';
|
import 'package:chopper/chopper.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/account_model.dart';
|
||||||
import 'package:fladder/models/credentials_model.dart';
|
import 'package:fladder/models/credentials_model.dart';
|
||||||
import 'package:fladder/models/login_screen_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/shared_provider.dart';
|
||||||
import 'package:fladder/providers/user_provider.dart';
|
import 'package:fladder/providers/user_provider.dart';
|
||||||
import 'package:fladder/providers/views_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<AuthNotifier, LoginScreenModel>((ref) {
|
final authProvider = StateNotifierProvider<AuthNotifier, LoginScreenModel>((ref) {
|
||||||
return AuthNotifier(ref);
|
return AuthNotifier(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
class AuthNotifier extends StateNotifier<LoginScreenModel> {
|
class AuthNotifier extends StateNotifier<LoginScreenModel> {
|
||||||
AuthNotifier(this.ref)
|
AuthNotifier(this.ref) : super(LoginScreenModel());
|
||||||
: super(
|
|
||||||
LoginScreenModel(
|
|
||||||
accounts: [],
|
|
||||||
tempCredentials: CredentialsModel.createNewCredentials(),
|
|
||||||
loading: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
late final JellyService api = ref.read(jellyApiProvider);
|
late final JellyService api = ref.read(jellyApiProvider);
|
||||||
|
|
||||||
|
BuildContext? context;
|
||||||
|
|
||||||
|
Future<void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state = state.copyWith(
|
||||||
|
accounts: currentAccounts,
|
||||||
|
screen: currentAccounts.isEmpty ? LoginScreenType.login : LoginScreenType.users,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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) {
|
||||||
|
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<Response<List<AccountModel>>?> getPublicUsers() async {
|
Future<Response<List<AccountModel>>?> getPublicUsers() async {
|
||||||
try {
|
try {
|
||||||
var response = await api.usersPublicGet(state.tempCredentials);
|
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) {
|
if (response.isSuccessful && response.body != null) {
|
||||||
var models = response.body ?? [];
|
var models = response.body ?? [];
|
||||||
|
|
||||||
return response.copyWith(body: models.toList());
|
return response.copyWith(body: models.toList());
|
||||||
}
|
}
|
||||||
|
state = state.copyWith(
|
||||||
|
serverLoginModel: state.serverLoginModel?.copyWith(
|
||||||
|
accounts: response.body ?? [],
|
||||||
|
),
|
||||||
|
);
|
||||||
return response.copyWith(body: []);
|
return response.copyWith(body: []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
state = state.copyWith(loading: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Response<AccountModel>?> authenticateUsingSecret(String secret) async {
|
||||||
|
clearAllProviders();
|
||||||
|
var response = await api.quickConnectAuthenticate(secret);
|
||||||
|
return _createAccountModel(response);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Response<AccountModel>?> authenticateByName(String userName, String password) async {
|
Future<Response<AccountModel>?> authenticateByName(String userName, String password) async {
|
||||||
state = state.copyWith(loading: true);
|
|
||||||
clearAllProviders();
|
clearAllProviders();
|
||||||
var response = await api.usersAuthenticateByNamePost(userName: userName, password: password);
|
var response = await api.usersAuthenticateByNamePost(userName: userName, password: password);
|
||||||
CredentialsModel credentials = state.tempCredentials;
|
return _createAccountModel(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<AccountModel>?> _createAccountModel(Response<AuthenticationResult> response) async {
|
||||||
|
CredentialsModel? credentials = state.serverLoginModel?.tempCredentials;
|
||||||
|
if (credentials == null) return null;
|
||||||
if (response.isSuccessful && (response.body?.accessToken?.isNotEmpty ?? false)) {
|
if (response.isSuccessful && (response.body?.accessToken?.isNotEmpty ?? false)) {
|
||||||
var serverResponse = await api.systemInfoPublicGet();
|
var serverResponse = await api.systemInfoPublicGet();
|
||||||
credentials = credentials.copyWith(
|
credentials = credentials.copyWith(
|
||||||
|
|
@ -68,16 +160,21 @@ class AuthNotifier extends StateNotifier<LoginScreenModel> {
|
||||||
);
|
);
|
||||||
ref.read(sharedUtilityProvider).addAccount(newUser);
|
ref.read(sharedUtilityProvider).addAccount(newUser);
|
||||||
ref.read(userProvider.notifier).userState = 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);
|
return Response(response.base, newUser);
|
||||||
}
|
}
|
||||||
state = state.copyWith(loading: false);
|
|
||||||
return Response(response.base, null);
|
return Response(response.base, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response?> logOutUser() async {
|
Future<Response?> logOutUser() async {
|
||||||
final currentUser = ref.read(userProvider);
|
final currentUser = ref.read(userProvider);
|
||||||
state = state.copyWith(tempCredentials: CredentialsModel.createNewCredentials());
|
state = state.copyWith(serverLoginModel: null);
|
||||||
await ref.read(sharedUtilityProvider).removeAccount(currentUser);
|
await ref.read(sharedUtilityProvider).removeAccount(currentUser);
|
||||||
clearAllProviders();
|
clearAllProviders();
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -95,10 +192,17 @@ class AuthNotifier extends StateNotifier<LoginScreenModel> {
|
||||||
ref.read(libraryScreenProvider.notifier).clear();
|
ref.read(libraryScreenProvider.notifier).clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setServer(String server) {
|
Future<void> setServer(String server) async {
|
||||||
|
final url = (state.hasBaseUrl ? FladderConfig.baseUrl : server);
|
||||||
|
if (url == null || server.isEmpty) return;
|
||||||
|
final isUrlValid = _parseUrl(url);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
tempCredentials: state.tempCredentials.copyWith(server: server),
|
errorMessage: isUrlValid,
|
||||||
|
serverLoginModel: null,
|
||||||
);
|
);
|
||||||
|
if (isUrlValid == null) {
|
||||||
|
await _fetchServerInfo(url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AccountModel> getSavedAccounts() {
|
List<AccountModel> getSavedAccounts() {
|
||||||
|
|
@ -113,4 +217,27 @@ class AuthNotifier extends StateNotifier<LoginScreenModel> {
|
||||||
accounts.insert(newIndex, original);
|
accounts.insert(newIndex, original);
|
||||||
ref.read(sharedUtilityProvider).saveAccounts(accounts);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,16 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
|
||||||
final viewTypes =
|
final viewTypes =
|
||||||
ref.read(viewsProvider.select((value) => value.dashboardViews)).map((e) => e.collectionType).toSet().toList();
|
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])) {
|
if (viewTypes.containsAny([CollectionType.movies, CollectionType.tvshows])) {
|
||||||
final resumeVideoResponse = await api.usersUserIdItemsResumeGet(
|
final resumeVideoResponse = await api.usersUserIdItemsResumeGet(
|
||||||
limit: 16,
|
enableImageTypes: imagesToFetch,
|
||||||
fields: [
|
fields: [
|
||||||
ItemFields.parentid,
|
ItemFields.parentid,
|
||||||
ItemFields.mediastreams,
|
ItemFields.mediastreams,
|
||||||
|
|
@ -36,6 +43,8 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
|
||||||
ItemFields.candelete,
|
ItemFields.candelete,
|
||||||
ItemFields.candownload,
|
ItemFields.candownload,
|
||||||
ItemFields.primaryimageaspectratio,
|
ItemFields.primaryimageaspectratio,
|
||||||
|
ItemFields.overview,
|
||||||
|
ItemFields.genres,
|
||||||
],
|
],
|
||||||
mediaTypes: [MediaType.video],
|
mediaTypes: [MediaType.video],
|
||||||
enableTotalRecordCount: false,
|
enableTotalRecordCount: false,
|
||||||
|
|
@ -48,7 +57,7 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
|
||||||
|
|
||||||
if (viewTypes.contains(CollectionType.music)) {
|
if (viewTypes.contains(CollectionType.music)) {
|
||||||
final resumeAudioResponse = await api.usersUserIdItemsResumeGet(
|
final resumeAudioResponse = await api.usersUserIdItemsResumeGet(
|
||||||
limit: 16,
|
enableImageTypes: imagesToFetch,
|
||||||
fields: [
|
fields: [
|
||||||
ItemFields.parentid,
|
ItemFields.parentid,
|
||||||
ItemFields.mediastreams,
|
ItemFields.mediastreams,
|
||||||
|
|
@ -56,6 +65,8 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
|
||||||
ItemFields.candelete,
|
ItemFields.candelete,
|
||||||
ItemFields.candownload,
|
ItemFields.candownload,
|
||||||
ItemFields.primaryimageaspectratio,
|
ItemFields.primaryimageaspectratio,
|
||||||
|
ItemFields.overview,
|
||||||
|
ItemFields.genres,
|
||||||
],
|
],
|
||||||
mediaTypes: [MediaType.audio],
|
mediaTypes: [MediaType.audio],
|
||||||
enableTotalRecordCount: false,
|
enableTotalRecordCount: false,
|
||||||
|
|
@ -68,7 +79,7 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
|
||||||
|
|
||||||
if (viewTypes.contains(CollectionType.books)) {
|
if (viewTypes.contains(CollectionType.books)) {
|
||||||
final resumeBookResponse = await api.usersUserIdItemsResumeGet(
|
final resumeBookResponse = await api.usersUserIdItemsResumeGet(
|
||||||
limit: 16,
|
enableImageTypes: imagesToFetch,
|
||||||
fields: [
|
fields: [
|
||||||
ItemFields.parentid,
|
ItemFields.parentid,
|
||||||
ItemFields.mediastreams,
|
ItemFields.mediastreams,
|
||||||
|
|
@ -76,6 +87,8 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
|
||||||
ItemFields.candelete,
|
ItemFields.candelete,
|
||||||
ItemFields.candownload,
|
ItemFields.candownload,
|
||||||
ItemFields.primaryimageaspectratio,
|
ItemFields.primaryimageaspectratio,
|
||||||
|
ItemFields.overview,
|
||||||
|
ItemFields.genres,
|
||||||
],
|
],
|
||||||
mediaTypes: [MediaType.book],
|
mediaTypes: [MediaType.book],
|
||||||
enableTotalRecordCount: false,
|
enableTotalRecordCount: false,
|
||||||
|
|
@ -87,7 +100,6 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
|
||||||
}
|
}
|
||||||
|
|
||||||
final nextResponse = await api.showsNextUpGet(
|
final nextResponse = await api.showsNextUpGet(
|
||||||
limit: 16,
|
|
||||||
nextUpDateCutoff: DateTime.now().subtract(
|
nextUpDateCutoff: DateTime.now().subtract(
|
||||||
ref.read(clientSettingsProvider.select((value) => value.nextUpDateCutoff ?? const Duration(days: 28)))),
|
ref.read(clientSettingsProvider.select((value) => value.nextUpDateCutoff ?? const Duration(days: 28)))),
|
||||||
fields: [
|
fields: [
|
||||||
|
|
@ -97,6 +109,8 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
|
||||||
ItemFields.candelete,
|
ItemFields.candelete,
|
||||||
ItemFields.candownload,
|
ItemFields.candownload,
|
||||||
ItemFields.primaryimageaspectratio,
|
ItemFields.primaryimageaspectratio,
|
||||||
|
ItemFields.overview,
|
||||||
|
ItemFields.genres,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import 'package:fladder/providers/user_provider.dart';
|
||||||
|
|
||||||
const _defaultHeight = 576;
|
const _defaultHeight = 576;
|
||||||
const _defaultWidth = 384;
|
const _defaultWidth = 384;
|
||||||
const _defaultQuality = 96;
|
const _defaultQuality = 90;
|
||||||
|
|
||||||
final imageUtilityProvider = Provider<ImageNotifier>((ref) {
|
final imageUtilityProvider = Provider<ImageNotifier>((ref) {
|
||||||
return ImageNotifier(ref: ref);
|
return ImageNotifier(ref: ref);
|
||||||
|
|
@ -19,7 +19,7 @@ class ImageNotifier {
|
||||||
});
|
});
|
||||||
|
|
||||||
String get currentServerUrl {
|
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) {
|
String getUserImageUrl(String id) {
|
||||||
|
|
|
||||||
0
lib/providers/lock_screen_provider.dart
Normal file
0
lib/providers/lock_screen_provider.dart
Normal file
|
|
@ -77,7 +77,7 @@ class JellyService {
|
||||||
final JellyfinOpenApi _api;
|
final JellyfinOpenApi _api;
|
||||||
|
|
||||||
JellyfinOpenApi get 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;
|
var currentServer = ref.read(userProvider)?.credentials.server;
|
||||||
if ((authServer.isNotEmpty ? authServer : currentServer) == FakeHelper.fakeTestServerUrl) {
|
if ((authServer.isNotEmpty ? authServer : currentServer) == FakeHelper.fakeTestServerUrl) {
|
||||||
return FakeJellyfinOpenApi();
|
return FakeJellyfinOpenApi();
|
||||||
|
|
@ -1126,6 +1126,8 @@ class JellyService {
|
||||||
|
|
||||||
Future<Response<bool>> quickConnectEnabled() async => api.quickConnectEnabledGet();
|
Future<Response<bool>> quickConnectEnabled() async => api.quickConnectEnabledGet();
|
||||||
|
|
||||||
|
Future<Response<BrandingOptions>> getBranding() async => api.brandingConfigurationGet();
|
||||||
|
|
||||||
Future<Response<dynamic>> deleteItem(String itemId) => api.itemsItemIdDelete(itemId: itemId);
|
Future<Response<dynamic>> deleteItem(String itemId) => api.itemsItemIdDelete(itemId: itemId);
|
||||||
|
|
||||||
Future<UserConfiguration?> _updateUserConfiguration(UserConfiguration newUserConfiguration) async {
|
Future<UserConfiguration?> _updateUserConfiguration(UserConfiguration newUserConfiguration) async {
|
||||||
|
|
@ -1161,6 +1163,22 @@ class JellyService {
|
||||||
);
|
);
|
||||||
return _updateUserConfiguration(updated);
|
return _updateUserConfiguration(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Response<QuickConnectResult>> quickConnectInitiate() async {
|
||||||
|
return api.quickConnectInitiatePost();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<QuickConnectResult>> quickConnectConnectGet({
|
||||||
|
String? secret,
|
||||||
|
}) async {
|
||||||
|
return api.quickConnectConnectGet(secret: secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<AuthenticationResult>> quickConnectAuthenticate(String secret) async {
|
||||||
|
return api.usersAuthenticateWithQuickConnectPost(
|
||||||
|
body: QuickConnectDto(secret: secret),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ParsedMap on Map<String, dynamic> {
|
extension ParsedMap on Map<String, dynamic> {
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,13 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:screen_brightness/screen_brightness.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/key_combinations.dart';
|
||||||
import 'package:fladder/models/settings/video_player_settings.dart';
|
import 'package:fladder/models/settings/video_player_settings.dart';
|
||||||
import 'package:fladder/providers/shared_provider.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/providers/video_player_provider.dart';
|
||||||
|
import 'package:fladder/src/player_settings_helper.g.dart' as pigeon;
|
||||||
|
|
||||||
final videoPlayerSettingsProvider =
|
final videoPlayerSettingsProvider =
|
||||||
StateNotifierProvider<VideoPlayerSettingsProviderNotifier, VideoPlayerSettingsModel>((ref) {
|
StateNotifierProvider<VideoPlayerSettingsProviderNotifier, VideoPlayerSettingsModel>((ref) {
|
||||||
|
|
@ -30,6 +33,30 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier<VideoPlayerSetti
|
||||||
if (!oldState.playerSame(value)) {
|
if (!oldState.playerSame(value)) {
|
||||||
ref.read(videoPlayerProvider.notifier).init();
|
ref.read(videoPlayerProvider.notifier).init();
|
||||||
}
|
}
|
||||||
|
final userData = ref.read(userProvider);
|
||||||
|
pigeon.PlayerSettingsPigeon().sendPlayerSettings(
|
||||||
|
pigeon.PlayerSettings(
|
||||||
|
skipTypes: value.segmentSkipSettings.map(
|
||||||
|
(key, value) => 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 {
|
void setScreenBrightness(double? value) async {
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,30 @@ class SharedUtility {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool?> addAccount(AccountModel account) async {
|
Future<bool?> addAccount(AccountModel account) async {
|
||||||
return await saveAccounts(getAccounts()
|
final newAccount = account.copyWith(
|
||||||
..add(account.copyWith(
|
|
||||||
lastUsed: DateTime.now(),
|
lastUsed: DateTime.now(),
|
||||||
)));
|
);
|
||||||
|
|
||||||
|
List<AccountModel> 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<bool?> removeAccount(AccountModel? account) async {
|
Future<bool?> removeAccount(AccountModel? account) async {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:fladder/models/media_playback_model.dart';
|
import 'package:fladder/models/media_playback_model.dart';
|
||||||
|
|
@ -77,6 +79,8 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
|
||||||
mediaState.update(
|
mediaState.update(
|
||||||
(state) => state.playing == event ? state : state.copyWith(playing: event),
|
(state) => state.playing == event ? state : state.copyWith(playing: event),
|
||||||
);
|
);
|
||||||
|
final currentState = playbackState;
|
||||||
|
ref.read(playBackModel)?.updatePlaybackPosition(currentState.position, playbackState.playing, ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updatePosition(Duration event) async {
|
Future<void> updatePosition(Duration event) async {
|
||||||
|
|
@ -105,7 +109,7 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> loadPlaybackItem(PlaybackModel model, {Duration? startPosition}) async {
|
Future<bool> loadPlaybackItem(PlaybackModel model, Duration startPosition) async {
|
||||||
await state.stop();
|
await state.stop();
|
||||||
mediaState
|
mediaState
|
||||||
.update((state) => state.copyWith(state: VideoPlayerState.fullScreen, buffering: true, errorPlaying: false));
|
.update((state) => state.copyWith(state: VideoPlayerState.fullScreen, buffering: true, errorPlaying: false));
|
||||||
|
|
@ -114,13 +118,13 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
|
||||||
PlaybackModel? newPlaybackModel = model;
|
PlaybackModel? newPlaybackModel = model;
|
||||||
|
|
||||||
if (media != null) {
|
if (media != null) {
|
||||||
await state.open(media.url, false);
|
await state.loadVideo(model, startPosition, false);
|
||||||
await state.setVolume(ref.read(videoPlayerSettingsProvider).volume);
|
await state.setVolume(ref.read(videoPlayerSettingsProvider).volume);
|
||||||
state.stateStream?.takeWhile((event) => event.buffering == true).listen(
|
state.stateStream?.takeWhile((event) => event.buffering == true).listen(
|
||||||
null,
|
null,
|
||||||
onDone: () async {
|
onDone: () async {
|
||||||
final start = startPosition ?? await model.startDuration();
|
final start = startPosition;
|
||||||
if (start != null) {
|
if (start != Duration.zero) {
|
||||||
await state.seek(start);
|
await state.seek(start);
|
||||||
}
|
}
|
||||||
await state.setAudioTrack(null, model);
|
await state.setAudioTrack(null, model);
|
||||||
|
|
@ -138,4 +142,6 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
|
||||||
mediaState.update((state) => state.copyWith(errorPlaying: true));
|
mediaState.update((state) => state.copyWith(errorPlaying: true));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> openPlayer(BuildContext context) async => state.openPlayer(context);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ class ViewsNotifier extends StateNotifier<ViewsModel> {
|
||||||
ItemFields.candelete,
|
ItemFields.candelete,
|
||||||
ItemFields.candownload,
|
ItemFields.candownload,
|
||||||
ItemFields.primaryimageaspectratio,
|
ItemFields.primaryimageaspectratio,
|
||||||
|
ItemFields.overview,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return e.copyWith(recentlyAdded: recents.body?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList());
|
return e.copyWith(recentlyAdded: recents.body?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList());
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:fladder/providers/user_provider.dart';
|
import 'package:fladder/providers/user_provider.dart';
|
||||||
import 'package:fladder/routes/auto_router.gr.dart';
|
import 'package:fladder/routes/auto_router.gr.dart';
|
||||||
import 'package:fladder/screens/login/lock_screen.dart';
|
import 'package:fladder/screens/login/lock_screen.dart';
|
||||||
|
import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart';
|
||||||
|
|
||||||
const settingsPageRoute = "settings";
|
const settingsPageRoute = "settings";
|
||||||
|
|
||||||
|
|
@ -53,18 +54,22 @@ final List<AutoRoute> homeRoutes = [
|
||||||
page: DashboardRoute.page,
|
page: DashboardRoute.page,
|
||||||
initial: true,
|
initial: true,
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
|
maintainState: false,
|
||||||
),
|
),
|
||||||
AutoRoute(
|
AutoRoute(
|
||||||
page: FavouritesRoute.page,
|
page: FavouritesRoute.page,
|
||||||
path: 'favourites',
|
path: 'favourites',
|
||||||
|
maintainState: false,
|
||||||
),
|
),
|
||||||
AutoRoute(
|
AutoRoute(
|
||||||
page: SyncedRoute.page,
|
page: SyncedRoute.page,
|
||||||
path: 'synced',
|
path: 'synced',
|
||||||
|
maintainState: false,
|
||||||
),
|
),
|
||||||
AutoRoute(
|
AutoRoute(
|
||||||
page: LibraryRoute.page,
|
page: LibraryRoute.page,
|
||||||
path: 'libraries',
|
path: 'libraries',
|
||||||
|
maintainState: false,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -76,7 +81,7 @@ final List<AutoRoute> detailsRoutes = [
|
||||||
|
|
||||||
final List<AutoRoute> _defaultRoutes = [
|
final List<AutoRoute> _defaultRoutes = [
|
||||||
AutoRoute(page: SplashRoute.page, path: '/splash'),
|
AutoRoute(page: SplashRoute.page, path: '/splash'),
|
||||||
AutoRoute(page: LoginRoute.page, path: '/login'),
|
AutoRoute(page: LoginRoute.page, path: '/login', maintainState: false),
|
||||||
];
|
];
|
||||||
|
|
||||||
final List<AutoRoute> _settingsChildren = [
|
final List<AutoRoute> _settingsChildren = [
|
||||||
|
|
@ -117,6 +122,8 @@ class AuthGuard extends AutoRouteGuard {
|
||||||
if (ref.read(userProvider) != null ||
|
if (ref.read(userProvider) != null ||
|
||||||
resolver.routeName == const LoginRoute().routeName ||
|
resolver.routeName == const LoginRoute().routeName ||
|
||||||
resolver.routeName == SplashRoute().routeName) {
|
resolver.routeName == SplashRoute().routeName) {
|
||||||
|
// We assume the last main focus is no longer active after navigating
|
||||||
|
lastMainFocus = null;
|
||||||
return resolver.next(true);
|
return resolver.next(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,5 +134,9 @@ class AuthGuard extends AutoRouteGuard {
|
||||||
router.replace(const LoginRoute());
|
router.replace(const LoginRoute());
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// We assume the last main focus is no longer active after navigating
|
||||||
|
lastMainFocus = null;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,12 @@ class DetailsRoute extends _i18.PageRouteInfo<DetailsRouteArgs> {
|
||||||
DetailsRoute({
|
DetailsRoute({
|
||||||
String id = '',
|
String id = '',
|
||||||
_i19.ItemBaseModel? item,
|
_i19.ItemBaseModel? item,
|
||||||
|
Object? tag,
|
||||||
_i20.Key? key,
|
_i20.Key? key,
|
||||||
List<_i18.PageRouteInfo>? children,
|
List<_i18.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
DetailsRoute.name,
|
DetailsRoute.name,
|
||||||
args: DetailsRouteArgs(id: id, item: item, key: key),
|
args: DetailsRouteArgs(id: id, item: item, tag: tag, key: key),
|
||||||
rawQueryParams: {'id': id},
|
rawQueryParams: {'id': id},
|
||||||
initialChildren: children,
|
initialChildren: children,
|
||||||
);
|
);
|
||||||
|
|
@ -111,34 +112,44 @@ class DetailsRoute extends _i18.PageRouteInfo<DetailsRouteArgs> {
|
||||||
final args = data.argsAs<DetailsRouteArgs>(
|
final args = data.argsAs<DetailsRouteArgs>(
|
||||||
orElse: () => DetailsRouteArgs(id: queryParams.getString('id', '')),
|
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 {
|
class DetailsRouteArgs {
|
||||||
const DetailsRouteArgs({this.id = '', this.item, this.key});
|
const DetailsRouteArgs({this.id = '', this.item, this.tag, this.key});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
final _i19.ItemBaseModel? item;
|
final _i19.ItemBaseModel? item;
|
||||||
|
|
||||||
|
final Object? tag;
|
||||||
|
|
||||||
final _i20.Key? key;
|
final _i20.Key? key;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'DetailsRouteArgs{id: $id, item: $item, key: $key}';
|
return 'DetailsRouteArgs{id: $id, item: $item, tag: $tag, key: $key}';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
if (other is! DetailsRouteArgs) return false;
|
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
|
@override
|
||||||
int get hashCode => id.hashCode ^ item.hashCode ^ key.hashCode;
|
int get hashCode => id.hashCode ^ item.hashCode ^ tag.hashCode ^ key.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,13 @@ import 'package:fladder/util/fladder_image.dart';
|
||||||
class DetailsScreen extends ConsumerStatefulWidget {
|
class DetailsScreen extends ConsumerStatefulWidget {
|
||||||
final String id;
|
final String id;
|
||||||
final ItemBaseModel? item;
|
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
|
@override
|
||||||
ConsumerState<ConsumerStatefulWidget> createState() => _DetailsScreenState();
|
ConsumerState<ConsumerStatefulWidget> createState() => _DetailsScreenState();
|
||||||
|
|
@ -66,7 +72,7 @@ class _DetailsScreenState extends ConsumerState<DetailsScreen> {
|
||||||
key: Key(widget.id),
|
key: Key(widget.id),
|
||||||
children: [
|
children: [
|
||||||
Hero(
|
Hero(
|
||||||
tag: widget.id,
|
tag: widget.tag ?? UniqueKey(),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 1.0),
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 1.0),
|
||||||
|
|
|
||||||
|
|
@ -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/providers/settings/book_viewer_settings_provider.dart';
|
||||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||||
import 'package:fladder/util/string_extensions.dart';
|
import 'package:fladder/util/string_extensions.dart';
|
||||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||||
import 'package:fladder/widgets/shared/fladder_slider.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:fladder/widgets/shared/modal_side_sheet.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
Future<void> showBookViewerSettings(
|
Future<void> showBookViewerSettings(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
|
|
@ -80,10 +83,9 @@ class BookViewerSettingsScreen extends ConsumerWidget {
|
||||||
label: const Text("Read direction"),
|
label: const Text("Read direction"),
|
||||||
current: settings.readDirection.name.toUpperCaseSplit(),
|
current: settings.readDirection.name.toUpperCaseSplit(),
|
||||||
itemBuilder: (context) => ReadDirection.values
|
itemBuilder: (context) => ReadDirection.values
|
||||||
.map((value) => PopupMenuItem(
|
.map((value) => ItemActionButton(
|
||||||
value: value,
|
label: Text(value.name.toUpperCaseSplit()),
|
||||||
child: Text(value.name.toUpperCaseSplit()),
|
action: () => ref
|
||||||
onTap: () => ref
|
|
||||||
.read(bookViewerSettingsProvider.notifier)
|
.read(bookViewerSettingsProvider.notifier)
|
||||||
.update((state) => state.copyWith(readDirection: value)),
|
.update((state) => state.copyWith(readDirection: value)),
|
||||||
))
|
))
|
||||||
|
|
@ -102,10 +104,9 @@ class BookViewerSettingsScreen extends ConsumerWidget {
|
||||||
label: const Text("Init zoom"),
|
label: const Text("Init zoom"),
|
||||||
current: settings.initZoomState.name.toUpperCaseSplit(),
|
current: settings.initZoomState.name.toUpperCaseSplit(),
|
||||||
itemBuilder: (context) => InitZoomState.values
|
itemBuilder: (context) => InitZoomState.values
|
||||||
.map((value) => PopupMenuItem(
|
.map((value) => ItemActionButton(
|
||||||
value: value,
|
label: Text(value.name.toUpperCaseSplit()),
|
||||||
child: Text(value.name.toUpperCaseSplit()),
|
action: () => ref
|
||||||
onTap: () => ref
|
|
||||||
.read(bookViewerSettingsProvider.notifier)
|
.read(bookViewerSettingsProvider.notifier)
|
||||||
.update((state) => state.copyWith(initZoomState: value)),
|
.update((state) => state.copyWith(initZoomState: value)),
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:fladder/util/list_padding.dart';
|
||||||
import 'package:fladder/util/localization_helper.dart';
|
import 'package:fladder/util/localization_helper.dart';
|
||||||
import 'package:fladder/util/string_extensions.dart';
|
import 'package:fladder/util/string_extensions.dart';
|
||||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||||
|
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||||
|
|
||||||
final _selectedWarningProvider = StateProvider<ErrorType?>((ref) => null);
|
final _selectedWarningProvider = StateProvider<ErrorType?>((ref) => null);
|
||||||
|
|
||||||
|
|
@ -41,16 +42,14 @@ class CrashScreen extends ConsumerWidget {
|
||||||
EnumBox(
|
EnumBox(
|
||||||
current: selectedType == null ? context.localized.all : selectedType.name.capitalize(),
|
current: selectedType == null ? context.localized.all : selectedType.name.capitalize(),
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem(
|
ItemActionButton(
|
||||||
value: null,
|
label: Text(context.localized.all),
|
||||||
child: Text(context.localized.all),
|
action: () => ref.read(_selectedWarningProvider.notifier).update((state) => null),
|
||||||
onTap: () => ref.read(_selectedWarningProvider.notifier).update((state) => null),
|
|
||||||
),
|
),
|
||||||
...ErrorType.values.map(
|
...ErrorType.values.map(
|
||||||
(entry) => PopupMenuItem(
|
(entry) => ItemActionButton(
|
||||||
value: entry,
|
label: Text(entry.name.capitalize()),
|
||||||
child: Text(entry.name.capitalize()),
|
action: () => ref.read(_selectedWarningProvider.notifier).update((state) => entry),
|
||||||
onTap: () => ref.read(_selectedWarningProvider.notifier).update((state) => entry),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.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_scaffold.dart';
|
||||||
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
|
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
|
||||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.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/list_padding.dart';
|
||||||
import 'package:fladder/util/localization_helper.dart';
|
import 'package:fladder/util/localization_helper.dart';
|
||||||
import 'package:fladder/util/sliver_list_padding.dart';
|
import 'package:fladder/util/sliver_list_padding.dart';
|
||||||
|
|
@ -45,6 +47,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||||
late final Timer _timer;
|
late final Timer _timer;
|
||||||
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
|
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
|
||||||
|
|
||||||
|
final textController = TextEditingController();
|
||||||
|
|
||||||
|
ItemBaseModel? selectedPoster;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -70,6 +76,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final padding = AdaptiveLayout.adaptivePadding(context);
|
final padding = AdaptiveLayout.adaptivePadding(context);
|
||||||
|
final bannerType = ref.watch(homeSettingsProvider.select((value) => value.homeBanner));
|
||||||
|
|
||||||
final dashboardData = ref.watch(dashboardProvider);
|
final dashboardData = ref.watch(dashboardProvider);
|
||||||
final views = ref.watch(viewsProvider);
|
final views = ref.watch(viewsProvider);
|
||||||
|
|
@ -87,10 +94,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||||
HomeCarouselSettings.cont => allResume,
|
HomeCarouselSettings.cont => allResume,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
final viewSize = AdaptiveLayout.viewSizeOf(context);
|
||||||
|
|
||||||
return MediaQuery.removeViewInsets(
|
return MediaQuery.removeViewInsets(
|
||||||
context: context,
|
context: context,
|
||||||
child: NestedScaffold(
|
child: NestedScaffold(
|
||||||
background: BackgroundImage(items: [...homeCarouselItems, ...dashboardData.nextUp, ...allResume]),
|
background: BackgroundImage(
|
||||||
|
items: selectedPoster != null
|
||||||
|
? [selectedPoster!]
|
||||||
|
: [...homeCarouselItems, ...dashboardData.nextUp, ...allResume]),
|
||||||
body: PullToRefresh(
|
body: PullToRefresh(
|
||||||
refreshKey: _refreshIndicatorKey,
|
refreshKey: _refreshIndicatorKey,
|
||||||
displacement: 80 + MediaQuery.of(context).viewPadding.top,
|
displacement: 80 + MediaQuery.of(context).viewPadding.top,
|
||||||
|
|
@ -101,8 +113,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||||
controller: AdaptiveLayout.scrollOf(context, HomeTabs.dashboard),
|
controller: AdaptiveLayout.scrollOf(context, HomeTabs.dashboard),
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
const DefaultSliverTopBadding(),
|
if (bannerType != HomeBanner.detailedBanner) const DefaultSliverTopBadding(),
|
||||||
if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone)
|
if (viewSize == ViewSize.phone)
|
||||||
NestedSliverAppBar(
|
NestedSliverAppBar(
|
||||||
route: LibrarySearchRoute(),
|
route: LibrarySearchRoute(),
|
||||||
parent: context,
|
parent: context,
|
||||||
|
|
@ -114,7 +126,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||||
context,
|
context,
|
||||||
horizontalPadding: 0,
|
horizontalPadding: 0,
|
||||||
),
|
),
|
||||||
child: HomeBannerWidget(posters: homeCarouselItems),
|
child: HomeBannerWidget(
|
||||||
|
posters: homeCarouselItems,
|
||||||
|
onSelect: (selected) {
|
||||||
|
// if (selectedPoster != selected) {
|
||||||
|
// setState(() {
|
||||||
|
// selectedPoster = selected;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -130,52 +151,40 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||||
...[
|
...[
|
||||||
if (resumeVideo.isNotEmpty &&
|
if (resumeVideo.isNotEmpty &&
|
||||||
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
|
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
|
||||||
SliverToBoxAdapter(
|
PosterRow(
|
||||||
child: PosterRow(
|
|
||||||
contentPadding: padding,
|
contentPadding: padding,
|
||||||
label: context.localized.dashboardContinueWatching,
|
label: context.localized.dashboardContinueWatching,
|
||||||
posters: resumeVideo,
|
posters: resumeVideo,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (resumeAudio.isNotEmpty &&
|
if (resumeAudio.isNotEmpty &&
|
||||||
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
|
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
|
||||||
SliverToBoxAdapter(
|
PosterRow(
|
||||||
child: PosterRow(
|
|
||||||
contentPadding: padding,
|
contentPadding: padding,
|
||||||
label: context.localized.dashboardContinueListening,
|
label: context.localized.dashboardContinueListening,
|
||||||
posters: resumeAudio,
|
posters: resumeAudio,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (resumeBooks.isNotEmpty &&
|
if (resumeBooks.isNotEmpty &&
|
||||||
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
|
(homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate))
|
||||||
SliverToBoxAdapter(
|
PosterRow(
|
||||||
child: PosterRow(
|
|
||||||
contentPadding: padding,
|
contentPadding: padding,
|
||||||
label: context.localized.dashboardContinueReading,
|
label: context.localized.dashboardContinueReading,
|
||||||
posters: resumeBooks,
|
posters: resumeBooks,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (dashboardData.nextUp.isNotEmpty &&
|
if (dashboardData.nextUp.isNotEmpty &&
|
||||||
(homeSettings.nextUp == HomeNextUp.nextUp || homeSettings.nextUp == HomeNextUp.separate))
|
(homeSettings.nextUp == HomeNextUp.nextUp || homeSettings.nextUp == HomeNextUp.separate))
|
||||||
SliverToBoxAdapter(
|
PosterRow(
|
||||||
child: PosterRow(
|
|
||||||
contentPadding: padding,
|
contentPadding: padding,
|
||||||
label: context.localized.nextUp,
|
label: context.localized.nextUp,
|
||||||
posters: dashboardData.nextUp,
|
posters: dashboardData.nextUp,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if ([...allResume, ...dashboardData.nextUp].isNotEmpty && homeSettings.nextUp == HomeNextUp.combined)
|
if ([...allResume, ...dashboardData.nextUp].isNotEmpty && homeSettings.nextUp == HomeNextUp.combined)
|
||||||
SliverToBoxAdapter(
|
PosterRow(
|
||||||
child: PosterRow(
|
|
||||||
contentPadding: padding,
|
contentPadding: padding,
|
||||||
label: context.localized.dashboardContinue,
|
label: context.localized.dashboardContinue,
|
||||||
posters: [...allResume, ...dashboardData.nextUp],
|
posters: [...allResume, ...dashboardData.nextUp],
|
||||||
),
|
),
|
||||||
),
|
...views.dashboardViews.where((element) => element.recentlyAdded.isNotEmpty).map(
|
||||||
...views.dashboardViews
|
(view) => PosterRow(
|
||||||
.where((element) => element.recentlyAdded.isNotEmpty)
|
|
||||||
.map((view) => SliverToBoxAdapter(
|
|
||||||
child: PosterRow(
|
|
||||||
contentPadding: padding,
|
contentPadding: padding,
|
||||||
label: context.localized.dashboardRecentlyAdded(view.name),
|
label: context.localized.dashboardRecentlyAdded(view.name),
|
||||||
collectionAspectRatio: view.collectionType.aspectRatio,
|
collectionAspectRatio: view.collectionType.aspectRatio,
|
||||||
|
|
@ -202,8 +211,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||||
),
|
),
|
||||||
posters: view.recentlyAdded,
|
posters: view.recentlyAdded,
|
||||||
),
|
),
|
||||||
)),
|
),
|
||||||
].nonNulls.toList().addInBetween(const SliverToBoxAdapter(child: SizedBox(height: 16))),
|
]
|
||||||
|
.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),
|
||||||
|
),
|
||||||
|
),
|
||||||
const DefautlSliverBottomPadding(),
|
const DefautlSliverBottomPadding(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,26 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:fladder/models/item_base_model.dart';
|
import 'package:fladder/models/item_base_model.dart';
|
||||||
import 'package:fladder/models/settings/home_settings_model.dart';
|
import 'package:fladder/models/settings/home_settings_model.dart';
|
||||||
import 'package:fladder/providers/settings/home_settings_provider.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/carousel_banner.dart';
|
||||||
|
import 'package:fladder/screens/shared/media/detailed_banner.dart';
|
||||||
import 'package:fladder/screens/shared/media/media_banner.dart';
|
import 'package:fladder/screens/shared/media/media_banner.dart';
|
||||||
|
|
||||||
class HomeBannerWidget extends ConsumerWidget {
|
class HomeBannerWidget extends ConsumerWidget {
|
||||||
final List<ItemBaseModel> posters;
|
final List<ItemBaseModel> posters;
|
||||||
const HomeBannerWidget({required this.posters, super.key});
|
final Function(ItemBaseModel selected) onSelect;
|
||||||
|
|
||||||
|
const HomeBannerWidget({
|
||||||
|
required this.posters,
|
||||||
|
required this.onSelect,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bannerType = ref.watch(homeSettingsProvider.select((value) => value.homeBanner));
|
final bannerType = ref.watch(homeSettingsProvider.select((value) => value.homeBanner));
|
||||||
final maxHeight = (MediaQuery.sizeOf(context).shortestSide * 0.6).clamp(125.0, 375.0);
|
final maxHeight = (MediaQuery.sizeOf(context).shortestSide * 0.6).clamp(125.0, 375.0);
|
||||||
|
|
||||||
return switch (bannerType) {
|
return switch (bannerType) {
|
||||||
HomeBanner.carousel => Column(
|
HomeBanner.carousel => Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -34,6 +43,12 @@ class HomeBannerWidget extends ConsumerWidget {
|
||||||
maxHeight: maxHeight,
|
maxHeight: maxHeight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
HomeBanner.detailedBanner => AnimatedFadeSize(
|
||||||
|
child: DetailedBanner(
|
||||||
|
posters: posters,
|
||||||
|
onSelect: onSelect,
|
||||||
|
),
|
||||||
|
),
|
||||||
_ => const SizedBox.shrink(),
|
_ => const SizedBox.shrink(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:fladder/models/items/media_streams_model.dart';
|
import 'package:fladder/models/items/media_streams_model.dart';
|
||||||
import 'package:fladder/screens/details_screens/components/label_title_item.dart';
|
import 'package:fladder/screens/details_screens/components/label_title_item.dart';
|
||||||
import 'package:fladder/util/localization_helper.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 {
|
class MediaStreamInformation extends ConsumerWidget {
|
||||||
final MediaStreamsModel mediaStream;
|
final MediaStreamsModel mediaStream;
|
||||||
|
|
@ -30,15 +32,13 @@ class MediaStreamInformation extends ConsumerWidget {
|
||||||
label: Text(context.localized.version),
|
label: Text(context.localized.version),
|
||||||
current: mediaStream.currentVersionStream?.name ?? "",
|
current: mediaStream.currentVersionStream?.name ?? "",
|
||||||
itemBuilder: (context) => mediaStream.versionStreams
|
itemBuilder: (context) => mediaStream.versionStreams
|
||||||
.map((e) => PopupMenuItem(
|
.map((e) => ItemActionButton(
|
||||||
value: e,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
child: textWidget(
|
|
||||||
context,
|
|
||||||
selected: mediaStream.currentVersionStream == e,
|
selected: mediaStream.currentVersionStream == e,
|
||||||
|
label: textWidget(
|
||||||
|
context,
|
||||||
label: e.name,
|
label: e.name,
|
||||||
),
|
),
|
||||||
onTap: () => onVersionIndexChanged?.call(e.index),
|
action: () => onVersionIndexChanged?.call(e.index),
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
|
|
@ -48,10 +48,8 @@ class MediaStreamInformation extends ConsumerWidget {
|
||||||
current: (mediaStream.videoStreams.first).prettyName,
|
current: (mediaStream.videoStreams.first).prettyName,
|
||||||
itemBuilder: (context) => mediaStream.videoStreams
|
itemBuilder: (context) => mediaStream.videoStreams
|
||||||
.map(
|
.map(
|
||||||
(e) => PopupMenuItem(
|
(e) => ItemActionButton(
|
||||||
value: e,
|
label: Text(e.prettyName),
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
child: Text(e.prettyName),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|
@ -62,12 +60,13 @@ class MediaStreamInformation extends ConsumerWidget {
|
||||||
current: mediaStream.currentAudioStream?.displayTitle ?? "",
|
current: mediaStream.currentAudioStream?.displayTitle ?? "",
|
||||||
itemBuilder: (context) => [AudioStreamModel.no(), ...mediaStream.audioStreams]
|
itemBuilder: (context) => [AudioStreamModel.no(), ...mediaStream.audioStreams]
|
||||||
.map(
|
.map(
|
||||||
(e) => PopupMenuItem(
|
(e) => ItemActionButton(
|
||||||
value: e,
|
selected: mediaStream.currentAudioStream?.index == e.index,
|
||||||
padding: EdgeInsets.zero,
|
label: textWidget(
|
||||||
child: textWidget(context,
|
context,
|
||||||
selected: mediaStream.currentAudioStream?.index == e.index, label: e.displayTitle),
|
label: e.displayTitle,
|
||||||
onTap: () => onAudioIndexChanged?.call(e.index),
|
),
|
||||||
|
action: () => onAudioIndexChanged?.call(e.index),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|
@ -78,12 +77,13 @@ class MediaStreamInformation extends ConsumerWidget {
|
||||||
current: mediaStream.currentSubStream?.displayTitle ?? "",
|
current: mediaStream.currentSubStream?.displayTitle ?? "",
|
||||||
itemBuilder: (context) => [SubStreamModel.no(), ...mediaStream.subStreams]
|
itemBuilder: (context) => [SubStreamModel.no(), ...mediaStream.subStreams]
|
||||||
.map(
|
.map(
|
||||||
(e) => PopupMenuItem(
|
(e) => ItemActionButton(
|
||||||
value: e,
|
selected: mediaStream.currentSubStream?.index == e.index,
|
||||||
padding: EdgeInsets.zero,
|
label: textWidget(
|
||||||
child: textWidget(context,
|
context,
|
||||||
selected: mediaStream.currentSubStream?.index == e.index, label: e.displayTitle),
|
label: e.displayTitle,
|
||||||
onTap: () => onSubIndexChanged?.call(e.index),
|
),
|
||||||
|
action: () => onSubIndexChanged?.call(e.index),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|
@ -92,22 +92,12 @@ class MediaStreamInformation extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget textWidget(BuildContext context, {required bool selected, required String label}) {
|
Widget textWidget(BuildContext context, {required String label}) {
|
||||||
return Container(
|
return Text(
|
||||||
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,
|
label,
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +105,7 @@ class MediaStreamInformation extends ConsumerWidget {
|
||||||
class _StreamOptionSelect<T> extends StatelessWidget {
|
class _StreamOptionSelect<T> extends StatelessWidget {
|
||||||
final Text label;
|
final Text label;
|
||||||
final String current;
|
final String current;
|
||||||
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
|
final List<ItemAction> Function(BuildContext context) itemBuilder;
|
||||||
const _StreamOptionSelect({
|
const _StreamOptionSelect({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.current,
|
required this.current,
|
||||||
|
|
@ -124,47 +114,14 @@ class _StreamOptionSelect<T> extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final textStyle = Theme.of(context).textTheme.titleMedium;
|
return Padding(
|
||||||
const padding = EdgeInsets.all(6);
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
final itemList = itemBuilder(context);
|
child: LabelTitleItem(
|
||||||
return LabelTitleItem(
|
|
||||||
title: label,
|
title: label,
|
||||||
content: Flexible(
|
content: Flexible(
|
||||||
child: PopupMenuButton(
|
child: EnumBox(
|
||||||
tooltip: '',
|
current: current,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
||||||
enabled: itemList.length > 1,
|
|
||||||
itemBuilder: itemBuilder,
|
itemBuilder: itemBuilder,
|
||||||
enableFeedback: false,
|
|
||||||
menuPadding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
padding: padding,
|
|
||||||
child: Padding(
|
|
||||||
padding: padding,
|
|
||||||
child: Material(
|
|
||||||
textStyle: textStyle?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: itemList.length > 1 ? Theme.of(context).colorScheme.primary : null),
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
current,
|
|
||||||
textAlign: TextAlign.start,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
if (itemList.length > 1)
|
|
||||||
Icon(
|
|
||||||
Icons.keyboard_arrow_down,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ class OverviewHeader extends ConsumerWidget {
|
||||||
final EdgeInsets? padding;
|
final EdgeInsets? padding;
|
||||||
final String? subTitle;
|
final String? subTitle;
|
||||||
final String? originalTitle;
|
final String? originalTitle;
|
||||||
|
final Alignment logoAlignment;
|
||||||
final Function()? onTitleClicked;
|
final Function()? onTitleClicked;
|
||||||
final int? productionYear;
|
final int? productionYear;
|
||||||
|
final String? summary;
|
||||||
final Duration? runTime;
|
final Duration? runTime;
|
||||||
final String? officialRating;
|
final String? officialRating;
|
||||||
final double? communityRating;
|
final double? communityRating;
|
||||||
|
|
@ -32,8 +34,10 @@ class OverviewHeader extends ConsumerWidget {
|
||||||
this.padding,
|
this.padding,
|
||||||
this.subTitle,
|
this.subTitle,
|
||||||
this.originalTitle,
|
this.originalTitle,
|
||||||
|
this.logoAlignment = Alignment.bottomCenter,
|
||||||
this.onTitleClicked,
|
this.onTitleClicked,
|
||||||
this.productionYear,
|
this.productionYear,
|
||||||
|
this.summary,
|
||||||
this.runTime,
|
this.runTime,
|
||||||
this.officialRating,
|
this.officialRating,
|
||||||
this.communityRating,
|
this.communityRating,
|
||||||
|
|
@ -68,12 +72,18 @@ class OverviewHeader extends ConsumerWidget {
|
||||||
crossAxisAlignment: crossAlignment,
|
crossAxisAlignment: crossAlignment,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
MediaHeader(
|
Flexible(
|
||||||
|
child: ExcludeFocus(
|
||||||
|
child: MediaHeader(
|
||||||
name: name,
|
name: name,
|
||||||
logo: image?.logo,
|
logo: image?.logo,
|
||||||
onTap: onTitleClicked,
|
onTap: onTitleClicked,
|
||||||
|
alignment: logoAlignment,
|
||||||
),
|
),
|
||||||
Column(
|
),
|
||||||
|
),
|
||||||
|
ExcludeFocus(
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: crossAlignment,
|
crossAxisAlignment: crossAlignment,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -93,7 +103,9 @@ class OverviewHeader extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
].addInBetween(const SizedBox(height: 4)),
|
].addInBetween(const SizedBox(height: 4)),
|
||||||
),
|
),
|
||||||
Column(
|
),
|
||||||
|
ExcludeFocus(
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: crossAlignment,
|
crossAxisAlignment: crossAlignment,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -140,12 +152,22 @@ class OverviewHeader extends ConsumerWidget {
|
||||||
backgroundColor: Theme.of(context).colorScheme.onSurface,
|
backgroundColor: Theme.of(context).colorScheme.onSurface,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
|
if (summary?.isNotEmpty == true)
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
summary ?? "",
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (genres.isNotEmpty)
|
if (genres.isNotEmpty)
|
||||||
Genres(
|
Genres(
|
||||||
genres: genres.take(6).toList(),
|
genres: genres.take(6).toList(),
|
||||||
),
|
),
|
||||||
].addInBetween(const SizedBox(height: 10)),
|
].addInBetween(const SizedBox(height: 10)),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (centerButtons != null) centerButtons!,
|
if (centerButtons != null) centerButtons!,
|
||||||
].addInBetween(const SizedBox(height: 21)),
|
].addInBetween(const SizedBox(height: 21)),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:iconsax_plus/iconsax_plus.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||||
|
|
||||||
import 'package:fladder/models/item_base_model.dart';
|
import 'package:fladder/models/item_base_model.dart';
|
||||||
import 'package:fladder/providers/items/episode_details_provider.dart';
|
import 'package:fladder/providers/items/episode_details_provider.dart';
|
||||||
|
|
@ -25,6 +25,8 @@ import 'package:fladder/util/localization_helper.dart';
|
||||||
import 'package:fladder/util/people_extension.dart';
|
import 'package:fladder/util/people_extension.dart';
|
||||||
import 'package:fladder/util/router_extension.dart';
|
import 'package:fladder/util/router_extension.dart';
|
||||||
import 'package:fladder/util/widget_extensions.dart';
|
import 'package:fladder/util/widget_extensions.dart';
|
||||||
|
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||||
|
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||||
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
||||||
|
|
||||||
class EpisodeDetailScreen extends ConsumerStatefulWidget {
|
class EpisodeDetailScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -77,7 +79,13 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
|
||||||
OverviewHeader(
|
OverviewHeader(
|
||||||
name: details.series?.name ?? "",
|
name: details.series?.name ?? "",
|
||||||
image: seasonDetails.images,
|
image: seasonDetails.images,
|
||||||
centerButtons: episodeDetails.playAble
|
centerButtons: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: wrapAlignment,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
episodeDetails.playAble
|
||||||
? MediaPlayButton(
|
? MediaPlayButton(
|
||||||
item: episodeDetails,
|
item: episodeDetails,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
|
@ -90,23 +98,6 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
padding: padding,
|
|
||||||
subTitle: details.episode?.detailedName(context),
|
|
||||||
originalTitle: details.series?.originalTitle,
|
|
||||||
onTitleClicked: () => details.series?.navigateTo(context),
|
|
||||||
productionYear: details.series?.overview.productionYear,
|
|
||||||
runTime: details.episode?.overview.runTime,
|
|
||||||
studios: details.series?.overview.studios ?? [],
|
|
||||||
genres: details.series?.overview.genreItems ?? [],
|
|
||||||
officialRating: details.series?.overview.parentalRating,
|
|
||||||
communityRating: details.series?.overview.communityRating,
|
|
||||||
),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
alignment: wrapAlignment,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
SelectableIconButton(
|
SelectableIconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await ref
|
await ref
|
||||||
|
|
@ -127,8 +118,34 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
|
||||||
selectedIcon: IconsaxPlusBold.tick_circle,
|
selectedIcon: IconsaxPlusBold.tick_circle,
|
||||||
icon: IconsaxPlusLinear.tick_circle,
|
icon: IconsaxPlusLinear.tick_circle,
|
||||||
),
|
),
|
||||||
].addPadding(const EdgeInsets.symmetric(horizontal: 6)),
|
SelectableIconButton(
|
||||||
).padding(padding),
|
onPressed: () async {
|
||||||
|
await showBottomSheetPill(
|
||||||
|
context: context,
|
||||||
|
content: (context, scrollController) => ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
shrinkWrap: true,
|
||||||
|
children:
|
||||||
|
episodeDetails.generateActions(context, ref).listTileItems(context, useIcons: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
icon: IconsaxPlusLinear.more,
|
||||||
|
),
|
||||||
|
].nonNulls.toList().addPadding(const EdgeInsets.symmetric(horizontal: 6)),
|
||||||
|
),
|
||||||
|
padding: padding,
|
||||||
|
subTitle: details.episode?.detailedName(context),
|
||||||
|
originalTitle: details.series?.originalTitle,
|
||||||
|
onTitleClicked: () => details.series?.navigateTo(context),
|
||||||
|
productionYear: details.series?.overview.productionYear,
|
||||||
|
runTime: details.episode?.overview.runTime,
|
||||||
|
studios: details.series?.overview.studios ?? [],
|
||||||
|
genres: details.series?.overview.genreItems ?? [],
|
||||||
|
officialRating: details.series?.overview.parentalRating,
|
||||||
|
communityRating: details.series?.overview.communityRating,
|
||||||
|
),
|
||||||
if (details.episode?.mediaStreams != null)
|
if (details.episode?.mediaStreams != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import 'package:fladder/util/item_base_model/play_item_helpers.dart';
|
||||||
import 'package:fladder/util/list_padding.dart';
|
import 'package:fladder/util/list_padding.dart';
|
||||||
import 'package:fladder/util/router_extension.dart';
|
import 'package:fladder/util/router_extension.dart';
|
||||||
import 'package:fladder/util/widget_extensions.dart';
|
import 'package:fladder/util/widget_extensions.dart';
|
||||||
|
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||||
|
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||||
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
||||||
|
|
||||||
class MovieDetailScreen extends ConsumerStatefulWidget {
|
class MovieDetailScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -71,7 +73,13 @@ class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
|
||||||
name: details.name,
|
name: details.name,
|
||||||
image: details.images,
|
image: details.images,
|
||||||
padding: padding,
|
padding: padding,
|
||||||
centerButtons: MediaPlayButton(
|
centerButtons: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: wrapAlignment,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
MediaPlayButton(
|
||||||
item: details,
|
item: details,
|
||||||
onLongPressed: () async {
|
onLongPressed: () async {
|
||||||
await details.play(
|
await details.play(
|
||||||
|
|
@ -89,20 +97,6 @@ class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
|
||||||
ref.read(providerInstance.notifier).fetchDetails(widget.item);
|
ref.read(providerInstance.notifier).fetchDetails(widget.item);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
originalTitle: details.originalTitle,
|
|
||||||
productionYear: details.overview.productionYear,
|
|
||||||
runTime: details.overview.runTime,
|
|
||||||
genres: details.overview.genreItems,
|
|
||||||
studios: details.overview.studios,
|
|
||||||
officialRating: details.overview.parentalRating,
|
|
||||||
communityRating: details.overview.communityRating,
|
|
||||||
),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
alignment: wrapAlignment,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
SelectableIconButton(
|
SelectableIconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await ref
|
await ref
|
||||||
|
|
@ -121,8 +115,30 @@ class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
|
||||||
selectedIcon: IconsaxPlusBold.tick_circle,
|
selectedIcon: IconsaxPlusBold.tick_circle,
|
||||||
icon: IconsaxPlusLinear.tick_circle,
|
icon: IconsaxPlusLinear.tick_circle,
|
||||||
),
|
),
|
||||||
|
SelectableIconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await showBottomSheetPill(
|
||||||
|
context: context,
|
||||||
|
content: (context, scrollController) => ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: details.generateActions(context, ref).listTileItems(context, useIcons: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
icon: IconsaxPlusLinear.more,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).padding(padding),
|
),
|
||||||
|
originalTitle: details.originalTitle,
|
||||||
|
productionYear: details.overview.productionYear,
|
||||||
|
runTime: details.overview.runTime,
|
||||||
|
genres: details.overview.genreItems,
|
||||||
|
studios: details.overview.studios,
|
||||||
|
officialRating: details.overview.parentalRating,
|
||||||
|
communityRating: details.overview.communityRating,
|
||||||
|
),
|
||||||
if (details.mediaStreams.isNotEmpty)
|
if (details.mediaStreams.isNotEmpty)
|
||||||
MediaStreamInformation(
|
MediaStreamInformation(
|
||||||
onVersionIndexChanged: (index) {
|
onVersionIndexChanged: (index) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:iconsax_plus/iconsax_plus.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||||
|
|
||||||
import 'package:fladder/models/item_base_model.dart';
|
import 'package:fladder/models/item_base_model.dart';
|
||||||
import 'package:fladder/models/items/series_model.dart';
|
import 'package:fladder/models/items/series_model.dart';
|
||||||
|
|
@ -25,6 +25,8 @@ import 'package:fladder/util/list_padding.dart';
|
||||||
import 'package:fladder/util/localization_helper.dart';
|
import 'package:fladder/util/localization_helper.dart';
|
||||||
import 'package:fladder/util/router_extension.dart';
|
import 'package:fladder/util/router_extension.dart';
|
||||||
import 'package:fladder/util/widget_extensions.dart';
|
import 'package:fladder/util/widget_extensions.dart';
|
||||||
|
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||||
|
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
||||||
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
import 'package:fladder/widgets/shared/selectable_icon_button.dart';
|
||||||
|
|
||||||
class SeriesDetailScreen extends ConsumerStatefulWidget {
|
class SeriesDetailScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -74,7 +76,13 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
||||||
OverviewHeader(
|
OverviewHeader(
|
||||||
name: details.name,
|
name: details.name,
|
||||||
image: details.images,
|
image: details.images,
|
||||||
centerButtons: MediaPlayButton(
|
centerButtons: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: wrapAlignment,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
MediaPlayButton(
|
||||||
item: details.nextUp,
|
item: details.nextUp,
|
||||||
onPressed: details.nextUp != null
|
onPressed: details.nextUp != null
|
||||||
? () async {
|
? () async {
|
||||||
|
|
@ -89,21 +97,6 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
padding: padding,
|
|
||||||
originalTitle: details.originalTitle,
|
|
||||||
productionYear: details.overview.productionYear,
|
|
||||||
runTime: details.overview.runTime,
|
|
||||||
studios: details.overview.studios,
|
|
||||||
officialRating: details.overview.parentalRating,
|
|
||||||
genres: details.overview.genreItems,
|
|
||||||
communityRating: details.overview.communityRating,
|
|
||||||
),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
alignment: wrapAlignment,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
SelectableIconButton(
|
SelectableIconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await ref
|
await ref
|
||||||
|
|
@ -122,8 +115,31 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
||||||
selectedIcon: IconsaxPlusBold.tick_circle,
|
selectedIcon: IconsaxPlusBold.tick_circle,
|
||||||
icon: IconsaxPlusLinear.tick_circle,
|
icon: IconsaxPlusLinear.tick_circle,
|
||||||
),
|
),
|
||||||
|
SelectableIconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await showBottomSheetPill(
|
||||||
|
context: context,
|
||||||
|
content: (context, scrollController) => ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: details.generateActions(context, ref).listTileItems(context, useIcons: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
icon: IconsaxPlusLinear.more,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).padding(padding),
|
),
|
||||||
|
padding: padding,
|
||||||
|
originalTitle: details.originalTitle,
|
||||||
|
productionYear: details.overview.productionYear,
|
||||||
|
runTime: details.overview.runTime,
|
||||||
|
studios: details.overview.studios,
|
||||||
|
officialRating: details.overview.parentalRating,
|
||||||
|
genres: details.overview.genreItems,
|
||||||
|
communityRating: details.overview.communityRating,
|
||||||
|
),
|
||||||
if (details.nextUp != null)
|
if (details.nextUp != null)
|
||||||
NextUpEpisode(
|
NextUpEpisode(
|
||||||
nextEpisode: details.nextUp!,
|
nextEpisode: details.nextUp!,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:fladder/models/library_filter_model.dart';
|
import 'package:fladder/models/library_filter_model.dart';
|
||||||
|
|
@ -12,6 +13,7 @@ import 'package:fladder/screens/shared/media/poster_row.dart';
|
||||||
import 'package:fladder/screens/shared/nested_scaffold.dart';
|
import 'package:fladder/screens/shared/nested_scaffold.dart';
|
||||||
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
|
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
|
||||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||||
|
import 'package:fladder/util/focus_provider.dart';
|
||||||
import 'package:fladder/util/localization_helper.dart';
|
import 'package:fladder/util/localization_helper.dart';
|
||||||
import 'package:fladder/util/sliver_list_padding.dart';
|
import 'package:fladder/util/sliver_list_padding.dart';
|
||||||
import 'package:fladder/widgets/navigation_scaffold/components/background_image.dart';
|
import 'package:fladder/widgets/navigation_scaffold/components/background_image.dart';
|
||||||
|
|
@ -54,9 +56,9 @@ class FavouritesScreen extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
...[
|
||||||
...favourites.favourites.entries.where((element) => element.value.isNotEmpty).map(
|
...favourites.favourites.entries.where((element) => element.value.isNotEmpty).map(
|
||||||
(e) => SliverToBoxAdapter(
|
(e) => PosterRow(
|
||||||
child: PosterRow(
|
|
||||||
contentPadding: padding,
|
contentPadding: padding,
|
||||||
onLabelClick: () => context.pushRoute(
|
onLabelClick: () => context.pushRoute(
|
||||||
LibrarySearchRoute().withFilter(
|
LibrarySearchRoute().withFilter(
|
||||||
|
|
@ -71,14 +73,16 @@ class FavouritesScreen extends ConsumerWidget {
|
||||||
posters: e.value,
|
posters: e.value,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (favourites.people.isNotEmpty)
|
if (favourites.people.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
PosterRow(
|
||||||
child: PosterRow(
|
|
||||||
contentPadding: padding,
|
contentPadding: padding,
|
||||||
label: context.localized.actor(favourites.people.length),
|
label: context.localized.actor(favourites.people.length),
|
||||||
posters: favourites.people,
|
posters: favourites.people,
|
||||||
),
|
),
|
||||||
|
].mapIndexed(
|
||||||
|
(index, e) => SliverToBoxAdapter(
|
||||||
|
child: FocusProvider(hasFocus: false, autoFocus: index == 0, child: e),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const DefautlSliverBottomPadding(),
|
const DefautlSliverBottomPadding(),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@ import 'package:fladder/providers/library_screen_provider.dart';
|
||||||
import 'package:fladder/routes/auto_router.gr.dart';
|
import 'package:fladder/routes/auto_router.gr.dart';
|
||||||
import 'package:fladder/screens/home_screen.dart';
|
import 'package:fladder/screens/home_screen.dart';
|
||||||
import 'package:fladder/screens/metadata/refresh_metadata.dart';
|
import 'package:fladder/screens/metadata/refresh_metadata.dart';
|
||||||
import 'package:fladder/screens/shared/flat_button.dart';
|
|
||||||
import 'package:fladder/screens/shared/media/poster_row.dart';
|
import 'package:fladder/screens/shared/media/poster_row.dart';
|
||||||
import 'package:fladder/screens/shared/nested_scaffold.dart';
|
import 'package:fladder/screens/shared/nested_scaffold.dart';
|
||||||
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
|
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
|
||||||
import 'package:fladder/theme.dart';
|
import 'package:fladder/theme.dart';
|
||||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||||
import 'package:fladder/util/fladder_image.dart';
|
import 'package:fladder/util/fladder_image.dart';
|
||||||
|
import 'package:fladder/util/focus_provider.dart';
|
||||||
import 'package:fladder/util/localization_helper.dart';
|
import 'package:fladder/util/localization_helper.dart';
|
||||||
import 'package:fladder/util/sliver_list_padding.dart';
|
import 'package:fladder/util/sliver_list_padding.dart';
|
||||||
import 'package:fladder/widgets/navigation_scaffold/components/background_image.dart';
|
import 'package:fladder/widgets/navigation_scaffold/components/background_image.dart';
|
||||||
|
|
@ -233,9 +233,10 @@ class LibraryRow extends ConsumerWidget {
|
||||||
label: context.localized.library(views.length),
|
label: context.localized.library(views.length),
|
||||||
items: views,
|
items: views,
|
||||||
height: 155,
|
height: 155,
|
||||||
|
autoFocus: true,
|
||||||
startIndex: selectedView != null ? views.indexOf(selectedView!) : null,
|
startIndex: selectedView != null ? views.indexOf(selectedView!) : null,
|
||||||
contentPadding: padding,
|
contentPadding: padding,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index, selected) {
|
||||||
final view = views[index];
|
final view = views[index];
|
||||||
final isSelected = selectedView == view;
|
final isSelected = selectedView == view;
|
||||||
final List<ItemActionButton> viewActions = [
|
final List<ItemActionButton> viewActions = [
|
||||||
|
|
@ -250,7 +251,13 @@ class LibraryRow extends ConsumerWidget {
|
||||||
action: () => showRefreshPopup(context, view.id, view.name),
|
action: () => showRefreshPopup(context, view.id, view.name),
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
return FlatButton(
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
FocusButton(
|
||||||
|
key: Key(view.id),
|
||||||
onTap: isSelected ? null : () => onSelected?.call(view),
|
onTap: isSelected ? null : () => onSelected?.call(view),
|
||||||
onLongPress: () => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)),
|
onLongPress: () => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)),
|
||||||
onSecondaryTapDown: (details) async {
|
onSecondaryTapDown: (details) async {
|
||||||
|
|
@ -263,12 +270,7 @@ class LibraryRow extends ConsumerWidget {
|
||||||
items: viewActions.popupMenuItems(useIcons: true),
|
items: viewActions.popupMenuItems(useIcons: true),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Container(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: FladderTheme.defaultShape.borderRadius,
|
borderRadius: FladderTheme.defaultShape.borderRadius,
|
||||||
),
|
),
|
||||||
|
|
@ -294,6 +296,7 @@ class LibraryRow extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
view.name,
|
view.name,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
|
@ -302,7 +305,6 @@ class LibraryRow extends ConsumerWidget {
|
||||||
textAlign: TextAlign.start,
|
textAlign: TextAlign.start,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -352,29 +352,8 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: librarySearchResults.nestedCurrentItem?.type.label(context) ??
|
message: librarySearchResults.nestedCurrentItem?.type.label(context) ??
|
||||||
context.localized.library(1),
|
context.localized.library(1),
|
||||||
child: InkWell(
|
child: IconButton(
|
||||||
onTapUp: (details) async {
|
onPressed: () async {
|
||||||
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
|
|
||||||
double left = details.globalPosition.dx;
|
|
||||||
double top = details.globalPosition.dy;
|
|
||||||
await showMenu(
|
|
||||||
context: context,
|
|
||||||
position: RelativeRect.fromLTRB(left, top, 40, 100),
|
|
||||||
items: <PopupMenuEntry>[
|
|
||||||
PopupMenuItem(
|
|
||||||
child: Text(librarySearchResults.nestedCurrentItem?.type.label(context) ??
|
|
||||||
context.localized.library(0))),
|
|
||||||
itemCountWidget.toPopupMenuItem(useIcons: true),
|
|
||||||
refreshAction.toPopupMenuItem(useIcons: true),
|
|
||||||
itemViewAction.toPopupMenuItem(useIcons: true),
|
|
||||||
if (librarySearchResults.views.hasEnabled == true)
|
|
||||||
showSavedFiltersDialogue.toPopupMenuItem(useIcons: true),
|
|
||||||
if (itemActions.isNotEmpty) ItemActionDivider().toPopupMenuItem(),
|
|
||||||
...itemActions.popupMenuItems(useIcons: true),
|
|
||||||
],
|
|
||||||
elevation: 8.0,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await showBottomSheetPill(
|
await showBottomSheetPill(
|
||||||
context: context,
|
context: context,
|
||||||
content: (context, scrollController) => ListView(
|
content: (context, scrollController) => ListView(
|
||||||
|
|
@ -385,16 +364,15 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
|
||||||
refreshAction.toListItem(context, useIcons: true),
|
refreshAction.toListItem(context, useIcons: true),
|
||||||
itemViewAction.toListItem(context, useIcons: true),
|
itemViewAction.toListItem(context, useIcons: true),
|
||||||
if (librarySearchResults.views.hasEnabled == true)
|
if (librarySearchResults.views.hasEnabled == true)
|
||||||
showSavedFiltersDialogue.toPopupMenuItem(useIcons: true),
|
showSavedFiltersDialogue.toListItem(context, useIcons: true),
|
||||||
if (itemActions.isNotEmpty) ItemActionDivider().toListItem(context),
|
if (itemActions.isNotEmpty) ItemActionDivider().toListItem(context),
|
||||||
...itemActions.listTileItems(context, useIcons: true),
|
...itemActions.listTileItems(context, useIcons: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: Padding(
|
icon: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(6),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
isFavorite
|
isFavorite
|
||||||
? librarySearchResults.nestedCurrentItem?.type.selectedicon
|
? librarySearchResults.nestedCurrentItem?.type.selectedicon
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,9 @@ class _LibraryFilterChipsState extends ConsumerState<LibraryFilterChips> {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return Row(
|
return FocusTraversalGroup(
|
||||||
|
policy: ReadingOrderTraversalPolicy(),
|
||||||
|
child: Row(
|
||||||
spacing: 4,
|
spacing: 4,
|
||||||
children: chips.mapIndexed(
|
children: chips.mapIndexed(
|
||||||
(index, element) {
|
(index, element) {
|
||||||
|
|
@ -159,6 +161,7 @@ class _LibraryFilterChipsState extends ConsumerState<LibraryFilterChips> {
|
||||||
return PositionProvider(position: position, child: element);
|
return PositionProvider(position: position, child: element);
|
||||||
},
|
},
|
||||||
).toList(),
|
).toList(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,14 @@ import 'package:fladder/routes/auto_router.gr.dart';
|
||||||
import 'package:fladder/screens/shared/media/poster_list_item.dart';
|
import 'package:fladder/screens/shared/media/poster_list_item.dart';
|
||||||
import 'package:fladder/screens/shared/media/poster_widget.dart';
|
import 'package:fladder/screens/shared/media/poster_widget.dart';
|
||||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||||
|
import 'package:fladder/util/focus_provider.dart';
|
||||||
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
|
||||||
import 'package:fladder/util/localization_helper.dart';
|
import 'package:fladder/util/localization_helper.dart';
|
||||||
import 'package:fladder/util/refresh_state.dart';
|
import 'package:fladder/util/refresh_state.dart';
|
||||||
import 'package:fladder/util/string_extensions.dart';
|
import 'package:fladder/util/string_extensions.dart';
|
||||||
import 'package:fladder/util/theme_extensions.dart';
|
import 'package:fladder/util/theme_extensions.dart';
|
||||||
|
import 'package:fladder/widgets/shared/ensure_visible.dart';
|
||||||
|
import 'package:fladder/widgets/shared/grid_focus_traveler.dart';
|
||||||
import 'package:fladder/widgets/shared/item_actions.dart';
|
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||||
|
|
||||||
final libraryViewTypeProvider = StateProvider<LibraryViewTypes>((ref) {
|
final libraryViewTypeProvider = StateProvider<LibraryViewTypes>((ref) {
|
||||||
|
|
@ -63,12 +66,12 @@ class LibraryViews extends ConsumerWidget {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
sliver: SliverAnimatedSwitcher(
|
sliver: SliverAnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
child: _getWidget(ref, context),
|
child: _getWidget(context, ref),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _getWidget(WidgetRef ref, BuildContext context) {
|
Widget _getWidget(BuildContext context, WidgetRef ref) {
|
||||||
final selected = ref.watch(librarySearchProvider(key!).select((value) => value.selectedPosters));
|
final selected = ref.watch(librarySearchProvider(key!).select((value) => value.selectedPosters));
|
||||||
final posterSizeMultiplier = ref.watch(clientSettingsProvider.select((value) => value.posterSize));
|
final posterSizeMultiplier = ref.watch(clientSettingsProvider.select((value) => value.posterSize));
|
||||||
final libraryProvider = ref.read(librarySearchProvider(key!).notifier);
|
final libraryProvider = ref.read(librarySearchProvider(key!).notifier);
|
||||||
|
|
@ -111,21 +114,24 @@ class LibraryViews extends ConsumerWidget {
|
||||||
switch (ref.watch(libraryViewTypeProvider)) {
|
switch (ref.watch(libraryViewTypeProvider)) {
|
||||||
case LibraryViewTypes.grid:
|
case LibraryViewTypes.grid:
|
||||||
Widget createGrid(List<ItemBaseModel> items) {
|
Widget createGrid(List<ItemBaseModel> items) {
|
||||||
return SliverGrid.builder(
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
final cellWidth = (width / posterSize).floorToDouble();
|
||||||
|
final crossAxisCount = ((width / cellWidth).floor()).clamp(2, 10);
|
||||||
|
return GridFocusTraveler(
|
||||||
|
itemCount: items.length,
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: posterSize.clamp(2, double.maxFinite).toInt(),
|
crossAxisCount: crossAxisCount,
|
||||||
mainAxisSpacing: (8 * decimal) + 8,
|
crossAxisSpacing: 8,
|
||||||
crossAxisSpacing: (8 * decimal) + 8,
|
mainAxisSpacing: 8,
|
||||||
childAspectRatio: items.getMostCommonType.aspectRatio,
|
childAspectRatio: items.getMostCommonType.aspectRatio,
|
||||||
),
|
),
|
||||||
itemCount: items.length,
|
itemBuilder: (other, selectedIndex, index) {
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
return PosterWidget(
|
return PosterWidget(
|
||||||
key: Key(item.id),
|
key: Key(item.id),
|
||||||
poster: item,
|
poster: item,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
heroTag: true,
|
|
||||||
subTitle: item.subTitle(sortingOptions),
|
subTitle: item.subTitle(sortingOptions),
|
||||||
excludeActions: excludeActions,
|
excludeActions: excludeActions,
|
||||||
otherActions: otherActions(item),
|
otherActions: otherActions(item),
|
||||||
|
|
@ -134,6 +140,11 @@ class LibraryViews extends ConsumerWidget {
|
||||||
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
|
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
|
||||||
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
|
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
|
||||||
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
||||||
|
onFocusChanged: (focus) {
|
||||||
|
if (focus) {
|
||||||
|
other.ensureVisible();
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -165,7 +176,9 @@ class LibraryViews extends ConsumerWidget {
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final poster = items[index];
|
final poster = items[index];
|
||||||
return PosterListItem(
|
return FocusProvider(
|
||||||
|
autoFocus: index == 0,
|
||||||
|
child: PosterListItem(
|
||||||
poster: poster,
|
poster: poster,
|
||||||
selected: selected.contains(poster),
|
selected: selected.contains(poster),
|
||||||
excludeActions: excludeActions,
|
excludeActions: excludeActions,
|
||||||
|
|
@ -175,6 +188,7 @@ class LibraryViews extends ConsumerWidget {
|
||||||
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
|
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
|
||||||
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
|
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
|
||||||
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -228,7 +242,6 @@ class LibraryViews extends ConsumerWidget {
|
||||||
aspectRatio: item.primaryRatio,
|
aspectRatio: item.primaryRatio,
|
||||||
selected: selected.contains(item),
|
selected: selected.contains(item),
|
||||||
inlineTitle: true,
|
inlineTitle: true,
|
||||||
heroTag: true,
|
|
||||||
subTitle: item.subTitle(sortingOptions),
|
subTitle: item.subTitle(sortingOptions),
|
||||||
excludeActions: excludeActions,
|
excludeActions: excludeActions,
|
||||||
otherActions: otherActions(group[index]),
|
otherActions: otherActions(group[index]),
|
||||||
|
|
@ -257,7 +270,6 @@ class LibraryViews extends ConsumerWidget {
|
||||||
aspectRatio: item.primaryRatio,
|
aspectRatio: item.primaryRatio,
|
||||||
selected: selected.contains(item),
|
selected: selected.contains(item),
|
||||||
inlineTitle: true,
|
inlineTitle: true,
|
||||||
heroTag: true,
|
|
||||||
excludeActions: excludeActions,
|
excludeActions: excludeActions,
|
||||||
otherActions: otherActions(item),
|
otherActions: otherActions(item),
|
||||||
subTitle: item.subTitle(sortingOptions),
|
subTitle: item.subTitle(sortingOptions),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:page_transition/page_transition.dart';
|
||||||
|
|
||||||
import 'package:fladder/models/item_base_model.dart';
|
import 'package:fladder/models/item_base_model.dart';
|
||||||
import 'package:fladder/providers/library_search_provider.dart';
|
import 'package:fladder/providers/library_search_provider.dart';
|
||||||
|
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||||
import 'package:fladder/theme.dart';
|
import 'package:fladder/theme.dart';
|
||||||
import 'package:fladder/util/debouncer.dart';
|
import 'package:fladder/util/debouncer.dart';
|
||||||
import 'package:fladder/util/fladder_image.dart';
|
import 'package:fladder/util/fladder_image.dart';
|
||||||
|
|
@ -87,7 +88,7 @@ class _SearchBarState extends ConsumerState<SuggestionSearchBar> {
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
builder: (context, controller, focusNode) => TextField(
|
builder: (context, controller, focusNode) => OutlinedTextField(
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
onSubmitted: (value) {
|
onSubmitted: (value) {
|
||||||
|
|
@ -99,6 +100,7 @@ class _SearchBarState extends ConsumerState<SuggestionSearchBar> {
|
||||||
isEmpty = value.isEmpty;
|
isEmpty = value.isEmpty;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
placeHolder: widget.title ?? "${context.localized.search}...",
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: widget.title ?? "${context.localized.search}...",
|
hintText: widget.title ?? "${context.localized.search}...",
|
||||||
prefixIcon: const Icon(IconsaxPlusLinear.search_normal),
|
prefixIcon: const Icon(IconsaxPlusLinear.search_normal),
|
||||||
|
|
|
||||||
0
lib/screens/login/controllers/login_controller.dart
Normal file
0
lib/screens/login/controllers/login_controller.dart
Normal file
140
lib/screens/login/login_code_dialog.dart
Normal file
140
lib/screens/login/login_code_dialog.dart
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:async/async.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
||||||
|
import 'package:fladder/providers/api_provider.dart';
|
||||||
|
import 'package:fladder/providers/auth_provider.dart';
|
||||||
|
import 'package:fladder/util/list_padding.dart';
|
||||||
|
import 'package:fladder/util/localization_helper.dart';
|
||||||
|
|
||||||
|
Future<void> openLoginCodeDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required QuickConnectResult quickConnectInfo,
|
||||||
|
required Function(BuildContext context, String secret) onAuthenticated,
|
||||||
|
}) {
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => LoginCodeDialog(
|
||||||
|
quickConnectInfo: quickConnectInfo,
|
||||||
|
onAuthenticated: onAuthenticated,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginCodeDialog extends ConsumerStatefulWidget {
|
||||||
|
final QuickConnectResult quickConnectInfo;
|
||||||
|
final Function(BuildContext context, String secret) onAuthenticated;
|
||||||
|
const LoginCodeDialog({
|
||||||
|
required this.quickConnectInfo,
|
||||||
|
required this.onAuthenticated,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<LoginCodeDialog> createState() => _LoginCodeDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginCodeDialogState extends ConsumerState<LoginCodeDialog> {
|
||||||
|
late QuickConnectResult quickConnectInfo = widget.quickConnectInfo;
|
||||||
|
|
||||||
|
RestartableTimer? timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
createTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void createTimer() {
|
||||||
|
timer?.cancel();
|
||||||
|
timer = RestartableTimer(const Duration(seconds: 1), () async {
|
||||||
|
final result = await ref.read(jellyApiProvider).quickConnectConnectGet(
|
||||||
|
secret: quickConnectInfo.secret,
|
||||||
|
);
|
||||||
|
final newSecret = result.body?.secret;
|
||||||
|
if (result.isSuccessful && result.body?.authenticated == true && newSecret != null) {
|
||||||
|
widget.onAuthenticated.call(context, newSecret);
|
||||||
|
} else {
|
||||||
|
timer?.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final code = quickConnectInfo.code;
|
||||||
|
final serverName = ref.watch(authProvider.select((value) => value.serverLoginModel?.tempCredentials.serverName));
|
||||||
|
return Dialog(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: 16,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
serverName?.isNotEmpty == true
|
||||||
|
? "${context.localized.quickConnectTitle} - $serverName"
|
||||||
|
: context.localized.quickConnectTitle,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
if (code != null) ...[
|
||||||
|
Text(
|
||||||
|
context.localized.quickConnectEnterCodeDescription,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
IntrinsicWidth(
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Text(
|
||||||
|
code,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
wordSpacing: 8,
|
||||||
|
letterSpacing: 8,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
semanticsLabel: code,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final response = await ref.read(jellyApiProvider).quickConnectInitiate();
|
||||||
|
if (response.isSuccessful && response.body != null) {
|
||||||
|
setState(() {
|
||||||
|
quickConnectInfo = response.body!;
|
||||||
|
});
|
||||||
|
createTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
context.localized.refresh,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
].addInBetween(const SizedBox(height: 16)),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
|
@ -7,25 +5,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:iconsax_plus/iconsax_plus.dart';
|
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||||
|
|
||||||
import 'package:fladder/models/account_model.dart';
|
import 'package:fladder/models/account_model.dart';
|
||||||
|
import 'package:fladder/models/login_screen_model.dart';
|
||||||
import 'package:fladder/providers/auth_provider.dart';
|
import 'package:fladder/providers/auth_provider.dart';
|
||||||
import 'package:fladder/providers/shared_provider.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/screens/login/login_edit_user.dart';
|
import 'package:fladder/screens/login/login_edit_user.dart';
|
||||||
|
import 'package:fladder/screens/login/login_screen_credentials.dart';
|
||||||
import 'package:fladder/screens/login/login_user_grid.dart';
|
import 'package:fladder/screens/login/login_user_grid.dart';
|
||||||
import 'package:fladder/screens/login/widgets/discover_servers_widget.dart';
|
|
||||||
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||||
import 'package:fladder/screens/shared/fladder_logo.dart';
|
import 'package:fladder/screens/shared/fladder_logo.dart';
|
||||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
|
||||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
|
||||||
import 'package:fladder/screens/shared/passcode_input.dart';
|
|
||||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||||
import 'package:fladder/util/auth_service.dart';
|
|
||||||
import 'package:fladder/util/fladder_config.dart';
|
|
||||||
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/navigation_scaffold/components/fladder_app_bar.dart';
|
import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
|
|
@ -37,373 +24,85 @@ class LoginScreen extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginPageState extends ConsumerState<LoginScreen> {
|
class _LoginPageState extends ConsumerState<LoginScreen> {
|
||||||
List<AccountModel> users = const [];
|
|
||||||
bool loading = false;
|
|
||||||
String? invalidUrl = "";
|
|
||||||
bool startCheckingForErrors = false;
|
|
||||||
bool addingNewUser = false;
|
|
||||||
bool editingUsers = false;
|
|
||||||
late final TextEditingController serverTextController = TextEditingController(text: '');
|
late final TextEditingController serverTextController = TextEditingController(text: '');
|
||||||
|
|
||||||
final usernameController = TextEditingController();
|
final usernameController = TextEditingController();
|
||||||
final passwordController = TextEditingController();
|
final passwordController = TextEditingController();
|
||||||
final FocusNode focusNode = FocusNode();
|
final FocusNode focusNode = FocusNode();
|
||||||
|
bool editUsersMode = false;
|
||||||
void startAddingNewUser() {
|
|
||||||
setState(() {
|
|
||||||
addingNewUser = true;
|
|
||||||
editingUsers = false;
|
|
||||||
});
|
|
||||||
if (FladderConfig.baseUrl != null) {
|
|
||||||
serverTextController.text = FladderConfig.baseUrl!;
|
|
||||||
_parseUrl(FladderConfig.baseUrl!);
|
|
||||||
retrieveListOfUsers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
Future.microtask(() {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ref.read(userProvider.notifier).clear();
|
ref.read(authProvider.notifier).initModel(context);
|
||||||
final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts();
|
|
||||||
addingNewUser = currentAccounts.isEmpty;
|
|
||||||
ref.read(lockScreenActiveProvider.notifier).update((state) => true);
|
|
||||||
if (FladderConfig.baseUrl != null) {
|
|
||||||
serverTextController.text = FladderConfig.baseUrl!;
|
|
||||||
_parseUrl(FladderConfig.baseUrl!);
|
|
||||||
retrieveListOfUsers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final loggedInUsers = ref.watch(authProvider.select((value) => value.accounts));
|
final screen = ref.watch(authProvider.select((value) => value.screen));
|
||||||
final authLoading = ref.watch(authProvider.select((value) => value.loading));
|
final accounts = ref.watch(authProvider.select((value) => value.accounts));
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const FladderAppBar(),
|
appBar: const FladderAppBar(),
|
||||||
floatingActionButton: !addingNewUser
|
extendBody: true,
|
||||||
? Row(
|
extendBodyBehindAppBar: true,
|
||||||
|
floatingActionButton: switch (screen) {
|
||||||
|
LoginScreenType.users => Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
spacing: 16,
|
||||||
children: [
|
children: [
|
||||||
if (!AdaptiveLayout.of(context).isDesktop)
|
if (!AdaptiveLayout.of(context).isDesktop)
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
key: const Key("edit_button"),
|
key: const Key("edit_user_button"),
|
||||||
heroTag: "edit_button",
|
heroTag: "edit_user_button",
|
||||||
backgroundColor: editingUsers ? Theme.of(context).colorScheme.errorContainer : null,
|
backgroundColor: editUsersMode ? Theme.of(context).colorScheme.errorContainer : null,
|
||||||
child: const Icon(IconsaxPlusLinear.edit_2),
|
child: const Icon(IconsaxPlusLinear.edit_2),
|
||||||
onPressed: () => setState(() => editingUsers = !editingUsers),
|
onPressed: () => setState(() => editUsersMode = !editUsersMode),
|
||||||
),
|
),
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
key: const Key("new_button"),
|
key: const Key("new_user_button"),
|
||||||
heroTag: "new_button",
|
heroTag: "new_user_button",
|
||||||
child: const Icon(IconsaxPlusLinear.add_square),
|
child: const Icon(IconsaxPlusLinear.add_square),
|
||||||
onPressed: startAddingNewUser,
|
onPressed: () => ref.read(authProvider.notifier).addNewUser(),
|
||||||
),
|
|
||||||
].addInBetween(const SizedBox(width: 16)),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
body: Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 900),
|
|
||||||
child: ListView(
|
|
||||||
shrinkWrap: true,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
|
|
||||||
children: [
|
|
||||||
const Center(
|
|
||||||
child: FladderLogo(),
|
|
||||||
),
|
|
||||||
AnimatedFadeSize(
|
|
||||||
child: addingNewUser
|
|
||||||
? addUserFields(loggedInUsers, authLoading)
|
|
||||||
: Column(
|
|
||||||
key: UniqueKey(),
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
LoginUserGrid(
|
|
||||||
users: loggedInUsers,
|
|
||||||
editMode: editingUsers,
|
|
||||||
onPressed: (user) async => tapLoggedInAccount(user),
|
|
||||||
onLongPress: (user) => openUserEditDialogue(context, user),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
body: Center(
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: MediaQuery.paddingOf(context).add(const EdgeInsetsGeometry.all(16)),
|
||||||
|
children: [
|
||||||
|
const FladderLogo(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
AnimatedFadeSize(
|
||||||
|
child: switch (screen) {
|
||||||
|
LoginScreenType.login || LoginScreenType.code => const LoginScreenCredentials(),
|
||||||
|
_ => LoginUserGrid(
|
||||||
|
users: accounts,
|
||||||
|
editMode: editUsersMode,
|
||||||
|
onPressed: (user) => tapLoggedInAccount(context, user, ref),
|
||||||
|
onLongPress: (user) => openUserEditDialogue(context, user),
|
||||||
),
|
),
|
||||||
].addPadding(const EdgeInsets.symmetric(vertical: 16)),
|
},
|
||||||
),
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _parseUrl(String url) {
|
|
||||||
setState(() {
|
|
||||||
ref.read(authProvider.notifier).setServer("");
|
|
||||||
users = [];
|
|
||||||
|
|
||||||
if (url.isEmpty) {
|
|
||||||
invalidUrl = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Uri.parse(url).isAbsolute) {
|
|
||||||
invalidUrl = context.localized.invalidUrl;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url.startsWith('https://') && !url.startsWith('http://')) {
|
|
||||||
invalidUrl = context.localized.invalidUrlDesc;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidUrl = null;
|
|
||||||
|
|
||||||
if (invalidUrl == null) {
|
|
||||||
ref.read(authProvider.notifier).setServer(url.rtrim('/'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void openUserEditDialogue(BuildContext context, AccountModel user) {
|
void openUserEditDialogue(BuildContext context, AccountModel user) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => LoginEditUser(
|
builder: (context) => LoginEditUser(
|
||||||
user: user,
|
user: user,
|
||||||
onTapServer: (value) {
|
onTapServer: (value) {
|
||||||
setState(() {
|
ref.read(authProvider.notifier).setServer(value);
|
||||||
_parseUrl(value);
|
|
||||||
serverTextController.text = value;
|
|
||||||
startAddingNewUser();
|
|
||||||
});
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void tapLoggedInAccount(AccountModel user) async {
|
|
||||||
switch (user.authMethod) {
|
|
||||||
case Authentication.autoLogin:
|
|
||||||
handleLogin(user);
|
|
||||||
break;
|
|
||||||
case Authentication.biometrics:
|
|
||||||
final authenticated = await AuthService.authenticateUser(context, user);
|
|
||||||
if (authenticated) {
|
|
||||||
handleLogin(user);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Authentication.passcode:
|
|
||||||
if (context.mounted) {
|
|
||||||
showPassCodeDialog(context, (newPin) {
|
|
||||||
if (newPin == user.localPin) {
|
|
||||||
handleLogin(user);
|
|
||||||
} else {
|
|
||||||
fladderSnackbar(context, title: context.localized.incorrectPinTryAgain);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Authentication.none:
|
|
||||||
handleLogin(user);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> handleLogin(AccountModel user) async {
|
|
||||||
await ref.read(authProvider.notifier).switchUser();
|
|
||||||
await ref.read(sharedUtilityProvider).updateAccountInfo(user.copyWith(
|
|
||||||
lastUsed: DateTime.now(),
|
|
||||||
));
|
|
||||||
ref.read(userProvider.notifier).updateUser(user.copyWith(lastUsed: DateTime.now()));
|
|
||||||
|
|
||||||
loggedInGoToHome();
|
|
||||||
}
|
|
||||||
|
|
||||||
void loggedInGoToHome() {
|
|
||||||
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
|
|
||||||
if (context.mounted) {
|
|
||||||
context.router.replaceAll([const DashboardRoute()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Null> Function()? get enterCredentialsTryLogin => emptyFields()
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
serverTextController.text = serverTextController.text.rtrim('/');
|
|
||||||
ref.read(authProvider.notifier).setServer(serverTextController.text.rtrim('/'));
|
|
||||||
final response = await ref.read(authProvider.notifier).authenticateByName(
|
|
||||||
usernameController.text,
|
|
||||||
passwordController.text,
|
|
||||||
);
|
|
||||||
if (response?.isSuccessful == false) {
|
|
||||||
fladderSnackbar(context,
|
|
||||||
title:
|
|
||||||
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
|
|
||||||
} else if (response?.body != null) {
|
|
||||||
loggedInGoToHome();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
bool emptyFields() => usernameController.text.isEmpty;
|
|
||||||
|
|
||||||
void retrieveListOfUsers() async {
|
|
||||||
serverTextController.text = serverTextController.text.rtrim('/');
|
|
||||||
ref.read(authProvider.notifier).setServer(serverTextController.text);
|
|
||||||
setState(() => loading = true);
|
|
||||||
final response = await ref.read(authProvider.notifier).getPublicUsers();
|
|
||||||
if ((response == null || response.isSuccessful == false) && context.mounted) {
|
|
||||||
fladderSnackbar(context, title: response?.base.reasonPhrase ?? context.localized.unableToConnectHost);
|
|
||||||
setState(() => startCheckingForErrors = true);
|
|
||||||
}
|
|
||||||
if (response?.body?.isEmpty == true) {
|
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
users = response?.body ?? [];
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget addUserFields(List<AccountModel> accounts, bool authLoading) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (accounts.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: IconButton.filledTonal(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
addingNewUser = false;
|
|
||||||
loading = false;
|
|
||||||
startCheckingForErrors = false;
|
|
||||||
serverTextController.text = "";
|
|
||||||
usernameController.text = "";
|
|
||||||
passwordController.text = "";
|
|
||||||
invalidUrl = "";
|
|
||||||
});
|
|
||||||
ref.read(authProvider.notifier).setServer("");
|
|
||||||
},
|
|
||||||
icon: const Icon(
|
|
||||||
IconsaxPlusLinear.arrow_left_2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (FladderConfig.baseUrl == null) ...[
|
|
||||||
Flexible(
|
|
||||||
child: OutlinedTextField(
|
|
||||||
controller: serverTextController,
|
|
||||||
onChanged: _parseUrl,
|
|
||||||
onSubmitted: (value) => retrieveListOfUsers(),
|
|
||||||
autoFillHints: const [AutofillHints.url],
|
|
||||||
keyboardType: TextInputType.url,
|
|
||||||
autocorrect: false,
|
|
||||||
textInputAction: TextInputAction.go,
|
|
||||||
label: context.localized.server,
|
|
||||||
errorText: (invalidUrl == null || serverTextController.text.isEmpty || !startCheckingForErrors)
|
|
||||||
? null
|
|
||||||
: invalidUrl,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Tooltip(
|
|
||||||
message: context.localized.retrievePublicListOfUsers,
|
|
||||||
waitDuration: const Duration(seconds: 1),
|
|
||||||
child: IconButton.filled(
|
|
||||||
onPressed: () => retrieveListOfUsers(),
|
|
||||||
icon: const Icon(
|
|
||||||
IconsaxPlusLinear.refresh,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
AnimatedFadeSize(
|
|
||||||
child: invalidUrl == null
|
|
||||||
? Column(
|
|
||||||
mainAxisSize: MainAxisSize.max,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
if (loading || users.isNotEmpty)
|
|
||||||
AnimatedFadeSize(
|
|
||||||
duration: const Duration(milliseconds: 250),
|
|
||||||
child: loading
|
|
||||||
? CircularProgressIndicator(key: UniqueKey(), strokeCap: StrokeCap.round)
|
|
||||||
: LoginUserGrid(
|
|
||||||
users: users,
|
|
||||||
onPressed: (value) {
|
|
||||||
usernameController.text = value.name;
|
|
||||||
passwordController.text = "";
|
|
||||||
focusNode.requestFocus();
|
|
||||||
setState(() {});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AutofillGroup(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
OutlinedTextField(
|
|
||||||
controller: usernameController,
|
|
||||||
autoFillHints: const [AutofillHints.username],
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
autocorrect: false,
|
|
||||||
onChanged: (value) => setState(() {}),
|
|
||||||
label: context.localized.userName,
|
|
||||||
),
|
|
||||||
OutlinedTextField(
|
|
||||||
controller: passwordController,
|
|
||||||
autoFillHints: const [AutofillHints.password],
|
|
||||||
keyboardType: TextInputType.visiblePassword,
|
|
||||||
focusNode: focusNode,
|
|
||||||
autocorrect: false,
|
|
||||||
textInputAction: TextInputAction.send,
|
|
||||||
onSubmitted: (value) => enterCredentialsTryLogin?.call(),
|
|
||||||
onChanged: (value) => setState(() {}),
|
|
||||||
label: context.localized.password,
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: enterCredentialsTryLogin,
|
|
||||||
child: authLoading
|
|
||||||
? SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: Theme.of(context).colorScheme.inversePrimary,
|
|
||||||
strokeCap: StrokeCap.round),
|
|
||||||
)
|
|
||||||
: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(context.localized.login),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Icon(IconsaxPlusBold.send_1),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
].addPadding(const EdgeInsets.symmetric(vertical: 4)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
].addPadding(const EdgeInsets.symmetric(vertical: 10)),
|
|
||||||
)
|
|
||||||
: DiscoverServersWidget(
|
|
||||||
serverCredentials: accounts.map((e) => e.credentials).toList(),
|
|
||||||
onPressed: (server) {
|
|
||||||
serverTextController.text = server.address;
|
|
||||||
_parseUrl(server.address);
|
|
||||||
retrieveListOfUsers();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
].addPadding(const EdgeInsets.symmetric(vertical: 8)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
315
lib/screens/login/login_screen_credentials.dart
Normal file
315
lib/screens/login/login_screen_credentials.dart
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||||
|
|
||||||
|
import 'package:fladder/models/account_model.dart';
|
||||||
|
import 'package:fladder/providers/api_provider.dart';
|
||||||
|
import 'package:fladder/providers/auth_provider.dart';
|
||||||
|
import 'package:fladder/providers/shared_provider.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/screens/login/login_code_dialog.dart';
|
||||||
|
import 'package:fladder/screens/login/login_user_grid.dart';
|
||||||
|
import 'package:fladder/screens/login/widgets/discover_servers_widget.dart';
|
||||||
|
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
||||||
|
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||||
|
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||||
|
import 'package:fladder/screens/shared/passcode_input.dart';
|
||||||
|
import 'package:fladder/util/auth_service.dart';
|
||||||
|
import 'package:fladder/util/localization_helper.dart';
|
||||||
|
|
||||||
|
class LoginScreenCredentials extends ConsumerStatefulWidget {
|
||||||
|
const LoginScreenCredentials({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ConsumerStatefulWidget> createState() => _LoginScreenCredentialsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenCredentialsState extends ConsumerState<LoginScreenCredentials> {
|
||||||
|
late final TextEditingController serverTextController = TextEditingController(text: '');
|
||||||
|
final usernameController = TextEditingController();
|
||||||
|
final passwordController = TextEditingController();
|
||||||
|
final FocusNode focusNode = FocusNode();
|
||||||
|
|
||||||
|
bool loggingIn = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final existingUsers = ref.watch(authProvider.select((value) => value.accounts));
|
||||||
|
final otherCredentials = existingUsers.map((e) => e.credentials).toList();
|
||||||
|
final serverCredentials = ref.watch(authProvider.select((value) => value.serverLoginModel));
|
||||||
|
final users = serverCredentials?.accounts ?? [];
|
||||||
|
final provider = ref.read(authProvider.notifier);
|
||||||
|
final loading = ref.watch(authProvider.select((value) => value.loading));
|
||||||
|
final hasBaseUrl = ref.watch(authProvider.select((value) => value.hasBaseUrl));
|
||||||
|
final urlError = ref.watch(authProvider.select((value) => value.errorMessage));
|
||||||
|
final hasQuickConnect = ref.watch(authProvider.select((value) => value.serverLoginModel?.hasQuickConnect ?? false));
|
||||||
|
|
||||||
|
ref.listen(
|
||||||
|
authProvider.select((value) => value.serverLoginModel),
|
||||||
|
(previous, next) {
|
||||||
|
if (next?.tempCredentials.server.isNotEmpty == true) {
|
||||||
|
serverTextController.text = next?.tempCredentials.server ?? "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
spacing: 16,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if (existingUsers.isNotEmpty)
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () => provider.goUserSelect(),
|
||||||
|
iconSize: 28,
|
||||||
|
icon: const Icon(
|
||||||
|
IconsaxPlusLinear.arrow_left_2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!hasBaseUrl)
|
||||||
|
Flexible(
|
||||||
|
child: OutlinedTextField(
|
||||||
|
controller: serverTextController,
|
||||||
|
onChanged: (value) => provider.tryParseUrl(value),
|
||||||
|
onSubmitted: (value) => provider.setServer(value),
|
||||||
|
autoFillHints: const [AutofillHints.url],
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
autocorrect: false,
|
||||||
|
textInputAction: TextInputAction.go,
|
||||||
|
label: context.localized.server,
|
||||||
|
errorText: urlError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: context.localized.retrievePublicListOfUsers,
|
||||||
|
waitDuration: const Duration(seconds: 1),
|
||||||
|
child: IconButton.filled(
|
||||||
|
onPressed: () => provider.setServer(serverTextController.text),
|
||||||
|
iconSize: 28,
|
||||||
|
icon: const Icon(
|
||||||
|
IconsaxPlusLinear.refresh,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (serverCredentials == null)
|
||||||
|
DiscoverServersWidget(
|
||||||
|
serverCredentials: otherCredentials,
|
||||||
|
onPressed: (info) => provider.setServer(info.address),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
spacing: 16,
|
||||||
|
children: [
|
||||||
|
if (loading || users.isNotEmpty)
|
||||||
|
AnimatedFadeSize(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
child: loading
|
||||||
|
? CircularProgressIndicator(key: UniqueKey(), strokeCap: StrokeCap.round)
|
||||||
|
: LoginUserGrid(
|
||||||
|
users: users,
|
||||||
|
onPressed: (value) {
|
||||||
|
usernameController.text = value.name;
|
||||||
|
passwordController.text = "";
|
||||||
|
focusNode.requestFocus();
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AutofillGroup(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: OutlinedTextField(
|
||||||
|
controller: usernameController,
|
||||||
|
autoFillHints: const [AutofillHints.username],
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autocorrect: false,
|
||||||
|
onChanged: (value) => setState(() {}),
|
||||||
|
label: context.localized.userName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: OutlinedTextField(
|
||||||
|
controller: passwordController,
|
||||||
|
autoFillHints: const [AutofillHints.password],
|
||||||
|
keyboardType: TextInputType.visiblePassword,
|
||||||
|
focusNode: focusNode,
|
||||||
|
autocorrect: false,
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (value) => enterCredentialsTryLogin?.call(),
|
||||||
|
onChanged: (value) => setState(() {}),
|
||||||
|
label: context.localized.password,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(
|
||||||
|
indent: 32,
|
||||||
|
endIndent: 32,
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: enterCredentialsTryLogin,
|
||||||
|
child: loggingIn
|
||||||
|
? SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Theme.of(context).colorScheme.inversePrimary, strokeCap: StrokeCap.round),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(context.localized.login),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(IconsaxPlusBold.send_1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasQuickConnect)
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await ref.read(jellyApiProvider).quickConnectInitiate();
|
||||||
|
if (result.body != null) {
|
||||||
|
await openLoginCodeDialog(
|
||||||
|
context,
|
||||||
|
quickConnectInfo: result.body!,
|
||||||
|
onAuthenticated: (context, secret) async {
|
||||||
|
context.pop();
|
||||||
|
if (secret.isNotEmpty) {
|
||||||
|
await loginUsingSecret(secret);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fladderSnackbar(context, title: context.localized.quickConnectPostFailed);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(context.localized.quickConnectLoginUsingCode),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(IconsaxPlusBold.scan_barcode),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (serverCredentials.serverMessage?.isEmpty == false) ...[
|
||||||
|
const Divider(),
|
||||||
|
Text(
|
||||||
|
serverCredentials.serverMessage ?? "",
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> Function()? get enterCredentialsTryLogin => emptyFields() ? null : () => loginUsingCredentials();
|
||||||
|
|
||||||
|
Future<void> loginUsingCredentials() async {
|
||||||
|
setState(() {
|
||||||
|
loggingIn = true;
|
||||||
|
});
|
||||||
|
final response = await ref.read(authProvider.notifier).authenticateByName(
|
||||||
|
usernameController.text,
|
||||||
|
passwordController.text,
|
||||||
|
);
|
||||||
|
if (response?.isSuccessful == false) {
|
||||||
|
fladderSnackbar(context,
|
||||||
|
title:
|
||||||
|
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
|
||||||
|
} else if (response?.body != null) {
|
||||||
|
loggedInGoToHome(context, ref);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
loggingIn = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loginUsingSecret(String secret) async {
|
||||||
|
setState(() {
|
||||||
|
loggingIn = true;
|
||||||
|
});
|
||||||
|
final response = await ref.read(authProvider.notifier).authenticateUsingSecret(secret);
|
||||||
|
if (response?.isSuccessful == false) {
|
||||||
|
fladderSnackbar(context,
|
||||||
|
title:
|
||||||
|
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
|
||||||
|
} else if (response?.body != null) {
|
||||||
|
loggedInGoToHome(context, ref);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
loggingIn = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool emptyFields() => usernameController.text.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loggedInGoToHome(BuildContext context, WidgetRef ref) {
|
||||||
|
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
|
||||||
|
if (context.mounted) {
|
||||||
|
context.router.replaceAll([const DashboardRoute()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLogin(BuildContext context, AccountModel user, WidgetRef ref) async {
|
||||||
|
await ref.read(authProvider.notifier).switchUser();
|
||||||
|
await ref.read(sharedUtilityProvider).updateAccountInfo(user.copyWith(
|
||||||
|
lastUsed: DateTime.now(),
|
||||||
|
));
|
||||||
|
ref.read(userProvider.notifier).updateUser(user.copyWith(lastUsed: DateTime.now()));
|
||||||
|
|
||||||
|
loggedInGoToHome(context, ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tapLoggedInAccount(BuildContext context, AccountModel user, WidgetRef ref) async {
|
||||||
|
Future<void> loginFunction() => _handleLogin(context, user, ref);
|
||||||
|
switch (user.authMethod) {
|
||||||
|
case Authentication.autoLogin:
|
||||||
|
loginFunction();
|
||||||
|
break;
|
||||||
|
case Authentication.biometrics:
|
||||||
|
final authenticated = await AuthService.authenticateUser(context, user);
|
||||||
|
if (authenticated) {
|
||||||
|
loginFunction();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Authentication.passcode:
|
||||||
|
if (context.mounted) {
|
||||||
|
showPassCodeDialog(context, (newPin) {
|
||||||
|
if (newPin == user.localPin) {
|
||||||
|
loginFunction();
|
||||||
|
} else {
|
||||||
|
fladderSnackbar(context, title: context.localized.incorrectPinTryAgain);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Authentication.none:
|
||||||
|
loginFunction();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,9 @@ import 'package:reorderable_grid/reorderable_grid.dart';
|
||||||
|
|
||||||
import 'package:fladder/models/account_model.dart';
|
import 'package:fladder/models/account_model.dart';
|
||||||
import 'package:fladder/providers/auth_provider.dart';
|
import 'package:fladder/providers/auth_provider.dart';
|
||||||
import 'package:fladder/screens/shared/flat_button.dart';
|
|
||||||
import 'package:fladder/screens/shared/user_icon.dart';
|
import 'package:fladder/screens/shared/user_icon.dart';
|
||||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.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/list_padding.dart';
|
||||||
|
|
||||||
class LoginUserGrid extends ConsumerWidget {
|
class LoginUserGrid extends ConsumerWidget {
|
||||||
|
|
@ -21,15 +21,21 @@ class LoginUserGrid extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final mainAxisExtent = 175.0;
|
final mainAxisExtent = 175.0;
|
||||||
final maxCount = (MediaQuery.of(context).size.width ~/ mainAxisExtent).clamp(1, 3);
|
final maxCount = (MediaQuery.of(context).size.width / mainAxisExtent).floor().clamp(1, 3);
|
||||||
|
|
||||||
return ReorderableGridView.builder(
|
final crossAxisCount = users.length == 1 ? 1 : maxCount;
|
||||||
|
|
||||||
|
final neededWidth = crossAxisCount * mainAxisExtent + (crossAxisCount - 1) * 24.0;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: neededWidth,
|
||||||
|
child: ReorderableGridView.builder(
|
||||||
onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex),
|
onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex),
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
autoScroll: true,
|
autoScroll: true,
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: users.length == 1 ? 1 : maxCount,
|
crossAxisCount: (users.length == 1 ? 1 : maxCount).toInt(),
|
||||||
mainAxisSpacing: 24,
|
mainAxisSpacing: 24,
|
||||||
crossAxisSpacing: 24,
|
crossAxisSpacing: 24,
|
||||||
mainAxisExtent: mainAxisExtent,
|
mainAxisExtent: mainAxisExtent,
|
||||||
|
|
@ -37,13 +43,18 @@ class LoginUserGrid extends ConsumerWidget {
|
||||||
itemCount: users.length,
|
itemCount: users.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final user = users[index];
|
final user = users[index];
|
||||||
return FlatButton(
|
return Center(
|
||||||
key: Key(user.id),
|
key: Key(user.id),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: FocusButton(
|
||||||
onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
|
onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
|
||||||
onLongPress:
|
onLongPress: switch (AdaptiveLayout.inputDeviceOf(context)) {
|
||||||
AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? () => onLongPress?.call(user) : null,
|
InputDevice.dpad || InputDevice.pointer => () => onLongPress?.call(user),
|
||||||
child: _CardHolder(
|
InputDevice.touch => null,
|
||||||
content: Stack(
|
},
|
||||||
|
darkOverlay: false,
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
|
@ -119,29 +130,9 @@ class LoginUserGrid extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CardHolder extends StatelessWidget {
|
|
||||||
final Widget content;
|
|
||||||
|
|
||||||
const _CardHolder({
|
|
||||||
required this.content,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
elevation: 1,
|
|
||||||
shadowColor: Colors.transparent,
|
|
||||||
clipBehavior: Clip.hardEdge,
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxHeight: 150, maxWidth: 150),
|
|
||||||
child: content,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
0
lib/screens/login/screens/server_selection_screen.dart
Normal file
0
lib/screens/login/screens/server_selection_screen.dart
Normal file
0
lib/screens/login/widgets/credentials_input_section.dart
Normal file
0
lib/screens/login/widgets/credentials_input_section.dart
Normal file
|
|
@ -1,8 +1,8 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:iconsax_plus/iconsax_plus.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||||
|
|
||||||
import 'package:fladder/models/credentials_model.dart';
|
import 'package:fladder/models/credentials_model.dart';
|
||||||
import 'package:fladder/providers/discovery_provider.dart';
|
import 'package:fladder/providers/discovery_provider.dart';
|
||||||
|
|
@ -37,6 +37,7 @@ class DiscoverServersWidget extends ConsumerWidget {
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
if (existingServers.isNotEmpty) ...[
|
if (existingServers.isNotEmpty) ...[
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -123,8 +124,8 @@ class _ServerInfoCard extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
child: InkWell(
|
child: TextButton(
|
||||||
onTap: () => onPressed(server),
|
onPressed: () => onPressed(server),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
|
||||||
0
lib/screens/login/widgets/server_input_section.dart
Normal file
0
lib/screens/login/widgets/server_input_section.dart
Normal file
0
lib/screens/login/widgets/server_url_input.dart
Normal file
0
lib/screens/login/widgets/server_url_input.dart
Normal file
|
|
@ -1,5 +1,11 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:iconsax_plus/iconsax_plus.dart';
|
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
||||||
import 'package:fladder/models/items/item_shared_models.dart';
|
import 'package:fladder/models/items/item_shared_models.dart';
|
||||||
import 'package:fladder/providers/edit_item_provider.dart';
|
import 'package:fladder/providers/edit_item_provider.dart';
|
||||||
|
|
@ -12,10 +18,7 @@ import 'package:fladder/util/list_extensions.dart';
|
||||||
import 'package:fladder/util/string_extensions.dart';
|
import 'package:fladder/util/string_extensions.dart';
|
||||||
import 'package:fladder/widgets/shared/adaptive_date_picker.dart';
|
import 'package:fladder/widgets/shared/adaptive_date_picker.dart';
|
||||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
class EditFields extends ConsumerStatefulWidget {
|
class EditFields extends ConsumerStatefulWidget {
|
||||||
final Map<String, dynamic> fields;
|
final Map<String, dynamic> fields;
|
||||||
|
|
@ -63,14 +66,14 @@ class _EditGeneralState extends ConsumerState<EditFields> {
|
||||||
trailing: EnumBox(
|
trailing: EnumBox(
|
||||||
current: map.entries.firstWhereOrNull((element) => element.value == true)?.key ?? "",
|
current: map.entries.firstWhereOrNull((element) => element.value == true)?.key ?? "",
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem(
|
ItemActionButton(
|
||||||
child: const Text(""),
|
label: const Text(""),
|
||||||
onTap: () => ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, "")),
|
action: () => ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, "")),
|
||||||
),
|
),
|
||||||
...map.entries.map(
|
...map.entries.map(
|
||||||
(mapEntry) => PopupMenuItem(
|
(mapEntry) => ItemActionButton(
|
||||||
child: Text(mapEntry.key),
|
label: Text(mapEntry.key),
|
||||||
onTap: () => ref
|
action: () => ref
|
||||||
.read(editItemProvider.notifier)
|
.read(editItemProvider.notifier)
|
||||||
.updateField(MapEntry(e.key, mapEntry.key)),
|
.updateField(MapEntry(e.key, mapEntry.key)),
|
||||||
),
|
),
|
||||||
|
|
@ -240,9 +243,9 @@ class _EditGeneralState extends ConsumerState<EditFields> {
|
||||||
.whereNot(
|
.whereNot(
|
||||||
(element) => element == PersonKind.swaggerGeneratedUnknown)
|
(element) => element == PersonKind.swaggerGeneratedUnknown)
|
||||||
.map(
|
.map(
|
||||||
(entry) => PopupMenuItem(
|
(entry) => ItemActionButton(
|
||||||
child: Text(entry.name.toUpperCaseSplit()),
|
label: Text(entry.name.toUpperCaseSplit()),
|
||||||
onTap: () {
|
action: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
personType = entry;
|
personType = entry;
|
||||||
});
|
});
|
||||||
|
|
@ -570,9 +573,9 @@ class _EditGeneralState extends ConsumerState<EditFields> {
|
||||||
current: (e.value as DisplayOrder).value.toUpperCaseSplit(),
|
current: (e.value as DisplayOrder).value.toUpperCaseSplit(),
|
||||||
itemBuilder: (context) => DisplayOrder.values
|
itemBuilder: (context) => DisplayOrder.values
|
||||||
.map(
|
.map(
|
||||||
(mapEntry) => PopupMenuItem(
|
(mapEntry) => ItemActionButton(
|
||||||
child: Text(mapEntry.value.toUpperCaseSplit()),
|
label: Text(mapEntry.value.toUpperCaseSplit()),
|
||||||
onTap: () => ref
|
action: () => ref
|
||||||
.read(editItemProvider.notifier)
|
.read(editItemProvider.notifier)
|
||||||
.updateField(MapEntry(e.key, mapEntry.value)),
|
.updateField(MapEntry(e.key, mapEntry.value)),
|
||||||
),
|
),
|
||||||
|
|
@ -594,9 +597,9 @@ class _EditGeneralState extends ConsumerState<EditFields> {
|
||||||
current: (e.value as ShowStatus).value,
|
current: (e.value as ShowStatus).value,
|
||||||
itemBuilder: (context) => ShowStatus.values
|
itemBuilder: (context) => ShowStatus.values
|
||||||
.map(
|
.map(
|
||||||
(mapEntry) => PopupMenuItem(
|
(mapEntry) => ItemActionButton(
|
||||||
child: Text(mapEntry.value),
|
label: Text(mapEntry.value),
|
||||||
onTap: () => ref
|
action: () => ref
|
||||||
.read(editItemProvider.notifier)
|
.read(editItemProvider.notifier)
|
||||||
.updateField(MapEntry(e.key, mapEntry.value)),
|
.updateField(MapEntry(e.key, mapEntry.value)),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||||
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
||||||
import 'package:fladder/util/localization_helper.dart';
|
import 'package:fladder/util/localization_helper.dart';
|
||||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||||
|
import 'package:fladder/widgets/shared/item_actions.dart';
|
||||||
|
|
||||||
Future<void> showRefreshPopup(BuildContext context, String itemId, String itemName) async {
|
Future<void> showRefreshPopup(BuildContext context, String itemId, String itemName) async {
|
||||||
return showDialog(
|
return showDialog(
|
||||||
|
|
@ -69,10 +70,9 @@ class _RefreshPopupDialogState extends ConsumerState<RefreshPopupDialog> {
|
||||||
child: EnumBox(
|
child: EnumBox(
|
||||||
current: refreshMode.label(context),
|
current: refreshMode.label(context),
|
||||||
itemBuilder: (context) => MetadataRefresh.values
|
itemBuilder: (context) => MetadataRefresh.values
|
||||||
.map((value) => PopupMenuItem(
|
.map((value) => ItemActionButton(
|
||||||
value: value,
|
label: Text(value.label(context)),
|
||||||
child: Text(value.label(context)),
|
action: () => setState(() {
|
||||||
onTap: () => setState(() {
|
|
||||||
refreshMode = value;
|
refreshMode = value;
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:iconsax_plus/iconsax_plus.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:iconsax_plus/iconsax_plus.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
@ -35,6 +35,7 @@ class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with Wind
|
||||||
late final BasePlayer player = switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) {
|
late final BasePlayer player = switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) {
|
||||||
PlayerOptions.libMDK => LibMDK(),
|
PlayerOptions.libMDK => LibMDK(),
|
||||||
PlayerOptions.libMPV => LibMPV(),
|
PlayerOptions.libMPV => LibMPV(),
|
||||||
|
_ => LibMDK(),
|
||||||
};
|
};
|
||||||
late String videoUrl = "";
|
late String videoUrl = "";
|
||||||
|
|
||||||
|
|
@ -102,7 +103,7 @@ class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with Wind
|
||||||
duration = event.duration;
|
duration = event.duration;
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
await player.open(videoUrl, !ref.watch(photoViewSettingsProvider).autoPlay);
|
await player.loadVideo(videoUrl, !ref.watch(photoViewSettingsProvider).autoPlay);
|
||||||
await player.setVolume(ref.watch(photoViewSettingsProvider.select((value) => value.mute)) ? 0 : 100);
|
await player.setVolume(ref.watch(photoViewSettingsProvider.select((value) => value.mute)) ? 0 : 100);
|
||||||
await player.loop(ref.watch(photoViewSettingsProvider.select((value) => value.repeat)));
|
await player.loop(ref.watch(photoViewSettingsProvider.select((value) => value.repeat)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue