feat: Android TV support (#503)

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

2
.vscode/launch.json vendored
View file

@ -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
View file

@ -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
View file

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

View file

@ -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"
} }
@ -8,7 +9,7 @@ plugins {
def keystoreProperties = new Properties() def keystoreProperties = new Properties()
def keystorePropertiesFile = file('key.properties') def keystorePropertiesFile = file('key.properties')
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
} }
def localProperties = new Properties() def localProperties = new Properties()
@ -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,25 +71,29 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs { composeOptions {
release { kotlinCompilerExtensionVersion '1.6.4'
storeFile file('keystore.jks') }
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword'] signingConfigs {
storePassword keystoreProperties['storePassword'] release {
storeFile file('keystore.jks')
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storePassword keystoreProperties['storePassword']
} }
} }
buildTypes { buildTypes {
release { release {
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
signingConfig signingConfigs.release signingConfig signingConfigs.release
} }
} }
flavorDimensions "default" flavorDimensions "default"
productFlavors { productFlavors {
development { development {
dimension "default" dimension "default"
applicationIdSuffix ".dev" applicationIdSuffix ".dev"
@ -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")
}

View file

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

View file

@ -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)
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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

View file

@ -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

Before After
Before After

Binary file not shown.

View file

@ -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 {

View file

@ -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"
} }

View file

@ -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,9 +285,12 @@ class _MainState extends ConsumerState<Main> with WindowListener, WidgetsBinding
} }
return locale; return locale;
}, },
builder: (context, child) => LocalizationContextWrapper( builder: (context, child) => MediaQueryScaler(
child: ScaffoldMessenger(child: child ?? Container()), child: LocalizationContextWrapper(
currentLocale: language, child: ScaffoldMessenger(child: child ?? Container()),
currentLocale: language,
),
enable: ref.read(argumentsStateProvider).leanBackMode,
), ),
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
darkTheme: darkTheme.copyWith( darkTheme: darkTheme.copyWith(
@ -300,7 +307,7 @@ class _MainState extends ConsumerState<Main> with WindowListener, WidgetsBinding
routerConfig: autoRouter.config(), routerConfig: autoRouter.config(),
), ),
); );
}), },
); );
} }
} }

View file

@ -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;
} }
} }

View file

@ -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,

View file

@ -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] ?? '')

View file

@ -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;

View file

@ -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()

View file

@ -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 ?? "",

View file

@ -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, @Freezed(copyWith: true)
tempCredentials: tempCredentials ?? this.tempCredentials, abstract class LoginScreenModel with _$LoginScreenModel {
loading: loading ?? this.loading, 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;
} }

View 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

View file

@ -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);
} }
} }
} }

View file

@ -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,
); );
} }
} }

View file

@ -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)';
} }
} }

View file

@ -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'
}; };
} }

View file

@ -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 = {

View file

@ -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",
}; };
} }

View file

@ -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 = {

View file

@ -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(

View file

@ -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);
Future<Response<List<AccountModel>>?> getPublicUsers() async { BuildContext? context;
try {
var response = await api.usersPublicGet(state.tempCredentials);
if (response.isSuccessful && response.body != null) {
var models = response.body ?? [];
return response.copyWith(body: models.toList()); 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);
} }
return response.copyWith(body: []); }
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) { } catch (e) {
return null; state = state.copyWith(
errorMessage: context?.localized.invalidUrl,
loading: false,
);
if (context != null) {
fladderSnackbar(context!, title: context!.localized.unableToConnectHost);
}
} }
} }
String? _parseUrl(String url) {
if (url.isEmpty) {
return null;
}
if (!Uri.parse(url).isAbsolute) {
return context?.localized.invalidUrl;
}
if (!url.startsWith('https://') && !url.startsWith('http://')) {
return context?.localized.invalidUrlDesc;
}
return null;
}
Future<Response<List<AccountModel>>?> getPublicUsers() async {
try {
state = state.copyWith(loading: true);
final credentials = state.serverLoginModel?.tempCredentials;
if (credentials == null) return null;
var response = await api.usersPublicGet(credentials);
if (response.isSuccessful && response.body != null) {
var models = response.body ?? [];
return response.copyWith(body: models.toList());
}
state = state.copyWith(
serverLoginModel: state.serverLoginModel?.copyWith(
accounts: response.body ?? [],
),
);
return response.copyWith(body: []);
} catch (e) {
return null;
} finally {
state = state.copyWith(loading: false);
}
}
Future<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,
);
}
}
} }

View file

@ -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,
], ],
); );

View file

@ -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) {

View file

View 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> {

View file

@ -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 {

View file

@ -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 {

View file

@ -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);
} }

View file

@ -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());

View file

@ -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;
} }
} }

View file

@ -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

View file

@ -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),

View file

@ -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)),
)) ))

View file

@ -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),
), ),
) )
], ],

View file

@ -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,80 +151,84 @@ 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(
(view) => PosterRow(
contentPadding: padding,
label: context.localized.dashboardRecentlyAdded(view.name),
collectionAspectRatio: view.collectionType.aspectRatio,
onLabelClick: () => context.router.push(
LibrarySearchRoute(
viewModelId: view.id,
types: switch (view.collectionType) {
CollectionType.tvshows => {
FladderItemType.episode: true,
},
_ => {},
},
sortingOptions: switch (view.collectionType) {
CollectionType.books ||
CollectionType.boxsets ||
CollectionType.folders ||
CollectionType.music =>
SortingOptions.dateLastContentAdded,
_ => SortingOptions.dateAdded,
},
sortOrder: SortingOrder.descending,
recursive: true,
),
),
posters: view.recentlyAdded,
),
),
]
.nonNulls
.toList()
.mapIndexed(
(index, child) => SliverToBoxAdapter(
child: FocusProvider(
autoFocus: bannerType != HomeBanner.detailedBanner ? index == 0 : false,
child: child,
),
),
)
.toList()
.addInBetween(
const SliverToBoxAdapter(
child: SizedBox(height: 16),
), ),
), ),
...views.dashboardViews
.where((element) => element.recentlyAdded.isNotEmpty)
.map((view) => SliverToBoxAdapter(
child: PosterRow(
contentPadding: padding,
label: context.localized.dashboardRecentlyAdded(view.name),
collectionAspectRatio: view.collectionType.aspectRatio,
onLabelClick: () => context.router.push(
LibrarySearchRoute(
viewModelId: view.id,
types: switch (view.collectionType) {
CollectionType.tvshows => {
FladderItemType.episode: true,
},
_ => {},
},
sortingOptions: switch (view.collectionType) {
CollectionType.books ||
CollectionType.boxsets ||
CollectionType.folders ||
CollectionType.music =>
SortingOptions.dateLastContentAdded,
_ => SortingOptions.dateAdded,
},
sortOrder: SortingOrder.descending,
recursive: true,
),
),
posters: view.recentlyAdded,
),
)),
].nonNulls.toList().addInBetween(const SliverToBoxAdapter(child: SizedBox(height: 16))),
const DefautlSliverBottomPadding(), const DefautlSliverBottomPadding(),
], ],
), ),

View file

@ -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(),
}; };
} }

View file

@ -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, selected: mediaStream.currentVersionStream == e,
padding: EdgeInsets.zero, label: textWidget(
child: textWidget(
context, context,
selected: mediaStream.currentVersionStream == e,
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, label,
width: double.maxFinite, style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: selected ? Theme.of(context).colorScheme.primary : null, fontWeight: FontWeight.bold,
alignment: Alignment.centerLeft, ),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
fontWeight: FontWeight.bold,
),
),
),
); );
} }
} }
@ -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: EnumBox(
child: PopupMenuButton( current: current,
tooltip: '', itemBuilder: itemBuilder,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
enabled: itemList.length > 1,
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,
)
],
),
),
), ),
), ),
), ),

View file

@ -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,83 +72,101 @@ class OverviewHeader extends ConsumerWidget {
crossAxisAlignment: crossAlignment, crossAxisAlignment: crossAlignment,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
MediaHeader( Flexible(
name: name, child: ExcludeFocus(
logo: image?.logo, child: MediaHeader(
onTap: onTitleClicked, name: name,
), logo: image?.logo,
Column( onTap: onTitleClicked,
mainAxisSize: MainAxisSize.min, alignment: logoAlignment,
crossAxisAlignment: crossAlignment,
children: [
if (subTitle != null)
Flexible(
child: SelectableText(
subTitle ?? "",
textAlign: TextAlign.center,
style: mainStyle,
),
),
if (name.toLowerCase() != originalTitle?.toLowerCase() && originalTitle != null)
SelectableText(
originalTitle.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
].addInBetween(const SizedBox(height: 4)),
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAlignment,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
direction: Axis.horizontal,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (officialRating != null)
ChipButton(
label: officialRating.toString(),
),
if (productionYear != null)
SelectableText(
productionYear.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
if (runTime != null && (runTime?.inSeconds ?? 0) > 1)
SelectableText(
runTime.humanize.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
if (communityRating != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star_rate_rounded,
color: Theme.of(context).colorScheme.primary,
),
Text(
communityRating?.toStringAsFixed(2) ?? "",
style: subStyle,
),
],
),
].addInBetween(CircleAvatar(
radius: 3,
backgroundColor: Theme.of(context).colorScheme.onSurface,
)),
), ),
if (genres.isNotEmpty) ),
Genres( ),
genres: genres.take(6).toList(), ExcludeFocus(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAlignment,
children: [
if (subTitle != null)
Flexible(
child: SelectableText(
subTitle ?? "",
textAlign: TextAlign.center,
style: mainStyle,
),
),
if (name.toLowerCase() != originalTitle?.toLowerCase() && originalTitle != null)
SelectableText(
originalTitle.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
].addInBetween(const SizedBox(height: 4)),
),
),
ExcludeFocus(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAlignment,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
direction: Axis.horizontal,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (officialRating != null)
ChipButton(
label: officialRating.toString(),
),
if (productionYear != null)
SelectableText(
productionYear.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
if (runTime != null && (runTime?.inSeconds ?? 0) > 1)
SelectableText(
runTime.humanize.toString(),
textAlign: TextAlign.center,
style: subStyle,
),
if (communityRating != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star_rate_rounded,
color: Theme.of(context).colorScheme.primary,
),
Text(
communityRating?.toStringAsFixed(2) ?? "",
style: subStyle,
),
],
),
].addInBetween(CircleAvatar(
radius: 3,
backgroundColor: Theme.of(context).colorScheme.onSurface,
)),
), ),
].addInBetween(const SizedBox(height: 10)), if (summary?.isNotEmpty == true)
Flexible(
child: Text(
summary ?? "",
style: Theme.of(context).textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
maxLines: 3,
),
),
if (genres.isNotEmpty)
Genres(
genres: genres.take(6).toList(),
),
].addInBetween(const SizedBox(height: 10)),
),
), ),
if (centerButtons != null) centerButtons!, if (centerButtons != null) centerButtons!,
].addInBetween(const SizedBox(height: 21)), ].addInBetween(const SizedBox(height: 21)),

View file

@ -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,19 +79,62 @@ 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(
? MediaPlayButton( spacing: 8,
item: episodeDetails, runSpacing: 8,
onPressed: () async { alignment: wrapAlignment,
await details.episode.play(context, ref); crossAxisAlignment: WrapCrossAlignment.center,
ref.read(providerInstance.notifier).fetchDetails(widget.item); children: [
}, episodeDetails.playAble
onLongPressed: () async { ? MediaPlayButton(
await details.episode.play(context, ref, showPlaybackOption: true); item: episodeDetails,
ref.read(providerInstance.notifier).fetchDetails(widget.item); onPressed: () async {
}, await details.episode.play(context, ref);
) ref.read(providerInstance.notifier).fetchDetails(widget.item);
: null, },
onLongPressed: () async {
await details.episode.play(context, ref, showPlaybackOption: true);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
)
: null,
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!(episodeDetails.userData.isFavourite), episodeDetails.id);
},
selected: episodeDetails.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.markAsPlayed(!(episodeDetails.userData.played), episodeDetails.id);
},
selected: episodeDetails.userData.played,
selectedIcon: IconsaxPlusBold.tick_circle,
icon: IconsaxPlusLinear.tick_circle,
),
SelectableIconButton(
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, padding: padding,
subTitle: details.episode?.detailedName(context), subTitle: details.episode?.detailedName(context),
originalTitle: details.series?.originalTitle, originalTitle: details.series?.originalTitle,
@ -101,34 +146,6 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
officialRating: details.series?.overview.parentalRating, officialRating: details.series?.overview.parentalRating,
communityRating: details.series?.overview.communityRating, communityRating: details.series?.overview.communityRating,
), ),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!(episodeDetails.userData.isFavourite), episodeDetails.id);
},
selected: episodeDetails.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.markAsPlayed(!(episodeDetails.userData.played), episodeDetails.id);
},
selected: episodeDetails.userData.played,
selectedIcon: IconsaxPlusBold.tick_circle,
icon: IconsaxPlusLinear.tick_circle,
),
].addPadding(const EdgeInsets.symmetric(horizontal: 6)),
).padding(padding),
if (details.episode?.mediaStreams != null) if (details.episode?.mediaStreams != null)
Padding( Padding(
padding: padding, padding: padding,

View file

@ -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,23 +73,63 @@ 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(
item: details, spacing: 8,
onLongPressed: () async { runSpacing: 8,
await details.play( alignment: wrapAlignment,
context, crossAxisAlignment: WrapCrossAlignment.center,
ref, children: [
showPlaybackOption: true, MediaPlayButton(
); item: details,
ref.read(providerInstance.notifier).fetchDetails(widget.item); onLongPressed: () async {
}, await details.play(
onPressed: () async { context,
await details.play( ref,
context, showPlaybackOption: true,
ref, );
); ref.read(providerInstance.notifier).fetchDetails(widget.item);
ref.read(providerInstance.notifier).fetchDetails(widget.item); },
}, onPressed: () async {
await details.play(
context,
ref,
);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
),
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!details.userData.isFavourite, details.id);
},
selected: details.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref.read(userProvider.notifier).markAsPlayed(!details.userData.played, details.id);
},
selected: details.userData.played,
selectedIcon: IconsaxPlusBold.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,
),
],
), ),
originalTitle: details.originalTitle, originalTitle: details.originalTitle,
productionYear: details.overview.productionYear, productionYear: details.overview.productionYear,
@ -97,32 +139,6 @@ class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
officialRating: details.overview.parentalRating, officialRating: details.overview.parentalRating,
communityRating: details.overview.communityRating, communityRating: details.overview.communityRating,
), ),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!details.userData.isFavourite, details.id);
},
selected: details.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref.read(userProvider.notifier).markAsPlayed(!details.userData.played, details.id);
},
selected: details.userData.played,
selectedIcon: IconsaxPlusBold.tick_circle,
icon: IconsaxPlusLinear.tick_circle,
),
],
).padding(padding),
if (details.mediaStreams.isNotEmpty) if (details.mediaStreams.isNotEmpty)
MediaStreamInformation( MediaStreamInformation(
onVersionIndexChanged: (index) { onVersionIndexChanged: (index) {

View file

@ -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,20 +76,60 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
OverviewHeader( OverviewHeader(
name: details.name, name: details.name,
image: details.images, image: details.images,
centerButtons: MediaPlayButton( centerButtons: Wrap(
item: details.nextUp, spacing: 8,
onPressed: details.nextUp != null runSpacing: 8,
? () async { alignment: wrapAlignment,
await details.nextUp.play(context, ref); crossAxisAlignment: WrapCrossAlignment.center,
ref.read(providerId.notifier).fetchDetails(widget.item); children: [
} MediaPlayButton(
: null, item: details.nextUp,
onLongPressed: details.nextUp != null onPressed: details.nextUp != null
? () async { ? () async {
await details.nextUp.play(context, ref, showPlaybackOption: true); await details.nextUp.play(context, ref);
ref.read(providerId.notifier).fetchDetails(widget.item); ref.read(providerId.notifier).fetchDetails(widget.item);
} }
: null, : null,
onLongPressed: details.nextUp != null
? () async {
await details.nextUp.play(context, ref, showPlaybackOption: true);
ref.read(providerId.notifier).fetchDetails(widget.item);
}
: null,
),
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!details.userData.isFavourite, details.id);
},
selected: details.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref.read(userProvider.notifier).markAsPlayed(!details.userData.played, details.id);
},
selected: details.userData.played,
selectedIcon: IconsaxPlusBold.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, originalTitle: details.originalTitle,
@ -98,32 +140,6 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
genres: details.overview.genreItems, genres: details.overview.genreItems,
communityRating: details.overview.communityRating, communityRating: details.overview.communityRating,
), ),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SelectableIconButton(
onPressed: () async {
await ref
.read(userProvider.notifier)
.setAsFavorite(!details.userData.isFavourite, details.id);
},
selected: details.userData.isFavourite,
selectedIcon: IconsaxPlusBold.heart,
icon: IconsaxPlusLinear.heart,
),
SelectableIconButton(
onPressed: () async {
await ref.read(userProvider.notifier).markAsPlayed(!details.userData.played, details.id);
},
selected: details.userData.played,
selectedIcon: IconsaxPlusBold.tick_circle,
icon: IconsaxPlusLinear.tick_circle,
),
],
).padding(padding),
if (details.nextUp != null) if (details.nextUp != null)
NextUpEpisode( NextUpEpisode(
nextEpisode: details.nextUp!, nextEpisode: details.nextUp!,

View file

@ -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( ...[
(e) => SliverToBoxAdapter( ...favourites.favourites.entries.where((element) => element.value.isNotEmpty).map(
child: PosterRow( (e) => PosterRow(
contentPadding: padding, contentPadding: padding,
onLabelClick: () => context.pushRoute( onLabelClick: () => context.pushRoute(
LibrarySearchRoute().withFilter( LibrarySearchRoute().withFilter(
@ -71,15 +73,17 @@ class FavouritesScreen extends ConsumerWidget {
posters: e.value, posters: e.value,
), ),
), ),
), if (favourites.people.isNotEmpty)
if (favourites.people.isNotEmpty) PosterRow(
SliverToBoxAdapter(
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(),
], ],
), ),

View file

@ -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,25 +251,26 @@ class LibraryRow extends ConsumerWidget {
action: () => showRefreshPopup(context, view.id, view.name), action: () => showRefreshPopup(context, view.id, view.name),
) )
]; ];
return FlatButton( return Column(
onTap: isSelected ? null : () => onSelected?.call(view), mainAxisSize: MainAxisSize.min,
onLongPress: () => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)), crossAxisAlignment: CrossAxisAlignment.start,
onSecondaryTapDown: (details) async { mainAxisAlignment: MainAxisAlignment.center,
Offset localPosition = details.globalPosition; children: [
RelativeRect position = FocusButton(
RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); key: Key(view.id),
await showMenu( onTap: isSelected ? null : () => onSelected?.call(view),
context: context, onLongPress: () => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)),
position: position, onSecondaryTapDown: (details) async {
items: viewActions.popupMenuItems(useIcons: true), Offset localPosition = details.globalPosition;
); RelativeRect position =
}, RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy);
child: Column( await showMenu(
mainAxisSize: MainAxisSize.min, context: context,
crossAxisAlignment: CrossAxisAlignment.start, position: position,
mainAxisAlignment: MainAxisAlignment.center, items: viewActions.popupMenuItems(useIcons: true),
children: [ );
Container( },
child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: FladderTheme.defaultShape.borderRadius, borderRadius: FladderTheme.defaultShape.borderRadius,
), ),
@ -294,15 +296,15 @@ class LibraryRow extends ConsumerWidget {
), ),
), ),
), ),
Text( ),
view.name, Text(
style: Theme.of(context).textTheme.titleMedium, view.name,
maxLines: 2, style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis, maxLines: 2,
textAlign: TextAlign.start, overflow: TextOverflow.ellipsis,
) textAlign: TextAlign.start,
], )
), ],
); );
}, },
); );

View file

@ -352,49 +352,27 @@ 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) { await showBottomSheetPill(
double left = details.globalPosition.dx; context: context,
double top = details.globalPosition.dy; content: (context, scrollController) => ListView(
await showMenu( shrinkWrap: true,
context: context, controller: scrollController,
position: RelativeRect.fromLTRB(left, top, 40, 100), children: [
items: <PopupMenuEntry>[ itemCountWidget.toListItem(context, useIcons: true),
PopupMenuItem( refreshAction.toListItem(context, useIcons: true),
child: Text(librarySearchResults.nestedCurrentItem?.type.label(context) ?? itemViewAction.toListItem(context, useIcons: true),
context.localized.library(0))),
itemCountWidget.toPopupMenuItem(useIcons: true),
refreshAction.toPopupMenuItem(useIcons: true),
itemViewAction.toPopupMenuItem(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().toPopupMenuItem(), if (itemActions.isNotEmpty) ItemActionDivider().toListItem(context),
...itemActions.popupMenuItems(useIcons: true), ...itemActions.listTileItems(context, useIcons: true),
], ],
elevation: 8.0, ),
); );
} else {
await showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: [
itemCountWidget.toListItem(context, useIcons: true),
refreshAction.toListItem(context, useIcons: true),
itemViewAction.toListItem(context, useIcons: true),
if (librarySearchResults.views.hasEnabled == true)
showSavedFiltersDialogue.toPopupMenuItem(useIcons: true),
if (itemActions.isNotEmpty) ItemActionDivider().toListItem(context),
...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

View file

@ -149,16 +149,19 @@ class _LibraryFilterChipsState extends ConsumerState<LibraryFilterChips> {
), ),
]; ];
return Row( return FocusTraversalGroup(
spacing: 4, policy: ReadingOrderTraversalPolicy(),
children: chips.mapIndexed( child: Row(
(index, element) { spacing: 4,
final position = index == 0 children: chips.mapIndexed(
? PositionContext.first (index, element) {
: (index == chips.length - 1 ? PositionContext.last : PositionContext.middle); final position = index == 0
return PositionProvider(position: position, child: element); ? PositionContext.first
}, : (index == chips.length - 1 ? PositionContext.last : PositionContext.middle);
).toList(), return PositionProvider(position: position, child: element);
},
).toList(),
),
); );
} }

View file

@ -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,16 +176,19 @@ 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(
poster: poster, autoFocus: index == 0,
selected: selected.contains(poster), child: PosterListItem(
excludeActions: excludeActions, poster: poster,
otherActions: otherActions(poster), selected: selected.contains(poster),
subTitle: poster.subTitle(sortingOptions), excludeActions: excludeActions,
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData), otherActions: otherActions(poster),
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]), subTitle: poster.subTitle(sortingOptions),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem), onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context), onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
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),

View file

@ -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),

View 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)),
)
],
),
),
);
}
}

View file

@ -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,
mainAxisAlignment: MainAxisAlignment.end, floatingActionButton: switch (screen) {
children: [ LoginScreenType.users => Row(
if (!AdaptiveLayout.of(context).isDesktop) mainAxisAlignment: MainAxisAlignment.end,
FloatingActionButton( spacing: 16,
key: const Key("edit_button"),
heroTag: "edit_button",
backgroundColor: editingUsers ? Theme.of(context).colorScheme.errorContainer : null,
child: const Icon(IconsaxPlusLinear.edit_2),
onPressed: () => setState(() => editingUsers = !editingUsers),
),
FloatingActionButton(
key: const Key("new_button"),
heroTag: "new_button",
child: const Icon(IconsaxPlusLinear.add_square),
onPressed: startAddingNewUser,
),
].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: [ children: [
const Center( if (!AdaptiveLayout.of(context).isDesktop)
child: FladderLogo(), FloatingActionButton(
key: const Key("edit_user_button"),
heroTag: "edit_user_button",
backgroundColor: editUsersMode ? Theme.of(context).colorScheme.errorContainer : null,
child: const Icon(IconsaxPlusLinear.edit_2),
onPressed: () => setState(() => editUsersMode = !editUsersMode),
),
FloatingActionButton(
key: const Key("new_user_button"),
heroTag: "new_user_button",
child: const Icon(IconsaxPlusLinear.add_square),
onPressed: () => ref.read(authProvider.notifier).addNewUser(),
), ),
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),
),
],
),
),
].addPadding(const EdgeInsets.symmetric(vertical: 16)),
), ),
_ => 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),
),
},
)
],
), ),
), ),
); );
} }
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)),
);
}
} }

View 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;
}
}

View file

@ -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,127 +21,118 @@ 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;
onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex),
shrinkWrap: true, final neededWidth = crossAxisCount * mainAxisExtent + (crossAxisCount - 1) * 24.0;
physics: const NeverScrollableScrollPhysics(),
autoScroll: true, return SizedBox(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( width: neededWidth,
crossAxisCount: users.length == 1 ? 1 : maxCount, child: ReorderableGridView.builder(
mainAxisSpacing: 24, onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex),
crossAxisSpacing: 24, shrinkWrap: true,
mainAxisExtent: mainAxisExtent, physics: const NeverScrollableScrollPhysics(),
), autoScroll: true,
itemCount: users.length, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
itemBuilder: (context, index) { crossAxisCount: (users.length == 1 ? 1 : maxCount).toInt(),
final user = users[index]; mainAxisSpacing: 24,
return FlatButton( crossAxisSpacing: 24,
key: Key(user.id), mainAxisExtent: mainAxisExtent,
onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user), ),
onLongPress: itemCount: users.length,
AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? () => onLongPress?.call(user) : null, itemBuilder: (context, index) {
child: _CardHolder( final user = users[index];
content: Stack( return Center(
children: [ key: Key(user.id),
Padding( child: AspectRatio(
padding: const EdgeInsets.all(8.0), aspectRatio: 1.0,
child: Column( child: FocusButton(
mainAxisAlignment: MainAxisAlignment.spaceAround, onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
mainAxisSize: MainAxisSize.min, onLongPress: switch (AdaptiveLayout.inputDeviceOf(context)) {
children: [ InputDevice.dpad || InputDevice.pointer => () => onLongPress?.call(user),
Flexible( InputDevice.touch => null,
child: UserIcon( },
labelStyle: Theme.of(context).textTheme.headlineMedium, darkOverlay: false,
user: user, child: Stack(
), children: [
), Padding(
Row( padding: const EdgeInsets.all(8.0),
mainAxisAlignment: MainAxisAlignment.center, child: Column(
mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceAround,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(
user.authMethod.icon,
size: 18,
),
const SizedBox(width: 4),
Flexible( Flexible(
child: Text( child: UserIcon(
user.name, labelStyle: Theme.of(context).textTheme.headlineMedium,
maxLines: 2, user: user,
softWrap: true, ),
)), ),
], Row(
),
if (user.credentials.serverName.isNotEmpty)
Opacity(
opacity: 0.75,
child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [
const Icon( Icon(
IconsaxPlusBold.driver_2, user.authMethod.icon,
size: 14, size: 18,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Flexible( Flexible(
child: Text( child: Text(
user.credentials.serverName, user.name,
maxLines: 2, maxLines: 2,
softWrap: true, softWrap: true,
), )),
),
], ],
), ),
) if (user.credentials.serverName.isNotEmpty)
].addInBetween(const SizedBox(width: 4, height: 4)), Opacity(
), opacity: 0.75,
), child: Row(
if (editMode) mainAxisAlignment: MainAxisAlignment.center,
Align( mainAxisSize: MainAxisSize.max,
alignment: Alignment.topRight, children: [
child: Padding( const Icon(
padding: const EdgeInsets.all(8.0), IconsaxPlusBold.driver_2,
child: Card( size: 14,
color: Theme.of(context).colorScheme.errorContainer, ),
child: const Padding( const SizedBox(width: 4),
padding: EdgeInsets.all(8.0), Flexible(
child: Icon( child: Text(
IconsaxPlusBold.edit_2, user.credentials.serverName,
size: 14, maxLines: 2,
softWrap: true,
),
),
],
),
)
].addInBetween(const SizedBox(width: 4, height: 4)),
),
),
if (editMode)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: Theme.of(context).colorScheme.errorContainer,
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(
IconsaxPlusBold.edit_2,
size: 14,
),
),
), ),
), ),
), ),
), ],
), ),
], ),
), ),
), );
); },
},
);
}
}
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,
), ),
); );
} }

View 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(

View 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)),
), ),

View file

@ -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;
}), }),
)) ))

View file

@ -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