diff --git a/.vscode/launch.json b/.vscode/launch.json index ee28073..0c54b15 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -77,7 +77,6 @@ "name": "Web", "request": "launch", "type": "dart", - "deviceId": "chrome", "args": [ "--web-port", "9090", @@ -87,7 +86,6 @@ "name": "Web (release mode)", "request": "launch", "type": "dart", - "deviceId": "chrome", "flutterMode": "release", "args": [ "--web-port", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f7de74e..21691be 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -114,6 +114,23 @@ "group": { "kind": "build", } + }, + { + "label": "Generate Pigeon Files", + "type": "shell", + "command": "powershell", + "args": [ + "-Command", + "Get-ChildItem -Path pigeons/*.dart | ForEach-Object { dart run pigeon --input $_.FullName }" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}" + } } ], } \ No newline at end of file diff --git a/android/.gitignore b/android/.gitignore index 55afd91..0050c9b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,3 +11,5 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +**/TestData.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index 2026b86..168a5aa 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,6 +1,7 @@ plugins { id "com.android.application" id "kotlin-android" + id "org.jetbrains.kotlin.plugin.compose" // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id "dev.flutter.flutter-gradle-plugin" } @@ -8,7 +9,7 @@ plugins { def keystoreProperties = new Properties() def keystorePropertiesFile = file('key.properties') if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } def localProperties = new Properties() @@ -30,6 +31,21 @@ if (flutterVersionName == null) { } android { + packagingOptions { + jniLibs { + pickFirsts += [ + "lib/x86_64/libc++_shared.so", + "lib/arm64-v8a/libc++_shared.so", + "lib/armeabi-v7a/libc++_shared.so", + "lib/x86/libc++_shared.so", + + "lib/x86_64/libass.so", + "lib/arm64-v8a/libass.so", + "lib/armeabi-v7a/libass.so", + "lib/x86/libass.so", + ] + } + } namespace = "nl.jknaapen.fladder" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -39,8 +55,12 @@ android { targetCompatibility = JavaVersion.VERSION_1_8 } + buildFeatures { + compose = true + } + kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 + jvmTarget = "1.8" } defaultConfig { @@ -51,25 +71,29 @@ android { versionName = flutter.versionName } - signingConfigs { - release { - storeFile file('keystore.jks') - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storePassword keystoreProperties['storePassword'] + composeOptions { + kotlinCompilerExtensionVersion '1.6.4' + } + + signingConfigs { + release { + storeFile file('keystore.jks') + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storePassword keystoreProperties['storePassword'] } - } + } buildTypes { - release { + release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" - signingConfig signingConfigs.release - } - } + signingConfig signingConfigs.release + } + } flavorDimensions "default" - productFlavors { + productFlavors { development { dimension "default" applicationIdSuffix ".dev" @@ -84,3 +108,29 @@ android { flutter { source = "../.." } + +dependencies { + def composeBom = platform('androidx.compose:compose-bom:2025.09.00') + implementation composeBom + androidTestImplementation composeBom + implementation('androidx.compose.material3:material3') + implementation('androidx.compose.ui:ui-tooling-preview') + debugImplementation('androidx.compose.ui:ui-tooling') + implementation('androidx.activity:activity-compose:1.11.0') + + // Media3 (ExoPlayer) + def media3_version = "1.8.0+1" + implementation("androidx.media3:media3-exoplayer:$media3_version") + implementation("androidx.media3:media3-session:$media3_version") + implementation("androidx.media3:media3-ui:$media3_version") + + implementation("androidx.media3:media3-exoplayer-dash:$media3_version") + implementation("androidx.media3:media3-exoplayer-hls:$media3_version") + implementation("org.jellyfin.media3:media3-ffmpeg-decoder:$media3_version") + implementation("io.github.peerless2012:ass-media:0.3.0-rc03") + + //UI + implementation("io.github.rabehx:iconsax-compose:0.0.3") + implementation("io.coil-kt.coil3:coil-compose:3.3.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ec9ea68..52293ec 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,42 +1,95 @@ - - - - - - - + xmlns:tools="http://schemas.android.com/tools" + package="nl.jknaapen.fladder"> + + + + + + + - - - - - - - - - + - - - - - - + + + + + - + + + + + + + + + + + + + + + + - + - - - - - - + + + + + + diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/MainActivity.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/MainActivity.kt index 9726f5e..223023f 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/MainActivity.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/MainActivity.kt @@ -1,7 +1,77 @@ package nl.jknaapen.fladder -import io.flutter.embedding.android.FlutterFragmentActivity -import com.ryanheise.audioservice.AudioServiceFragmentActivity; +import NativeVideoActivity +import PlayerSettingsPigeon +import StartResult +import VideoPlayerApi +import VideoPlayerControlsCallback +import VideoPlayerListenerCallback +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import com.ryanheise.audioservice.AudioServiceFragmentActivity +import io.flutter.embedding.engine.FlutterEngine +import nl.jknaapen.fladder.objects.PlayerSettingsObject +import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.utility.leanBackEnabled -class MainActivity: AudioServiceFragmentActivity () { +class MainActivity : AudioServiceFragmentActivity(), NativeVideoActivity { + private lateinit var videoPlayerLauncher: ActivityResultLauncher + private var videoPlayerCallback: ((Result) -> Unit)? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + val videoPlayerHost = VideoPlayerObject + NativeVideoActivity.setUp( + flutterEngine.dartExecutor.binaryMessenger, + this + ) + VideoPlayerApi.setUp( + flutterEngine.dartExecutor.binaryMessenger, + videoPlayerHost.implementation + ) + videoPlayerHost.videoPlayerListener = + VideoPlayerListenerCallback(flutterEngine.dartExecutor.binaryMessenger) + + videoPlayerHost.videoPlayerControls = + VideoPlayerControlsCallback(flutterEngine.dartExecutor.binaryMessenger) + + PlayerSettingsPigeon.setUp( + flutterEngine.dartExecutor.binaryMessenger, + api = PlayerSettingsObject + ) + + videoPlayerLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + val callback = videoPlayerCallback + videoPlayerCallback = null + + val startResult = if (result.resultCode == RESULT_OK) { + StartResult(resultValue = result.data?.getStringExtra("result") ?: "Finished") + } else { + StartResult(resultValue = "Cancelled") + } + + callback?.invoke(Result.success(startResult)) + } + } + + override fun launchActivity(callback: (Result) -> Unit) { + try { + videoPlayerCallback = callback + val intent = Intent(this, VideoPlayerActivity::class.java) + videoPlayerLauncher.launch(intent) + } catch (e: Exception) { + e.printStackTrace() + callback(Result.failure(e)) + } + } + + override fun disposeActivity() { + VideoPlayerObject.currentActivity?.finish() + } + + override fun isLeanBackEnabled(): Boolean = leanBackEnabled(applicationContext) } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt new file mode 100644 index 0000000..9a4fda2 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt @@ -0,0 +1,26 @@ +package nl.jknaapen.fladder + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF3B82F6) + +) + +@Composable +fun VideoPlayerTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = DarkColorScheme, + ) { + CompositionLocalProvider { + content() + } + } +} + diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt new file mode 100644 index 0000000..2714d6c --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/VideoPlayerActivity.kt @@ -0,0 +1,54 @@ +package nl.jknaapen.fladder + +import android.graphics.PixelFormat +import android.os.Build +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.annotation.OptIn +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.util.UnstableApi +import nl.jknaapen.fladder.composables.controls.CustomVideoControls +import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.player.ExoPlayer +import nl.jknaapen.fladder.utility.ScaledContent +import nl.jknaapen.fladder.utility.leanBackEnabled + +class VideoPlayerActivity : ComponentActivity() { + @RequiresApi(Build.VERSION_CODES.O) + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + VideoPlayerObject.currentActivity = this + + window.setFlags( + WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED + ) + + window.setFormat(PixelFormat.TRANSLUCENT) + + setContent { + VideoPlayerTheme { + VideoPlayerScreen() + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@OptIn(UnstableApi::class) +@Composable +fun VideoPlayerScreen( +) { + val leanBackEnabled = leanBackEnabled(LocalContext.current) + ExoPlayer { player -> + ScaledContent(if (leanBackEnabled) 0.50f else 1f) { + CustomVideoControls(player) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt new file mode 100644 index 0000000..ffbf3fa --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt @@ -0,0 +1,200 @@ +// Autogenerated from Pigeon (v26.0.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object PlayerSettingsHelperPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +enum class SegmentType(val raw: Int) { + COMMERCIAL(0), + PREVIEW(1), + RECAP(2), + INTRO(3), + OUTRO(4); + + companion object { + fun ofRaw(raw: Int): SegmentType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class SegmentSkip(val raw: Int) { + ASK(0), + SKIP(1), + NONE(2); + + companion object { + fun ofRaw(raw: Int): SegmentSkip? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class PlayerSettings ( + val skipTypes: Map, + val skipForward: Long, + val skipBackward: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): PlayerSettings { + val skipTypes = pigeonVar_list[0] as Map + val skipForward = pigeonVar_list[1] as Long + val skipBackward = pigeonVar_list[2] as Long + return PlayerSettings(skipTypes, skipForward, skipBackward) + } + } + fun toList(): List { + return listOf( + skipTypes, + skipForward, + skipBackward, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is PlayerSettings) { + return false + } + if (this === other) { + return true + } + return PlayerSettingsHelperPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class PlayerSettingsHelperPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + SegmentType.ofRaw(it.toInt()) + } + } + 130.toByte() -> { + return (readValue(buffer) as Long?)?.let { + SegmentSkip.ofRaw(it.toInt()) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + PlayerSettings.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is SegmentType -> { + stream.write(129) + writeValue(stream, value.raw) + } + is SegmentSkip -> { + stream.write(130) + writeValue(stream, value.raw) + } + is PlayerSettings -> { + stream.write(131) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface PlayerSettingsPigeon { + fun sendPlayerSettings(playerSettings: PlayerSettings) + + companion object { + /** The codec used by PlayerSettingsPigeon. */ + val codec: MessageCodec by lazy { + PlayerSettingsHelperPigeonCodec() + } + /** Sets up an instance of `PlayerSettingsPigeon` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: PlayerSettingsPigeon?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.settings.PlayerSettingsPigeon.sendPlayerSettings$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val playerSettingsArg = args[0] as PlayerSettings + val wrapped: List = try { + api.sendPlayerSettings(playerSettingsArg) + listOf(null) + } catch (exception: Throwable) { + PlayerSettingsHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt new file mode 100644 index 0000000..d730a7d --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt @@ -0,0 +1,911 @@ +// Autogenerated from Pigeon (v26.0.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object VideoPlayerHelperPigeonUtils { + + fun createConnectionError(channelName: String): FlutterError { + return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") } + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +enum class MediaSegmentType(val raw: Int) { + COMMERCIAL(0), + PREVIEW(1), + RECAP(2), + INTRO(3), + OUTRO(4); + + companion object { + fun ofRaw(raw: Int): MediaSegmentType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class PlayableData ( + val id: String, + val title: String, + val subTitle: String? = null, + val logoUrl: String? = null, + val description: String, + val startPosition: Long, + val defaultAudioTrack: Long, + val audioTracks: List, + val defaultSubtrack: Long, + val subtitleTracks: List, + val trickPlayModel: TrickPlayModel? = null, + val chapters: List, + val segments: List, + val previousVideo: String? = null, + val nextVideo: String? = null, + val url: String +) + { + companion object { + fun fromList(pigeonVar_list: List): PlayableData { + val id = pigeonVar_list[0] as String + val title = pigeonVar_list[1] as String + val subTitle = pigeonVar_list[2] as String? + val logoUrl = pigeonVar_list[3] as String? + val description = pigeonVar_list[4] as String + val startPosition = pigeonVar_list[5] as Long + val defaultAudioTrack = pigeonVar_list[6] as Long + val audioTracks = pigeonVar_list[7] as List + val defaultSubtrack = pigeonVar_list[8] as Long + val subtitleTracks = pigeonVar_list[9] as List + val trickPlayModel = pigeonVar_list[10] as TrickPlayModel? + val chapters = pigeonVar_list[11] as List + val segments = pigeonVar_list[12] as List + val previousVideo = pigeonVar_list[13] as String? + val nextVideo = pigeonVar_list[14] as String? + val url = pigeonVar_list[15] as String + return PlayableData(id, title, subTitle, logoUrl, description, startPosition, defaultAudioTrack, audioTracks, defaultSubtrack, subtitleTracks, trickPlayModel, chapters, segments, previousVideo, nextVideo, url) + } + } + fun toList(): List { + return listOf( + id, + title, + subTitle, + logoUrl, + description, + startPosition, + defaultAudioTrack, + audioTracks, + defaultSubtrack, + subtitleTracks, + trickPlayModel, + chapters, + segments, + previousVideo, + nextVideo, + url, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is PlayableData) { + return false + } + if (this === other) { + return true + } + return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class MediaSegment ( + val type: MediaSegmentType, + val name: String, + val start: Long, + val end: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): MediaSegment { + val type = pigeonVar_list[0] as MediaSegmentType + val name = pigeonVar_list[1] as String + val start = pigeonVar_list[2] as Long + val end = pigeonVar_list[3] as Long + return MediaSegment(type, name, start, end) + } + } + fun toList(): List { + return listOf( + type, + name, + start, + end, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is MediaSegment) { + return false + } + if (this === other) { + return true + } + return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class AudioTrack ( + val name: String, + val languageCode: String, + val codec: String, + val index: Long, + val external: Boolean, + val url: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): AudioTrack { + val name = pigeonVar_list[0] as String + val languageCode = pigeonVar_list[1] as String + val codec = pigeonVar_list[2] as String + val index = pigeonVar_list[3] as Long + val external = pigeonVar_list[4] as Boolean + val url = pigeonVar_list[5] as String? + return AudioTrack(name, languageCode, codec, index, external, url) + } + } + fun toList(): List { + return listOf( + name, + languageCode, + codec, + index, + external, + url, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is AudioTrack) { + return false + } + if (this === other) { + return true + } + return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class SubtitleTrack ( + val name: String, + val languageCode: String, + val codec: String, + val index: Long, + val external: Boolean, + val url: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): SubtitleTrack { + val name = pigeonVar_list[0] as String + val languageCode = pigeonVar_list[1] as String + val codec = pigeonVar_list[2] as String + val index = pigeonVar_list[3] as Long + val external = pigeonVar_list[4] as Boolean + val url = pigeonVar_list[5] as String? + return SubtitleTrack(name, languageCode, codec, index, external, url) + } + } + fun toList(): List { + return listOf( + name, + languageCode, + codec, + index, + external, + url, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is SubtitleTrack) { + return false + } + if (this === other) { + return true + } + return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class Chapter ( + val name: String, + val url: String, + val time: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): Chapter { + val name = pigeonVar_list[0] as String + val url = pigeonVar_list[1] as String + val time = pigeonVar_list[2] as Long + return Chapter(name, url, time) + } + } + fun toList(): List { + return listOf( + name, + url, + time, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is Chapter) { + return false + } + if (this === other) { + return true + } + return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class TrickPlayModel ( + val width: Long, + val height: Long, + val tileWidth: Long, + val tileHeight: Long, + val thumbnailCount: Long, + val interval: Long, + val images: List +) + { + companion object { + fun fromList(pigeonVar_list: List): TrickPlayModel { + val width = pigeonVar_list[0] as Long + val height = pigeonVar_list[1] as Long + val tileWidth = pigeonVar_list[2] as Long + val tileHeight = pigeonVar_list[3] as Long + val thumbnailCount = pigeonVar_list[4] as Long + val interval = pigeonVar_list[5] as Long + val images = pigeonVar_list[6] as List + return TrickPlayModel(width, height, tileWidth, tileHeight, thumbnailCount, interval, images) + } + } + fun toList(): List { + return listOf( + width, + height, + tileWidth, + tileHeight, + thumbnailCount, + interval, + images, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is TrickPlayModel) { + return false + } + if (this === other) { + return true + } + return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class StartResult ( + val resultValue: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): StartResult { + val resultValue = pigeonVar_list[0] as String? + return StartResult(resultValue) + } + } + fun toList(): List { + return listOf( + resultValue, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is StartResult) { + return false + } + if (this === other) { + return true + } + return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class PlaybackState ( + val position: Long, + val buffered: Long, + val duration: Long, + val playing: Boolean, + val buffering: Boolean, + val completed: Boolean, + val failed: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): PlaybackState { + val position = pigeonVar_list[0] as Long + val buffered = pigeonVar_list[1] as Long + val duration = pigeonVar_list[2] as Long + val playing = pigeonVar_list[3] as Boolean + val buffering = pigeonVar_list[4] as Boolean + val completed = pigeonVar_list[5] as Boolean + val failed = pigeonVar_list[6] as Boolean + return PlaybackState(position, buffered, duration, playing, buffering, completed, failed) + } + } + fun toList(): List { + return listOf( + position, + buffered, + duration, + playing, + buffering, + completed, + failed, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is PlaybackState) { + return false + } + if (this === other) { + return true + } + return VideoPlayerHelperPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class VideoPlayerHelperPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + MediaSegmentType.ofRaw(it.toInt()) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + PlayableData.fromList(it) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + MediaSegment.fromList(it) + } + } + 132.toByte() -> { + return (readValue(buffer) as? List)?.let { + AudioTrack.fromList(it) + } + } + 133.toByte() -> { + return (readValue(buffer) as? List)?.let { + SubtitleTrack.fromList(it) + } + } + 134.toByte() -> { + return (readValue(buffer) as? List)?.let { + Chapter.fromList(it) + } + } + 135.toByte() -> { + return (readValue(buffer) as? List)?.let { + TrickPlayModel.fromList(it) + } + } + 136.toByte() -> { + return (readValue(buffer) as? List)?.let { + StartResult.fromList(it) + } + } + 137.toByte() -> { + return (readValue(buffer) as? List)?.let { + PlaybackState.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is MediaSegmentType -> { + stream.write(129) + writeValue(stream, value.raw) + } + is PlayableData -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is MediaSegment -> { + stream.write(131) + writeValue(stream, value.toList()) + } + is AudioTrack -> { + stream.write(132) + writeValue(stream, value.toList()) + } + is SubtitleTrack -> { + stream.write(133) + writeValue(stream, value.toList()) + } + is Chapter -> { + stream.write(134) + writeValue(stream, value.toList()) + } + is TrickPlayModel -> { + stream.write(135) + writeValue(stream, value.toList()) + } + is StartResult -> { + stream.write(136) + writeValue(stream, value.toList()) + } + is PlaybackState -> { + stream.write(137) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface NativeVideoActivity { + fun launchActivity(callback: (Result) -> Unit) + fun disposeActivity() + fun isLeanBackEnabled(): Boolean + + companion object { + /** The codec used by NativeVideoActivity. */ + val codec: MessageCodec by lazy { + VideoPlayerHelperPigeonCodec() + } + /** Sets up an instance of `NativeVideoActivity` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: NativeVideoActivity?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.launchActivity$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.launchActivity{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(VideoPlayerHelperPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(VideoPlayerHelperPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.disposeActivity$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.disposeActivity() + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.isLeanBackEnabled$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.isLeanBackEnabled()) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface VideoPlayerApi { + fun sendPlayableModel(playableData: PlayableData): Boolean + fun open(url: String, play: Boolean) + fun setLooping(looping: Boolean) + /** Sets the volume, with 0.0 being muted and 1.0 being full volume. */ + fun setVolume(volume: Double) + /** Sets the playback speed as a multiple of normal speed. */ + fun setPlaybackSpeed(speed: Double) + fun play() + /** Pauses playback if the video is currently playing. */ + fun pause() + /** Seeks to the given playback position, in milliseconds. */ + fun seekTo(position: Long) + fun stop() + + companion object { + /** The codec used by VideoPlayerApi. */ + val codec: MessageCodec by lazy { + VideoPlayerHelperPigeonCodec() + } + /** Sets up an instance of `VideoPlayerApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: VideoPlayerApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.sendPlayableModel$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val playableDataArg = args[0] as PlayableData + val wrapped: List = try { + listOf(api.sendPlayableModel(playableDataArg)) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.open$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + val playArg = args[1] as Boolean + val wrapped: List = try { + api.open(urlArg, playArg) + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setLooping$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val loopingArg = args[0] as Boolean + val wrapped: List = try { + api.setLooping(loopingArg) + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setVolume$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val volumeArg = args[0] as Double + val wrapped: List = try { + api.setVolume(volumeArg) + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setPlaybackSpeed$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val speedArg = args[0] as Double + val wrapped: List = try { + api.setPlaybackSpeed(speedArg) + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.play$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.play() + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.pause$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.pause() + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.seekTo$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val positionArg = args[0] as Long + val wrapped: List = try { + api.seekTo(positionArg) + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.stop$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.stop() + listOf(null) + } catch (exception: Throwable) { + VideoPlayerHelperPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class VideoPlayerListenerCallback(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by VideoPlayerListenerCallback. */ + val codec: MessageCodec by lazy { + VideoPlayerHelperPigeonCodec() + } + } + fun onPlaybackStateChanged(stateArg: PlaybackState, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(stateArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class VideoPlayerControlsCallback(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by VideoPlayerControlsCallback. */ + val codec: MessageCodec by lazy { + VideoPlayerHelperPigeonCodec() + } + } + fun loadNextVideo(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadNextVideo$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } + fun loadPreviousVideo(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadPreviousVideo$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onStop(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onStop$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } + fun swapSubtitleTrack(valueArg: Long, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(valueArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } + fun swapAudioTrack(valueArg: Long, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(valueArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(VideoPlayerHelperPigeonUtils.createConnectionError(channelName))) + } + } + } +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomIconButton.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomIconButton.kt new file mode 100644 index 0000000..d7d9941 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/CustomIconButton.kt @@ -0,0 +1,89 @@ +package nl.jknaapen.fladder.composables.controls + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import nl.jknaapen.fladder.utility.conditional +import nl.jknaapen.fladder.utility.highlightOnFocus + +@Composable +internal fun CustomIconButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + enabled: Boolean = true, + enableFocusIndicator: Boolean = true, + enableScaledFocus: Boolean = false, + backgroundColor: Color = Color.Transparent, + foreGroundColor: Color = Color.White, + backgroundFocusedColor: Color = Color.Transparent, + foreGroundFocusedColor: Color = Color.White, + icon: @Composable () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + var isFocused by remember { mutableStateOf(false) } + val currentContentColor by remember { + derivedStateOf { + if (isFocused) { + foreGroundFocusedColor + } else { + foreGroundColor + } + } + } + + val currentBackgroundColor by remember { + derivedStateOf { + if (isFocused) { + backgroundFocusedColor + } else { + backgroundColor + } + } + } + + Box( + modifier = modifier + .wrapContentSize() // parent expands to fit children + .conditional(enableScaledFocus) { + scale(if (isFocused) 1.05f else 1f) + } + .conditional(enableFocusIndicator) { + highlightOnFocus() + } + .background(currentBackgroundColor, shape = RoundedCornerShape(8.dp)) + .onFocusChanged { isFocused = it.isFocused } + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .alpha(if (enabled) 1f else 0.5f), + contentAlignment = Alignment.Center + ) { + CompositionLocalProvider(LocalContentColor provides currentContentColor) { + Box(modifier = Modifier.padding(8.dp)) { + icon() + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt new file mode 100644 index 0000000..5fc23f1 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt @@ -0,0 +1,51 @@ +package nl.jknaapen.fladder.composables.controls + +import PlayableData +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage + +@Composable +fun ItemHeader(state: PlayableData?) { + val title = state?.title + val logoUrl = state?.logoUrl + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.CenterStart + ) { + if (!logoUrl.isNullOrBlank()) { + AsyncImage( + model = logoUrl, + contentDescription = title ?: "logo", + alignment = Alignment.CenterStart, + modifier = Modifier + .heightIn(max = 100.dp) + .widthIn(max = 200.dp) + ) + } else { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineMedium.copy( + color = Color.White, + fontWeight = FontWeight.Bold + ) + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt new file mode 100644 index 0000000..174bcea --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt @@ -0,0 +1,423 @@ +package nl.jknaapen.fladder.composables.controls + +import MediaSegment +import MediaSegmentType +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.Key.Companion.Back +import androidx.compose.ui.input.key.Key.Companion.ButtonSelect +import androidx.compose.ui.input.key.Key.Companion.DirectionCenter +import androidx.compose.ui.input.key.Key.Companion.DirectionLeft +import androidx.compose.ui.input.key.Key.Companion.DirectionRight +import androidx.compose.ui.input.key.Key.Companion.Enter +import androidx.compose.ui.input.key.Key.Companion.Escape +import androidx.compose.ui.input.key.Key.Companion.Spacebar +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.media3.exoplayer.ExoPlayer +import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.utility.formatTime +import kotlin.math.max +import kotlin.math.min +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +internal fun ProgressBar( + modifier: Modifier = Modifier, + player: ExoPlayer, + bottomControlFocusRequester: FocusRequester, + onUserInteraction: () -> Unit = {} +) { + val position by VideoPlayerObject.position.collectAsState(0L) + val duration by VideoPlayerObject.duration.collectAsState(0L) + + var tempPosition by remember { mutableLongStateOf(position) } + var scrubbingTimeLine by remember { mutableStateOf(false) } + + val playableData by VideoPlayerObject.implementation.playbackData.collectAsState(null) + + val currentPosition by remember { + derivedStateOf { + if (scrubbingTimeLine) { + tempPosition + } else { + position + } + } + } + + Column { + val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState(null) + if (scrubbingTimeLine) + FilmstripTrickPlayOverlay( + modifier = Modifier + .fillMaxWidth() + .height(125.dp) + .align(alignment = Alignment.CenterHorizontally), + currentPosition = tempPosition.milliseconds, + trickPlayModel = playbackData?.trickPlayModel + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + val subTitle = playableData?.subTitle + subTitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge.copy(color = Color.White), + ) + } + VideoEndTime() + } + Row( + horizontalArrangement = Arrangement.spacedBy( + 8.dp, + alignment = Alignment.CenterHorizontally + ), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth() + ) { + Text( + formatTime(currentPosition), + color = Color.White, + style = MaterialTheme.typography.labelMedium + ) + SimpleProgressBar( + player, + bottomControlFocusRequester, + onUserInteraction, + tempPosition, + scrubbingTimeLine, + onTempPosChanged = { + tempPosition = it + }, + onScrubbingChanged = { + scrubbingTimeLine = it + } + ) + Text( + "-" + formatTime( + (duration - currentPosition).fastCoerceIn( + minimumValue = 0L, + maximumValue = duration + ) + ), + color = Color.White, + style = MaterialTheme.typography.labelMedium + ) + } + } + +} + +@Composable +internal fun RowScope.SimpleProgressBar( + player: ExoPlayer, + playFocusRequester: FocusRequester, + onUserInteraction: () -> Unit, + tempPosition: Long, + scrubbingTimeLine: Boolean, + onTempPosChanged: (Long) -> Unit = {}, + onScrubbingChanged: (Boolean) -> Unit = {} +) { + val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState() + + var width by remember { mutableIntStateOf(0) } + val position by VideoPlayerObject.position.collectAsState(0L) + val duration by VideoPlayerObject.duration.collectAsState(0L) + + val slideBarShape = RoundedCornerShape(size = 8.dp) + + var thumbFocused by remember { mutableStateOf(false) } + + var internalTempPosition by remember { mutableLongStateOf(0L) } + + val progress by remember(scrubbingTimeLine, tempPosition, position) { + derivedStateOf { + if (scrubbingTimeLine) { + tempPosition.toFloat() / duration.toFloat() + } else { + position.toFloat() / duration.toFloat() + } + } + } + + Box( + modifier = Modifier + .weight(1f) + .onGloballyPositioned( + onGloballyPositioned = { + width = it.size.width + } + ) + .heightIn(min = 26.dp) + .pointerInput(Unit) { + detectTapGestures { offset -> + onUserInteraction() + val clickRelativeOffset = offset.x / width.toFloat() + val newPosition = duration.milliseconds * clickRelativeOffset.toDouble() + player.seekTo(newPosition.toLong(DurationUnit.MILLISECONDS)) + } + } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + onScrubbingChanged(true) + onUserInteraction() + onTempPosChanged(player.currentPosition) + }, + onDrag = { change, dragAmount -> + onUserInteraction() + change.consume() + val relative = change.position.x / size.width.toFloat() + internalTempPosition = (duration.milliseconds * relative.toDouble()) + .toLong(DurationUnit.MILLISECONDS) + onTempPosChanged( + internalTempPosition + ) + }, + onDragEnd = { + onScrubbingChanged(false) + player.seekTo(internalTempPosition) + }, + onDragCancel = { + onScrubbingChanged(false) + } + ) + }, + contentAlignment = Alignment.CenterStart, + ) { + Box( + modifier = Modifier + .focusable(enabled = false) + .fillMaxWidth() + .height(12.dp) + .background( + color = Color.Black.copy(alpha = 0.15f), + shape = slideBarShape + ), + ) { + Box( + modifier = Modifier + .focusable(enabled = false) + .fillMaxHeight() + .fillMaxWidth(progress) + .padding(end = 9.dp) + .background( + color = Color.White.copy(alpha = 0.75f), + shape = slideBarShape + ) + ) + + val density = LocalDensity.current + + val mediaSegments = playbackData?.segments + if (width > 0 && duration.toDuration(DurationUnit.MILLISECONDS) > Duration.ZERO) { + mediaSegments?.forEach { segment -> + val segStartMs = max( + 0.0, + segment.start.toDuration(DurationUnit.MILLISECONDS) + .toDouble(DurationUnit.MILLISECONDS) + ) + val segEndMs = max( + segStartMs, + segment.end.toDuration(DurationUnit.MILLISECONDS) + .toDouble(DurationUnit.MILLISECONDS) + ) + val durMs = duration.toDouble().coerceAtLeast(1.0) + + if (segStartMs >= durMs) return@forEach + + val startPx = (width * (segStartMs / durMs)).toFloat() + val segPx = + (width * ((segEndMs - segStartMs) / durMs)).toFloat().coerceAtLeast(1f) + + val segDp = with(density) { segPx.toDp() } + Box( + modifier = Modifier + .focusable(enabled = false) + .graphicsLayer { + translationX = startPx + translationY = 16.dp.toPx() + } + .width(segDp) + .height(6.dp) + .background( + color = segment.color.copy(alpha = 0.75f), + shape = RoundedCornerShape(8.dp) + ) + ) + } + } + + //Generate chapter dots + val chapters = playbackData?.chapters ?: listOf() + chapters.forEach { chapter -> + val chapterDuration = chapter.time.toDuration(DurationUnit.SECONDS) + .toDouble(DurationUnit.SECONDS) + val isAfterCurrentPositon = chapterDuration > position.toDouble() + val segStartMs = max( + 0.0, + chapterDuration + ) + + val durMs = duration.toDouble().coerceAtLeast(1.0) + val startPx = (width * (segStartMs / durMs)).toFloat() + + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(horizontal = 2.dp) + .focusable(enabled = false) + .graphicsLayer { + translationX = startPx + } + .size(6.dp) + .background( + color = (if (isAfterCurrentPositon) Color.White else Color.Black).copy( + alpha = 0.45f + ), + shape = CircleShape + ) + ) + } + } + + //Thumb + Box( + modifier = Modifier + .onFocusChanged { state: FocusState -> + thumbFocused = state.isFocused + if (!state.isFocused) { + onScrubbingChanged(false) + } else { + onTempPosChanged(position) + } + } + .focusable(enabled = true) + .onKeyEvent { keyEvent: KeyEvent -> + if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false + + onUserInteraction() + + when (keyEvent.key) { + Key.DirectionDown -> { + playFocusRequester.requestFocus() + onScrubbingChanged(false) + true + } + + DirectionLeft -> { + if (!scrubbingTimeLine) { + onTempPosChanged(position) + onScrubbingChanged(true) + player.pause() + } + val newPos = max(0L, tempPosition - 3000L) + onTempPosChanged(newPos) + true + } + + DirectionRight -> { + if (!scrubbingTimeLine) { + onTempPosChanged(position) + onScrubbingChanged(true) + player.pause() + } + val newPos = min(player.duration.takeIf { it > 0 } ?: 1L, + tempPosition + 3000L) + onTempPosChanged(newPos) + true + } + + Enter, Spacebar, ButtonSelect, DirectionCenter -> { + if (scrubbingTimeLine) { + player.seekTo(tempPosition) + player.play() + onScrubbingChanged(false) + true + } else false + } + + Escape, Back -> { + if (scrubbingTimeLine) { + onScrubbingChanged(false) + player.play() + true + } + false + } + + else -> false + } + } + .graphicsLayer { + translationX = (width * progress) - 4.dp.toPx() + } + .background( + color = Color.White, + shape = CircleShape, + ) + .width(8.dp) + .height(if (thumbFocused) 21.dp else 8.dp) + ) + } +} + +val MediaSegment.color: Color + get() = when (this.type) { + MediaSegmentType.COMMERCIAL -> Color.Magenta + MediaSegmentType.PREVIEW -> Color(255, 128, 0) + MediaSegmentType.RECAP -> Color(135, 206, 250) + MediaSegmentType.OUTRO -> Color.Yellow + MediaSegmentType.INTRO -> Color.Green + } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt new file mode 100644 index 0000000..ba6cf7b --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/SkipOverlay.kt @@ -0,0 +1,180 @@ +package nl.jknaapen.fladder.composables.controls + +import MediaSegment +import MediaSegmentType +import SegmentSkip +import SegmentType +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import nl.jknaapen.fladder.objects.PlayerSettingsObject +import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.utility.defaultSelected +import nl.jknaapen.fladder.utility.leanBackEnabled +import kotlin.time.Duration.Companion.milliseconds + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +internal fun BoxScope.SegmentSkipOverlay( + modifier: Modifier = Modifier, +) { + + val isAndroidTV = leanBackEnabled(LocalContext.current) + val focusRequester = remember { FocusRequester() } + + val state by VideoPlayerObject.implementation.playbackData.collectAsState() + val position by VideoPlayerObject.position.collectAsState(0L) + + val segments = state?.segments ?: emptyList() + val player = VideoPlayerObject.implementation.player + val skipMap by PlayerSettingsObject.skipMap.collectAsState(mapOf()) + + var isFocused by remember { mutableStateOf(false) } + + LaunchedEffect(segments, skipMap) { } + + if (segments.isEmpty() || player == null) return + + val activeSegment = segments.firstOrNull { it.start <= position && it.end >= position } + + val segmentType = activeSegment?.type?.toSegment + val skip = skipMap[segmentType] + + fun skipSegment(segment: MediaSegment) { + player.seekTo(segment.end + 250.milliseconds.inWholeMilliseconds) + } + + LaunchedEffect(activeSegment, position, skipMap) { + if (skipMap.isEmpty()) return@LaunchedEffect + if (activeSegment != null) { + if (skip == SegmentSkip.SKIP) { + skipSegment(activeSegment) + } + } + } + + LaunchedEffect(activeSegment) { + if (activeSegment != null) { + focusRequester.captureFocus() + } + } + + val shape = RoundedCornerShape(8.dp) + + AnimatedVisibility( + activeSegment != null && skip == SegmentSkip.ASK, + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .safeContentPadding() + ) { + Box { + FilledTonalButton( + modifier = modifier + .align(alignment = Alignment.CenterEnd) + .focusRequester(focusRequester) + .onFocusChanged { state -> + isFocused = state.isFocused + } + .border( + width = 2.dp, + color = if (isFocused) Color.White.copy(alpha = 0.4f) else Color.Transparent, + shape = shape, + ) + .defaultSelected(true), + contentPadding = PaddingValues(horizontal = 12.dp), + shape = shape, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = Color.White.copy(alpha = 0.75f), + contentColor = Color.Black, + ), + onClick = { + activeSegment?.let { + player.seekTo(it.end) + } + } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isAndroidTV) { + Box( + modifier = Modifier + .size(24.dp) + .background( + color = Color.Black.copy(alpha = 0.15f), + shape = CircleShape, + ) + .border( + width = 1.5.dp, + color = Color.Black.copy(alpha = 0.15f), + shape = CircleShape, + ) + ) { + Box( + modifier = Modifier + .padding(7.dp) + .fillMaxSize() + .background( + color = Color.White, + shape = CircleShape, + ) + ) { + + } + } + } + activeSegment?.let { + Text( + "Skip ${it.name.lowercase()}", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold) + ) + } + } + } + } + } +} + +private val MediaSegmentType.toSegment: SegmentType + get() = when (this) { + MediaSegmentType.COMMERCIAL -> SegmentType.COMMERCIAL + MediaSegmentType.PREVIEW -> SegmentType.PREVIEW + MediaSegmentType.RECAP -> SegmentType.RECAP + MediaSegmentType.INTRO -> SegmentType.INTRO + MediaSegmentType.OUTRO -> SegmentType.OUTRO + } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/TrickplayOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/TrickplayOverlay.kt new file mode 100644 index 0000000..a8544da --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/TrickplayOverlay.kt @@ -0,0 +1,195 @@ +package nl.jknaapen.fladder.composables.controls + +import TrickPlayModel +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import coil3.compose.AsyncImagePainter +import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.toBitmap +import kotlin.time.Duration + +private data class ThumbnailData(val tileUrl: String, val offset: Pair) + +@Composable +fun FilmstripTrickPlayOverlay( + modifier: Modifier = Modifier, + currentPosition: Duration, + trickPlayModel: TrickPlayModel?, + thumbnailsToShowOnEachSide: Int = 2, +) { + if (trickPlayModel == null) { + return + } + + val uniqueThumbnails = remember(currentPosition, trickPlayModel, thumbnailsToShowOnEachSide) { + val currentFrameIndex = (currentPosition.inWholeMilliseconds / trickPlayModel.interval) + .toInt() + .coerceIn(0, (trickPlayModel.thumbnailCount - 1).toInt()) + + val currentFrame = trickPlayModel.getThumbnailDetailsForIndex(currentFrameIndex) + if (currentFrame == null) return@remember emptyList() + + val foundThumbnails = mutableSetOf(currentFrame) + val previousFrames = mutableListOf() + val nextFrames = mutableListOf() + + var searchIndex = currentFrameIndex - 1 + while (previousFrames.size < thumbnailsToShowOnEachSide && searchIndex >= 0) { + trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let { + if (foundThumbnails.add(it)) previousFrames.add(it) + } + searchIndex-- + } + + searchIndex = currentFrameIndex + 1 + while (nextFrames.size < thumbnailsToShowOnEachSide && searchIndex < trickPlayModel.thumbnailCount) { + trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let { + if (foundThumbnails.add(it)) nextFrames.add(it) + } + searchIndex++ + } + + if (previousFrames.size < thumbnailsToShowOnEachSide) { + var extraNeeded = thumbnailsToShowOnEachSide - previousFrames.size + while (extraNeeded > 0 && searchIndex < trickPlayModel.thumbnailCount) { + trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let { + if (foundThumbnails.add(it)) { + nextFrames.add(it) + extraNeeded-- + } + } + searchIndex++ + } + } else if (nextFrames.size < thumbnailsToShowOnEachSide) { + var extraNeeded = thumbnailsToShowOnEachSide - nextFrames.size + searchIndex = currentFrameIndex - previousFrames.size - 1 + while (extraNeeded > 0 && searchIndex >= 0) { + trickPlayModel.getThumbnailDetailsForIndex(searchIndex)?.let { + if (foundThumbnails.add(it)) { + previousFrames.add(it) + extraNeeded-- + } + } + searchIndex-- + } + } + + previousFrames.reversed() + currentFrame + nextFrames + } + + val currentFrameData = trickPlayModel.getThumbnailDetailsForIndex( + (currentPosition.inWholeMilliseconds / trickPlayModel.interval).toInt() + ) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + uniqueThumbnails.forEach { thumbnailData -> + val isCenter = thumbnailData == currentFrameData + val scale = if (isCenter) 1.2f else 1.0f + val alpha = if (isCenter) 1.0f else 0.7f + + Thumbnail( + modifier = Modifier + .weight(1f) + .zIndex(if (isCenter) 1f else 0f) + .fillMaxHeight() + .padding(horizontal = 4.dp) + .padding(bottom = 8.dp) + .scale(scale) + .graphicsLayer { + this.alpha = alpha + }, + trickPlayModel = trickPlayModel, + tileUrl = thumbnailData.tileUrl, + offset = thumbnailData.offset + ) + } + } +} + +@Composable +private fun Thumbnail( + modifier: Modifier = Modifier, + trickPlayModel: TrickPlayModel, + tileUrl: String, + offset: Pair, +) { + val context = LocalContext.current + val painter = rememberAsyncImagePainter( + ImageRequest.Builder(context) + .data(tileUrl) + .build() + ) + val imageState by painter.state.collectAsState() + + Box( + modifier = modifier + .aspectRatio(16f / 9f) + .clip( + shape = RoundedCornerShape(12.dp) + ) + ) { + when (val state = imageState) { + is AsyncImagePainter.State.Success -> { + val imageBitmap = state.result.image.toBitmap().asImageBitmap() + Canvas(modifier = Modifier.matchParentSize()) { + val (offsetX, offsetY) = offset + drawImage( + image = imageBitmap, + srcOffset = IntOffset(offsetX.toInt(), offsetY.toInt()), + srcSize = IntSize( + trickPlayModel.width.toInt(), + trickPlayModel.height.toInt() + ), + dstSize = IntSize(size.width.toInt(), size.height.toInt()) + ) + } + } + + else -> return@Box + } + } +} + + +val TrickPlayModel.imagesPerTile: Int + get() = (tileWidth * tileHeight).toInt() + +private fun TrickPlayModel.getThumbnailDetailsForIndex(index: Int): ThumbnailData? { + val safeIndex = index.coerceIn(0, (thumbnailCount - 1).toInt()) + val indexOfTile = (safeIndex / imagesPerTile).coerceIn(0, images.size - 1) + val tileUrl = images.getOrNull(indexOfTile) ?: return null + + val tileIndex = safeIndex % imagesPerTile + val column = tileIndex % tileWidth + val row = tileIndex / tileWidth + val offset = Pair( + (width * column).toDouble(), + (height * row).toDouble() + ) + + return ThumbnailData(tileUrl, offset) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoEndTime.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoEndTime.kt new file mode 100644 index 0000000..40f8e6d --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoEndTime.kt @@ -0,0 +1,44 @@ +package nl.jknaapen.fladder.composables.controls + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import nl.jknaapen.fladder.objects.VideoPlayerObject +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.toJavaInstant + +@RequiresApi(Build.VERSION_CODES.O) +@OptIn(ExperimentalTime::class) +@Composable +fun VideoEndTime() { + val startInstant = remember { Clock.System.now() } + val durationMs by VideoPlayerObject.duration.collectAsState(initial = 0L) + val zone = ZoneId.systemDefault() + + val javaInstant = remember(startInstant) { startInstant.toJavaInstant() } + val endJavaInstant = remember(javaInstant, durationMs) { + javaInstant.plusMillis(durationMs) + } + val endZoned = remember(endJavaInstant, zone) { + endJavaInstant.atZone(zone) + } + + val formatter = DateTimeFormatter.ofPattern("hh:mm a") + val formattedEnd = remember(endZoned, formatter) { + endZoned.format(formatter) + } + + Text( + text = "ends at $formattedEnd", + style = MaterialTheme.typography.bodyLarge.copy(color = Color.White), + ) +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt new file mode 100644 index 0000000..22a94e6 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt @@ -0,0 +1,448 @@ +package nl.jknaapen.fladder.composables.controls + +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.annotation.OptIn +import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import io.github.rabehx.iconsax.Iconsax +import io.github.rabehx.iconsax.filled.AudioSquare +import io.github.rabehx.iconsax.filled.Backward +import io.github.rabehx.iconsax.filled.Check +import io.github.rabehx.iconsax.filled.Forward +import io.github.rabehx.iconsax.filled.PauseCircle +import io.github.rabehx.iconsax.filled.PlayCircle +import io.github.rabehx.iconsax.filled.Subtitle +import io.github.rabehx.iconsax.outline.CloseSquare +import io.github.rabehx.iconsax.outline.Refresh +import kotlinx.coroutines.delay +import nl.jknaapen.fladder.composables.dialogs.AudioPicker +import nl.jknaapen.fladder.composables.dialogs.ChapterSelectionSheet +import nl.jknaapen.fladder.composables.dialogs.SubtitlePicker +import nl.jknaapen.fladder.objects.PlayerSettingsObject +import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.utility.ImmersiveSystemBars +import nl.jknaapen.fladder.utility.defaultSelected +import nl.jknaapen.fladder.utility.leanBackEnabled +import nl.jknaapen.fladder.utility.visible +import kotlin.time.Duration.Companion.seconds + + +@RequiresApi(Build.VERSION_CODES.O) +@OptIn(UnstableApi::class) +@Composable +fun CustomVideoControls( + exoPlayer: ExoPlayer, +) { + var showControls by remember { mutableStateOf(false) } + val lastInteraction = remember { mutableLongStateOf(System.currentTimeMillis()) } + + val showAudioDialog = remember { mutableStateOf(false) } + val showSubDialog = remember { mutableStateOf(false) } + var showChapterDialog by remember { mutableStateOf(false) } + + val interactionSource = remember { MutableInteractionSource() } + + val activity = LocalActivity.current + + val buffering by VideoPlayerObject.buffering.collectAsState(true) + val playing by VideoPlayerObject.playing.collectAsState(false) + + ImmersiveSystemBars(isImmersive = !showControls) + + BackHandler( + enabled = showControls + ) { + showControls = false + } + + // Restart the hide timer whenever `lastInteraction` changes. + LaunchedEffect(lastInteraction.longValue) { + delay(5.seconds) + showControls = false + } + + val bottomControlFocusRequester = remember { FocusRequester() } + + fun updateLastInteraction() { + showControls = true + lastInteraction.longValue = System.currentTimeMillis() + } + + Box( + modifier = Modifier + .fillMaxSize() + .focusable(enabled = false) + .onFocusChanged { focusState -> + if (focusState.hasFocus) { + bottomControlFocusRequester.requestFocus() + } + } + .onKeyEvent { keyEvent: KeyEvent -> + if (keyEvent.type != KeyEventType.KeyDown) return@onKeyEvent false + if (!showControls) { + bottomControlFocusRequester.requestFocus() + } + updateLastInteraction() + return@onKeyEvent false + } + .clickable( + indication = null, + interactionSource = interactionSource, + ) { + showControls = !showControls + if (showControls) lastInteraction.longValue = System.currentTimeMillis() + }, + contentAlignment = Alignment.BottomCenter + ) { + Box( + modifier = Modifier.visible( + visible = showControls, + ) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .background( + brush = Brush.linearGradient( + colors = listOf( + Color.Black.copy(alpha = 0.85f), + Color.Black.copy(alpha = 0f), + ), + start = Offset(0f, 0f), + end = Offset(0f, Float.POSITIVE_INFINITY) + ), + ) + .safeContentPadding(), + horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.Start) + ) { + Column( + modifier = Modifier.weight(1f), + ) { + val state by VideoPlayerObject.implementation.playbackData.collectAsState( + null + ) + state?.let { + ItemHeader(it) + } + } + if (!leanBackEnabled(LocalContext.current)) { + IconButton( + { + activity?.finish() + } + ) { + Icon( + Iconsax.Outline.CloseSquare, + modifier = Modifier + .size(48.dp) + .focusable(false), + contentDescription = "Close icon", + tint = Color.White, + ) + } + } + } + Spacer(modifier = Modifier.weight(1f)) + // Progress Bar + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .displayCutoutPadding(), + ) { + ProgressBar( + modifier = Modifier, + exoPlayer, bottomControlFocusRequester, ::updateLastInteraction + ) + } + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.linearGradient( + colors = listOf( + Color.Black.copy(alpha = 0f), + Color.Black.copy(alpha = 0.85f), + ), + start = Offset(0f, 0f), + end = Offset(0f, Float.POSITIVE_INFINITY) + ), + ) + .displayCutoutPadding() + .padding(horizontal = 16.dp) + .padding(top = 8.dp, bottom = 16.dp) + ) { + LeftButtons( + openChapterSelection = { + showChapterDialog = true + } + ) + PlaybackButtons(exoPlayer, bottomControlFocusRequester) + RightButtons(showAudioDialog, showSubDialog) + } + } + } + SegmentSkipOverlay() + if (buffering && !playing) { + CircularProgressIndicator( + modifier = Modifier + .align(alignment = Alignment.Center) + ) + } + } + + if (showAudioDialog.value) { + AudioPicker( + player = exoPlayer, + onDismissRequest = { + showAudioDialog.value = false + } + ) + } + + if (showSubDialog.value) { + SubtitlePicker( + player = exoPlayer, + onDismissRequest = { + showSubDialog.value = false + } + ) + } + + if (showChapterDialog) { + ChapterSelectionSheet( + onSelected = { + exoPlayer.seekTo(it.time) + showChapterDialog = false + }, + onDismiss = { + showChapterDialog = false + } + ) + } +} + +// Control Buttons +@Composable +fun PlaybackButtons( + player: ExoPlayer, + bottomControlFocusRequester: FocusRequester, +) { + val state by VideoPlayerObject.videoPlayerState.collectAsState(null) + + val forwardSpeed by PlayerSettingsObject.forwardSpeed.collectAsState(30.seconds) + val backwardSpeed by PlayerSettingsObject.backwardSpeed.collectAsState(15.seconds) + + val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState(null) + val nextVideo = playbackData?.nextVideo + val previousVideo = playbackData?.previousVideo + + val isPlaying = state?.playing ?: false + + Row( + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 6.dp) + .wrapContentWidth(), + horizontalArrangement = Arrangement.spacedBy( + 8.dp, + alignment = Alignment.CenterHorizontally + ), + verticalAlignment = Alignment.CenterVertically, + ) { + CustomIconButton( + onClick = { VideoPlayerObject.videoPlayerControls?.loadPreviousVideo {} }, + enabled = previousVideo != null, + ) { + Icon( + Iconsax.Filled.Backward, + modifier = Modifier.size(32.dp), + contentDescription = previousVideo, + tint = Color.White + ) + } + CustomIconButton( + onClick = { + player.seekTo( + player.currentPosition - backwardSpeed.inWholeMilliseconds + ) + }, + ) { + Box( + modifier = Modifier + .wrapContentSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + Iconsax.Outline.Refresh, + contentDescription = "Forward", + modifier = Modifier + .size(48.dp), + ) + Text("-${backwardSpeed.inWholeSeconds}") + } + } + CustomIconButton( + modifier = Modifier + .focusRequester(bottomControlFocusRequester) + .defaultSelected(true), + enableScaledFocus = true, + onClick = { + if (player.isPlaying) player.pause() else player.play() + }, + ) { + Icon( + if (isPlaying) Iconsax.Filled.PauseCircle else Iconsax.Filled.PlayCircle, + modifier = Modifier.size(55.dp), + contentDescription = if (isPlaying) "Pause" else "Play", + ) + } + CustomIconButton( + onClick = { + player.seekTo( + player.currentPosition + forwardSpeed.inWholeMilliseconds + ) + }, + ) { + Box( + modifier = Modifier + .wrapContentSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + Iconsax.Outline.Refresh, + contentDescription = "Forward", + modifier = Modifier + .size(48.dp) + .scale(scaleX = -1f, scaleY = 1f), + ) + Text(forwardSpeed.inWholeSeconds.toString()) + } + } + + CustomIconButton( + onClick = { VideoPlayerObject.videoPlayerControls?.loadNextVideo {} }, + enabled = nextVideo != null, + ) { + Icon( + Iconsax.Filled.Forward, + modifier = Modifier.size(32.dp), + contentDescription = nextVideo, + ) + } + } +} + +@Composable +internal fun RowScope.LeftButtons( + openChapterSelection: () -> Unit, +) { + val chapters by VideoPlayerObject.chapters.collectAsState(emptyList()) + + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.Start + ) { + CustomIconButton( + onClick = openChapterSelection, + enabled = chapters?.isNotEmpty() == true + ) { + Icon( + Iconsax.Filled.Check, + modifier = Modifier.size(32.dp), + contentDescription = "Show chapters", + ) + } + } +} + +@Composable +internal fun RowScope.RightButtons( + showAudioDialog: MutableState, + showSubDialog: MutableState +) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.End + ) { + CustomIconButton( + onClick = { + showAudioDialog.value = true + }, + ) { + Icon( + Iconsax.Filled.AudioSquare, + modifier = Modifier.size(32.dp), + contentDescription = "Audio Track", + ) + } + CustomIconButton( + onClick = { + showSubDialog.value = true + }, + ) { + Icon( + Iconsax.Filled.Subtitle, + modifier = Modifier.size(32.dp), + contentDescription = "Subtitles", + ) + } + } +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt new file mode 100644 index 0000000..f6eb79f --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt @@ -0,0 +1,92 @@ +package nl.jknaapen.fladder.composables.dialogs + +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.utility.clearAudioTrack +import nl.jknaapen.fladder.utility.defaultSelected +import nl.jknaapen.fladder.utility.setInternalAudioTrack + +@OptIn(UnstableApi::class) +@Composable +fun AudioPicker( + player: ExoPlayer, + onDismissRequest: () -> Unit, +) { + val selectedIndex by VideoPlayerObject.currentAudioTrackIndex.collectAsState() + val audioTracks by VideoPlayerObject.audioTracks.collectAsState(listOf()) + val internalAudioTracks by VideoPlayerObject.exoAudioTracks + + val listState = rememberLazyListState() + + LaunchedEffect(selectedIndex) { + listState.scrollToItem( + audioTracks.indexOfFirst { it.index == selectedIndex.toLong() } + ) + } + + CustomModalBottomSheet( + onDismissRequest, + maxWidth = 600.dp, + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + item { + TrackButton( + modifier = Modifier + .fillMaxWidth() + .defaultSelected(-1 == selectedIndex), + onClick = { + VideoPlayerObject.setAudioTrackIndex(-1) + player.clearAudioTrack() + }, + selected = -1 == selectedIndex + ) { + Text( + text = "Off", + ) + } + } + internalAudioTracks.forEachIndexed { index, track -> + val serverTrack = audioTracks.elementAtOrNull(index + 1) + val selected = serverTrack?.index == selectedIndex.toLong() + item { + TrackButton( + modifier = Modifier + .fillMaxWidth() + .defaultSelected(selected), + onClick = { + serverTrack?.index?.let { + VideoPlayerObject.setAudioTrackIndex(it.toInt()) + } + player.setInternalAudioTrack(track) + }, + selected = selected + ) { + Text( + text = serverTrack?.name ?: "", + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/ChapterSelectionSheet.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/ChapterSelectionSheet.kt new file mode 100644 index 0000000..71b6403 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/ChapterSelectionSheet.kt @@ -0,0 +1,156 @@ +package nl.jknaapen.fladder.composables.dialogs + +import Chapter +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.utility.defaultSelected + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ChapterSelectionSheet( + onSelected: (Chapter) -> Unit, + onDismiss: () -> Unit +) { + val playbackData by VideoPlayerObject.implementation.playbackData.collectAsState() + val chapters = playbackData?.chapters ?: listOf() + val currentPosition by VideoPlayerObject.position.collectAsState(0L) + + var currentChapter: Chapter? by remember { + mutableStateOf( + chapters[chapters.indexOfCurrent( + currentPosition + )] + ) + } + + val lazyListState = rememberLazyListState() + + LaunchedEffect(Unit) { + lazyListState.animateScrollToItem( + chapters.indexOfCurrent(currentPosition) + ) + } + + CustomModalBottomSheet( + onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .wrapContentHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Chapters", + style = MaterialTheme.typography.titleLarge, + color = Color.White + ) + LazyRow( + state = lazyListState, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + chapters.forEachIndexed { index, chapter -> + val selectedChapter = currentChapter == chapter + val isCurrentChapter = chapters.indexOfCurrent(currentPosition) == index + item { + Column( + modifier = Modifier + .background( + color = Color.Black.copy(alpha = 0.75f), + shape = RoundedCornerShape(8.dp) + ) + .border( + width = 2.dp, + color = Color.White.copy(alpha = if (selectedChapter) 1f else 0f), + shape = RoundedCornerShape(8.dp) + ) + .defaultSelected(index == 0) + .onFocusChanged { + if (it.isFocused) { + currentChapter = chapter + } + } + .clickable( + onClick = { + onSelected(chapter) + } + ) + .padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + 8.dp, + alignment = Alignment.CenterVertically + ), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy( + 8.dp, + alignment = Alignment.CenterHorizontally + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + chapter.name, + style = MaterialTheme.typography.bodyLarge, + color = Color.White + ) + } + AsyncImage( + model = chapter.url, + modifier = Modifier + .clip( + shape = RoundedCornerShape(24.dp) + ) + .heightIn(min = 125.dp, max = 150.dp) + .border( + width = 2.dp, + color = Color.White.copy(alpha = if (isCurrentChapter) 1f else 0f), + shape = RoundedCornerShape(24.dp) + ), + contentDescription = "" + ) + } + } + } + } + } + } +} + +private fun List.indexOfCurrent(currentPosition: Long): Int { + return this.indexOfFirst { chapter -> + val nextChapterTime = + this.getOrNull(this.indexOf(chapter) + 1)?.time ?: Long.MAX_VALUE + currentPosition >= chapter.time && currentPosition < nextChapterTime + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/CustomModalBottomSheet.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/CustomModalBottomSheet.kt new file mode 100644 index 0000000..65a78af --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/CustomModalBottomSheet.kt @@ -0,0 +1,63 @@ +package nl.jknaapen.fladder.composables.dialogs + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CustomModalBottomSheet( + onDismissRequest: () -> Unit, + maxWidth: Dp = LocalConfiguration.current.screenWidthDp.dp, + content: @Composable () -> Unit, +) { + val modalBottomSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + ModalBottomSheet( + onDismissRequest, + dragHandle = null, + sheetState = modalBottomSheetState, + contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, + containerColor = Color.Transparent, + sheetMaxWidth = maxWidth, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .systemBarsPadding() + .displayCutoutPadding() + .background( + brush = Brush.linearGradient( + colors = listOf( + Color.Black.copy(alpha = 0f), + Color.Black.copy(alpha = 0.8f), + ), + start = Offset(0f, 0f), + end = Offset(0f, Float.POSITIVE_INFINITY) + ) + ) + .navigationBarsPadding() + ) { + content() + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt new file mode 100644 index 0000000..41be3ca --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt @@ -0,0 +1,110 @@ +package nl.jknaapen.fladder.composables.dialogs + +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.utility.clearSubtitleTrack +import nl.jknaapen.fladder.utility.defaultSelected +import nl.jknaapen.fladder.utility.setInternalSubtitleTrack + +@OptIn(UnstableApi::class) +@Composable +fun SubtitlePicker( + player: ExoPlayer, + onDismissRequest: () -> Unit, +) { + val selectedIndex by VideoPlayerObject.currentSubtitleTrackIndex.collectAsState() + val subTitles by VideoPlayerObject.subtitleTracks.collectAsState(listOf()) + val internalSubTracks by VideoPlayerObject.exoSubTracks + + val listState = rememberLazyListState() + + LaunchedEffect(selectedIndex) { + listState.scrollToItem( + subTitles.indexOfFirst { it.index == selectedIndex.toLong() } + ) + } + + CustomModalBottomSheet( + onDismissRequest, + maxWidth = 600.dp, + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + item { + TrackButton( + modifier = Modifier + .fillMaxWidth() + .defaultSelected(-1 == selectedIndex), + onClick = { + VideoPlayerObject.setSubtitleTrackIndex(-1) + player.clearSubtitleTrack() + }, + selected = -1 == selectedIndex + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy( + 8.dp, + alignment = Alignment.CenterVertically + ) + ) { + Text( + text = "Off", + ) + } + } + } + internalSubTracks.forEachIndexed { index, subtitle -> + val serverSub = subTitles.elementAtOrNull(index + 1) + val selected = serverSub?.index == selectedIndex.toLong() + item { + TrackButton( + modifier = Modifier + .fillMaxWidth() + .defaultSelected(selected), + onClick = { + serverSub?.index?.let { + VideoPlayerObject.setSubtitleTrackIndex(it.toInt()) + } + player.setInternalSubtitleTrack(subtitle) + }, + selected = selected, + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy( + 8.dp, + alignment = Alignment.CenterVertically + ) + ) { + Text( + text = serverSub?.name ?: "", + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/TrackButton.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/TrackButton.kt new file mode 100644 index 0000000..68a7923 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/TrackButton.kt @@ -0,0 +1,52 @@ +package nl.jknaapen.fladder.composables.dialogs + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +internal fun TrackButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + selected: Boolean = false, + content: @Composable () -> Unit, +) { + val backgroundColor = if (selected) Color.White else Color.Black + val textColor = if (selected) Color.Black else Color.White + val textStyle = + MaterialTheme.typography.bodyLarge.copy(color = textColor, fontWeight = FontWeight.Bold) + + val interactionSource = remember { MutableInteractionSource() } + + TextButton( + modifier = modifier + .background( + color = backgroundColor.copy(alpha = 0.65f), + shape = RoundedCornerShape(12.dp), + ) + .padding(12.dp) + .clickable( + onClick = onClick, + interactionSource = interactionSource, + indication = null, + ), + onClick = onClick, + interactionSource = interactionSource, + ) { + CompositionLocalProvider(LocalTextStyle provides textStyle) { + content() + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt new file mode 100644 index 0000000..f505c60 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt @@ -0,0 +1,149 @@ +package nl.jknaapen.fladder.messengers + +import PlayableData +import VideoPlayerApi +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.flow.MutableStateFlow +import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.utility.clearAudioTrack +import nl.jknaapen.fladder.utility.clearSubtitleTrack +import nl.jknaapen.fladder.utility.enableSubtitles +import nl.jknaapen.fladder.utility.getAudioTracks +import nl.jknaapen.fladder.utility.getSubtitleTracks +import nl.jknaapen.fladder.utility.setInternalAudioTrack +import nl.jknaapen.fladder.utility.setInternalSubtitleTrack + +class VideoPlayerImplementation( +) : VideoPlayerApi { + var player: ExoPlayer? = null + val playbackData: MutableStateFlow = MutableStateFlow(null) + + override fun sendPlayableModel(playableData: PlayableData): Boolean { + try { + println("Send playable data") + playbackData.value = playableData + return true + } catch (e: Exception) { + println("Error loading data $e") + return false + } + } + + override fun open(url: String, play: Boolean) { + try { + player?.stop() + player?.clearMediaItems() + + playbackData.value?.let { + VideoPlayerObject.setAudioTrackIndex(it.defaultAudioTrack.toInt(), true) + VideoPlayerObject.setSubtitleTrackIndex(it.defaultSubtrack.toInt(), true) + } + + val startPosition = playbackData.value?.startPosition ?: 0L + println("Loading video in native $url") + val subTitles = playbackData.value?.subtitleTracks ?: listOf() + val mediaItem = MediaItem.Builder().apply { + setUri(url) + setTag(playbackData.value?.title) + setMediaId(playbackData.value?.id ?: "") + setSubtitleConfigurations( + subTitles.filter { it.external && it.url?.isNotEmpty() == true }.map { sub -> + MediaItem.SubtitleConfiguration.Builder(sub.url!!.toUri()) + .setMimeType(guessSubtitleMimeType(sub.url)) + .setLanguage(sub.languageCode) + .setLabel(sub.name) + .build() + } + ) + }.build() + + player?.setMediaItem(mediaItem, startPosition) + player?.prepare() + player?.playWhenReady = play + } catch (e: Exception) { + println("Error playing video $e") + } + } + + override fun setLooping(looping: Boolean) { + player?.repeatMode = if (looping) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + } + + override fun setVolume(volume: Double) { + player?.volume = volume.toFloat() + } + + override fun setPlaybackSpeed(speed: Double) { + player?.setPlaybackSpeed(speed.toFloat()) + } + + override fun play() { + player?.play() + } + + override fun pause() { + player?.pause() + } + + override fun seekTo(position: Long) { + player?.seekTo(position) + } + + override fun stop() { + player?.stop() + } + + fun init(exoPlayer: ExoPlayer?) { + player = exoPlayer + //exoPlayer initializes after the playbackData is set for the first load + playbackData.value?.let { + sendPlayableModel(it) + VideoPlayerObject.setAudioTrackIndex(it.defaultAudioTrack.toInt(), true) + VideoPlayerObject.setSubtitleTrackIndex(it.defaultSubtrack.toInt(), true) + open(it.url, true) + } + } +} + +fun guessSubtitleMimeType(fileName: String): String = when { + fileName.contains(".srt", ignoreCase = true) -> MimeTypes.APPLICATION_SUBRIP + fileName.contains(".vtt", ignoreCase = true) -> MimeTypes.TEXT_VTT + fileName.contains(".ass", ignoreCase = true) -> MimeTypes.TEXT_SSA + else -> MimeTypes.APPLICATION_SUBRIP +} + +fun ExoPlayer.properlySetSubAndAudioTracks(playableData: PlayableData) { + try { + val currentSubIndex = playableData.defaultSubtrack + val indexOfSubtitleTrack = + playableData.subtitleTracks.indexOfFirst { it.index == currentSubIndex } + val internalSubTracks = this.getSubtitleTracks() + + val wantedSubIndex = indexOfSubtitleTrack - 1 + if (wantedSubIndex < 0) { + clearSubtitleTrack() + } else { + enableSubtitles() + setInternalSubtitleTrack(internalSubTracks[wantedSubIndex]) + } + + val currentAudioIndex = playableData.defaultAudioTrack + val indexOfAudioTrack = + playableData.audioTracks.indexOfFirst { it.index == currentAudioIndex } + val internalAudioTracks = this.getAudioTracks() + + val wantedAudioIndex = indexOfAudioTrack - 1 + if (wantedAudioIndex < 0) { + clearAudioTrack() + } else { + clearAudioTrack(false) + setInternalAudioTrack(internalAudioTracks[wantedAudioIndex]) + } + } catch (e: Exception) { + e.printStackTrace() + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/PlayerSettingsObject.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/PlayerSettingsObject.kt new file mode 100644 index 0000000..bfe2892 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/PlayerSettingsObject.kt @@ -0,0 +1,27 @@ +package nl.jknaapen.fladder.objects + +import PlayerSettings +import PlayerSettingsPigeon +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +object PlayerSettingsObject : PlayerSettingsPigeon { + val settings: MutableStateFlow = MutableStateFlow(null) + val skipMap = settings.map { it?.skipTypes ?: mapOf() } + + val forwardSpeed = + settings.map { + (it?.skipForward ?: 1L).toDuration(DurationUnit.MILLISECONDS) + } + val backwardSpeed = settings.map { + (it?.skipBackward ?: 1L).toDuration( + DurationUnit.MILLISECONDS + ) + } + + override fun sendPlayerSettings(playerSettings: PlayerSettings) { + settings.value = playerSettings + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt new file mode 100644 index 0000000..71a14c8 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt @@ -0,0 +1,61 @@ +package nl.jknaapen.fladder.objects + +import PlaybackState +import VideoPlayerControlsCallback +import VideoPlayerListenerCallback +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import nl.jknaapen.fladder.VideoPlayerActivity +import nl.jknaapen.fladder.messengers.VideoPlayerImplementation +import nl.jknaapen.fladder.utility.InternalTrack + +object VideoPlayerObject { + val implementation: VideoPlayerImplementation = VideoPlayerImplementation() + private var _currentState = MutableStateFlow(null) + + val videoPlayerState = _currentState.map { it } + + val buffering = _currentState.map { it?.buffering ?: true } + val position = _currentState.map { it?.position ?: 0L } + val duration = _currentState.map { it?.duration ?: 0L } + val playing = _currentState.map { it?.playing ?: false } + + val chapters = implementation.playbackData.map { it?.chapters } + + val currentSubtitleTrackIndex = + MutableStateFlow((implementation.playbackData.value?.defaultSubtrack ?: -1).toInt()) + val currentAudioTrackIndex = + MutableStateFlow((implementation.playbackData.value?.defaultAudioTrack ?: -1).toInt()) + + val exoAudioTracks = mutableStateOf>(listOf()) + val exoSubTracks = mutableStateOf>(listOf()) + + fun setSubtitleTrackIndex(value: Int, init: Boolean = false) { + currentSubtitleTrackIndex.value = value + if (!init) { + videoPlayerControls?.swapSubtitleTrack(value.toLong(), callback = {}) + } + } + + fun setAudioTrackIndex(value: Int, init: Boolean = false) { + currentAudioTrackIndex.value = value + if (!init) { + videoPlayerControls?.swapAudioTrack(value.toLong(), callback = {}) + } + } + + val subtitleTracks = implementation.playbackData.map { it?.subtitleTracks ?: listOf() } + val audioTracks = implementation.playbackData.map { it?.audioTracks ?: listOf() } + + fun setPlaybackState(state: PlaybackState) { + _currentState.value = state + videoPlayerListener?.onPlaybackStateChanged( + state, callback = {} + ) + } + + var videoPlayerListener: VideoPlayerListenerCallback? = null + var videoPlayerControls: VideoPlayerControlsCallback? = null + var currentActivity: VideoPlayerActivity? = null +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt new file mode 100644 index 0000000..1cbb755 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt @@ -0,0 +1,232 @@ +package nl.jknaapen.fladder.player + +import PlaybackState +import android.app.ActivityManager +import android.content.Context +import android.view.ViewGroup +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.getSystemService +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.ts.TsExtractor +import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.PlayerView +import io.github.peerless2012.ass.media.kt.buildWithAssSupport +import io.github.peerless2012.ass.media.type.AssRenderType +import nl.jknaapen.fladder.messengers.properlySetSubAndAudioTracks +import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.utility.getAudioTracks +import nl.jknaapen.fladder.utility.getSubtitleTracks +import java.io.File + +val LocalPlayer = compositionLocalOf { null } + +@OptIn(UnstableApi::class) +@Composable +internal fun ExoPlayer( + controls: @Composable ( + player: ExoPlayer, + ) -> Unit, +) { + val videoHost = VideoPlayerObject + val context = LocalContext.current + + var initialized = false + + val extractorsFactory = DefaultExtractorsFactory().apply { + val isLowRamDevice = context.getSystemService()?.isLowRamDevice == true + setTsExtractorTimestampSearchBytes( + when (isLowRamDevice) { + true -> TsExtractor.TS_PACKET_SIZE * 1800 + false -> TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES + } + ) + setConstantBitrateSeekingEnabled(true) + setConstantBitrateSeekingAlwaysEnabled(true) + } + + val videoCache = remember { VideoCache.buildCacheDataSourceFactory(context) } + + val audioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build() + + val renderersFactory = DefaultRenderersFactory(context) + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + .setEnableDecoderFallback(true) + + val exoPlayer = remember { + ExoPlayer.Builder(context, renderersFactory) + .setTrackSelector(DefaultTrackSelector(context).apply { + setParameters(buildUponParameters().apply { + setAudioOffloadPreferences( + TrackSelectionParameters.AudioOffloadPreferences.DEFAULT.buildUpon().apply { + setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) + }.build() + ) + setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true) + setTunnelingEnabled(true) + }) + }) + .setMediaSourceFactory( + DefaultMediaSourceFactory( + videoCache, + extractorsFactory + ), + ) + .setAudioAttributes(audioAttributes, true) + .setHandleAudioBecomingNoisy(true) + .setPauseAtEndOfMediaItems(true) + .setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT) + .buildWithAssSupport(context, AssRenderType.LEGACY) + } + + DisposableEffect(exoPlayer) { + val listener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + videoHost.setPlaybackState( + PlaybackState( + position = exoPlayer.currentPosition, + buffered = exoPlayer.bufferedPosition, + duration = exoPlayer.duration, + playing = exoPlayer.isPlaying, + buffering = playbackState == Player.STATE_BUFFERING, + completed = playbackState == Player.STATE_ENDED, + failed = playbackState == Player.STATE_IDLE + ) + ) + } + + override fun onEvents(player: Player, events: Player.Events) { + super.onEvents(player, events) + videoHost.setPlaybackState( + PlaybackState( + position = exoPlayer.currentPosition, + buffered = exoPlayer.bufferedPosition, + duration = exoPlayer.duration, + playing = exoPlayer.isPlaying, + buffering = exoPlayer.playbackState == Player.STATE_BUFFERING, + completed = exoPlayer.playbackState == Player.STATE_ENDED, + failed = exoPlayer.playbackState == Player.STATE_IDLE + ) + ) + } + + override fun onTracksChanged(tracks: Tracks) { + super.onTracksChanged(tracks) + if (!initialized) { + initialized = true + VideoPlayerObject.implementation.playbackData.value?.let { + exoPlayer.properlySetSubAndAudioTracks(it) + } + VideoPlayerObject.exoSubTracks.value = exoPlayer.getSubtitleTracks() + VideoPlayerObject.exoAudioTracks.value = exoPlayer.getAudioTracks() + } + } + } + exoPlayer.addListener(listener) + onDispose { + exoPlayer.removeListener(listener) + } + } + + DisposableEffect(Unit) { + VideoPlayerObject.implementation.init(exoPlayer) + onDispose { + videoHost.videoPlayerControls?.onStop(callback = {}) + VideoPlayerObject.implementation.init(null) + exoPlayer.release() + } + } + + + Box( + modifier = Modifier + .background(Color.Black) + .fillMaxSize() + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + PlayerView(it).apply { + player = exoPlayer + useController = false + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + subtitleView?.apply { + setStyle( + CaptionStyleCompat( + android.graphics.Color.WHITE, + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + CaptionStyleCompat.EDGE_TYPE_OUTLINE, + android.graphics.Color.BLACK, + null + ) + ) + } + } + }, + ) + CompositionLocalProvider(LocalPlayer provides exoPlayer) { + controls(exoPlayer) + } + } +} + +@UnstableApi +object VideoCache { + private const val CACHE_SIZE: Long = 150L * 1024L * 1024L // 150 MB + + @Volatile + private var cache: SimpleCache? = null + + fun getCache(context: Context): SimpleCache { + return cache ?: synchronized(this) { + cache ?: SimpleCache( + File(context.cacheDir, "video_cache"), + LeastRecentlyUsedCacheEvictor(CACHE_SIZE) + ).also { cache = it } + } + } + + fun buildCacheDataSourceFactory(context: Context): DataSource.Factory { + val httpDataSourceFactory = DefaultHttpDataSource.Factory() + val upstreamFactory = DefaultDataSource.Factory(context, httpDataSourceFactory) + + return CacheDataSource.Factory() + .setCache(getCache(context)) + .setUpstreamDataSourceFactory(upstreamFactory) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/FormatTime.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/FormatTime.kt new file mode 100644 index 0000000..28ac1c4 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/FormatTime.kt @@ -0,0 +1,11 @@ +package nl.jknaapen.fladder.utility + +fun formatTime(ms: Long): String { + if (ms < 0) { + return "0:00" + } + val totalSeconds = ms / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%d:%02d".format(minutes, seconds) +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ImmersiveSystemBars.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ImmersiveSystemBars.kt new file mode 100644 index 0000000..03ff402 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ImmersiveSystemBars.kt @@ -0,0 +1,35 @@ +package nl.jknaapen.fladder.utility + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat + +@Composable +fun ImmersiveSystemBars(isImmersive: Boolean) { + val view = LocalView.current + LaunchedEffect(view) { + val activity = view.context as? Activity + val window = activity?.window + if (window != null) { + WindowCompat.setDecorFitsSystemWindows(window, false) + } + } + + DisposableEffect(view, isImmersive) { + val activity = view.context as? Activity + val window = activity?.window + val controller = window?.let { WindowInsetsControllerCompat(it, view) } + + if (isImmersive) { + controller?.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + } else { + controller?.show(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + } + + onDispose { } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/LeanBackCheck.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/LeanBackCheck.kt new file mode 100644 index 0000000..d0d80b5 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/LeanBackCheck.kt @@ -0,0 +1,14 @@ +package nl.jknaapen.fladder.utility + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi + +@RequiresApi(Build.VERSION_CODES.O) +fun leanBackEnabled(context: Context): Boolean { + val pm = context.packageManager + val leanBackEnabled = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + val leanBackOnly = pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK_ONLY) + return leanBackEnabled || leanBackOnly +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/Modifiers.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/Modifiers.kt new file mode 100644 index 0000000..9cb42e2 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/Modifiers.kt @@ -0,0 +1,95 @@ +package nl.jknaapen.fladder.utility + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Adds a subtle background when focused is true. Use this to visually mark the focused/selected + * element in D-pad / keyboard navigation. + */ +fun Modifier.highlightOnFocus( + color: Color = Color.White.copy(alpha = 0.85f), + width: Dp = 1.5.dp, + shape: Shape = RoundedCornerShape(16.dp) +): Modifier = composed { + var hasFocus by remember { mutableStateOf(false) } + val highlightModifier = remember { + Modifier + .clip(RoundedCornerShape(8.dp)) + .background( + color = color.copy(alpha = 0.25f), + shape = shape, + ) + .border( + width = width, + color = color.copy(alpha = 0.5f), + shape = shape + ) + } + + this + .onFocusChanged { focusState -> + hasFocus = focusState.hasFocus + } + .then(if (hasFocus) highlightModifier else Modifier) +} + + +/** + * Requests focus on first composition when [defaultSelected] is true. + * Returns a modifier with a focus requester attached so it can be combined with focusable()/onKeyEvent. + */ +@Composable +fun Modifier.defaultSelected(defaultSelected: Boolean): Modifier { + val requester = remember { FocusRequester() } + LaunchedEffect(defaultSelected) { + if (defaultSelected) requester.requestFocus() + } + return this.focusRequester(requester) +} + +/** + * Conditional if modifier + */ +@Composable +fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): Modifier { + return if (condition) { + then(modifier(Modifier)) + } else { + this + } +} + +@Composable +fun Modifier.visible( + visible: Boolean, +): Modifier { + val alphaAnimated by animateFloatAsState(if (visible) 1f else 0f) + return this + .graphicsLayer { + alpha = alphaAnimated + } + .then( + if (!visible) Modifier.pointerInput(Unit) {} else Modifier + ) +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ScaledContent.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ScaledContent.kt new file mode 100644 index 0000000..fb5e07b --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/ScaledContent.kt @@ -0,0 +1,21 @@ +package nl.jknaapen.fladder.utility + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density + +@Composable +fun ScaledContent( + scale: Float, + content: @Composable () -> Unit +) { + val density = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides Density( + density = density.density * scale, + ) + ) { + content() + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TestPlaybackData.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TestPlaybackData.kt new file mode 100644 index 0000000..44e2e30 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TestPlaybackData.kt @@ -0,0 +1,94 @@ +package nl.jknaapen.fladder.utility + +import AudioTrack +import Chapter +import PlayableData +import SubtitleTrack +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +val testPlaybackData = PlayableData( + id = "8lsf8234l99sdf923lsd8f23j98j", + title = "Big buck bunny", + subTitle = "Episode 1x2", + startPosition = 0, + description = "Short description of the movie that is being watched", + defaultSubtrack = 1, + defaultAudioTrack = 1, + subtitleTracks = listOf( + SubtitleTrack( + name = "English", + languageCode = "EN", + codec = "SRT", + index = 1, + external = false, + ), + SubtitleTrack( + name = "Dutch", + languageCode = "NL", + codec = "SRT", + index = 2, + external = false, + ), + SubtitleTrack( + name = "Japanese", + languageCode = "JP", + codec = "srt", + index = 3, + url = "https://gist.githubusercontent.com/matibzurovski/d690d5c14acbaa399e7f0829f9d6888e/raw/63578ca30e7430be1fa4942d4d8dd599f78151c7/example.srt", + external = true, + ), + ), + audioTracks = listOf( + AudioTrack( + name = "English", + languageCode = "EN", + codec = "AC3", + index = 1, + external = false, + ), + AudioTrack( + name = "Dutch", + languageCode = "NL", + codec = "SRT", + index = 2, + external = false, + ), + ), + chapters = listOf( + Chapter( + name = "Chapter 1", + url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c", + time = 5.seconds.toLong(DurationUnit.MILLISECONDS), + ), + Chapter( + name = "Chapter 2", + url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c", + time = 10.seconds.toLong(DurationUnit.MILLISECONDS), + ), + Chapter( + name = "Chapter 3", + url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c", + time = 15.seconds.toLong(DurationUnit.MILLISECONDS), + ), + Chapter( + name = "Chapter 4", + url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c", + time = 20.seconds.toLong(DurationUnit.MILLISECONDS), + ), + Chapter( + name = "Chapter 5", + url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c", + time = 25.seconds.toLong(DurationUnit.MILLISECONDS), + ), + Chapter( + name = "Chapter 6", + url = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcmosshoptalk.com%2Fwp-content%2Fuploads%2F2022%2F09%2FChapter-1_01.jpg&f=1&nofb=1&ipt=5e303e3979332ac9b0f3d1b7961dbeeab8fe488a5b2420654877ebb154347d8c", + time = 30.seconds.toLong(DurationUnit.MILLISECONDS), + ) + ), + nextVideo = null, + previousVideo = "Previous episode name", + segments = listOf(), + url = "https://github.com/ietf-wg-cellar/matroska-test-files/raw/refs/heads/master/test_files/test5.mkv", +) \ No newline at end of file diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TrackHelper.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TrackHelper.kt new file mode 100644 index 0000000..e2f45b9 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/TrackHelper.kt @@ -0,0 +1,157 @@ +package nl.jknaapen.fladder.utility + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector + +data class InternalTrack( + val rendererIndex: Int, + val groupIndex: Int, + val trackIndex: Int, + val label: String +) + +@OptIn(UnstableApi::class) +fun ExoPlayer.getAudioTracks(): List { + val selector = trackSelector as? DefaultTrackSelector ?: return emptyList() + val mapped = selector.currentMappedTrackInfo ?: return emptyList() + val result = mutableListOf() + + for (rendererIndex in 0 until mapped.rendererCount) { + if (mapped.getRendererType(rendererIndex) != C.TRACK_TYPE_AUDIO) continue + + val groups = mapped.getTrackGroups(rendererIndex) + for (groupIndex in 0 until groups.length) { + val group = groups[groupIndex] + for (trackIndex in 0 until group.length) { + val format = group.getFormat(trackIndex) + result.add( + InternalTrack( + rendererIndex = rendererIndex, + groupIndex = groupIndex, + trackIndex = trackIndex, + label = format.label ?: format.language ?: "Audiotrack: $trackIndex", + ) + ) + } + } + } + return result +} + +@OptIn(UnstableApi::class) +fun ExoPlayer.setInternalAudioTrack(audioTrack: InternalTrack) { + try { + val selector = trackSelector as? DefaultTrackSelector ?: return + val mapped = selector.currentMappedTrackInfo ?: return + val groups = mapped.getTrackGroups(audioTrack.rendererIndex) + if (audioTrack.groupIndex >= groups.length) return + + val group = groups[audioTrack.groupIndex] + val override = TrackSelectionOverride(group, audioTrack.trackIndex) + + selector.setParameters( + selector.buildUponParameters() + .setRendererDisabled(audioTrack.rendererIndex, false) + .build() + ) + + this.trackSelectionParameters = this.trackSelectionParameters + .buildUpon() + .setOverrideForType(override) + .build() + } catch (e: Exception) { + e.printStackTrace() + } +} + +@OptIn(UnstableApi::class) +fun ExoPlayer.clearAudioTrack(disable: Boolean = true) { + val selector = trackSelector as? DefaultTrackSelector ?: return + selector.setParameters( + selector.buildUponParameters() + .setRendererDisabled(C.TRACK_TYPE_AUDIO, disable) + .build() + ) +} + +@OptIn(UnstableApi::class) +fun ExoPlayer.getSubtitleTracks(): List { + val selector = trackSelector as? DefaultTrackSelector ?: return emptyList() + val mapped = selector.currentMappedTrackInfo ?: return emptyList() + val result = mutableListOf() + + for (rendererIndex in 0 until mapped.rendererCount) { + if (mapped.getRendererType(rendererIndex) != C.TRACK_TYPE_TEXT) continue + + val groups = mapped.getTrackGroups(rendererIndex) + for (groupIndex in 0 until groups.length) { + val group = groups[groupIndex] + for (trackIndex in 0 until group.length) { + val format = group.getFormat(trackIndex) + result.add( + InternalTrack( + rendererIndex = rendererIndex, + groupIndex = groupIndex, + trackIndex = trackIndex, + label = format.label ?: format.language ?: "Subtitletrack: $trackIndex", + ) + ) + } + } + } + return result +} + +@OptIn(UnstableApi::class) +fun ExoPlayer.clearSubtitleTrack() { + val selector = trackSelector as? DefaultTrackSelector ?: return + val newParams = selector.buildUponParameters() + .setRendererDisabled(C.TRACK_TYPE_TEXT, false) // keep text renderer active + .setPreferredTextLanguage(null) // don't auto-pick a language + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) // <– disables selection of *any* text track + .build() + selector.setParameters(newParams) +} + +@OptIn(UnstableApi::class) +fun ExoPlayer.enableSubtitles(language: String? = null) { + val selector = trackSelector as? DefaultTrackSelector ?: return + val newParams = selector.buildUponParameters() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) // allow text again + .setPreferredTextLanguage(language) // optional: auto-pick by language + .build() + selector.setParameters(newParams) +} + + +@OptIn(UnstableApi::class) +fun ExoPlayer.setInternalSubtitleTrack(subtitleTrack: InternalTrack) { + try { + enableSubtitles() + val selector = trackSelector as? DefaultTrackSelector ?: return + val mapped = selector.currentMappedTrackInfo ?: return + val groups = mapped.getTrackGroups(subtitleTrack.rendererIndex) + if (subtitleTrack.groupIndex >= groups.length) return + + val group = groups[subtitleTrack.groupIndex] + val override = TrackSelectionOverride(group, subtitleTrack.trackIndex) + + selector.setParameters( + selector.buildUponParameters() + .setRendererDisabled(subtitleTrack.rendererIndex, false) + .build() + ) + + // Apply override (replaces other text overrides) + this.trackSelectionParameters = this.trackSelectionParameters + .buildUpon() + .setOverrideForType(override) + .build() + } catch (e: Exception) { + e.printStackTrace() + } +} diff --git a/android/app/src/main/res/drawable/app_banner.png b/android/app/src/main/res/drawable/app_banner.png new file mode 100644 index 0000000..99028d3 Binary files /dev/null and b/android/app/src/main/res/drawable/app_banner.png differ diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..daf289c --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 5d6560a..02767eb 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 2faf9ab..2a7a11c 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -13,13 +13,17 @@ pluginManagement { google() mavenCentral() gradlePluginPortal() + maven { + url "https://repo.jellyfin.org/releases/client/android" + } } } plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.4.0" apply false - id "org.jetbrains.kotlin.android" version "1.9.0" apply false + id "com.android.application" version '8.12.2' apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false + id "org.jetbrains.kotlin.plugin.compose" version "2.1.0" apply false } include ":app" diff --git a/assets/marketing/banner.png b/assets/marketing/banner.png index 3a46d20..472e9a0 100644 Binary files a/assets/marketing/banner.png and b/assets/marketing/banner.png differ diff --git a/assets/marketing/banner2.afphoto b/assets/marketing/banner2.afphoto index ced3e2e..50e9ca3 100644 Binary files a/assets/marketing/banner2.afphoto and b/assets/marketing/banner2.afphoto differ diff --git a/lib/fake/fake_jellyfin_open_api.dart b/lib/fake/fake_jellyfin_open_api.dart index a40d7e6..a4c11f9 100644 --- a/lib/fake/fake_jellyfin_open_api.dart +++ b/lib/fake/fake_jellyfin_open_api.dart @@ -679,6 +679,12 @@ class FakeJellyfinOpenApi extends JellyfinOpenApi { ), ); } + + @override + Future> brandingConfigurationGet() async => chopper.Response( + FakeHelper.fakeCorrectResponse, + const BrandingOptions(loginDisclaimer: "Test server"), + ); } class FakeHelper { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fe09e62..8ae9b96 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1171,6 +1171,7 @@ "phone": "Phone", "tablet": "Tablet", "desktop": "Desktop", + "television": "Television", "layoutModeSingle": "Single", "layoutModeDual": "Dual", "copiedToClipboard": "Copied to clipboard", @@ -1191,6 +1192,7 @@ "segmentActionSkip": "Skip", "loading": "Loading", "exitFladderTitle": "Exit Fladder", + "exitFladderDesc": "Are you sure you want to close Fladder?", "castAndCrew": "Cast & Crew", "guestActor": "{count, plural, other{Guest Actors} one{Guest Actor}}", "@guestActor": { @@ -1336,5 +1338,8 @@ "type": "double" } } - } + }, + "quickConnectPostFailed": "Failed to get quick connect code", + "quickConnectLoginUsingCode": "Using quick connect", + "quickConnectEnterCodeDescription": "Enter the code below to login" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index c1b1b34..71b1291 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,6 +29,7 @@ import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/routes/auto_router.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/login/lock_screen.dart'; +import 'package:fladder/src/video_player_helper.g.dart'; import 'package:fladder/theme.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/application_info.dart'; @@ -36,6 +37,7 @@ import 'package:fladder/util/fladder_config.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/util/themes_data.dart'; +import 'package:fladder/widgets/media_query_scaler.dart'; bool get _isDesktop { if (kIsWeb) return false; @@ -86,13 +88,16 @@ void main(List args) async { os: !kIsWeb ? defaultTargetPlatform.name.capitalize() : "${defaultTargetPlatform.name.capitalize()} Web", ); + // Check if running on android TV + final leanBackEnabled = !kIsWeb && Platform.isAndroid ? await NativeVideoActivity().isLeanBackEnabled() : false; + runApp( ProviderScope( overrides: [ sharedPreferencesProvider.overrideWith((ref) => sharedPreferences), applicationInfoProvider.overrideWith((ref) => applicationInfo), crashLogProvider.overrideWith((ref) => crashProvider), - argumentsStateProvider.overrideWith((ref) => ArgumentsModel.fromArguments(args)), + argumentsStateProvider.overrideWith((ref) => ArgumentsModel.fromArguments(args, leanBackEnabled)), syncProvider.overrideWith((ref) => SyncNotifier(ref, applicationDirectory)) ], child: AdaptiveLayoutBuilder( @@ -116,7 +121,9 @@ class _MainState extends ConsumerState
with WindowListener, WidgetsBinding @override void didChangeAppLifecycleState(AppLifecycleState state) async { - if (ref.read(lockScreenActiveProvider) || ref.read(userProvider) == null) { + if (ref.read(lockScreenActiveProvider) || + ref.read(userProvider) == null || + ref.read(videoPlayerProvider).lastState?.playing == true) { dateTime = DateTime.now(); return; } @@ -248,11 +255,8 @@ class _MainState extends ConsumerState
with WindowListener, WidgetsBinding final language = ref.watch(clientSettingsProvider .select((value) => value.selectedLocale ?? WidgetsBinding.instance.platformDispatcher.locale)); final scrollBehaviour = const MaterialScrollBehavior(); - return Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), - }, - child: DynamicColorBuilder(builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { + return DynamicColorBuilder( + builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { final lightTheme = themeColor == null ? FladderTheme.theme(lightDynamic ?? FladderTheme.defaultScheme(Brightness.light), schemeVariant) : FladderTheme.theme(themeColor.schemeLight, schemeVariant); @@ -281,9 +285,12 @@ class _MainState extends ConsumerState
with WindowListener, WidgetsBinding } return locale; }, - builder: (context, child) => LocalizationContextWrapper( - child: ScaffoldMessenger(child: child ?? Container()), - currentLocale: language, + builder: (context, child) => MediaQueryScaler( + child: LocalizationContextWrapper( + child: ScaffoldMessenger(child: child ?? Container()), + currentLocale: language, + ), + enable: ref.read(argumentsStateProvider).leanBackMode, ), debugShowCheckedModeBanner: false, darkTheme: darkTheme.copyWith( @@ -300,7 +307,7 @@ class _MainState extends ConsumerState
with WindowListener, WidgetsBinding routerConfig: autoRouter.config(), ), ); - }), + }, ); } } diff --git a/lib/models/item_base_model.dart b/lib/models/item_base_model.dart index 7c3b9fc..b7c9a08 100644 --- a/lib/models/item_base_model.dart +++ b/lib/models/item_base_model.dart @@ -164,7 +164,7 @@ class ItemBaseModel with ItemBaseModelMappable { } } - Future navigateTo(BuildContext context, {WidgetRef? ref}) async { + Future navigateTo(BuildContext context, {WidgetRef? ref, Object? tag}) async { switch (this) { case FolderModel _: case BoxSetModel _: @@ -191,7 +191,7 @@ class ItemBaseModel with ItemBaseModelMappable { case SeasonModel _: case PersonModel _: default: - context.router.push(DetailsRoute(id: id, item: this)); + context.router.push(DetailsRoute(id: id, item: this, tag: tag)); break; } } diff --git a/lib/models/items/episode_model.dart b/lib/models/items/episode_model.dart index 4ed4351..64cc76c 100644 --- a/lib/models/items/episode_model.dart +++ b/lib/models/items/episode_model.dart @@ -181,7 +181,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable { playlistId: item.playlistItemId, dateAired: item.premiereDate, chapters: Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], ref), - images: ImagesData.fromBaseItem(item, ref, getOriginalSize: true), + images: ImagesData.fromBaseItem(item, ref), primaryRatio: item.primaryImageAspectRatio, season: item.parentIndexNumber ?? 0, episode: item.indexNumber ?? 0, diff --git a/lib/models/items/images_models.dart b/lib/models/items/images_models.dart index 47e546d..a9f957a 100644 --- a/lib/models/items/images_models.dart +++ b/lib/models/items/images_models.dart @@ -38,10 +38,9 @@ class ImagesData { dto.BaseItemDto item, Ref ref, { Size backDrop = const Size(2000, 2000), - Size logo = const Size(1000, 1000), + Size logo = const Size(500, 500), Size primary = const Size(600, 600), bool getOriginalSize = false, - int quality = 95, }) { final itemid = item.id; if (itemid == null) return null; @@ -59,7 +58,6 @@ class ImagesData { type: enums.ImageType.primary, maxHeight: primary.height.toInt(), maxWidth: primary.width.toInt(), - quality: quality, ), key: "${itemid}_primary_${item.imageTags?['Primary']}", hash: item.imageBlurHashes?.primary?[item.imageTags?['Primary']] ?? "", @@ -77,7 +75,6 @@ class ImagesData { type: enums.ImageType.logo, maxHeight: logo.height.toInt(), maxWidth: logo.width.toInt(), - quality: quality, ), key: "${itemid}_logo_${item.imageTags?['Logo']}", hash: item.imageBlurHashes?.logo?[item.imageTags?['Logo']] ?? "") @@ -98,7 +95,6 @@ class ImagesData { backdrop, maxHeight: backDrop.height.toInt(), maxWidth: backDrop.width.toInt(), - quality: quality, ), key: "${itemid}_backdrop_${index}_$backdrop", hash: item.imageBlurHashes?.backdrop?[backdrop] ?? "", @@ -116,9 +112,8 @@ class ImagesData { dto.BaseItemDto item, Ref ref, { Size backDrop = const Size(2000, 2000), - Size logo = const Size(1000, 1000), + Size logo = const Size(500, 500), Size primary = const Size(600, 600), - int quality = 95, }) { if (item.seriesId == null && item.parentId == null) return null; @@ -132,7 +127,6 @@ class ImagesData { type: enums.ImageType.primary, maxHeight: primary.height.toInt(), maxWidth: primary.width.toInt(), - quality: quality, ), key: "${item.seriesId}_primary_${item.seriesPrimaryImageTag ?? ""}", hash: item.imageBlurHashes?.primary?[item.seriesPrimaryImageTag] ?? "") @@ -144,7 +138,6 @@ class ImagesData { type: enums.ImageType.logo, maxHeight: logo.height.toInt(), maxWidth: logo.width.toInt(), - quality: quality, ), key: "${item.seriesId}_logo_${item.parentLogoImageTag ?? ""}", hash: item.imageBlurHashes?.logo?[item.parentLogoImageTag] ?? "") @@ -161,7 +154,6 @@ class ImagesData { backdrop, maxHeight: backDrop.height.toInt(), maxWidth: backDrop.width.toInt(), - quality: quality, ), key: "${itemId}_backdrop_${index}_$backdrop", hash: item.imageBlurHashes?.backdrop?[backdrop] ?? "", @@ -180,8 +172,7 @@ class ImagesData { Ref ref, { Size backDrop = const Size(2000, 2000), Size logo = const Size(1000, 1000), - Size primary = const Size(2000, 2000), - int quality = 95, + Size primary = const Size(500, 500), }) { return ImagesData( primary: (item.primaryImageTag != null && item.imageBlurHashes != null) @@ -191,7 +182,6 @@ class ImagesData { type: enums.ImageType.primary, maxHeight: primary.height.toInt(), maxWidth: primary.width.toInt(), - quality: quality, ), key: "${item.id ?? ""}_primary_${item.primaryImageTag ?? ''}", hash: item.imageBlurHashes?.primary?[item.primaryImageTag] ?? '') diff --git a/lib/models/items/movie_model.dart b/lib/models/items/movie_model.dart index d550b43..0aebe4e 100644 --- a/lib/models/items/movie_model.dart +++ b/lib/models/items/movie_model.dart @@ -74,6 +74,11 @@ class MovieModel extends ItemStreamModel with MovieModelMappable { @override MediaStreamsModel? get streamModel => mediaStreams; + @override + String? label(BuildContext context) { + return name; + } + @override bool get syncAble => true; diff --git a/lib/models/items/person_model.dart b/lib/models/items/person_model.dart index ebbaf2a..7cc1a12 100644 --- a/lib/models/items/person_model.dart +++ b/lib/models/items/person_model.dart @@ -1,15 +1,14 @@ -import 'package:fladder/models/items/images_models.dart'; -import 'package:fladder/models/items/overview_model.dart'; +import 'package:dart_mappable/dart_mappable.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/models/items/images_models.dart'; import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/models/items/movie_model.dart'; +import 'package:fladder/models/items/overview_model.dart'; import 'package:fladder/models/items/series_model.dart'; -import 'package:dart_mappable/dart_mappable.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - part 'person_model.mapper.dart'; @MappableClass() diff --git a/lib/models/items/series_model.dart b/lib/models/items/series_model.dart index 1a22ef9..5ab8dde 100644 --- a/lib/models/items/series_model.dart +++ b/lib/models/items/series_model.dart @@ -85,7 +85,7 @@ class SeriesModel extends ItemBaseModel with SeriesModelMappable { userData: UserData.fromDto(item.userData), parentId: item.parentId, playlistId: item.playlistItemId, - images: ImagesData.fromBaseItem(item, ref, getOriginalSize: true), + images: ImagesData.fromBaseItem(item, ref), primaryRatio: item.primaryImageAspectRatio, originalTitle: item.originalTitle ?? "", sortName: item.sortName ?? "", diff --git a/lib/models/login_screen_model.dart b/lib/models/login_screen_model.dart index c607bbc..ac6fa07 100644 --- a/lib/models/login_screen_model.dart +++ b/lib/models/login_screen_model.dart @@ -1,26 +1,34 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:fladder/models/account_model.dart'; import 'package:fladder/models/credentials_model.dart'; -class LoginScreenModel { - final List accounts; - final CredentialsModel tempCredentials; - final bool loading; - LoginScreenModel({ - required this.accounts, - required this.tempCredentials, - required this.loading, - }); +part 'login_screen_model.freezed.dart'; - LoginScreenModel copyWith({ - List? accounts, - CredentialsModel? tempCredentials, - bool? loading, - }) { - return LoginScreenModel( - accounts: accounts ?? this.accounts, - tempCredentials: tempCredentials ?? this.tempCredentials, - loading: loading ?? this.loading, - ); - } +enum LoginScreenType { + users, + login, + code, +} + +@Freezed(copyWith: true) +abstract class LoginScreenModel with _$LoginScreenModel { + factory LoginScreenModel({ + @Default([]) List accounts, + @Default(LoginScreenType.users) LoginScreenType screen, + ServerLoginModel? serverLoginModel, + String? errorMessage, + @Default(false) bool hasBaseUrl, + @Default(false) bool loading, + }) = _LoginScreenModel; +} + +@Freezed(copyWith: true) +abstract class ServerLoginModel with _$ServerLoginModel { + factory ServerLoginModel({ + required CredentialsModel tempCredentials, + @Default([]) List accounts, + String? serverMessage, + @Default(false) bool hasQuickConnect, + }) = _ServerLoginModel; } diff --git a/lib/models/login_screen_model.freezed.dart b/lib/models/login_screen_model.freezed.dart new file mode 100644 index 0000000..acf33cf --- /dev/null +++ b/lib/models/login_screen_model.freezed.dart @@ -0,0 +1,774 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'login_screen_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$LoginScreenModel { + List get accounts; + LoginScreenType get screen; + ServerLoginModel? get serverLoginModel; + String? get errorMessage; + bool get hasBaseUrl; + bool get loading; + + /// Create a copy of LoginScreenModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $LoginScreenModelCopyWith get copyWith => + _$LoginScreenModelCopyWithImpl( + this as LoginScreenModel, _$identity); + + @override + String toString() { + return 'LoginScreenModel(accounts: $accounts, screen: $screen, serverLoginModel: $serverLoginModel, errorMessage: $errorMessage, hasBaseUrl: $hasBaseUrl, loading: $loading)'; + } +} + +/// @nodoc +abstract mixin class $LoginScreenModelCopyWith<$Res> { + factory $LoginScreenModelCopyWith( + LoginScreenModel value, $Res Function(LoginScreenModel) _then) = + _$LoginScreenModelCopyWithImpl; + @useResult + $Res call( + {List accounts, + LoginScreenType screen, + ServerLoginModel? serverLoginModel, + String? errorMessage, + bool hasBaseUrl, + bool loading}); + + $ServerLoginModelCopyWith<$Res>? get serverLoginModel; +} + +/// @nodoc +class _$LoginScreenModelCopyWithImpl<$Res> + implements $LoginScreenModelCopyWith<$Res> { + _$LoginScreenModelCopyWithImpl(this._self, this._then); + + final LoginScreenModel _self; + final $Res Function(LoginScreenModel) _then; + + /// Create a copy of LoginScreenModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? accounts = null, + Object? screen = null, + Object? serverLoginModel = freezed, + Object? errorMessage = freezed, + Object? hasBaseUrl = null, + Object? loading = null, + }) { + return _then(_self.copyWith( + accounts: null == accounts + ? _self.accounts + : accounts // ignore: cast_nullable_to_non_nullable + as List, + screen: null == screen + ? _self.screen + : screen // ignore: cast_nullable_to_non_nullable + as LoginScreenType, + serverLoginModel: freezed == serverLoginModel + ? _self.serverLoginModel + : serverLoginModel // ignore: cast_nullable_to_non_nullable + as ServerLoginModel?, + errorMessage: freezed == errorMessage + ? _self.errorMessage + : errorMessage // ignore: cast_nullable_to_non_nullable + as String?, + hasBaseUrl: null == hasBaseUrl + ? _self.hasBaseUrl + : hasBaseUrl // ignore: cast_nullable_to_non_nullable + as bool, + loading: null == loading + ? _self.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + )); + } + + /// Create a copy of LoginScreenModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ServerLoginModelCopyWith<$Res>? get serverLoginModel { + if (_self.serverLoginModel == null) { + return null; + } + + return $ServerLoginModelCopyWith<$Res>(_self.serverLoginModel!, (value) { + return _then(_self.copyWith(serverLoginModel: value)); + }); + } +} + +/// Adds pattern-matching-related methods to [LoginScreenModel]. +extension LoginScreenModelPatterns on LoginScreenModel { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_LoginScreenModel value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _LoginScreenModel() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_LoginScreenModel value) $default, + ) { + final _that = this; + switch (_that) { + case _LoginScreenModel(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_LoginScreenModel value)? $default, + ) { + final _that = this; + switch (_that) { + case _LoginScreenModel() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + List accounts, + LoginScreenType screen, + ServerLoginModel? serverLoginModel, + String? errorMessage, + bool hasBaseUrl, + bool loading)? + $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _LoginScreenModel() when $default != null: + return $default(_that.accounts, _that.screen, _that.serverLoginModel, + _that.errorMessage, _that.hasBaseUrl, _that.loading); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function( + List accounts, + LoginScreenType screen, + ServerLoginModel? serverLoginModel, + String? errorMessage, + bool hasBaseUrl, + bool loading) + $default, + ) { + final _that = this; + switch (_that) { + case _LoginScreenModel(): + return $default(_that.accounts, _that.screen, _that.serverLoginModel, + _that.errorMessage, _that.hasBaseUrl, _that.loading); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function( + List accounts, + LoginScreenType screen, + ServerLoginModel? serverLoginModel, + String? errorMessage, + bool hasBaseUrl, + bool loading)? + $default, + ) { + final _that = this; + switch (_that) { + case _LoginScreenModel() when $default != null: + return $default(_that.accounts, _that.screen, _that.serverLoginModel, + _that.errorMessage, _that.hasBaseUrl, _that.loading); + case _: + return null; + } + } +} + +/// @nodoc + +class _LoginScreenModel implements LoginScreenModel { + _LoginScreenModel( + {final List accounts = const [], + this.screen = LoginScreenType.users, + this.serverLoginModel, + this.errorMessage, + this.hasBaseUrl = false, + this.loading = false}) + : _accounts = accounts; + + final List _accounts; + @override + @JsonKey() + List get accounts { + if (_accounts is EqualUnmodifiableListView) return _accounts; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_accounts); + } + + @override + @JsonKey() + final LoginScreenType screen; + @override + final ServerLoginModel? serverLoginModel; + @override + final String? errorMessage; + @override + @JsonKey() + final bool hasBaseUrl; + @override + @JsonKey() + final bool loading; + + /// Create a copy of LoginScreenModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$LoginScreenModelCopyWith<_LoginScreenModel> get copyWith => + __$LoginScreenModelCopyWithImpl<_LoginScreenModel>(this, _$identity); + + @override + String toString() { + return 'LoginScreenModel(accounts: $accounts, screen: $screen, serverLoginModel: $serverLoginModel, errorMessage: $errorMessage, hasBaseUrl: $hasBaseUrl, loading: $loading)'; + } +} + +/// @nodoc +abstract mixin class _$LoginScreenModelCopyWith<$Res> + implements $LoginScreenModelCopyWith<$Res> { + factory _$LoginScreenModelCopyWith( + _LoginScreenModel value, $Res Function(_LoginScreenModel) _then) = + __$LoginScreenModelCopyWithImpl; + @override + @useResult + $Res call( + {List accounts, + LoginScreenType screen, + ServerLoginModel? serverLoginModel, + String? errorMessage, + bool hasBaseUrl, + bool loading}); + + @override + $ServerLoginModelCopyWith<$Res>? get serverLoginModel; +} + +/// @nodoc +class __$LoginScreenModelCopyWithImpl<$Res> + implements _$LoginScreenModelCopyWith<$Res> { + __$LoginScreenModelCopyWithImpl(this._self, this._then); + + final _LoginScreenModel _self; + final $Res Function(_LoginScreenModel) _then; + + /// Create a copy of LoginScreenModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? accounts = null, + Object? screen = null, + Object? serverLoginModel = freezed, + Object? errorMessage = freezed, + Object? hasBaseUrl = null, + Object? loading = null, + }) { + return _then(_LoginScreenModel( + accounts: null == accounts + ? _self._accounts + : accounts // ignore: cast_nullable_to_non_nullable + as List, + screen: null == screen + ? _self.screen + : screen // ignore: cast_nullable_to_non_nullable + as LoginScreenType, + serverLoginModel: freezed == serverLoginModel + ? _self.serverLoginModel + : serverLoginModel // ignore: cast_nullable_to_non_nullable + as ServerLoginModel?, + errorMessage: freezed == errorMessage + ? _self.errorMessage + : errorMessage // ignore: cast_nullable_to_non_nullable + as String?, + hasBaseUrl: null == hasBaseUrl + ? _self.hasBaseUrl + : hasBaseUrl // ignore: cast_nullable_to_non_nullable + as bool, + loading: null == loading + ? _self.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + )); + } + + /// Create a copy of LoginScreenModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ServerLoginModelCopyWith<$Res>? get serverLoginModel { + if (_self.serverLoginModel == null) { + return null; + } + + return $ServerLoginModelCopyWith<$Res>(_self.serverLoginModel!, (value) { + return _then(_self.copyWith(serverLoginModel: value)); + }); + } +} + +/// @nodoc +mixin _$ServerLoginModel { + CredentialsModel get tempCredentials; + List get accounts; + String? get serverMessage; + bool get hasQuickConnect; + + /// Create a copy of ServerLoginModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ServerLoginModelCopyWith get copyWith => + _$ServerLoginModelCopyWithImpl( + this as ServerLoginModel, _$identity); + + @override + String toString() { + return 'ServerLoginModel(tempCredentials: $tempCredentials, accounts: $accounts, serverMessage: $serverMessage, hasQuickConnect: $hasQuickConnect)'; + } +} + +/// @nodoc +abstract mixin class $ServerLoginModelCopyWith<$Res> { + factory $ServerLoginModelCopyWith( + ServerLoginModel value, $Res Function(ServerLoginModel) _then) = + _$ServerLoginModelCopyWithImpl; + @useResult + $Res call( + {CredentialsModel tempCredentials, + List accounts, + String? serverMessage, + bool hasQuickConnect}); +} + +/// @nodoc +class _$ServerLoginModelCopyWithImpl<$Res> + implements $ServerLoginModelCopyWith<$Res> { + _$ServerLoginModelCopyWithImpl(this._self, this._then); + + final ServerLoginModel _self; + final $Res Function(ServerLoginModel) _then; + + /// Create a copy of ServerLoginModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tempCredentials = null, + Object? accounts = null, + Object? serverMessage = freezed, + Object? hasQuickConnect = null, + }) { + return _then(_self.copyWith( + tempCredentials: null == tempCredentials + ? _self.tempCredentials + : tempCredentials // ignore: cast_nullable_to_non_nullable + as CredentialsModel, + accounts: null == accounts + ? _self.accounts + : accounts // ignore: cast_nullable_to_non_nullable + as List, + serverMessage: freezed == serverMessage + ? _self.serverMessage + : serverMessage // ignore: cast_nullable_to_non_nullable + as String?, + hasQuickConnect: null == hasQuickConnect + ? _self.hasQuickConnect + : hasQuickConnect // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// Adds pattern-matching-related methods to [ServerLoginModel]. +extension ServerLoginModelPatterns on ServerLoginModel { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_ServerLoginModel value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _ServerLoginModel() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_ServerLoginModel value) $default, + ) { + final _that = this; + switch (_that) { + case _ServerLoginModel(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_ServerLoginModel value)? $default, + ) { + final _that = this; + switch (_that) { + case _ServerLoginModel() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + CredentialsModel tempCredentials, + List accounts, + String? serverMessage, + bool hasQuickConnect)? + $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _ServerLoginModel() when $default != null: + return $default(_that.tempCredentials, _that.accounts, + _that.serverMessage, _that.hasQuickConnect); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function( + CredentialsModel tempCredentials, + List accounts, + String? serverMessage, + bool hasQuickConnect) + $default, + ) { + final _that = this; + switch (_that) { + case _ServerLoginModel(): + return $default(_that.tempCredentials, _that.accounts, + _that.serverMessage, _that.hasQuickConnect); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function( + CredentialsModel tempCredentials, + List accounts, + String? serverMessage, + bool hasQuickConnect)? + $default, + ) { + final _that = this; + switch (_that) { + case _ServerLoginModel() when $default != null: + return $default(_that.tempCredentials, _that.accounts, + _that.serverMessage, _that.hasQuickConnect); + case _: + return null; + } + } +} + +/// @nodoc + +class _ServerLoginModel implements ServerLoginModel { + _ServerLoginModel( + {required this.tempCredentials, + final List accounts = const [], + this.serverMessage, + this.hasQuickConnect = false}) + : _accounts = accounts; + + @override + final CredentialsModel tempCredentials; + final List _accounts; + @override + @JsonKey() + List get accounts { + if (_accounts is EqualUnmodifiableListView) return _accounts; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_accounts); + } + + @override + final String? serverMessage; + @override + @JsonKey() + final bool hasQuickConnect; + + /// Create a copy of ServerLoginModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$ServerLoginModelCopyWith<_ServerLoginModel> get copyWith => + __$ServerLoginModelCopyWithImpl<_ServerLoginModel>(this, _$identity); + + @override + String toString() { + return 'ServerLoginModel(tempCredentials: $tempCredentials, accounts: $accounts, serverMessage: $serverMessage, hasQuickConnect: $hasQuickConnect)'; + } +} + +/// @nodoc +abstract mixin class _$ServerLoginModelCopyWith<$Res> + implements $ServerLoginModelCopyWith<$Res> { + factory _$ServerLoginModelCopyWith( + _ServerLoginModel value, $Res Function(_ServerLoginModel) _then) = + __$ServerLoginModelCopyWithImpl; + @override + @useResult + $Res call( + {CredentialsModel tempCredentials, + List accounts, + String? serverMessage, + bool hasQuickConnect}); +} + +/// @nodoc +class __$ServerLoginModelCopyWithImpl<$Res> + implements _$ServerLoginModelCopyWith<$Res> { + __$ServerLoginModelCopyWithImpl(this._self, this._then); + + final _ServerLoginModel _self; + final $Res Function(_ServerLoginModel) _then; + + /// Create a copy of ServerLoginModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? tempCredentials = null, + Object? accounts = null, + Object? serverMessage = freezed, + Object? hasQuickConnect = null, + }) { + return _then(_ServerLoginModel( + tempCredentials: null == tempCredentials + ? _self.tempCredentials + : tempCredentials // ignore: cast_nullable_to_non_nullable + as CredentialsModel, + accounts: null == accounts + ? _self._accounts + : accounts // ignore: cast_nullable_to_non_nullable + as List, + serverMessage: freezed == serverMessage + ? _self.serverMessage + : serverMessage // ignore: cast_nullable_to_non_nullable + as String?, + hasQuickConnect: null == hasQuickConnect + ? _self.hasQuickConnect + : hasQuickConnect // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +// dart format on diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 33ec571..7f17379 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -137,7 +137,7 @@ class PlaybackModelHelper { oldModel: currentModel, ); if (newModel == null) return null; - ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, startPosition: Duration.zero); + ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, Duration.zero); return newModel; } @@ -502,7 +502,7 @@ class PlaybackModelHelper { } if (newModel == null) return; if (newModel.runtimeType != playbackModel.runtimeType || newModel is TranscodePlaybackModel) { - ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, startPosition: currentPosition); + ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, currentPosition); } } } diff --git a/lib/models/settings/arguments_model.dart b/lib/models/settings/arguments_model.dart index 2331357..7dcaac2 100644 --- a/lib/models/settings/arguments_model.dart +++ b/lib/models/settings/arguments_model.dart @@ -8,12 +8,14 @@ abstract class ArgumentsModel with _$ArgumentsModel { factory ArgumentsModel({ @Default(false) bool htpcMode, + @Default(false) bool leanBackMode, }) = _ArgumentsModel; - factory ArgumentsModel.fromArguments(List arguments) { + factory ArgumentsModel.fromArguments(List arguments, bool leanBackEnabled) { arguments = arguments.map((e) => e.trim()).toList(); return ArgumentsModel( - htpcMode: arguments.contains('--htpc'), + htpcMode: arguments.contains('--htpc') || leanBackEnabled, + leanBackMode: leanBackEnabled, ); } } diff --git a/lib/models/settings/arguments_model.freezed.dart b/lib/models/settings/arguments_model.freezed.dart index 2e81426..333a23e 100644 --- a/lib/models/settings/arguments_model.freezed.dart +++ b/lib/models/settings/arguments_model.freezed.dart @@ -15,10 +15,11 @@ T _$identity(T value) => value; /// @nodoc mixin _$ArgumentsModel { bool get htpcMode; + bool get leanBackMode; @override String toString() { - return 'ArgumentsModel(htpcMode: $htpcMode)'; + return 'ArgumentsModel(htpcMode: $htpcMode, leanBackMode: $leanBackMode)'; } } @@ -115,13 +116,13 @@ extension ArgumentsModelPatterns on ArgumentsModel { @optionalTypeArgs TResult maybeWhen( - TResult Function(bool htpcMode)? $default, { + TResult Function(bool htpcMode, bool leanBackMode)? $default, { required TResult orElse(), }) { final _that = this; switch (_that) { case _ArgumentsModel() when $default != null: - return $default(_that.htpcMode); + return $default(_that.htpcMode, _that.leanBackMode); case _: return orElse(); } @@ -142,12 +143,12 @@ extension ArgumentsModelPatterns on ArgumentsModel { @optionalTypeArgs TResult when( - TResult Function(bool htpcMode) $default, + TResult Function(bool htpcMode, bool leanBackMode) $default, ) { final _that = this; switch (_that) { case _ArgumentsModel(): - return $default(_that.htpcMode); + return $default(_that.htpcMode, _that.leanBackMode); case _: throw StateError('Unexpected subclass'); } @@ -167,12 +168,12 @@ extension ArgumentsModelPatterns on ArgumentsModel { @optionalTypeArgs TResult? whenOrNull( - TResult? Function(bool htpcMode)? $default, + TResult? Function(bool htpcMode, bool leanBackMode)? $default, ) { final _that = this; switch (_that) { case _ArgumentsModel() when $default != null: - return $default(_that.htpcMode); + return $default(_that.htpcMode, _that.leanBackMode); case _: return null; } @@ -182,15 +183,19 @@ extension ArgumentsModelPatterns on ArgumentsModel { /// @nodoc class _ArgumentsModel extends ArgumentsModel { - _ArgumentsModel({this.htpcMode = false}) : super._(); + _ArgumentsModel({this.htpcMode = false, this.leanBackMode = false}) + : super._(); @override @JsonKey() final bool htpcMode; + @override + @JsonKey() + final bool leanBackMode; @override String toString() { - return 'ArgumentsModel(htpcMode: $htpcMode)'; + return 'ArgumentsModel(htpcMode: $htpcMode, leanBackMode: $leanBackMode)'; } } diff --git a/lib/models/settings/home_settings_model.dart b/lib/models/settings/home_settings_model.dart index 0f2de4b..a061c5f 100644 --- a/lib/models/settings/home_settings_model.dart +++ b/lib/models/settings/home_settings_model.dart @@ -40,7 +40,8 @@ T selectAvailableOrSmaller(T value, Set availableOptions, List allOptio enum HomeBanner { hide, carousel, - banner; + banner, + detailedBanner; const HomeBanner(); @@ -48,6 +49,7 @@ enum HomeBanner { HomeBanner.hide => context.localized.hide, HomeBanner.carousel => context.localized.homeBannerCarousel, HomeBanner.banner => context.localized.homeBannerSlideshow, + HomeBanner.detailedBanner => 'Detailed banner' }; } diff --git a/lib/models/settings/home_settings_model.g.dart b/lib/models/settings/home_settings_model.g.dart index 6307c72..8cb95ce 100644 --- a/lib/models/settings/home_settings_model.g.dart +++ b/lib/models/settings/home_settings_model.g.dart @@ -47,12 +47,14 @@ const _$ViewSizeEnumMap = { ViewSize.phone: 'phone', ViewSize.tablet: 'tablet', ViewSize.desktop: 'desktop', + ViewSize.television: 'television', }; const _$HomeBannerEnumMap = { HomeBanner.hide: 'hide', HomeBanner.carousel: 'carousel', HomeBanner.banner: 'banner', + HomeBanner.detailedBanner: 'detailedBanner', }; const _$HomeCarouselSettingsEnumMap = { diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index a735ba1..50ff903 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -127,11 +127,17 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { enum PlayerOptions { libMDK, - libMPV; + libMPV, + nativePlayer; const PlayerOptions(); - static Iterable get available => kIsWeb ? {PlayerOptions.libMPV} : PlayerOptions.values; + static Iterable get available => kIsWeb + ? {PlayerOptions.libMPV} + : switch (defaultTargetPlatform) { + TargetPlatform.android => PlayerOptions.values, + _ => {PlayerOptions.libMDK, PlayerOptions.libMPV}, + }; static PlayerOptions get platformDefaults { if (kIsWeb) return PlayerOptions.libMPV; @@ -143,6 +149,7 @@ enum PlayerOptions { String label(BuildContext context) => switch (this) { PlayerOptions.libMDK => "MDK", PlayerOptions.libMPV => "MPV", + PlayerOptions.nativePlayer => "Native", }; } diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart index 096d0bc..ec6da4c 100644 --- a/lib/models/settings/video_player_settings.g.dart +++ b/lib/models/settings/video_player_settings.g.dart @@ -82,6 +82,7 @@ const _$BoxFitEnumMap = { const _$PlayerOptionsEnumMap = { PlayerOptions.libMDK: 'libMDK', PlayerOptions.libMPV: 'libMPV', + PlayerOptions.nativePlayer: 'nativePlayer', }; const _$DeviceOrientationEnumMap = { diff --git a/lib/providers/api_provider.dart b/lib/providers/api_provider.dart index 777360f..c6ef052 100644 --- a/lib/providers/api_provider.dart +++ b/lib/providers/api_provider.dart @@ -37,10 +37,14 @@ class JellyRequest implements Interceptor { FutureOr> intercept(Chain chain) async { final connectivityNotifier = ref.read(connectivityStatusProvider.notifier); try { - final serverUrl = Uri.parse(ref.read(userProvider)?.server ?? ref.read(authProvider).tempCredentials.server); + final serverUrl = Uri.parse( + ref.read(userProvider)?.server ?? ref.read(authProvider).serverLoginModel?.tempCredentials.server ?? ""); //Use current logged in user otherwise use the authprovider - var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).tempCredentials; + var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).serverLoginModel?.tempCredentials; + + if (loginModel == null) throw UnimplementedError(); + var headers = loginModel.header(ref); final Response response = await chain.proceed( applyHeaders( diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index ff5cd87..3be9feb 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -1,6 +1,9 @@ +import 'package:flutter/material.dart'; + import 'package:chopper/chopper.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/account_model.dart'; import 'package:fladder/models/credentials_model.dart'; import 'package:fladder/models/login_screen_model.dart'; @@ -13,44 +16,133 @@ import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/shared_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/views_provider.dart'; +import 'package:fladder/screens/login/lock_screen.dart'; +import 'package:fladder/screens/shared/fladder_snackbar.dart'; +import 'package:fladder/util/fladder_config.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/util/string_extensions.dart'; final authProvider = StateNotifierProvider((ref) { return AuthNotifier(ref); }); class AuthNotifier extends StateNotifier { - AuthNotifier(this.ref) - : super( - LoginScreenModel( - accounts: [], - tempCredentials: CredentialsModel.createNewCredentials(), - loading: false, - ), - ); + AuthNotifier(this.ref) : super(LoginScreenModel()); final Ref ref; late final JellyService api = ref.read(jellyApiProvider); - Future>?> getPublicUsers() async { - try { - var response = await api.usersPublicGet(state.tempCredentials); - if (response.isSuccessful && response.body != null) { - var models = response.body ?? []; + BuildContext? context; - return response.copyWith(body: models.toList()); + Future initModel(BuildContext newContext) async { + context ??= newContext; + ref.read(userProvider.notifier).clear(); + final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts(); + ref.read(lockScreenActiveProvider.notifier).update((state) => true); + if (FladderConfig.baseUrl != null) { + final url = FladderConfig.baseUrl; + state = state.copyWith( + hasBaseUrl: true, + ); + if (url != null) { + await setServer(url); } - return response.copyWith(body: []); + } + state = state.copyWith( + accounts: currentAccounts, + screen: currentAccounts.isEmpty ? LoginScreenType.login : LoginScreenType.users, + ); + } + + Future _fetchServerInfo(String url) async { + try { + final newCredentials = CredentialsModel.createNewCredentials().copyWith(server: url.rtrim('/')); + final newLoginModel = ServerLoginModel(tempCredentials: newCredentials); + state = state.copyWith( + serverLoginModel: newLoginModel, + loading: true, + ); + final publicUsers = (await getPublicUsers())?.body ?? []; + final quickConnectStatus = (await api.quickConnectEnabled()).body ?? false; + final branding = await api.getBranding(); + final serverResponse = await api.systemInfoPublicGet(); + state = state.copyWith( + screen: quickConnectStatus ? LoginScreenType.code : LoginScreenType.login, + serverLoginModel: newLoginModel.copyWith( + tempCredentials: newCredentials.copyWith( + serverName: serverResponse.body?.serverName, + serverId: serverResponse.body?.id, + ), + accounts: publicUsers, + hasQuickConnect: quickConnectStatus, + serverMessage: branding.body?.loginDisclaimer, + ), + loading: false, + ); } catch (e) { - return null; + state = state.copyWith( + errorMessage: context?.localized.invalidUrl, + loading: false, + ); + if (context != null) { + fladderSnackbar(context!, title: context!.localized.unableToConnectHost); + } } } + String? _parseUrl(String url) { + if (url.isEmpty) { + return null; + } + if (!Uri.parse(url).isAbsolute) { + return context?.localized.invalidUrl; + } + + if (!url.startsWith('https://') && !url.startsWith('http://')) { + return context?.localized.invalidUrlDesc; + } + return null; + } + + Future>?> getPublicUsers() async { + try { + state = state.copyWith(loading: true); + final credentials = state.serverLoginModel?.tempCredentials; + if (credentials == null) return null; + var response = await api.usersPublicGet(credentials); + if (response.isSuccessful && response.body != null) { + var models = response.body ?? []; + return response.copyWith(body: models.toList()); + } + state = state.copyWith( + serverLoginModel: state.serverLoginModel?.copyWith( + accounts: response.body ?? [], + ), + ); + return response.copyWith(body: []); + } catch (e) { + return null; + } finally { + state = state.copyWith(loading: false); + } + } + + Future?> authenticateUsingSecret(String secret) async { + clearAllProviders(); + var response = await api.quickConnectAuthenticate(secret); + return _createAccountModel(response); + } + Future?> authenticateByName(String userName, String password) async { - state = state.copyWith(loading: true); clearAllProviders(); var response = await api.usersAuthenticateByNamePost(userName: userName, password: password); - CredentialsModel credentials = state.tempCredentials; + return _createAccountModel(response); + } + + Future?> _createAccountModel(Response response) async { + CredentialsModel? credentials = state.serverLoginModel?.tempCredentials; + if (credentials == null) return null; if (response.isSuccessful && (response.body?.accessToken?.isNotEmpty ?? false)) { var serverResponse = await api.systemInfoPublicGet(); credentials = credentials.copyWith( @@ -68,16 +160,21 @@ class AuthNotifier extends StateNotifier { ); ref.read(sharedUtilityProvider).addAccount(newUser); ref.read(userProvider.notifier).userState = newUser; - state = state.copyWith(loading: false); + final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts(); + + state = state.copyWith( + serverLoginModel: null, + accounts: currentAccounts, + ); + return Response(response.base, newUser); } - state = state.copyWith(loading: false); return Response(response.base, null); } Future logOutUser() async { final currentUser = ref.read(userProvider); - state = state.copyWith(tempCredentials: CredentialsModel.createNewCredentials()); + state = state.copyWith(serverLoginModel: null); await ref.read(sharedUtilityProvider).removeAccount(currentUser); clearAllProviders(); return null; @@ -95,10 +192,17 @@ class AuthNotifier extends StateNotifier { ref.read(libraryScreenProvider.notifier).clear(); } - void setServer(String server) { + Future setServer(String server) async { + final url = (state.hasBaseUrl ? FladderConfig.baseUrl : server); + if (url == null || server.isEmpty) return; + final isUrlValid = _parseUrl(url); state = state.copyWith( - tempCredentials: state.tempCredentials.copyWith(server: server), + errorMessage: isUrlValid, + serverLoginModel: null, ); + if (isUrlValid == null) { + await _fetchServerInfo(url); + } } List getSavedAccounts() { @@ -113,4 +217,27 @@ class AuthNotifier extends StateNotifier { accounts.insert(newIndex, original); ref.read(sharedUtilityProvider).saveAccounts(accounts); } + + void addNewUser() { + state = state.copyWith( + screen: LoginScreenType.login, + ); + } + + void goUserSelect() { + state = state.copyWith( + serverLoginModel: state.hasBaseUrl ? state.serverLoginModel : null, + screen: LoginScreenType.users, + ); + } + + void tryParseUrl(String server) { + if (server.isNotEmpty && state.errorMessage != null) { + final url = server; + final isUrlValid = _parseUrl(url); + state = state.copyWith( + errorMessage: isUrlValid, + ); + } + } } diff --git a/lib/providers/dashboard_provider.dart b/lib/providers/dashboard_provider.dart index 1c8ca7d..2d78a63 100644 --- a/lib/providers/dashboard_provider.dart +++ b/lib/providers/dashboard_provider.dart @@ -26,9 +26,16 @@ class DashboardNotifier extends StateNotifier { final viewTypes = ref.read(viewsProvider.select((value) => value.dashboardViews)).map((e) => e.collectionType).toSet().toList(); + final imagesToFetch = { + ImageType.logo, + ImageType.primary, + ImageType.backdrop, + ImageType.banner, + }.toList(); + if (viewTypes.containsAny([CollectionType.movies, CollectionType.tvshows])) { final resumeVideoResponse = await api.usersUserIdItemsResumeGet( - limit: 16, + enableImageTypes: imagesToFetch, fields: [ ItemFields.parentid, ItemFields.mediastreams, @@ -36,6 +43,8 @@ class DashboardNotifier extends StateNotifier { ItemFields.candelete, ItemFields.candownload, ItemFields.primaryimageaspectratio, + ItemFields.overview, + ItemFields.genres, ], mediaTypes: [MediaType.video], enableTotalRecordCount: false, @@ -48,7 +57,7 @@ class DashboardNotifier extends StateNotifier { if (viewTypes.contains(CollectionType.music)) { final resumeAudioResponse = await api.usersUserIdItemsResumeGet( - limit: 16, + enableImageTypes: imagesToFetch, fields: [ ItemFields.parentid, ItemFields.mediastreams, @@ -56,6 +65,8 @@ class DashboardNotifier extends StateNotifier { ItemFields.candelete, ItemFields.candownload, ItemFields.primaryimageaspectratio, + ItemFields.overview, + ItemFields.genres, ], mediaTypes: [MediaType.audio], enableTotalRecordCount: false, @@ -68,7 +79,7 @@ class DashboardNotifier extends StateNotifier { if (viewTypes.contains(CollectionType.books)) { final resumeBookResponse = await api.usersUserIdItemsResumeGet( - limit: 16, + enableImageTypes: imagesToFetch, fields: [ ItemFields.parentid, ItemFields.mediastreams, @@ -76,6 +87,8 @@ class DashboardNotifier extends StateNotifier { ItemFields.candelete, ItemFields.candownload, ItemFields.primaryimageaspectratio, + ItemFields.overview, + ItemFields.genres, ], mediaTypes: [MediaType.book], enableTotalRecordCount: false, @@ -87,7 +100,6 @@ class DashboardNotifier extends StateNotifier { } final nextResponse = await api.showsNextUpGet( - limit: 16, nextUpDateCutoff: DateTime.now().subtract( ref.read(clientSettingsProvider.select((value) => value.nextUpDateCutoff ?? const Duration(days: 28)))), fields: [ @@ -97,6 +109,8 @@ class DashboardNotifier extends StateNotifier { ItemFields.candelete, ItemFields.candownload, ItemFields.primaryimageaspectratio, + ItemFields.overview, + ItemFields.genres, ], ); diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 8b22e7f..401cba9 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -6,7 +6,7 @@ import 'package:fladder/providers/user_provider.dart'; const _defaultHeight = 576; const _defaultWidth = 384; -const _defaultQuality = 96; +const _defaultQuality = 90; final imageUtilityProvider = Provider((ref) { return ImageNotifier(ref: ref); @@ -19,7 +19,7 @@ class ImageNotifier { }); String get currentServerUrl { - return ref.read(userProvider)?.server ?? ref.read(authProvider).tempCredentials.server; + return ref.read(userProvider)?.server ?? ref.read(authProvider).serverLoginModel?.tempCredentials.server ?? ""; } String getUserImageUrl(String id) { diff --git a/lib/providers/lock_screen_provider.dart b/lib/providers/lock_screen_provider.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/providers/service_provider.dart b/lib/providers/service_provider.dart index 22823ba..f2f73b0 100644 --- a/lib/providers/service_provider.dart +++ b/lib/providers/service_provider.dart @@ -77,7 +77,7 @@ class JellyService { final JellyfinOpenApi _api; JellyfinOpenApi get api { - var authServer = ref.read(authProvider).tempCredentials.server; + var authServer = ref.read(authProvider).serverLoginModel?.tempCredentials.server ?? ""; var currentServer = ref.read(userProvider)?.credentials.server; if ((authServer.isNotEmpty ? authServer : currentServer) == FakeHelper.fakeTestServerUrl) { return FakeJellyfinOpenApi(); @@ -1126,6 +1126,8 @@ class JellyService { Future> quickConnectEnabled() async => api.quickConnectEnabledGet(); + Future> getBranding() async => api.brandingConfigurationGet(); + Future> deleteItem(String itemId) => api.itemsItemIdDelete(itemId: itemId); Future _updateUserConfiguration(UserConfiguration newUserConfiguration) async { @@ -1161,6 +1163,22 @@ class JellyService { ); return _updateUserConfiguration(updated); } + + Future> quickConnectInitiate() async { + return api.quickConnectInitiatePost(); + } + + Future> quickConnectConnectGet({ + String? secret, + }) async { + return api.quickConnectConnectGet(secret: secret); + } + + Future> quickConnectAuthenticate(String secret) async { + return api.usersAuthenticateWithQuickConnectPost( + body: QuickConnectDto(secret: secret), + ); + } } extension ParsedMap on Map { diff --git a/lib/providers/settings/video_player_settings_provider.dart b/lib/providers/settings/video_player_settings_provider.dart index bb66947..33aa724 100644 --- a/lib/providers/settings/video_player_settings_provider.dart +++ b/lib/providers/settings/video_player_settings_provider.dart @@ -5,10 +5,13 @@ import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:screen_brightness/screen_brightness.dart'; +import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/settings/key_combinations.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/shared_provider.dart'; +import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/src/player_settings_helper.g.dart' as pigeon; final videoPlayerSettingsProvider = StateNotifierProvider((ref) { @@ -30,6 +33,30 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier MapEntry( + switch (key) { + MediaSegmentType.unknown => pigeon.SegmentType.intro, + MediaSegmentType.commercial => pigeon.SegmentType.commercial, + MediaSegmentType.preview => pigeon.SegmentType.preview, + MediaSegmentType.recap => pigeon.SegmentType.recap, + MediaSegmentType.outro => pigeon.SegmentType.outro, + MediaSegmentType.intro => pigeon.SegmentType.intro, + }, + switch (value) { + SegmentSkip.none => pigeon.SegmentSkip.none, + SegmentSkip.askToSkip => pigeon.SegmentSkip.ask, + SegmentSkip.skip => pigeon.SegmentSkip.skip, + }, + ), + ), + skipBackward: (userData?.userSettings?.skipBackDuration ?? const Duration(seconds: 15)).inMilliseconds, + skipForward: (userData?.userSettings?.skipForwardDuration ?? const Duration(seconds: 30)).inMilliseconds, + ), + ); } void setScreenBrightness(double? value) async { diff --git a/lib/providers/shared_provider.dart b/lib/providers/shared_provider.dart index e3a6bac..480c1ae 100644 --- a/lib/providers/shared_provider.dart +++ b/lib/providers/shared_provider.dart @@ -55,10 +55,30 @@ class SharedUtility { } Future addAccount(AccountModel account) async { - return await saveAccounts(getAccounts() - ..add(account.copyWith( - lastUsed: DateTime.now(), - ))); + final newAccount = account.copyWith( + lastUsed: DateTime.now(), + ); + + List accounts = getAccounts().toList(); + if (accounts.any((element) => element.sameIdentity(newAccount))) { + accounts = accounts + .map( + (e) => e.sameIdentity(newAccount) + ? e.copyWith( + credentials: newAccount.credentials, + lastUsed: newAccount.lastUsed, + ) + : e, + ) + .toList(); + } else { + accounts = [ + ...accounts, + newAccount, + ]; + } + + return await saveAccounts(accounts); } Future removeAccount(AccountModel? account) async { diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index a077035..3e58ecf 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/material.dart'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/media_playback_model.dart'; @@ -77,6 +79,8 @@ class VideoPlayerNotifier extends StateNotifier { mediaState.update( (state) => state.playing == event ? state : state.copyWith(playing: event), ); + final currentState = playbackState; + ref.read(playBackModel)?.updatePlaybackPosition(currentState.position, playbackState.playing, ref); } Future updatePosition(Duration event) async { @@ -105,7 +109,7 @@ class VideoPlayerNotifier extends StateNotifier { } } - Future loadPlaybackItem(PlaybackModel model, {Duration? startPosition}) async { + Future loadPlaybackItem(PlaybackModel model, Duration startPosition) async { await state.stop(); mediaState .update((state) => state.copyWith(state: VideoPlayerState.fullScreen, buffering: true, errorPlaying: false)); @@ -114,13 +118,13 @@ class VideoPlayerNotifier extends StateNotifier { PlaybackModel? newPlaybackModel = model; if (media != null) { - await state.open(media.url, false); + await state.loadVideo(model, startPosition, false); await state.setVolume(ref.read(videoPlayerSettingsProvider).volume); state.stateStream?.takeWhile((event) => event.buffering == true).listen( null, onDone: () async { - final start = startPosition ?? await model.startDuration(); - if (start != null) { + final start = startPosition; + if (start != Duration.zero) { await state.seek(start); } await state.setAudioTrack(null, model); @@ -138,4 +142,6 @@ class VideoPlayerNotifier extends StateNotifier { mediaState.update((state) => state.copyWith(errorPlaying: true)); return false; } + + Future openPlayer(BuildContext context) async => state.openPlayer(context); } diff --git a/lib/providers/views_provider.dart b/lib/providers/views_provider.dart index c04e5a0..9d29f62 100644 --- a/lib/providers/views_provider.dart +++ b/lib/providers/views_provider.dart @@ -65,6 +65,7 @@ class ViewsNotifier extends StateNotifier { ItemFields.candelete, ItemFields.candownload, ItemFields.primaryimageaspectratio, + ItemFields.overview, ], ); return e.copyWith(recentlyAdded: recents.body?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList()); diff --git a/lib/routes/auto_router.dart b/lib/routes/auto_router.dart index f975458..444d143 100644 --- a/lib/routes/auto_router.dart +++ b/lib/routes/auto_router.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/login/lock_screen.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart'; const settingsPageRoute = "settings"; @@ -53,18 +54,22 @@ final List homeRoutes = [ page: DashboardRoute.page, initial: true, path: 'dashboard', + maintainState: false, ), AutoRoute( page: FavouritesRoute.page, path: 'favourites', + maintainState: false, ), AutoRoute( page: SyncedRoute.page, path: 'synced', + maintainState: false, ), AutoRoute( page: LibraryRoute.page, path: 'libraries', + maintainState: false, ), ]; @@ -76,7 +81,7 @@ final List detailsRoutes = [ final List _defaultRoutes = [ AutoRoute(page: SplashRoute.page, path: '/splash'), - AutoRoute(page: LoginRoute.page, path: '/login'), + AutoRoute(page: LoginRoute.page, path: '/login', maintainState: false), ]; final List _settingsChildren = [ @@ -117,6 +122,8 @@ class AuthGuard extends AutoRouteGuard { if (ref.read(userProvider) != null || resolver.routeName == const LoginRoute().routeName || resolver.routeName == SplashRoute().routeName) { + // We assume the last main focus is no longer active after navigating + lastMainFocus = null; return resolver.next(true); } @@ -127,5 +134,9 @@ class AuthGuard extends AutoRouteGuard { router.replace(const LoginRoute()); } })); + + // We assume the last main focus is no longer active after navigating + lastMainFocus = null; + return; } } diff --git a/lib/routes/auto_router.gr.dart b/lib/routes/auto_router.gr.dart index 3ee4e9c..b331868 100644 --- a/lib/routes/auto_router.gr.dart +++ b/lib/routes/auto_router.gr.dart @@ -93,11 +93,12 @@ class DetailsRoute extends _i18.PageRouteInfo { DetailsRoute({ String id = '', _i19.ItemBaseModel? item, + Object? tag, _i20.Key? key, List<_i18.PageRouteInfo>? children, }) : super( DetailsRoute.name, - args: DetailsRouteArgs(id: id, item: item, key: key), + args: DetailsRouteArgs(id: id, item: item, tag: tag, key: key), rawQueryParams: {'id': id}, initialChildren: children, ); @@ -111,34 +112,44 @@ class DetailsRoute extends _i18.PageRouteInfo { final args = data.argsAs( orElse: () => DetailsRouteArgs(id: queryParams.getString('id', '')), ); - return _i4.DetailsScreen(id: args.id, item: args.item, key: args.key); + return _i4.DetailsScreen( + id: args.id, + item: args.item, + tag: args.tag, + key: args.key, + ); }, ); } class DetailsRouteArgs { - const DetailsRouteArgs({this.id = '', this.item, this.key}); + const DetailsRouteArgs({this.id = '', this.item, this.tag, this.key}); final String id; final _i19.ItemBaseModel? item; + final Object? tag; + final _i20.Key? key; @override String toString() { - return 'DetailsRouteArgs{id: $id, item: $item, key: $key}'; + return 'DetailsRouteArgs{id: $id, item: $item, tag: $tag, key: $key}'; } @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! DetailsRouteArgs) return false; - return id == other.id && item == other.item && key == other.key; + return id == other.id && + item == other.item && + tag == other.tag && + key == other.key; } @override - int get hashCode => id.hashCode ^ item.hashCode ^ key.hashCode; + int get hashCode => id.hashCode ^ item.hashCode ^ tag.hashCode ^ key.hashCode; } /// generated route for diff --git a/lib/routes/nested_details_screen.dart b/lib/routes/nested_details_screen.dart index d037e3d..79c7204 100644 --- a/lib/routes/nested_details_screen.dart +++ b/lib/routes/nested_details_screen.dart @@ -13,7 +13,13 @@ import 'package:fladder/util/fladder_image.dart'; class DetailsScreen extends ConsumerStatefulWidget { final String id; final ItemBaseModel? item; - const DetailsScreen({@QueryParam() this.id = '', this.item, super.key}); + final Object? tag; + const DetailsScreen({ + @QueryParam() this.id = '', + this.item, + this.tag, + super.key, + }); @override ConsumerState createState() => _DetailsScreenState(); @@ -66,7 +72,7 @@ class _DetailsScreenState extends ConsumerState { key: Key(widget.id), children: [ Hero( - tag: widget.id, + tag: widget.tag ?? UniqueKey(), child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface.withValues(alpha: 1.0), diff --git a/lib/screens/book_viewer/book_viewer_settings.dart b/lib/screens/book_viewer/book_viewer_settings.dart index 38b6301..df69435 100644 --- a/lib/screens/book_viewer/book_viewer_settings.dart +++ b/lib/screens/book_viewer/book_viewer_settings.dart @@ -1,11 +1,14 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'package:fladder/providers/settings/book_viewer_settings_provider.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; import 'package:fladder/widgets/shared/fladder_slider.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_side_sheet.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; Future showBookViewerSettings( BuildContext context, @@ -80,10 +83,9 @@ class BookViewerSettingsScreen extends ConsumerWidget { label: const Text("Read direction"), current: settings.readDirection.name.toUpperCaseSplit(), itemBuilder: (context) => ReadDirection.values - .map((value) => PopupMenuItem( - value: value, - child: Text(value.name.toUpperCaseSplit()), - onTap: () => ref + .map((value) => ItemActionButton( + label: Text(value.name.toUpperCaseSplit()), + action: () => ref .read(bookViewerSettingsProvider.notifier) .update((state) => state.copyWith(readDirection: value)), )) @@ -102,10 +104,9 @@ class BookViewerSettingsScreen extends ConsumerWidget { label: const Text("Init zoom"), current: settings.initZoomState.name.toUpperCaseSplit(), itemBuilder: (context) => InitZoomState.values - .map((value) => PopupMenuItem( - value: value, - child: Text(value.name.toUpperCaseSplit()), - onTap: () => ref + .map((value) => ItemActionButton( + label: Text(value.name.toUpperCaseSplit()), + action: () => ref .read(bookViewerSettingsProvider.notifier) .update((state) => state.copyWith(initZoomState: value)), )) diff --git a/lib/screens/crash_screen/crash_screen.dart b/lib/screens/crash_screen/crash_screen.dart index 8f7a39c..a512a7e 100644 --- a/lib/screens/crash_screen/crash_screen.dart +++ b/lib/screens/crash_screen/crash_screen.dart @@ -9,6 +9,7 @@ import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; final _selectedWarningProvider = StateProvider((ref) => null); @@ -41,16 +42,14 @@ class CrashScreen extends ConsumerWidget { EnumBox( current: selectedType == null ? context.localized.all : selectedType.name.capitalize(), itemBuilder: (context) => [ - PopupMenuItem( - value: null, - child: Text(context.localized.all), - onTap: () => ref.read(_selectedWarningProvider.notifier).update((state) => null), + ItemActionButton( + label: Text(context.localized.all), + action: () => ref.read(_selectedWarningProvider.notifier).update((state) => null), ), ...ErrorType.values.map( - (entry) => PopupMenuItem( - value: entry, - child: Text(entry.name.capitalize()), - onTap: () => ref.read(_selectedWarningProvider.notifier).update((state) => entry), + (entry) => ItemActionButton( + label: Text(entry.name.capitalize()), + action: () => ref.read(_selectedWarningProvider.notifier).update((state) => entry), ), ) ], diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index a6bffc9..75b4002 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart'; @@ -23,6 +24,7 @@ import 'package:fladder/screens/shared/media/poster_row.dart'; import 'package:fladder/screens/shared/nested_scaffold.dart'; import 'package:fladder/screens/shared/nested_sliver_appbar.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/sliver_list_padding.dart'; @@ -45,6 +47,10 @@ class _DashboardScreenState extends ConsumerState { late final Timer _timer; final GlobalKey _refreshIndicatorKey = GlobalKey(); + final textController = TextEditingController(); + + ItemBaseModel? selectedPoster; + @override void initState() { super.initState(); @@ -70,6 +76,7 @@ class _DashboardScreenState extends ConsumerState { @override Widget build(BuildContext context) { final padding = AdaptiveLayout.adaptivePadding(context); + final bannerType = ref.watch(homeSettingsProvider.select((value) => value.homeBanner)); final dashboardData = ref.watch(dashboardProvider); final views = ref.watch(viewsProvider); @@ -87,10 +94,15 @@ class _DashboardScreenState extends ConsumerState { HomeCarouselSettings.cont => allResume, }; + final viewSize = AdaptiveLayout.viewSizeOf(context); + return MediaQuery.removeViewInsets( context: context, child: NestedScaffold( - background: BackgroundImage(items: [...homeCarouselItems, ...dashboardData.nextUp, ...allResume]), + background: BackgroundImage( + items: selectedPoster != null + ? [selectedPoster!] + : [...homeCarouselItems, ...dashboardData.nextUp, ...allResume]), body: PullToRefresh( refreshKey: _refreshIndicatorKey, displacement: 80 + MediaQuery.of(context).viewPadding.top, @@ -101,8 +113,8 @@ class _DashboardScreenState extends ConsumerState { controller: AdaptiveLayout.scrollOf(context, HomeTabs.dashboard), physics: const AlwaysScrollableScrollPhysics(), slivers: [ - const DefaultSliverTopBadding(), - if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) + if (bannerType != HomeBanner.detailedBanner) const DefaultSliverTopBadding(), + if (viewSize == ViewSize.phone) NestedSliverAppBar( route: LibrarySearchRoute(), parent: context, @@ -114,7 +126,16 @@ class _DashboardScreenState extends ConsumerState { context, horizontalPadding: 0, ), - child: HomeBannerWidget(posters: homeCarouselItems), + child: HomeBannerWidget( + posters: homeCarouselItems, + onSelect: (selected) { + // if (selectedPoster != selected) { + // setState(() { + // selectedPoster = selected; + // }); + // } + }, + ), ), ), }, @@ -130,80 +151,84 @@ class _DashboardScreenState extends ConsumerState { ...[ if (resumeVideo.isNotEmpty && (homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate)) - SliverToBoxAdapter( - child: PosterRow( - contentPadding: padding, - label: context.localized.dashboardContinueWatching, - posters: resumeVideo, - ), + PosterRow( + contentPadding: padding, + label: context.localized.dashboardContinueWatching, + posters: resumeVideo, ), if (resumeAudio.isNotEmpty && (homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate)) - SliverToBoxAdapter( - child: PosterRow( - contentPadding: padding, - label: context.localized.dashboardContinueListening, - posters: resumeAudio, - ), + PosterRow( + contentPadding: padding, + label: context.localized.dashboardContinueListening, + posters: resumeAudio, ), if (resumeBooks.isNotEmpty && (homeSettings.nextUp == HomeNextUp.cont || homeSettings.nextUp == HomeNextUp.separate)) - SliverToBoxAdapter( - child: PosterRow( - contentPadding: padding, - label: context.localized.dashboardContinueReading, - posters: resumeBooks, - ), + PosterRow( + contentPadding: padding, + label: context.localized.dashboardContinueReading, + posters: resumeBooks, ), if (dashboardData.nextUp.isNotEmpty && (homeSettings.nextUp == HomeNextUp.nextUp || homeSettings.nextUp == HomeNextUp.separate)) - SliverToBoxAdapter( - child: PosterRow( - contentPadding: padding, - label: context.localized.nextUp, - posters: dashboardData.nextUp, - ), + PosterRow( + contentPadding: padding, + label: context.localized.nextUp, + posters: dashboardData.nextUp, ), if ([...allResume, ...dashboardData.nextUp].isNotEmpty && homeSettings.nextUp == HomeNextUp.combined) - SliverToBoxAdapter( - child: PosterRow( - contentPadding: padding, - label: context.localized.dashboardContinue, - posters: [...allResume, ...dashboardData.nextUp], + PosterRow( + contentPadding: padding, + label: context.localized.dashboardContinue, + posters: [...allResume, ...dashboardData.nextUp], + ), + ...views.dashboardViews.where((element) => element.recentlyAdded.isNotEmpty).map( + (view) => PosterRow( + contentPadding: padding, + label: context.localized.dashboardRecentlyAdded(view.name), + collectionAspectRatio: view.collectionType.aspectRatio, + onLabelClick: () => context.router.push( + LibrarySearchRoute( + viewModelId: view.id, + types: switch (view.collectionType) { + CollectionType.tvshows => { + FladderItemType.episode: true, + }, + _ => {}, + }, + sortingOptions: switch (view.collectionType) { + CollectionType.books || + CollectionType.boxsets || + CollectionType.folders || + CollectionType.music => + SortingOptions.dateLastContentAdded, + _ => SortingOptions.dateAdded, + }, + sortOrder: SortingOrder.descending, + recursive: true, + ), + ), + posters: view.recentlyAdded, + ), + ), + ] + .nonNulls + .toList() + .mapIndexed( + (index, child) => SliverToBoxAdapter( + child: FocusProvider( + autoFocus: bannerType != HomeBanner.detailedBanner ? index == 0 : false, + child: child, + ), + ), + ) + .toList() + .addInBetween( + const SliverToBoxAdapter( + child: SizedBox(height: 16), ), ), - ...views.dashboardViews - .where((element) => element.recentlyAdded.isNotEmpty) - .map((view) => SliverToBoxAdapter( - child: PosterRow( - contentPadding: padding, - label: context.localized.dashboardRecentlyAdded(view.name), - collectionAspectRatio: view.collectionType.aspectRatio, - onLabelClick: () => context.router.push( - LibrarySearchRoute( - viewModelId: view.id, - types: switch (view.collectionType) { - CollectionType.tvshows => { - FladderItemType.episode: true, - }, - _ => {}, - }, - sortingOptions: switch (view.collectionType) { - CollectionType.books || - CollectionType.boxsets || - CollectionType.folders || - CollectionType.music => - SortingOptions.dateLastContentAdded, - _ => SortingOptions.dateAdded, - }, - sortOrder: SortingOrder.descending, - recursive: true, - ), - ), - posters: view.recentlyAdded, - ), - )), - ].nonNulls.toList().addInBetween(const SliverToBoxAdapter(child: SizedBox(height: 16))), const DefautlSliverBottomPadding(), ], ), diff --git a/lib/screens/dashboard/home_banner_widget.dart b/lib/screens/dashboard/home_banner_widget.dart index db10520..e569b80 100644 --- a/lib/screens/dashboard/home_banner_widget.dart +++ b/lib/screens/dashboard/home_banner_widget.dart @@ -5,17 +5,26 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/settings/home_settings_model.dart'; import 'package:fladder/providers/settings/home_settings_provider.dart'; +import 'package:fladder/screens/shared/animated_fade_size.dart'; import 'package:fladder/screens/shared/media/carousel_banner.dart'; +import 'package:fladder/screens/shared/media/detailed_banner.dart'; import 'package:fladder/screens/shared/media/media_banner.dart'; class HomeBannerWidget extends ConsumerWidget { final List posters; - const HomeBannerWidget({required this.posters, super.key}); + final Function(ItemBaseModel selected) onSelect; + + const HomeBannerWidget({ + required this.posters, + required this.onSelect, + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) { final bannerType = ref.watch(homeSettingsProvider.select((value) => value.homeBanner)); final maxHeight = (MediaQuery.sizeOf(context).shortestSide * 0.6).clamp(125.0, 375.0); + return switch (bannerType) { HomeBanner.carousel => Column( mainAxisSize: MainAxisSize.min, @@ -34,6 +43,12 @@ class HomeBannerWidget extends ConsumerWidget { maxHeight: maxHeight, ), ), + HomeBanner.detailedBanner => AnimatedFadeSize( + child: DetailedBanner( + posters: posters, + onSelect: onSelect, + ), + ), _ => const SizedBox.shrink(), }; } diff --git a/lib/screens/details_screens/components/media_stream_information.dart b/lib/screens/details_screens/components/media_stream_information.dart index dd8ee9c..036f43e 100644 --- a/lib/screens/details_screens/components/media_stream_information.dart +++ b/lib/screens/details_screens/components/media_stream_information.dart @@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/screens/details_screens/components/label_title_item.dart'; import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/shared/enum_selection.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; class MediaStreamInformation extends ConsumerWidget { final MediaStreamsModel mediaStream; @@ -30,15 +32,13 @@ class MediaStreamInformation extends ConsumerWidget { label: Text(context.localized.version), current: mediaStream.currentVersionStream?.name ?? "", itemBuilder: (context) => mediaStream.versionStreams - .map((e) => PopupMenuItem( - value: e, - padding: EdgeInsets.zero, - child: textWidget( + .map((e) => ItemActionButton( + selected: mediaStream.currentVersionStream == e, + label: textWidget( context, - selected: mediaStream.currentVersionStream == e, label: e.name, ), - onTap: () => onVersionIndexChanged?.call(e.index), + action: () => onVersionIndexChanged?.call(e.index), )) .toList(), ), @@ -48,10 +48,8 @@ class MediaStreamInformation extends ConsumerWidget { current: (mediaStream.videoStreams.first).prettyName, itemBuilder: (context) => mediaStream.videoStreams .map( - (e) => PopupMenuItem( - value: e, - padding: EdgeInsets.zero, - child: Text(e.prettyName), + (e) => ItemActionButton( + label: Text(e.prettyName), ), ) .toList(), @@ -62,12 +60,13 @@ class MediaStreamInformation extends ConsumerWidget { current: mediaStream.currentAudioStream?.displayTitle ?? "", itemBuilder: (context) => [AudioStreamModel.no(), ...mediaStream.audioStreams] .map( - (e) => PopupMenuItem( - value: e, - padding: EdgeInsets.zero, - child: textWidget(context, - selected: mediaStream.currentAudioStream?.index == e.index, label: e.displayTitle), - onTap: () => onAudioIndexChanged?.call(e.index), + (e) => ItemActionButton( + selected: mediaStream.currentAudioStream?.index == e.index, + label: textWidget( + context, + label: e.displayTitle, + ), + action: () => onAudioIndexChanged?.call(e.index), ), ) .toList(), @@ -78,12 +77,13 @@ class MediaStreamInformation extends ConsumerWidget { current: mediaStream.currentSubStream?.displayTitle ?? "", itemBuilder: (context) => [SubStreamModel.no(), ...mediaStream.subStreams] .map( - (e) => PopupMenuItem( - value: e, - padding: EdgeInsets.zero, - child: textWidget(context, - selected: mediaStream.currentSubStream?.index == e.index, label: e.displayTitle), - onTap: () => onSubIndexChanged?.call(e.index), + (e) => ItemActionButton( + selected: mediaStream.currentSubStream?.index == e.index, + label: textWidget( + context, + label: e.displayTitle, + ), + action: () => onSubIndexChanged?.call(e.index), ), ) .toList(), @@ -92,22 +92,12 @@ class MediaStreamInformation extends ConsumerWidget { ); } - Widget textWidget(BuildContext context, {required bool selected, required String label}) { - return Container( - height: kMinInteractiveDimension, - width: double.maxFinite, - color: selected ? Theme.of(context).colorScheme.primary : null, - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected ? Theme.of(context).colorScheme.onPrimary : null, - fontWeight: FontWeight.bold, - ), - ), - ), + Widget textWidget(BuildContext context, {required String label}) { + return Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ); } } @@ -115,7 +105,7 @@ class MediaStreamInformation extends ConsumerWidget { class _StreamOptionSelect extends StatelessWidget { final Text label; final String current; - final List> Function(BuildContext context) itemBuilder; + final List Function(BuildContext context) itemBuilder; const _StreamOptionSelect({ required this.label, required this.current, @@ -124,47 +114,14 @@ class _StreamOptionSelect extends StatelessWidget { @override Widget build(BuildContext context) { - final textStyle = Theme.of(context).textTheme.titleMedium; - const padding = EdgeInsets.all(6); - final itemList = itemBuilder(context); - return LabelTitleItem( - title: label, - content: Flexible( - child: PopupMenuButton( - tooltip: '', - 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, - ) - ], - ), - ), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: LabelTitleItem( + title: label, + content: Flexible( + child: EnumBox( + current: current, + itemBuilder: itemBuilder, ), ), ), diff --git a/lib/screens/details_screens/components/overview_header.dart b/lib/screens/details_screens/components/overview_header.dart index 9ad29e4..f27c7c8 100644 --- a/lib/screens/details_screens/components/overview_header.dart +++ b/lib/screens/details_screens/components/overview_header.dart @@ -18,8 +18,10 @@ class OverviewHeader extends ConsumerWidget { final EdgeInsets? padding; final String? subTitle; final String? originalTitle; + final Alignment logoAlignment; final Function()? onTitleClicked; final int? productionYear; + final String? summary; final Duration? runTime; final String? officialRating; final double? communityRating; @@ -32,8 +34,10 @@ class OverviewHeader extends ConsumerWidget { this.padding, this.subTitle, this.originalTitle, + this.logoAlignment = Alignment.bottomCenter, this.onTitleClicked, this.productionYear, + this.summary, this.runTime, this.officialRating, this.communityRating, @@ -68,83 +72,101 @@ class OverviewHeader extends ConsumerWidget { crossAxisAlignment: crossAlignment, mainAxisSize: MainAxisSize.min, children: [ - MediaHeader( - name: name, - logo: image?.logo, - onTap: onTitleClicked, - ), - 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)), - ), - 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, - )), + Flexible( + child: ExcludeFocus( + child: MediaHeader( + name: name, + logo: image?.logo, + onTap: onTitleClicked, + alignment: logoAlignment, ), - 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!, ].addInBetween(const SizedBox(height: 21)), diff --git a/lib/screens/details_screens/episode_detail_screen.dart b/lib/screens/details_screens/episode_detail_screen.dart index fcaaa5d..c1a5803 100644 --- a/lib/screens/details_screens/episode_detail_screen.dart +++ b/lib/screens/details_screens/episode_detail_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; -import 'package:iconsax_plus/iconsax_plus.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/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/router_extension.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'; class EpisodeDetailScreen extends ConsumerStatefulWidget { @@ -77,19 +79,62 @@ class _ItemDetailScreenState extends ConsumerState { OverviewHeader( name: details.series?.name ?? "", image: seasonDetails.images, - centerButtons: episodeDetails.playAble - ? MediaPlayButton( - item: episodeDetails, - onPressed: () async { - await details.episode.play(context, ref); - ref.read(providerInstance.notifier).fetchDetails(widget.item); - }, - onLongPressed: () async { - await details.episode.play(context, ref, showPlaybackOption: true); - ref.read(providerInstance.notifier).fetchDetails(widget.item); - }, - ) - : null, + centerButtons: Wrap( + spacing: 8, + runSpacing: 8, + alignment: wrapAlignment, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + episodeDetails.playAble + ? MediaPlayButton( + item: episodeDetails, + onPressed: () async { + await details.episode.play(context, ref); + ref.read(providerInstance.notifier).fetchDetails(widget.item); + }, + 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, subTitle: details.episode?.detailedName(context), originalTitle: details.series?.originalTitle, @@ -101,34 +146,6 @@ class _ItemDetailScreenState extends ConsumerState { officialRating: details.series?.overview.parentalRating, 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) Padding( padding: padding, diff --git a/lib/screens/details_screens/movie_detail_screen.dart b/lib/screens/details_screens/movie_detail_screen.dart index bf65d2f..45f20f1 100644 --- a/lib/screens/details_screens/movie_detail_screen.dart +++ b/lib/screens/details_screens/movie_detail_screen.dart @@ -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/router_extension.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'; class MovieDetailScreen extends ConsumerStatefulWidget { @@ -71,23 +73,63 @@ class _ItemDetailScreenState extends ConsumerState { name: details.name, image: details.images, padding: padding, - centerButtons: MediaPlayButton( - item: details, - onLongPressed: () async { - await details.play( - context, - ref, - showPlaybackOption: true, - ); - ref.read(providerInstance.notifier).fetchDetails(widget.item); - }, - onPressed: () async { - await details.play( - context, - ref, - ); - ref.read(providerInstance.notifier).fetchDetails(widget.item); - }, + centerButtons: Wrap( + spacing: 8, + runSpacing: 8, + alignment: wrapAlignment, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + MediaPlayButton( + item: details, + onLongPressed: () async { + await details.play( + context, + ref, + showPlaybackOption: true, + ); + 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, productionYear: details.overview.productionYear, @@ -97,32 +139,6 @@ class _ItemDetailScreenState extends ConsumerState { officialRating: details.overview.parentalRating, 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) MediaStreamInformation( onVersionIndexChanged: (index) { diff --git a/lib/screens/details_screens/series_detail_screen.dart b/lib/screens/details_screens/series_detail_screen.dart index b2a497f..e042808 100644 --- a/lib/screens/details_screens/series_detail_screen.dart +++ b/lib/screens/details_screens/series_detail_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; -import 'package:iconsax_plus/iconsax_plus.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/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/router_extension.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'; class SeriesDetailScreen extends ConsumerStatefulWidget { @@ -74,20 +76,60 @@ class _SeriesDetailScreenState extends ConsumerState { OverviewHeader( name: details.name, image: details.images, - centerButtons: MediaPlayButton( - item: details.nextUp, - onPressed: details.nextUp != null - ? () async { - await details.nextUp.play(context, ref); - ref.read(providerId.notifier).fetchDetails(widget.item); - } - : null, - onLongPressed: details.nextUp != null - ? () async { - await details.nextUp.play(context, ref, showPlaybackOption: true); - ref.read(providerId.notifier).fetchDetails(widget.item); - } - : null, + centerButtons: Wrap( + spacing: 8, + runSpacing: 8, + alignment: wrapAlignment, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + MediaPlayButton( + item: details.nextUp, + onPressed: details.nextUp != null + ? () async { + await details.nextUp.play(context, ref); + ref.read(providerId.notifier).fetchDetails(widget.item); + } + : 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, originalTitle: details.originalTitle, @@ -98,32 +140,6 @@ class _SeriesDetailScreenState extends ConsumerState { genres: details.overview.genreItems, 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) NextUpEpisode( nextEpisode: details.nextUp!, diff --git a/lib/screens/favourites/favourites_screen.dart b/lib/screens/favourites/favourites_screen.dart index ea5634f..58eb2d4 100644 --- a/lib/screens/favourites/favourites_screen.dart +++ b/lib/screens/favourites/favourites_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/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_sliver_appbar.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/sliver_list_padding.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( - child: PosterRow( + ...[ + ...favourites.favourites.entries.where((element) => element.value.isNotEmpty).map( + (e) => PosterRow( contentPadding: padding, onLabelClick: () => context.pushRoute( LibrarySearchRoute().withFilter( @@ -71,15 +73,17 @@ class FavouritesScreen extends ConsumerWidget { posters: e.value, ), ), - ), - if (favourites.people.isNotEmpty) - SliverToBoxAdapter( - child: PosterRow( + if (favourites.people.isNotEmpty) + PosterRow( contentPadding: padding, label: context.localized.actor(favourites.people.length), posters: favourites.people, ), + ].mapIndexed( + (index, e) => SliverToBoxAdapter( + child: FocusProvider(hasFocus: false, autoFocus: index == 0, child: e), ), + ), const DefautlSliverBottomPadding(), ], ), diff --git a/lib/screens/library/library_screen.dart b/lib/screens/library/library_screen.dart index a2b2a38..97122d3 100644 --- a/lib/screens/library/library_screen.dart +++ b/lib/screens/library/library_screen.dart @@ -13,13 +13,13 @@ import 'package:fladder/providers/library_screen_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/home_screen.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/nested_scaffold.dart'; import 'package:fladder/screens/shared/nested_sliver_appbar.dart'; import 'package:fladder/theme.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.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/sliver_list_padding.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), items: views, height: 155, + autoFocus: true, startIndex: selectedView != null ? views.indexOf(selectedView!) : null, contentPadding: padding, - itemBuilder: (context, index) { + itemBuilder: (context, index, selected) { final view = views[index]; final isSelected = selectedView == view; final List viewActions = [ @@ -250,25 +251,26 @@ class LibraryRow extends ConsumerWidget { action: () => showRefreshPopup(context, view.id, view.name), ) ]; - return FlatButton( - onTap: isSelected ? null : () => onSelected?.call(view), - onLongPress: () => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)), - onSecondaryTapDown: (details) async { - Offset localPosition = details.globalPosition; - RelativeRect position = - RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); - await showMenu( - context: context, - position: position, - items: viewActions.popupMenuItems(useIcons: true), - ); - }, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FocusButton( + key: Key(view.id), + onTap: isSelected ? null : () => onSelected?.call(view), + onLongPress: () => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)), + onSecondaryTapDown: (details) async { + Offset localPosition = details.globalPosition; + RelativeRect position = + RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); + await showMenu( + context: context, + position: position, + items: viewActions.popupMenuItems(useIcons: true), + ); + }, + child: Container( decoration: BoxDecoration( borderRadius: FladderTheme.defaultShape.borderRadius, ), @@ -294,15 +296,15 @@ class LibraryRow extends ConsumerWidget { ), ), ), - Text( - view.name, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.start, - ) - ], - ), + ), + Text( + view.name, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + ) + ], ); }, ); diff --git a/lib/screens/library_search/library_search_screen.dart b/lib/screens/library_search/library_search_screen.dart index f73ccf9..b76216b 100644 --- a/lib/screens/library_search/library_search_screen.dart +++ b/lib/screens/library_search/library_search_screen.dart @@ -352,49 +352,27 @@ class _LibrarySearchScreenState extends ConsumerState { child: Tooltip( message: librarySearchResults.nestedCurrentItem?.type.label(context) ?? context.localized.library(1), - child: InkWell( - onTapUp: (details) async { - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) { - double left = details.globalPosition.dx; - double top = details.globalPosition.dy; - await showMenu( - context: context, - position: RelativeRect.fromLTRB(left, top, 40, 100), - items: [ - PopupMenuItem( - child: Text(librarySearchResults.nestedCurrentItem?.type.label(context) ?? - context.localized.library(0))), - itemCountWidget.toPopupMenuItem(useIcons: true), - refreshAction.toPopupMenuItem(useIcons: true), - itemViewAction.toPopupMenuItem(useIcons: true), + child: IconButton( + onPressed: () async { + 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().toPopupMenuItem(), - ...itemActions.popupMenuItems(useIcons: true), + showSavedFiltersDialogue.toListItem(context, useIcons: true), + if (itemActions.isNotEmpty) ItemActionDivider().toListItem(context), + ...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( - padding: const EdgeInsets.all(12), + icon: Padding( + padding: const EdgeInsets.all(6), child: Icon( isFavorite ? librarySearchResults.nestedCurrentItem?.type.selectedicon diff --git a/lib/screens/library_search/widgets/library_filter_chips.dart b/lib/screens/library_search/widgets/library_filter_chips.dart index fbe6cc9..c688e0e 100644 --- a/lib/screens/library_search/widgets/library_filter_chips.dart +++ b/lib/screens/library_search/widgets/library_filter_chips.dart @@ -149,16 +149,19 @@ class _LibraryFilterChipsState extends ConsumerState { ), ]; - return Row( - spacing: 4, - children: chips.mapIndexed( - (index, element) { - final position = index == 0 - ? PositionContext.first - : (index == chips.length - 1 ? PositionContext.last : PositionContext.middle); - return PositionProvider(position: position, child: element); - }, - ).toList(), + return FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: Row( + spacing: 4, + children: chips.mapIndexed( + (index, element) { + final position = index == 0 + ? PositionContext.first + : (index == chips.length - 1 ? PositionContext.last : PositionContext.middle); + return PositionProvider(position: position, child: element); + }, + ).toList(), + ), ); } diff --git a/lib/screens/library_search/widgets/library_views.dart b/lib/screens/library_search/widgets/library_views.dart index 29f6672..94a2d8c 100644 --- a/lib/screens/library_search/widgets/library_views.dart +++ b/lib/screens/library_search/widgets/library_views.dart @@ -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_widget.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/localization_helper.dart'; import 'package:fladder/util/refresh_state.dart'; import 'package:fladder/util/string_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'; final libraryViewTypeProvider = StateProvider((ref) { @@ -63,12 +66,12 @@ class LibraryViews extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 4), sliver: SliverAnimatedSwitcher( 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 posterSizeMultiplier = ref.watch(clientSettingsProvider.select((value) => value.posterSize)); final libraryProvider = ref.read(librarySearchProvider(key!).notifier); @@ -111,21 +114,24 @@ class LibraryViews extends ConsumerWidget { switch (ref.watch(libraryViewTypeProvider)) { case LibraryViewTypes.grid: Widget createGrid(List 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( - crossAxisCount: posterSize.clamp(2, double.maxFinite).toInt(), - mainAxisSpacing: (8 * decimal) + 8, - crossAxisSpacing: (8 * decimal) + 8, + crossAxisCount: crossAxisCount, + crossAxisSpacing: 8, + mainAxisSpacing: 8, childAspectRatio: items.getMostCommonType.aspectRatio, ), - itemCount: items.length, - itemBuilder: (context, index) { + itemBuilder: (other, selectedIndex, index) { final item = items[index]; return PosterWidget( key: Key(item.id), poster: item, maxLines: 2, - heroTag: true, subTitle: item.subTitle(sortingOptions), excludeActions: excludeActions, otherActions: otherActions(item), @@ -134,6 +140,11 @@ class LibraryViews extends ConsumerWidget { onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]), onItemUpdated: (newItem) => libraryProvider.updateItem(newItem), 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, itemBuilder: (context, index) { final poster = items[index]; - return PosterListItem( - poster: poster, - selected: selected.contains(poster), - excludeActions: excludeActions, - otherActions: otherActions(poster), - subTitle: poster.subTitle(sortingOptions), - onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData), - onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]), - onItemUpdated: (newItem) => libraryProvider.updateItem(newItem), - onPressed: (action, item) async => onItemPressed(action, key, item, ref, context), + return FocusProvider( + autoFocus: index == 0, + child: PosterListItem( + poster: poster, + selected: selected.contains(poster), + excludeActions: excludeActions, + otherActions: otherActions(poster), + subTitle: poster.subTitle(sortingOptions), + onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData), + 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, selected: selected.contains(item), inlineTitle: true, - heroTag: true, subTitle: item.subTitle(sortingOptions), excludeActions: excludeActions, otherActions: otherActions(group[index]), @@ -257,7 +270,6 @@ class LibraryViews extends ConsumerWidget { aspectRatio: item.primaryRatio, selected: selected.contains(item), inlineTitle: true, - heroTag: true, excludeActions: excludeActions, otherActions: otherActions(item), subTitle: item.subTitle(sortingOptions), diff --git a/lib/screens/library_search/widgets/suggestion_search_bar.dart b/lib/screens/library_search/widgets/suggestion_search_bar.dart index 25633ea..576b13f 100644 --- a/lib/screens/library_search/widgets/suggestion_search_bar.dart +++ b/lib/screens/library_search/widgets/suggestion_search_bar.dart @@ -7,6 +7,7 @@ import 'package:page_transition/page_transition.dart'; import 'package:fladder/models/item_base_model.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/util/debouncer.dart'; import 'package:fladder/util/fladder_image.dart'; @@ -87,7 +88,7 @@ class _SearchBarState extends ConsumerState { ), child: child, ), - builder: (context, controller, focusNode) => TextField( + builder: (context, controller, focusNode) => OutlinedTextField( focusNode: focusNode, controller: controller, onSubmitted: (value) { @@ -99,6 +100,7 @@ class _SearchBarState extends ConsumerState { isEmpty = value.isEmpty; }); }, + placeHolder: widget.title ?? "${context.localized.search}...", decoration: InputDecoration( hintText: widget.title ?? "${context.localized.search}...", prefixIcon: const Icon(IconsaxPlusLinear.search_normal), diff --git a/lib/screens/login/controllers/login_controller.dart b/lib/screens/login/controllers/login_controller.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/screens/login/login_code_dialog.dart b/lib/screens/login/login_code_dialog.dart new file mode 100644 index 0000000..1305643 --- /dev/null +++ b/lib/screens/login/login_code_dialog.dart @@ -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 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 createState() => _LoginCodeDialogState(); +} + +class _LoginCodeDialogState extends ConsumerState { + 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)), + ) + ], + ), + ), + ); + } +} diff --git a/lib/screens/login/login_screen.dart b/lib/screens/login/login_screen.dart index 506212b..66350de 100644 --- a/lib/screens/login/login_screen.dart +++ b/lib/screens/login/login_screen.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.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:fladder/models/account_model.dart'; +import 'package:fladder/models/login_screen_model.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_screen_credentials.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_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/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'; @RoutePage() @@ -37,373 +24,85 @@ class LoginScreen extends ConsumerStatefulWidget { } class _LoginPageState extends ConsumerState { - List users = const []; - bool loading = false; - String? invalidUrl = ""; - bool startCheckingForErrors = false; - bool addingNewUser = false; - bool editingUsers = false; late final TextEditingController serverTextController = TextEditingController(text: ''); - final usernameController = TextEditingController(); final passwordController = TextEditingController(); final FocusNode focusNode = FocusNode(); - - void startAddingNewUser() { - setState(() { - addingNewUser = true; - editingUsers = false; - }); - if (FladderConfig.baseUrl != null) { - serverTextController.text = FladderConfig.baseUrl!; - _parseUrl(FladderConfig.baseUrl!); - retrieveListOfUsers(); - } - } + bool editUsersMode = false; @override void initState() { super.initState(); - Future.microtask(() { - ref.read(userProvider.notifier).clear(); - 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(); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(authProvider.notifier).initModel(context); }); } @override Widget build(BuildContext context) { - final loggedInUsers = ref.watch(authProvider.select((value) => value.accounts)); - final authLoading = ref.watch(authProvider.select((value) => value.loading)); + final screen = ref.watch(authProvider.select((value) => value.screen)); + final accounts = ref.watch(authProvider.select((value) => value.accounts)); return Scaffold( appBar: const FladderAppBar(), - floatingActionButton: !addingNewUser - ? Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (!AdaptiveLayout.of(context).isDesktop) - FloatingActionButton( - 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), + extendBody: true, + extendBodyBehindAppBar: true, + floatingActionButton: switch (screen) { + LoginScreenType.users => Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: 16, children: [ - const Center( - child: FladderLogo(), + if (!AdaptiveLayout.of(context).isDesktop) + 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) { showDialog( context: context, builder: (context) => LoginEditUser( user: user, onTapServer: (value) { - setState(() { - _parseUrl(value); - serverTextController.text = value; - startAddingNewUser(); - }); + ref.read(authProvider.notifier).setServer(value); 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 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 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 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)), - ); - } } diff --git a/lib/screens/login/login_screen_credentials.dart b/lib/screens/login/login_screen_credentials.dart new file mode 100644 index 0000000..924a9fc --- /dev/null +++ b/lib/screens/login/login_screen_credentials.dart @@ -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 createState() => _LoginScreenCredentialsState(); +} + +class _LoginScreenCredentialsState extends ConsumerState { + 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 Function()? get enterCredentialsTryLogin => emptyFields() ? null : () => loginUsingCredentials(); + + Future 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 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 _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 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; + } +} diff --git a/lib/screens/login/login_user_grid.dart b/lib/screens/login/login_user_grid.dart index e46c6e8..d3a64d3 100644 --- a/lib/screens/login/login_user_grid.dart +++ b/lib/screens/login/login_user_grid.dart @@ -6,9 +6,9 @@ import 'package:reorderable_grid/reorderable_grid.dart'; import 'package:fladder/models/account_model.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/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/list_padding.dart'; class LoginUserGrid extends ConsumerWidget { @@ -21,127 +21,118 @@ class LoginUserGrid extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { 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( - onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex), - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - autoScroll: true, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: users.length == 1 ? 1 : maxCount, - mainAxisSpacing: 24, - crossAxisSpacing: 24, - mainAxisExtent: mainAxisExtent, - ), - itemCount: users.length, - itemBuilder: (context, index) { - final user = users[index]; - return FlatButton( - key: Key(user.id), - onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user), - onLongPress: - AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? () => onLongPress?.call(user) : null, - child: _CardHolder( - content: Stack( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: UserIcon( - labelStyle: Theme.of(context).textTheme.headlineMedium, - user: user, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, + final crossAxisCount = users.length == 1 ? 1 : maxCount; + + final neededWidth = crossAxisCount * mainAxisExtent + (crossAxisCount - 1) * 24.0; + + return SizedBox( + width: neededWidth, + child: ReorderableGridView.builder( + onReorder: (oldIndex, newIndex) => ref.read(authProvider.notifier).reOrderUsers(oldIndex, newIndex), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + autoScroll: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: (users.length == 1 ? 1 : maxCount).toInt(), + mainAxisSpacing: 24, + crossAxisSpacing: 24, + mainAxisExtent: mainAxisExtent, + ), + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + return Center( + key: Key(user.id), + child: AspectRatio( + aspectRatio: 1.0, + child: FocusButton( + onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user), + onLongPress: switch (AdaptiveLayout.inputDeviceOf(context)) { + InputDevice.dpad || InputDevice.pointer => () => onLongPress?.call(user), + InputDevice.touch => null, + }, + darkOverlay: false, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.min, children: [ - Icon( - user.authMethod.icon, - size: 18, - ), - const SizedBox(width: 4), Flexible( - child: Text( - user.name, - maxLines: 2, - softWrap: true, - )), - ], - ), - if (user.credentials.serverName.isNotEmpty) - Opacity( - opacity: 0.75, - child: Row( + child: UserIcon( + labelStyle: Theme.of(context).textTheme.headlineMedium, + user: user, + ), + ), + Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.max, children: [ - const Icon( - IconsaxPlusBold.driver_2, - size: 14, + Icon( + user.authMethod.icon, + size: 18, ), const SizedBox(width: 4), Flexible( - child: Text( - user.credentials.serverName, - maxLines: 2, - softWrap: true, - ), - ), + child: Text( + user.name, + 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, + if (user.credentials.serverName.isNotEmpty) + Opacity( + opacity: 0.75, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + const Icon( + IconsaxPlusBold.driver_2, + size: 14, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + user.credentials.serverName, + 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, + ); + }, ), ); } diff --git a/lib/screens/login/screens/server_selection_screen.dart b/lib/screens/login/screens/server_selection_screen.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/screens/login/widgets/credentials_input_section.dart b/lib/screens/login/widgets/credentials_input_section.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/screens/login/widgets/discover_servers_widget.dart b/lib/screens/login/widgets/discover_servers_widget.dart index 9d326ad..323ddbd 100644 --- a/lib/screens/login/widgets/discover_servers_widget.dart +++ b/lib/screens/login/widgets/discover_servers_widget.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:iconsax_plus/iconsax_plus.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/providers/discovery_provider.dart'; @@ -37,6 +37,7 @@ class DiscoverServersWidget extends ConsumerWidget { return ListView( padding: const EdgeInsets.all(6), shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), children: [ if (existingServers.isNotEmpty) ...[ Row( @@ -123,8 +124,8 @@ class _ServerInfoCard extends StatelessWidget { @override Widget build(BuildContext context) { return Card( - child: InkWell( - onTap: () => onPressed(server), + child: TextButton( + onPressed: () => onPressed(server), child: Padding( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), child: Row( diff --git a/lib/screens/login/widgets/login_credentials_input_extensions.dart b/lib/screens/login/widgets/login_credentials_input_extensions.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/screens/login/widgets/server_input_section.dart b/lib/screens/login/widgets/server_input_section.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/screens/login/widgets/server_url_input.dart b/lib/screens/login/widgets/server_url_input.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/screens/login/widgets/server_url_input_extensions.dart b/lib/screens/login/widgets/server_url_input_extensions.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/screens/metadata/edit_screens/edit_fields.dart b/lib/screens/metadata/edit_screens/edit_fields.dart index 6c9aeab..5da66a6 100644 --- a/lib/screens/metadata/edit_screens/edit_fields.dart +++ b/lib/screens/metadata/edit_screens/edit_fields.dart @@ -1,5 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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/models/items/item_shared_models.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/widgets/shared/adaptive_date_picker.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; class EditFields extends ConsumerStatefulWidget { final Map fields; @@ -63,14 +66,14 @@ class _EditGeneralState extends ConsumerState { trailing: EnumBox( current: map.entries.firstWhereOrNull((element) => element.value == true)?.key ?? "", itemBuilder: (context) => [ - PopupMenuItem( - child: const Text(""), - onTap: () => ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, "")), + ItemActionButton( + label: const Text(""), + action: () => ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, "")), ), ...map.entries.map( - (mapEntry) => PopupMenuItem( - child: Text(mapEntry.key), - onTap: () => ref + (mapEntry) => ItemActionButton( + label: Text(mapEntry.key), + action: () => ref .read(editItemProvider.notifier) .updateField(MapEntry(e.key, mapEntry.key)), ), @@ -240,9 +243,9 @@ class _EditGeneralState extends ConsumerState { .whereNot( (element) => element == PersonKind.swaggerGeneratedUnknown) .map( - (entry) => PopupMenuItem( - child: Text(entry.name.toUpperCaseSplit()), - onTap: () { + (entry) => ItemActionButton( + label: Text(entry.name.toUpperCaseSplit()), + action: () { setState(() { personType = entry; }); @@ -570,9 +573,9 @@ class _EditGeneralState extends ConsumerState { current: (e.value as DisplayOrder).value.toUpperCaseSplit(), itemBuilder: (context) => DisplayOrder.values .map( - (mapEntry) => PopupMenuItem( - child: Text(mapEntry.value.toUpperCaseSplit()), - onTap: () => ref + (mapEntry) => ItemActionButton( + label: Text(mapEntry.value.toUpperCaseSplit()), + action: () => ref .read(editItemProvider.notifier) .updateField(MapEntry(e.key, mapEntry.value)), ), @@ -594,9 +597,9 @@ class _EditGeneralState extends ConsumerState { current: (e.value as ShowStatus).value, itemBuilder: (context) => ShowStatus.values .map( - (mapEntry) => PopupMenuItem( - child: Text(mapEntry.value), - onTap: () => ref + (mapEntry) => ItemActionButton( + label: Text(mapEntry.value), + action: () => ref .read(editItemProvider.notifier) .updateField(MapEntry(e.key, mapEntry.value)), ), diff --git a/lib/screens/metadata/refresh_metadata.dart b/lib/screens/metadata/refresh_metadata.dart index 8fe9a08..6d78a96 100644 --- a/lib/screens/metadata/refresh_metadata.dart +++ b/lib/screens/metadata/refresh_metadata.dart @@ -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/localization_helper.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; Future showRefreshPopup(BuildContext context, String itemId, String itemName) async { return showDialog( @@ -69,10 +70,9 @@ class _RefreshPopupDialogState extends ConsumerState { child: EnumBox( current: refreshMode.label(context), itemBuilder: (context) => MetadataRefresh.values - .map((value) => PopupMenuItem( - value: value, - child: Text(value.label(context)), - onTap: () => setState(() { + .map((value) => ItemActionButton( + label: Text(value.label(context)), + action: () => setState(() { refreshMode = value; }), )) diff --git a/lib/screens/photo_viewer/simple_video_player.dart b/lib/screens/photo_viewer/simple_video_player.dart index 28f5a0f..210e757 100644 --- a/lib/screens/photo_viewer/simple_video_player.dart +++ b/lib/screens/photo_viewer/simple_video_player.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:path/path.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:window_manager/window_manager.dart'; @@ -35,6 +35,7 @@ class _SimpleVideoPlayerState extends ConsumerState with Wind late final BasePlayer player = switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) { PlayerOptions.libMDK => LibMDK(), PlayerOptions.libMPV => LibMPV(), + _ => LibMDK(), }; late String videoUrl = ""; @@ -102,7 +103,7 @@ class _SimpleVideoPlayerState extends ConsumerState with Wind 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.loop(ref.watch(photoViewSettingsProvider.select((value) => value.repeat))); } diff --git a/lib/screens/settings/client_sections/client_settings_dashboard.dart b/lib/screens/settings/client_sections/client_settings_dashboard.dart index fc31f9c..0034d5c 100644 --- a/lib/screens/settings/client_sections/client_settings_dashboard.dart +++ b/lib/screens/settings/client_sections/client_settings_dashboard.dart @@ -10,6 +10,7 @@ import 'package:fladder/screens/settings/widgets/settings_label_divider.dart'; import 'package:fladder/screens/settings/widgets/settings_list_group.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; List buildClientSettingsDashboard(BuildContext context, WidgetRef ref) { final clientSettings = ref.watch(clientSettingsProvider); @@ -28,10 +29,9 @@ List buildClientSettingsDashboard(BuildContext context, WidgetRef ref) { ), itemBuilder: (context) => HomeBanner.values .map( - (entry) => PopupMenuItem( - value: entry, - child: Text(entry.label(context)), - onTap: () => + (entry) => ItemActionButton( + label: Text(entry.label(context)), + action: () => ref.read(homeSettingsProvider.notifier).update((context) => context.copyWith(homeBanner: entry)), ), ) @@ -48,10 +48,9 @@ List buildClientSettingsDashboard(BuildContext context, WidgetRef ref) { ), itemBuilder: (context) => HomeCarouselSettings.values .map( - (entry) => PopupMenuItem( - value: entry, - child: Text(entry.label(context)), - onTap: () => ref + (entry) => ItemActionButton( + label: Text(entry.label(context)), + action: () => ref .read(homeSettingsProvider.notifier) .update((context) => context.copyWith(carouselSettings: entry)), ), @@ -70,10 +69,9 @@ List buildClientSettingsDashboard(BuildContext context, WidgetRef ref) { ), itemBuilder: (context) => HomeNextUp.values .map( - (entry) => PopupMenuItem( - value: entry, - child: Text(entry.label(context)), - onTap: () => + (entry) => ItemActionButton( + label: Text(entry.label(context)), + action: () => ref.read(homeSettingsProvider.notifier).update((context) => context.copyWith(nextUp: entry)), ), ) diff --git a/lib/screens/settings/client_sections/client_settings_download.dart b/lib/screens/settings/client_sections/client_settings_download.dart index d9fd657..9e11b33 100644 --- a/lib/screens/settings/client_sections/client_settings_download.dart +++ b/lib/screens/settings/client_sections/client_settings_download.dart @@ -89,6 +89,21 @@ List buildClientSettingsDownload(BuildContext context, WidgetRef ref, Fu return SettingsListTile( label: Text(context.localized.downloadsSyncedData), subLabel: Text(data.byteFormat ?? ""), + onTap: () { + showDefaultAlertDialog( + context, + context.localized.downloadsClearTitle, + context.localized.downloadsClearDesc, + (context) async { + await ref.read(syncProvider.notifier).removeAllSyncedData(); + setState(() {}); + Navigator.of(context).pop(); + }, + context.localized.clear, + (context) => Navigator.of(context).pop(), + context.localized.cancel, + ); + }, trailing: FilledButton( onPressed: () { showDefaultAlertDialog( @@ -123,21 +138,22 @@ List buildClientSettingsDownload(BuildContext context, WidgetRef ref, Fu label: Text(context.localized.maxConcurrentDownloadsTitle), subLabel: Text(context.localized.maxConcurrentDownloadsDesc), trailing: SizedBox( - width: 100, - child: IntInputField( - controller: TextEditingController(text: clientSettings.maxConcurrentDownloads.toString()), - onSubmitted: (value) { - if (value != null) { - ref.read(clientSettingsProvider.notifier).update( - (current) => current.copyWith( - maxConcurrentDownloads: value, - ), - ); + width: 150, + child: IntInputField( + controller: TextEditingController(text: clientSettings.maxConcurrentDownloads.toString()), + onSubmitted: (value) { + if (value != null) { + ref.read(clientSettingsProvider.notifier).update( + (current) => current.copyWith( + maxConcurrentDownloads: value, + ), + ); - ref.read(backgroundDownloaderProvider.notifier).setMaxConcurrent(value); - } - }, - )), + ref.read(backgroundDownloaderProvider.notifier).setMaxConcurrent(value); + } + }, + ), + ), ), ]), const SizedBox(height: 12), diff --git a/lib/screens/settings/client_sections/client_settings_visual.dart b/lib/screens/settings/client_sections/client_settings_visual.dart index d799152..3a8b3f9 100644 --- a/lib/screens/settings/client_sections/client_settings_visual.dart +++ b/lib/screens/settings/client_sections/client_settings_visual.dart @@ -13,6 +13,7 @@ import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; import 'package:fladder/widgets/shared/fladder_slider.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; List buildClientSettingsVisual( BuildContext context, @@ -41,16 +42,15 @@ List buildClientSettingsVisual( itemBuilder: (context) { return [ ...AppLocalizations.supportedLocales.map( - (entry) => PopupMenuItem( - value: entry, - child: Localizations.override( + (entry) => ItemActionButton( + label: Localizations.override( context: context, locale: entry, child: Builder(builder: (context) { return Text("${context.localized.nativeName} (${entry.toDisplayCode()})"); }), ), - onTap: () => ref + action: () => ref .read(clientSettingsProvider.notifier) .update((state) => state.copyWith(selectedLocale: entry)), ), @@ -95,10 +95,9 @@ List buildClientSettingsVisual( current: clientSettings.backgroundImage.label(context), itemBuilder: (context) => BackgroundType.values .map( - (e) => PopupMenuItem( - value: e, - child: Text(e.label(context)), - onTap: () => + (e) => ItemActionButton( + label: Text(e.label(context)), + action: () => ref.read(clientSettingsProvider.notifier).update((cb) => cb.copyWith(backgroundImage: e)), ), ) diff --git a/lib/screens/settings/client_settings_page.dart b/lib/screens/settings/client_settings_page.dart index 87f7be0..4eea4e6 100644 --- a/lib/screens/settings/client_settings_page.dart +++ b/lib/screens/settings/client_settings_page.dart @@ -94,6 +94,15 @@ class _ClientSettingsPageState extends ConsumerState { ...buildClientSettingsAdvanced(context, ref), if (kDebugMode) ...[ const SizedBox(height: 64), + SettingsListTile( + label: const Text( + "Clear cache", + ), + contentColor: Theme.of(context).colorScheme.error, + onTap: () { + PaintingBinding.instance.imageCache.clear(); + }, + ), SettingsListTile( label: Text( context.localized.clearAllSettings, diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index 13b0a3c..c2326b3 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; +import 'package:fladder/providers/arguments_provider.dart'; import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/user_provider.dart'; @@ -27,6 +28,7 @@ import 'package:fladder/util/bitrate_helper.dart'; import 'package:fladder/util/box_fit_extension.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; @RoutePage() class PlayerSettingsPage extends ConsumerStatefulWidget { @@ -81,10 +83,9 @@ class _PlayerSettingsPageState extends ConsumerState { current: videoSettings.videoFit.label(context), itemBuilder: (context) => BoxFit.values .map( - (entry) => PopupMenuItem( - value: entry, - child: Text(entry.label(context)), - onTap: () => ref.read(videoPlayerSettingsProvider.notifier).setFitType(entry), + (entry) => ItemActionButton( + label: Text(entry.label(context)), + action: () => ref.read(videoPlayerSettingsProvider.notifier).setFitType(entry), ), ) .toList(), @@ -102,10 +103,9 @@ class _PlayerSettingsPageState extends ConsumerState { ), itemBuilder: (context) => Bitrate.values .map( - (entry) => PopupMenuItem( - value: entry, - child: Text(entry.label(context)), - onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state = + (entry) => ItemActionButton( + label: Text(entry.label(context)), + action: () => ref.read(videoPlayerSettingsProvider.notifier).state = videoSettings.copyWith(maxHomeBitrate: entry), ), ) @@ -124,10 +124,9 @@ class _PlayerSettingsPageState extends ConsumerState { ), itemBuilder: (context) => Bitrate.values .map( - (entry) => PopupMenuItem( - value: entry, - child: Text(entry.label(context)), - onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state = + (entry) => ItemActionButton( + label: Text(entry.label(context)), + action: () => ref.read(videoPlayerSettingsProvider.notifier).state = videoSettings.copyWith(maxInternetBitrate: entry), ), ) @@ -153,10 +152,9 @@ class _PlayerSettingsPageState extends ConsumerState { current: entry.value.label(context), itemBuilder: (context) => SegmentSkip.values .map( - (value) => PopupMenuItem( - value: value, - child: Text(value.label(context)), - onTap: () { + (value) => ItemActionButton( + label: Text(value.label(context)), + action: () { final newEntries = videoSettings.segmentSkipSettings.map( (key, currentValue) => MapEntry(key, key == entry.key ? value : currentValue)); ref.read(videoPlayerSettingsProvider.notifier).state = @@ -264,145 +262,151 @@ class _PlayerSettingsPageState extends ConsumerState { ), ]), const SizedBox(height: 12), - ...settingsListGroup(context, SettingsLabelDivider(label: context.localized.advanced), [ - if (PlayerOptions.available.length != 1) - SettingsListTile( - label: Text(context.localized.playerSettingsBackendTitle), - subLabel: Text(context.localized.playerSettingsBackendDesc), - trailing: Builder(builder: (context) { - final wantedPlayer = videoSettings.wantedPlayer; - final currentPlayer = videoSettings.playerOptions; - return EnumBox( - current: currentPlayer == null - ? "${context.localized.defaultLabel} (${PlayerOptions.platformDefaults.label(context)})" - : wantedPlayer.label(context), - itemBuilder: (context) => [ - PopupMenuItem( - value: null, - child: - Text("${context.localized.defaultLabel} (${PlayerOptions.platformDefaults.label(context)})"), - onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state = - videoSettings.copyWith(playerOptions: null), - ), - ...PlayerOptions.available.map( - (entry) => PopupMenuItem( - value: entry, - child: Text(entry.label(context)), - onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state = - videoSettings.copyWith(playerOptions: entry), - ), - ) - ], - ); - }), - ), - AnimatedFadeSize( - child: switch (videoSettings.wantedPlayer) { - PlayerOptions.libMPV => Column( - children: [ - SettingsListTile( - label: Text(context.localized.settingsPlayerVideoHWAccelTitle), - subLabel: Text(context.localized.settingsPlayerVideoHWAccelDesc), - onTap: () => provider.setHardwareAccel(!videoSettings.hardwareAccel), - trailing: Switch( - value: videoSettings.hardwareAccel, - onChanged: (value) => provider.setHardwareAccel(value), - ), - ), - if (!kIsWeb) - SettingsListTile( - label: Text(context.localized.settingsPlayerNativeLibassAccelTitle), - subLabel: Text(context.localized.settingsPlayerNativeLibassAccelDesc), - onTap: () => provider.setUseLibass(!videoSettings.useLibass), - trailing: Switch( - value: videoSettings.useLibass, - onChanged: (value) => provider.setUseLibass(value), + ...settingsListGroup( + context, + SettingsLabelDivider(label: context.localized.advanced), + [ + if (!ref.read(argumentsStateProvider).leanBackMode) ...[ + if (PlayerOptions.available.length != 1) + SettingsListTile( + label: Text(context.localized.playerSettingsBackendTitle), + subLabel: Text(context.localized.playerSettingsBackendDesc), + trailing: Builder(builder: (context) { + final wantedPlayer = videoSettings.wantedPlayer; + final currentPlayer = videoSettings.playerOptions; + return EnumBox( + current: currentPlayer == null + ? "${context.localized.defaultLabel} (${PlayerOptions.platformDefaults.label(context)})" + : wantedPlayer.label(context), + itemBuilder: (context) => [ + ItemActionButton( + label: Text( + "${context.localized.defaultLabel} (${PlayerOptions.platformDefaults.label(context)})"), + action: () => ref.read(videoPlayerSettingsProvider.notifier).state = + videoSettings.copyWith(playerOptions: null), ), - ), - if (!videoSettings.useLibass) - SettingsListTile( - label: Text(context.localized.settingsPlayerCustomSubtitlesTitle), - subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc), - onTap: videoSettings.useLibass - ? null - : () { - showDialog( - context: context, - barrierDismissible: true, - useSafeArea: false, - builder: (context) => const SubtitleEditor(), - ); - }, - ), - AnimatedFadeSize( - child: videoSettings.useLibass && videoSettings.hardwareAccel && Platform.isAndroid - ? SettingsMessageBox( - context.localized.settingsPlayerMobileWarning, - messageType: MessageType.warning, - ) - : Container(), - ), - SettingsListTile( - label: Text(context.localized.settingsPlayerBufferSizeTitle), - subLabel: Text(context.localized.settingsPlayerBufferSizeDesc), - trailing: SizedBox( - width: 70, - child: IntInputField( - suffix: 'MB', - controller: TextEditingController(text: videoSettings.bufferSize.toString()), - onSubmitted: (value) { - if (value != null) { - provider.setBufferSize(value); - } - }, - )), - ), - ], + ...PlayerOptions.available.map( + (entry) => ItemActionButton( + label: Text(entry.label(context)), + action: () => ref.read(videoPlayerSettingsProvider.notifier).state = + videoSettings.copyWith(playerOptions: entry), + ), + ) + ], + ); + }), ), - _ => SettingsMessageBox( - messageType: MessageType.info, - "${context.localized.noVideoPlayerOptions}\n${context.localized.mdkExperimental}") - }, - ), - Column( - children: [ - SettingsListTile( - label: Text(context.localized.settingsAutoNextTitle), - subLabel: Text(context.localized.settingsAutoNextDesc), - trailing: EnumBox( - current: ref.watch( - videoPlayerSettingsProvider.select( - (value) => value.nextVideoType.label(context), - ), - ), - itemBuilder: (context) => AutoNextType.values - .map( - (entry) => PopupMenuItem( - value: entry, - child: Text(entry.label(context)), - onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state = - videoSettings.copyWith(nextVideoType: entry), - ), - ) - .toList(), - ), - ), AnimatedFadeSize( - child: switch (ref.watch(videoPlayerSettingsProvider.select((value) => value.nextVideoType))) { - AutoNextType.smart => SettingsMessageBox(AutoNextType.smart.desc(context)), - AutoNextType.static => SettingsMessageBox(AutoNextType.static.desc(context)), - _ => const SizedBox.shrink(), + child: switch (videoSettings.wantedPlayer) { + PlayerOptions.libMPV => Column( + children: [ + SettingsListTile( + label: Text(context.localized.settingsPlayerVideoHWAccelTitle), + subLabel: Text(context.localized.settingsPlayerVideoHWAccelDesc), + onTap: () => provider.setHardwareAccel(!videoSettings.hardwareAccel), + trailing: Switch( + value: videoSettings.hardwareAccel, + onChanged: (value) => provider.setHardwareAccel(value), + ), + ), + if (!kIsWeb) + SettingsListTile( + label: Text(context.localized.settingsPlayerNativeLibassAccelTitle), + subLabel: Text(context.localized.settingsPlayerNativeLibassAccelDesc), + onTap: () => provider.setUseLibass(!videoSettings.useLibass), + trailing: Switch( + value: videoSettings.useLibass, + onChanged: (value) => provider.setUseLibass(value), + ), + ), + if (!videoSettings.useLibass) + SettingsListTile( + label: Text(context.localized.settingsPlayerCustomSubtitlesTitle), + subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc), + onTap: videoSettings.useLibass + ? null + : () { + showDialog( + context: context, + barrierDismissible: true, + useSafeArea: false, + builder: (context) => const SubtitleEditor(), + ); + }, + ), + AnimatedFadeSize( + child: videoSettings.useLibass && videoSettings.hardwareAccel && Platform.isAndroid + ? SettingsMessageBox( + context.localized.settingsPlayerMobileWarning, + messageType: MessageType.warning, + ) + : Container(), + ), + SettingsListTile( + label: Text(context.localized.settingsPlayerBufferSizeTitle), + subLabel: Text(context.localized.settingsPlayerBufferSizeDesc), + trailing: SizedBox( + width: 70, + child: IntInputField( + suffix: 'MB', + controller: TextEditingController(text: videoSettings.bufferSize.toString()), + onSubmitted: (value) { + if (value != null) { + provider.setBufferSize(value); + } + }, + )), + ), + ], + ), + PlayerOptions.libMDK => SettingsMessageBox( + messageType: MessageType.info, + "${context.localized.noVideoPlayerOptions}\n${context.localized.mdkExperimental}"), + _ => const SizedBox.shrink() }, ), ], - ), - if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb) - SettingsListTile( - label: Text(context.localized.playerSettingsOrientationTitle), - subLabel: Text(context.localized.playerSettingsOrientationDesc), - onTap: () => showOrientationOptions(context, ref), - ), - ]), + if (videoSettings.wantedPlayer != PlayerOptions.nativePlayer) ...[ + Column( + children: [ + SettingsListTile( + label: Text(context.localized.settingsAutoNextTitle), + subLabel: Text(context.localized.settingsAutoNextDesc), + trailing: EnumBox( + current: ref.watch( + videoPlayerSettingsProvider.select( + (value) => value.nextVideoType.label(context), + ), + ), + itemBuilder: (context) => AutoNextType.values + .map( + (entry) => ItemActionButton( + label: Text(entry.label(context)), + action: () => ref.read(videoPlayerSettingsProvider.notifier).state = + videoSettings.copyWith(nextVideoType: entry), + ), + ) + .toList(), + ), + ), + AnimatedFadeSize( + child: switch (ref.watch(videoPlayerSettingsProvider.select((value) => value.nextVideoType))) { + AutoNextType.smart => SettingsMessageBox(AutoNextType.smart.desc(context)), + AutoNextType.static => SettingsMessageBox(AutoNextType.static.desc(context)), + _ => const SizedBox.shrink(), + }, + ), + ], + ), + if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb && !ref.read(argumentsStateProvider).htpcMode) + SettingsListTile( + label: Text(context.localized.playerSettingsOrientationTitle), + subLabel: Text(context.localized.playerSettingsOrientationDesc), + onTap: () => showOrientationOptions(context, ref), + ), + ], + ], + ), ], ); } diff --git a/lib/screens/settings/quick_connect_window.dart b/lib/screens/settings/quick_connect_window.dart index b5487bd..edb59b7 100644 --- a/lib/screens/settings/quick_connect_window.dart +++ b/lib/screens/settings/quick_connect_window.dart @@ -1,11 +1,12 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/screens/login/widgets/login_icon.dart'; import 'package:fladder/screens/shared/outlined_text_field.dart'; -import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/localization_helper.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; Future openQuickConnectDialog( BuildContext context, @@ -30,18 +31,28 @@ class _QuickConnectDialogState extends ConsumerState { Widget build(BuildContext context) { final user = ref.watch(userProvider); return AlertDialog( - title: Text(context.localized.quickConnectTitle), + title: Text( + context.localized.quickConnectTitle, + textAlign: TextAlign.center, + ), + backgroundColor: Theme.of(context).colorScheme.surface, scrollable: true, content: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, children: [ - Text(context.localized.quickConnectAction), + Text( + context.localized.quickConnectAction, + textAlign: TextAlign.center, + ), if (user != null) SizedBox(child: LoginIcon(user: user)), Flexible( child: OutlinedTextField( label: context.localized.code, controller: controller, keyboardType: TextInputType.number, + textInputAction: TextInputAction.go, onChanged: (value) { if (value.isNotEmpty) { setState(() { @@ -50,6 +61,7 @@ class _QuickConnectDialogState extends ConsumerState { }); } }, + onSubmitted: (value) => tryLogin(), inputFormatters: [FilteringTextInputFormatter.digitsOnly], ), ), @@ -58,50 +70,24 @@ class _QuickConnectDialogState extends ConsumerState { child: error != null || success != null ? Card( key: Key(context.localized.error), - color: success == null ? Theme.of(context).colorScheme.errorContainer : Theme.of(context).colorScheme.surfaceContainer, + color: success == null + ? Theme.of(context).colorScheme.errorContainer + : Theme.of(context).colorScheme.surfaceContainer, child: Padding( padding: const EdgeInsets.all(8.0), child: Text( success ?? error ?? "", style: TextStyle( - color: - success == null ? Theme.of(context).colorScheme.onErrorContainer : Theme.of(context).colorScheme.onSurface), + color: success == null + ? Theme.of(context).colorScheme.onErrorContainer + : Theme.of(context).colorScheme.onSurface), ), ), ) : null, ), - ElevatedButton( - onPressed: loading - ? null - : () async { - setState(() { - error = null; - loading = true; - }); - final response = await ref.read(userProvider.notifier).quickConnect(controller.text); - if (response.isSuccessful) { - setState( - () { - error = null; - success = context.localized.loggedIn; - }, - ); - await Future.delayed(const Duration(seconds: 2)); - Navigator.of(context).pop(); - } else { - if (controller.text.isEmpty) { - error = context.localized.quickConnectInputACode; - } else { - error = context.localized.quickConnectWrongCode; - } - } - loading = false; - setState( - () {}, - ); - controller.text = ""; - }, + FilledButton( + onPressed: loading ? null : () => tryLogin(), child: loading ? const SizedBox.square( child: CircularProgressIndicator(), @@ -109,8 +95,37 @@ class _QuickConnectDialogState extends ConsumerState { ) : Text(context.localized.login), ) - ].addInBetween(const SizedBox(height: 16)), + ], ), ); } + + Future tryLogin() async { + setState(() { + error = null; + loading = true; + }); + final response = await ref.read(userProvider.notifier).quickConnect(controller.text); + if (response.isSuccessful) { + setState( + () { + error = null; + success = context.localized.loggedIn; + }, + ); + await Future.delayed(const Duration(seconds: 2)); + Navigator.of(context).pop(); + } else { + if (controller.text.isEmpty) { + error = context.localized.quickConnectInputACode; + } else { + error = context.localized.quickConnectWrongCode; + } + } + loading = false; + setState( + () {}, + ); + controller.text = ""; + } } diff --git a/lib/screens/settings/settings_list_tile.dart b/lib/screens/settings/settings_list_tile.dart index 8e2ecba..9d9bfea 100644 --- a/lib/screens/settings/settings_list_tile.dart +++ b/lib/screens/settings/settings_list_tile.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:fladder/screens/shared/flat_button.dart'; +import 'package:fladder/widgets/shared/ensure_visible.dart'; class SettingsListTile extends StatelessWidget { final Widget label; final Widget? subLabel; final Widget? trailing; final bool selected; + final bool autoFocus; final IconData? icon; final Widget? leading; final Color? contentColor; @@ -16,6 +18,7 @@ class SettingsListTile extends StatelessWidget { this.subLabel, this.trailing, this.selected = false, + this.autoFocus = false, this.leading, this.icon, this.contentColor, @@ -52,6 +55,12 @@ class SettingsListTile extends StatelessWidget { margin: EdgeInsets.zero, child: FlatButton( onTap: onTap, + autoFocus: autoFocus, + onFocusChange: (value) { + if (value) { + context.ensureVisible(); + } + }, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -66,6 +75,7 @@ class SettingsListTile extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Row( + mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.start, children: [ DefaultTextStyle.merge( @@ -85,7 +95,7 @@ class SettingsListTile extends StatelessWidget { children: [ Material( color: Colors.transparent, - textStyle: Theme.of(context).textTheme.titleLarge, + textStyle: Theme.of(context).textTheme.titleLarge?.copyWith(color: contentColor), child: label, ), if (subLabel != null) @@ -93,7 +103,7 @@ class SettingsListTile extends StatelessWidget { opacity: 0.65, child: Material( color: Colors.transparent, - textStyle: Theme.of(context).textTheme.labelLarge, + textStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: contentColor), child: subLabel, ), ), @@ -101,9 +111,12 @@ class SettingsListTile extends StatelessWidget { ), ), if (trailing != null) - Padding( - padding: const EdgeInsets.only(left: 16), - child: trailing, + ExcludeFocusTraversal( + excluding: onTap != null, + child: Padding( + padding: const EdgeInsets.only(left: 16), + child: trailing, + ), ) ], ), diff --git a/lib/screens/settings/settings_scaffold.dart b/lib/screens/settings/settings_scaffold.dart index bfcc437..c1538ff 100644 --- a/lib/screens/settings/settings_scaffold.dart +++ b/lib/screens/settings/settings_scaffold.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fladder/providers/arguments_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/screens/shared/user_icon.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; @@ -76,7 +77,7 @@ class SettingsScaffold extends ConsumerWidget { padding: MediaQuery.paddingOf(context).copyWith(bottom: 0), child: Row( children: [ - if (showBackButtonNested) + if (showBackButtonNested && !ref.read(argumentsStateProvider).htpcMode) BackButton( onPressed: () => backAction(context), ) diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index dc0a70f..27c7f05 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,6 +14,7 @@ import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/settings/quick_connect_window.dart'; import 'package:fladder/screens/settings/settings_list_tile.dart'; import 'package:fladder/screens/settings/settings_scaffold.dart'; +import 'package:fladder/screens/shared/default_alert_dialog.dart'; import 'package:fladder/screens/shared/fladder_icon.dart'; import 'package:fladder/screens/shared/fladder_snackbar.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; @@ -55,9 +57,9 @@ class _SettingsScreenState extends ConsumerState { mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded(flex: 1, child: _leftPane(context)), + Expanded(flex: 2, child: _leftPane(context)), Expanded( - flex: 2, + flex: 3, child: content, ), ], @@ -88,6 +90,8 @@ class _SettingsScreenState extends ConsumerState { return IconsaxPlusLinear.monitor; case ViewSize.desktop: return IconsaxPlusLinear.monitor; + case ViewSize.television: + return IconsaxPlusLinear.mirroring_screen; } } @@ -129,6 +133,7 @@ class _SettingsScreenState extends ConsumerState { SettingsListTile( label: Text(context.localized.settingsClientTitle), subLabel: Text(context.localized.settingsClientDesc), + autoFocus: true, selected: containsRoute(const ClientSettingsRoute()), icon: deviceIcon, onTap: () => navigateTo(const ClientSettingsRoute()), @@ -171,83 +176,81 @@ class _SettingsScreenState extends ConsumerState { label: Text(context.localized.exitFladderTitle), icon: IconsaxPlusLinear.close_square, onTap: () async { - final manager = WindowManager.instance; - if (await manager.isClosable()) { - manager.close(); - } else { - fladderSnackbar(context, title: context.localized.somethingWentWrong); - } + showDefaultAlertDialog( + context, + context.localized.exitFladderTitle, + context.localized.exitFladderDesc, + (context) async { + if (AdaptiveLayout.of(context).isDesktop) { + final manager = WindowManager.instance; + if (await manager.isClosable()) { + manager.close(); + } else { + fladderSnackbar(context, title: context.localized.somethingWentWrong); + } + } else { + SystemNavigator.pop(); + } + }, + context.localized.close, + (context) => context.pop(), + context.localized.cancel, + ); }, ), ], - ], - floatingActionButton: Padding( - padding: EdgeInsets.symmetric(horizontal: MediaQuery.paddingOf(context).horizontal), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const Spacer(), - FloatingActionButton( - key: Key(context.localized.switchUser), - tooltip: context.localized.switchUser, - onPressed: () async { - await ref.read(userProvider.notifier).logoutUser(); - context.router.replaceAll([const LoginRoute()]); - }, - child: const Icon( - IconsaxPlusLinear.arrow_swap_horizontal, - ), - ), - const SizedBox(width: 16), - FloatingActionButton( - heroTag: context.localized.logout, - key: Key(context.localized.logout), - tooltip: context.localized.logout, - backgroundColor: Theme.of(context).colorScheme.errorContainer, - onPressed: () { - final user = ref.read(userProvider); - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.localized.logoutUserPopupTitle(user?.name ?? "")), - scrollable: true, - content: Text( - context.localized.logoutUserPopupContent(user?.name ?? "", user?.server ?? ""), - ), - actions: [ - ElevatedButton( - onPressed: () => Navigator.pop(context), - child: Text(context.localized.cancel), - ), - ElevatedButton( - style: ElevatedButton.styleFrom().copyWith( - iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer), - foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer), - backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer), - ), - onPressed: () async { - await ref.read(authProvider.notifier).logOutUser(); - if (context.mounted) { - context.router.replaceAll([const LoginRoute()]); - } - }, - child: Text(context.localized.logout), - ), - ], - ), - ); - }, - child: Icon( - IconsaxPlusLinear.logout, - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ], - ), + const FractionallySizedBox( + widthFactor: 0.25, + child: Divider(), ), - ), + SettingsListTile( + label: Text(context.localized.switchUser), + icon: IconsaxPlusLinear.arrow_swap_horizontal, + contentColor: Colors.greenAccent, + onTap: () async { + await ref.read(userProvider.notifier).logoutUser(); + context.router.replaceAll([const LoginRoute()]); + }, + ), + SettingsListTile( + label: Text(context.localized.logout), + icon: IconsaxPlusLinear.logout, + contentColor: Theme.of(context).colorScheme.error, + onTap: () { + final user = ref.read(userProvider); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.localized.logoutUserPopupTitle(user?.name ?? "")), + scrollable: true, + content: Text( + context.localized.logoutUserPopupContent(user?.name ?? "", user?.server ?? ""), + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: Text(context.localized.cancel), + ), + ElevatedButton( + style: ElevatedButton.styleFrom().copyWith( + iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer), + foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer), + backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer), + ), + onPressed: () async { + await ref.read(authProvider.notifier).logOutUser(); + if (context.mounted) { + context.router.replaceAll([const LoginRoute()]); + } + }, + child: Text(context.localized.logout), + ), + ], + ), + ); + }, + ), + ], ), ), ); diff --git a/lib/screens/shared/default_alert_dialog.dart b/lib/screens/shared/default_alert_dialog.dart index 74e07cd..c05575b 100644 --- a/lib/screens/shared/default_alert_dialog.dart +++ b/lib/screens/shared/default_alert_dialog.dart @@ -2,6 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fladder/providers/arguments_provider.dart'; + Future showDefaultAlertDialog( BuildContext context, String title, @@ -18,9 +22,12 @@ Future showDefaultAlertDialog( content: content != null ? Text(content) : null, actions: [ if (decline != null) - ElevatedButton( - onPressed: () => decline.call(context), - child: Text(declineTitle), + Consumer( + builder: (context, ref, child) => ElevatedButton( + autofocus: ref.read(argumentsStateProvider).htpcMode, + onPressed: () => decline.call(context), + child: Text(declineTitle), + ), ), if (accept != null) ElevatedButton( diff --git a/lib/screens/shared/default_title_bar.dart b/lib/screens/shared/default_title_bar.dart index 7452932..3dff47e 100644 --- a/lib/screens/shared/default_title_bar.dart +++ b/lib/screens/shared/default_title_bar.dart @@ -44,167 +44,171 @@ class _DefaultTitleBarState extends ConsumerState with WindowLi final isOffline = ref.watch(connectivityStatusProvider.select((value) => value == ConnectionState.offline)); final surfaceColor = theme.colorScheme.surface; - return MouseRegion( - onEnter: (event) => setState(() => hovering = true), - onExit: (event) => setState(() => hovering = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: isOffline - ? [ - theme.colorScheme.errorContainer.withValues(alpha: 0.8), - theme.colorScheme.errorContainer.withValues(alpha: 0.25), - ] - : [ - surfaceColor.withValues(alpha: hovering ? 0.7 : 0), - surfaceColor.withValues(alpha: 0), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - )), - height: widget.height, - child: kIsWeb - ? const SizedBox.shrink() - : Stack( - fit: StackFit.expand, - children: [ - switch (AdaptiveLayout.of(context).platform) { - TargetPlatform.android || TargetPlatform.iOS => SizedBox(height: MediaQuery.paddingOf(context).top), - TargetPlatform.windows || TargetPlatform.linux => Container( - child: Row( - children: [ - Expanded( - child: Container( - color: Colors.black.withValues(alpha: 0), - child: DragToMoveArea( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.max, - children: [ - Container( - padding: const EdgeInsets.only(left: 16), - child: DefaultTextStyle( - style: TextStyle( - color: iconColor, - fontSize: 14, + return ExcludeFocus( + child: MouseRegion( + onEnter: (event) => setState(() => hovering = true), + onExit: (event) => setState(() => hovering = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: isOffline + ? [ + theme.colorScheme.errorContainer.withValues(alpha: 0.8), + theme.colorScheme.errorContainer.withValues(alpha: 0.25), + ] + : [ + surfaceColor.withValues(alpha: hovering ? 0.7 : 0), + surfaceColor.withValues(alpha: 0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + )), + height: widget.height, + child: kIsWeb + ? const SizedBox.shrink() + : Stack( + fit: StackFit.expand, + children: [ + switch (AdaptiveLayout.of(context).platform) { + TargetPlatform.android || + TargetPlatform.iOS => + SizedBox(height: MediaQuery.paddingOf(context).top), + TargetPlatform.windows || TargetPlatform.linux => Container( + child: Row( + children: [ + Expanded( + child: Container( + color: Colors.black.withValues(alpha: 0), + child: DragToMoveArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + children: [ + Container( + padding: const EdgeInsets.only(left: 16), + child: DefaultTextStyle( + style: TextStyle( + color: iconColor, + fontSize: 14, + ), + child: Text(widget.label ?? ""), ), - child: Text(widget.label ?? ""), ), - ), - ], + ], + ), ), ), ), - ), - Container( - decoration: BoxDecoration(boxShadow: [ - BoxShadow( - color: surfaceColor.withValues(alpha: isOffline ? 0 : 0.5), - blurRadius: 32, - spreadRadius: 10, - offset: const Offset(8, -6), - ), - ]), - child: Row( - children: [ - FutureBuilder>(future: Future.microtask(() async { - final isMinimized = await windowManager.isMinimized(); - return [isMinimized]; - }), builder: (context, snapshot) { - final isMinimized = snapshot.data?.firstOrNull ?? false; - return IconButton( + Container( + decoration: BoxDecoration(boxShadow: [ + BoxShadow( + color: surfaceColor.withValues(alpha: isOffline ? 0 : 0.5), + blurRadius: 32, + spreadRadius: 10, + offset: const Offset(8, -6), + ), + ]), + child: Row( + children: [ + FutureBuilder>(future: Future.microtask(() async { + final isMinimized = await windowManager.isMinimized(); + return [isMinimized]; + }), builder: (context, snapshot) { + final isMinimized = snapshot.data?.firstOrNull ?? false; + return IconButton( + style: IconButton.styleFrom( + hoverColor: brightness == Brightness.light + ? Colors.black.withValues(alpha: 0.1) + : Colors.white.withValues(alpha: 0.2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2))), + onPressed: () async { + fullScreenHelper.closeFullScreen(ref); + if (isMinimized) { + windowManager.restore(); + } else { + windowManager.minimize(); + } + }, + icon: Transform.translate( + offset: const Offset(0, -2), + child: Icon( + Icons.minimize_rounded, + color: iconColor, + size: 20, + ), + ), + ); + }), + FutureBuilder>( + future: Future.microtask(() async { + final isMaximized = await windowManager.isMaximized(); + return [isMaximized]; + }), + builder: (BuildContext context, AsyncSnapshot> snapshot) { + final maximized = snapshot.data?.firstOrNull ?? false; + return IconButton( + style: IconButton.styleFrom( + hoverColor: brightness == Brightness.light + ? Colors.black.withValues(alpha: 0.1) + : Colors.white.withValues(alpha: 0.2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + ), + onPressed: () async { + fullScreenHelper.closeFullScreen(ref); + if (maximized) { + await windowManager.unmaximize(); + return; + } + if (!maximized) { + await windowManager.maximize(); + } else { + await windowManager.unmaximize(); + } + }, + icon: Transform.translate( + offset: const Offset(0, 0), + child: Icon( + maximized ? Icons.maximize_rounded : Icons.crop_square_rounded, + color: iconColor, + size: 19, + ), + ), + ); + }, + ), + IconButton( style: IconButton.styleFrom( - hoverColor: brightness == Brightness.light - ? Colors.black.withValues(alpha: 0.1) - : Colors.white.withValues(alpha: 0.2), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2))), + hoverColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(2), + ), + ), onPressed: () async { - fullScreenHelper.closeFullScreen(ref); - if (isMinimized) { - windowManager.restore(); - } else { - windowManager.minimize(); - } + windowManager.close(); }, icon: Transform.translate( offset: const Offset(0, -2), child: Icon( - Icons.minimize_rounded, + Icons.close_rounded, color: iconColor, - size: 20, + size: 23, ), ), - ); - }), - FutureBuilder>( - future: Future.microtask(() async { - final isMaximized = await windowManager.isMaximized(); - return [isMaximized]; - }), - builder: (BuildContext context, AsyncSnapshot> snapshot) { - final maximized = snapshot.data?.firstOrNull ?? false; - return IconButton( - style: IconButton.styleFrom( - hoverColor: brightness == Brightness.light - ? Colors.black.withValues(alpha: 0.1) - : Colors.white.withValues(alpha: 0.2), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), - ), - onPressed: () async { - fullScreenHelper.closeFullScreen(ref); - if (maximized) { - await windowManager.unmaximize(); - return; - } - if (!maximized) { - await windowManager.maximize(); - } else { - await windowManager.unmaximize(); - } - }, - icon: Transform.translate( - offset: const Offset(0, 0), - child: Icon( - maximized ? Icons.maximize_rounded : Icons.crop_square_rounded, - color: iconColor, - size: 19, - ), - ), - ); - }, - ), - IconButton( - style: IconButton.styleFrom( - hoverColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(2), - ), ), - onPressed: () async { - windowManager.close(); - }, - icon: Transform.translate( - offset: const Offset(0, -2), - child: Icon( - Icons.close_rounded, - color: iconColor, - size: 23, - ), - ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), - ), - TargetPlatform.macOS => const SizedBox.shrink(), - _ => Text(widget.label ?? "Fladder"), - }, - const OfflineBanner() - ], - ), + TargetPlatform.macOS => const SizedBox.shrink(), + _ => Text(widget.label ?? "Fladder"), + }, + const OfflineBanner() + ], + ), + ), ), ); } diff --git a/lib/screens/shared/detail_scaffold.dart b/lib/screens/shared/detail_scaffold.dart index a481911..267ae84 100644 --- a/lib/screens/shared/detail_scaffold.dart +++ b/lib/screens/shared/detail_scaffold.dart @@ -63,17 +63,20 @@ class _DetailScaffoldState extends ConsumerState { @override Widget build(BuildContext context) { - final padding = EdgeInsets.symmetric(horizontal: MediaQuery.sizeOf(context).width / 25); + final size = MediaQuery.sizeOf(context); + final padding = EdgeInsets.symmetric(horizontal: size.width / 25); final backGroundColor = Theme.of(context).colorScheme.surface.withValues(alpha: 0.8); - final minHeight = 450.0.clamp(0, MediaQuery.sizeOf(context).height).toDouble(); - final maxHeight = MediaQuery.sizeOf(context).height - 10; + final minHeight = 450.0.clamp(0, size.height).toDouble(); + final maxHeight = size.height - 10; final sideBarPadding = AdaptiveLayout.of(context).sideBarWidth; return PullToRefresh( onRefresh: () async { await widget.onRefresh?.call(); setState(() { - if (widget.backDrops?.backDrop?.contains(backgroundImage) == true) { - backgroundImage = widget.backDrops?.randomBackDrop; + if (context.mounted) { + if (widget.backDrops?.backDrop?.contains(backgroundImage) == true) { + backgroundImage = widget.backDrops?.randomBackDrop; + } } }); }, @@ -89,7 +92,7 @@ class _DetailScaffoldState extends ConsumerState { children: [ SizedBox( height: maxHeight, - width: MediaQuery.sizeOf(context).width, + width: size.width, child: FladderImage( image: backgroundImage, blurOnly: true, @@ -120,14 +123,19 @@ class _DetailScaffoldState extends ConsumerState { maxHeight: maxHeight.clamp(minHeight, 2500) - 20, ), child: FadeInImage( - placeholder: backgroundImage!.imageProvider, + placeholder: ResizeImage( + backgroundImage!.imageProvider, + height: maxHeight ~/ 1.5, + ), placeholderColor: Colors.transparent, fit: BoxFit.cover, alignment: Alignment.topCenter, placeholderFit: BoxFit.cover, excludeFromSemantics: true, - placeholderFilterQuality: FilterQuality.low, - image: backgroundImage!.imageProvider, + image: ResizeImage( + backgroundImage!.imageProvider, + height: maxHeight ~/ 1.5, + ), ), ), ), @@ -151,8 +159,8 @@ class _DetailScaffoldState extends ConsumerState { ), ), Container( - height: MediaQuery.sizeOf(context).height, - width: MediaQuery.sizeOf(context).width, + height: size.height, + width: size.width, color: widget.backgroundColor, ), Padding( @@ -160,141 +168,148 @@ class _DetailScaffoldState extends ConsumerState { bottom: 0, top: MediaQuery.of(context).padding.top, ), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: MediaQuery.sizeOf(context).height, - maxWidth: MediaQuery.sizeOf(context).width, + child: FocusScope( + autofocus: true, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: size.height, + maxWidth: size.width, + ), + child: widget.content( + padding.copyWith( + left: sideBarPadding + 25 + MediaQuery.paddingOf(context).left, + ), + ), ), - child: widget.content(padding.copyWith( - left: sideBarPadding + 25 + MediaQuery.paddingOf(context).left, - )), ), ), ], ), ), //Top row buttons - IconTheme( - data: IconThemeData(color: Theme.of(context).colorScheme.onSurface), - child: Padding( - padding: MediaQuery.paddingOf(context) - .copyWith(left: sideBarPadding + MediaQuery.paddingOf(context).left) - .add( - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - child: Row( - children: [ - IconButton.filledTonal( - style: IconButton.styleFrom( - backgroundColor: backGroundColor, + if (AdaptiveLayout.of(context).viewSize < ViewSize.desktop) + IconTheme( + data: IconThemeData(color: Theme.of(context).colorScheme.onSurface), + child: Padding( + padding: MediaQuery.paddingOf(context) + .copyWith(left: sideBarPadding + MediaQuery.paddingOf(context).left) + .add( + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), - onPressed: () => context.router.popBack(), - icon: Padding( - padding: EdgeInsets.all(AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 0 : 4), - child: const BackButtonIcon(), - ), - ), - const Spacer(), - AnimatedSize( - duration: const Duration(milliseconds: 250), - child: Container( - decoration: - BoxDecoration(color: backGroundColor, borderRadius: FladderTheme.defaultShape.borderRadius), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.item != null) ...[ - ref.watch(syncedItemProvider(widget.item)).when( - error: (error, stackTrace) => const SizedBox.shrink(), - data: (syncedItem) { - if (syncedItem == null && - ref.read(userProvider.select( - (value) => value?.canDownload ?? false, - )) && - widget.item?.syncAble == true) { - return IconButton( - onPressed: () => - ref.read(syncProvider.notifier).addSyncItem(context, widget.item!), - icon: const Icon( - IconsaxPlusLinear.arrow_down_2, - ), - ); - } else if (syncedItem != null) { - return IconButton( - onPressed: () => showSyncItemDetails(context, syncedItem, ref), - icon: SyncButton(item: widget.item!, syncedItem: syncedItem), - ); - } - return const SizedBox.shrink(); - }, - loading: () => const SizedBox.shrink(), - ), - Builder( - builder: (context) { - final newActions = widget.actions?.call(context); - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) { - return PopupMenuButton( - tooltip: context.localized.moreOptions, - enabled: newActions?.isNotEmpty == true, - icon: Icon( - widget.item!.type.icon, - color: Theme.of(context).colorScheme.onSurface, - ), - itemBuilder: (context) => newActions?.popupMenuItems(useIcons: true) ?? [], - ); - } else { - return IconButton( - onPressed: () => showBottomSheetPill( - context: context, - content: (context, scrollController) => ListView( - controller: scrollController, - shrinkWrap: true, - children: newActions?.listTileItems(context, useIcons: true) ?? [], - ), - ), - icon: Icon( - widget.item!.type.icon, - ), - ); - } - }, - ), - ], - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) - Builder( - builder: (context) => Tooltip( - message: context.localized.refresh, - child: IconButton( - onPressed: () => context.refreshData(), - icon: const Icon(IconsaxPlusLinear.refresh), - ), - ), - ), - if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single || - AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) - Container( - margin: const EdgeInsets.symmetric(horizontal: 6), - child: const SizedBox( - height: 30, - width: 30, - child: SettingsUserIcon(), - ), - ), - if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) - Tooltip( - message: context.localized.home, - child: IconButton( - onPressed: () => context.navigateTo(const DashboardRoute()), - icon: const Icon(IconsaxPlusLinear.home), - )), - ], + child: Row( + children: [ + IconButton.filledTonal( + style: IconButton.styleFrom( + backgroundColor: backGroundColor, + ), + onPressed: () => context.router.popBack(), + icon: Padding( + padding: + EdgeInsets.all(AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 0 : 4), + child: const BackButtonIcon(), ), ), - ), - ], + const Spacer(), + AnimatedSize( + duration: const Duration(milliseconds: 250), + child: Container( + decoration: BoxDecoration( + color: backGroundColor, borderRadius: FladderTheme.defaultShape.borderRadius), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.item != null) ...[ + ref.watch(syncedItemProvider(widget.item)).when( + error: (error, stackTrace) => const SizedBox.shrink(), + data: (syncedItem) { + if (syncedItem == null && + ref.read(userProvider.select( + (value) => value?.canDownload ?? false, + )) && + widget.item?.syncAble == true) { + return IconButton( + onPressed: () => + ref.read(syncProvider.notifier).addSyncItem(context, widget.item!), + icon: const Icon( + IconsaxPlusLinear.arrow_down_2, + ), + ); + } else if (syncedItem != null) { + return IconButton( + onPressed: () => showSyncItemDetails(context, syncedItem, ref), + icon: SyncButton(item: widget.item!, syncedItem: syncedItem), + ); + } + return const SizedBox.shrink(); + }, + loading: () => const SizedBox.shrink(), + ), + Builder( + builder: (context) { + final newActions = widget.actions?.call(context); + if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) { + return PopupMenuButton( + tooltip: context.localized.moreOptions, + enabled: newActions?.isNotEmpty == true, + icon: Icon( + widget.item!.type.icon, + color: Theme.of(context).colorScheme.onSurface, + ), + itemBuilder: (context) => newActions?.popupMenuItems(useIcons: true) ?? [], + ); + } else { + return IconButton( + onPressed: () => showBottomSheetPill( + context: context, + content: (context, scrollController) => ListView( + controller: scrollController, + shrinkWrap: true, + children: newActions?.listTileItems(context, useIcons: true) ?? [], + ), + ), + icon: Icon( + widget.item!.type.icon, + ), + ); + } + }, + ), + ], + if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) + Builder( + builder: (context) => Tooltip( + message: context.localized.refresh, + child: IconButton( + onPressed: () => context.refreshData(), + icon: const Icon(IconsaxPlusLinear.refresh), + ), + ), + ), + if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single || + AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) + Container( + margin: const EdgeInsets.symmetric(horizontal: 6), + child: const SizedBox( + height: 30, + width: 30, + child: SettingsUserIcon(), + ), + ), + if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) + Tooltip( + message: context.localized.home, + child: IconButton( + onPressed: () => context.navigateTo(const DashboardRoute()), + icon: const Icon(IconsaxPlusLinear.home), + )), + ], + ), + ), + ), + ], + ), ), ), - ), ], ), ), diff --git a/lib/screens/shared/flat_button.dart b/lib/screens/shared/flat_button.dart index 9d091e4..c350d10 100644 --- a/lib/screens/shared/flat_button.dart +++ b/lib/screens/shared/flat_button.dart @@ -6,6 +6,9 @@ import 'package:fladder/theme.dart'; class FlatButton extends ConsumerWidget { final Widget? child; + final bool autoFocus; + final FocusNode? focusNode; + final Function(bool value)? onFocusChange; final Function()? onTap; final Function()? onLongPress; final Function()? onDoubleTap; @@ -17,6 +20,9 @@ class FlatButton extends ConsumerWidget { final Clip clipBehavior; const FlatButton({ this.child, + this.onFocusChange, + this.focusNode, + this.autoFocus = false, this.onTap, this.onLongPress, this.onDoubleTap, @@ -47,8 +53,11 @@ class FlatButton extends ConsumerWidget { borderRadius: borderRadiusGeometry ?? FladderTheme.defaultShape.borderRadius, elevation: 0, child: InkWell( + autofocus: autoFocus, + focusNode: focusNode, onTap: onTap, onLongPress: onLongPress, + onFocusChange: onFocusChange, onDoubleTap: onDoubleTap, onSecondaryTapDown: onSecondaryTapDown, borderRadius: borderRadiusGeometry ?? BorderRadius.circular(10), diff --git a/lib/screens/shared/input_fields.dart b/lib/screens/shared/input_fields.dart index 315491e..033206c 100644 --- a/lib/screens/shared/input_fields.dart +++ b/lib/screens/shared/input_fields.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fladder/screens/shared/outlined_text_field.dart'; + class IntInputField extends ConsumerWidget { final int? value; final TextEditingController? controller; @@ -19,25 +22,17 @@ class IntInputField extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return Card( - color: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.25), - elevation: 0, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: TextField( - controller: controller ?? TextEditingController(text: (value ?? 0).toString()), - keyboardType: const TextInputType.numberWithOptions(decimal: false, signed: false), - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textInputAction: TextInputAction.done, - onSubmitted: (value) => onSubmitted?.call(int.tryParse(value)), - textAlign: TextAlign.center, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(0), - hintText: placeHolder, - suffixText: suffix, - border: InputBorder.none, - ), - ), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: OutlinedTextField( + controller: controller ?? TextEditingController(text: (value ?? 0).toString()), + keyboardType: const TextInputType.numberWithOptions(decimal: false, signed: false), + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textInputAction: TextInputAction.done, + onSubmitted: (value) => onSubmitted?.call(int.tryParse(value)), + textAlign: TextAlign.center, + suffix: suffix, + placeHolder: placeHolder, ), ); } diff --git a/lib/screens/shared/media/carousel_banner.dart b/lib/screens/shared/media/carousel_banner.dart index 5db17c5..117d64c 100644 --- a/lib/screens/shared/media/carousel_banner.dart +++ b/lib/screens/shared/media/carousel_banner.dart @@ -156,7 +156,9 @@ class _CarouselBannerState extends ConsumerState { ); }, ), - BannerPlayButton(item: widget.items[index]), + ExcludeFocus( + child: BannerPlayButton(item: widget.items[index]), + ), IgnorePointer( child: Container( decoration: BoxDecoration( diff --git a/lib/screens/shared/media/chapter_row.dart b/lib/screens/shared/media/chapter_row.dart index c5badcd..c58db58 100644 --- a/lib/screens/shared/media/chapter_row.dart +++ b/lib/screens/shared/media/chapter_row.dart @@ -4,9 +4,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/items/chapters_model.dart'; -import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; -import 'package:fladder/util/disable_keypad_focus.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/humanize_duration.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/shared/horizontal_list.dart'; @@ -25,7 +24,7 @@ class ChapterRow extends ConsumerWidget { label: context.localized.chapter(chapters.length), height: AdaptiveLayout.poster(context).size / 1.75, items: chapters, - itemBuilder: (context, index) { + itemBuilder: (context, index, selected) { final chapter = chapters[index]; List generateActions() { return [ @@ -34,16 +33,39 @@ class ChapterRow extends ConsumerWidget { ]; } - return AspectRatio( - aspectRatio: 1.75, - child: Card( + return FocusButton( + onSecondaryTapDown: (details) async { + Offset localPosition = details.globalPosition; + RelativeRect position = + RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); + await showMenu( + context: context, + position: position, + items: generateActions().popupMenuItems(), + ); + }, + onLongPress: () { + showBottomSheetPill( + context: context, + content: (context, scrollController) { + return ListView( + shrinkWrap: true, + controller: scrollController, + children: [ + ...generateActions().listTileItems(context), + ], + ); + }, + ); + }, + child: AspectRatio( + aspectRatio: 1.75, child: Stack( + fit: StackFit.expand, children: [ - Positioned.fill( - child: CachedNetworkImage( - imageUrl: chapter.imageUrl, - fit: BoxFit.cover, - ), + CachedNetworkImage( + imageUrl: chapter.imageUrl, + fit: BoxFit.cover, ), Align( alignment: Alignment.bottomLeft, @@ -64,49 +86,25 @@ class ChapterRow extends ConsumerWidget { ), ), ), - FlatButton( - onSecondaryTapDown: (details) async { - Offset localPosition = details.globalPosition; - RelativeRect position = - RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); - await showMenu( - context: context, - position: position, - items: generateActions().popupMenuItems(), - ); - }, - onLongPress: () { - showBottomSheetPill( - context: context, - content: (context, scrollController) { - return ListView( - shrinkWrap: true, - controller: scrollController, - children: [ - ...generateActions().listTileItems(context), - ], - ); - }, - ); - }, - ), - if (AdaptiveLayout.of(context).isDesktop) - DisableFocus( - child: Align( - alignment: Alignment.bottomRight, - child: PopupMenuButton( - tooltip: context.localized.options, - icon: const Icon( - Icons.more_vert, - color: Colors.white, - ), - itemBuilder: (context) => generateActions().popupMenuItems(), - ), - ), - ), ], ), ), + overlays: [ + if (AdaptiveLayout.of(context).isDesktop) + ExcludeFocus( + child: Align( + alignment: Alignment.bottomRight, + child: PopupMenuButton( + tooltip: context.localized.options, + icon: const Icon( + Icons.more_vert, + color: Colors.white, + ), + itemBuilder: (context) => generateActions().popupMenuItems(), + ), + ), + ) + ], ); }, contentPadding: contentPadding, diff --git a/lib/screens/shared/media/components/media_header.dart b/lib/screens/shared/media/components/media_header.dart index cc1c79c..39db678 100644 --- a/lib/screens/shared/media/components/media_header.dart +++ b/lib/screens/shared/media/components/media_header.dart @@ -9,10 +9,12 @@ class MediaHeader extends ConsumerWidget { final String name; final ImageData? logo; final Function()? onTap; + final Alignment alignment; const MediaHeader({ required this.name, required this.logo, this.onTap, + this.alignment = Alignment.bottomCenter, super.key, }); @@ -48,7 +50,7 @@ class MediaHeader extends ConsumerWidget { ? FladderImage( image: logo, disableBlur: true, - alignment: Alignment.bottomCenter, + alignment: alignment, imageErrorBuilder: (context, object, stack) => textWidget, placeHolder: const SizedBox(height: 0), fit: BoxFit.contain, diff --git a/lib/screens/shared/media/components/media_play_button.dart b/lib/screens/shared/media/components/media_play_button.dart index 5fd4671..5da0a68 100644 --- a/lib/screens/shared/media/components/media_play_button.dart +++ b/lib/screens/shared/media/components/media_play_button.dart @@ -1,15 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:iconsax_plus/iconsax_plus.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/providers/arguments_provider.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart'; +import 'package:fladder/theme.dart'; +import 'package:fladder/widgets/shared/ensure_visible.dart'; class MediaPlayButton extends ConsumerWidget { final ItemBaseModel? item; - final Function()? onPressed; - final Function()? onLongPressed; + final VoidCallback? onPressed; + final VoidCallback? onLongPressed; + const MediaPlayButton({ required this.item, this.onPressed, @@ -19,66 +23,110 @@ class MediaPlayButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final resume = (item?.progress ?? 0) > 0; - Widget buttonBuilder(bool resume, ButtonStyle? style, Color? textColor) { - return ElevatedButton( - onPressed: onPressed, - onLongPress: onLongPressed, - style: style, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible( - child: Text( - item?.playButtonLabel(context) ?? "", - maxLines: 2, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - color: textColor, - ), - ), + final progress = (item?.progress ?? 0) / 100.0; + final radius = FladderTheme.defaultShape.borderRadius; + + Widget buttonTitle(Color contentColor) { + return Padding( + padding: const EdgeInsets.all(10.0), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Text( + item?.playButtonLabel(context) ?? "", + maxLines: 2, + overflow: TextOverflow.clip, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: contentColor, + ), ), - const SizedBox(width: 4), - const Icon( - IconsaxPlusBold.play, - ), - ], - ), + ), + const SizedBox(width: 4), + Icon( + IconsaxPlusBold.play, + color: contentColor, + ), + ], ), ); } return AnimatedFadeSize( duration: const Duration(milliseconds: 250), - child: onPressed != null - ? Stack( - children: [ - buttonBuilder(resume, null, null), - IgnorePointer( - child: ClipRect( - child: Align( - alignment: Alignment.centerLeft, - widthFactor: (item?.progress ?? 0) / 100, - child: buttonBuilder( - resume, - ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.primary), - foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onPrimary), - iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onPrimary), + child: onPressed == null + ? const SizedBox.shrink(key: ValueKey('empty')) + : TextButton( + onPressed: onPressed, + onLongPress: onLongPressed, + autofocus: ref.read(argumentsStateProvider).htpcMode, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + ), + onFocusChange: (value) { + if (value) { + context.ensureVisible( + alignment: 1.0, + ); + } + }, + child: Padding( + padding: const EdgeInsets.all(2.0), + child: Stack( + alignment: Alignment.center, + children: [ + // Progress background + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + boxShadow: [ + BoxShadow( + blurRadius: 8.0, + offset: const Offset(0, 2), + color: Colors.black.withValues(alpha: 0.3), + ) + ], + borderRadius: radius, ), - Theme.of(context).colorScheme.onPrimary, ), ), - ), + // Button content + buttonTitle(Theme.of(context).colorScheme.primary), + Positioned.fill( + child: ClipRect( + clipper: _ProgressClipper( + progress, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: radius, + ), + child: buttonTitle(Theme.of(context).colorScheme.onPrimary), + ), + ), + ), + ], ), - ], - ) - : Container( - key: UniqueKey(), + ), ), ); } } + +class _ProgressClipper extends CustomClipper { + final double progress; + _ProgressClipper(this.progress); + + @override + Rect getClip(Size size) { + final w = (progress.clamp(0.0, 1.0) * size.width); + return Rect.fromLTWH(0, 0, w, size.height); + } + + @override + bool shouldReclip(covariant _ProgressClipper old) => old.progress != progress; +} diff --git a/lib/screens/shared/media/components/poster_image.dart b/lib/screens/shared/media/components/poster_image.dart index 3e41267..75268a2 100644 --- a/lib/screens/shared/media/components/poster_image.dart +++ b/lib/screens/shared/media/components/poster_image.dart @@ -1,4 +1,3 @@ -import 'package:fladder/models/items/movie_model.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,14 +6,14 @@ import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/models/book_model.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/item_shared_models.dart'; +import 'package:fladder/models/items/movie_model.dart'; import 'package:fladder/models/items/photos_model.dart'; import 'package:fladder/models/items/series_model.dart'; -import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/media/components/poster_placeholder.dart'; import 'package:fladder/theme.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; -import 'package:fladder/util/disable_keypad_focus.dart'; import 'package:fladder/util/fladder_image.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/humanize_duration.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/localization_helper.dart'; @@ -26,7 +25,6 @@ import 'package:fladder/widgets/shared/status_card.dart'; class PosterImage extends ConsumerStatefulWidget { final ItemBaseModel poster; - final bool heroTag; final bool? selected; final ValueChanged? playVideo; final bool inlineTitle; @@ -36,9 +34,11 @@ class PosterImage extends ConsumerStatefulWidget { final Function(ItemBaseModel newItem)? onItemUpdated; final Function(ItemBaseModel oldItem)? onItemRemoved; final Function(Function() action, ItemBaseModel item)? onPressed; + final bool primaryPosters; + final Function(bool focus)? onFocusChanged; + const PosterImage({ required this.poster, - this.heroTag = false, this.selected, this.playVideo, this.inlineTitle = false, @@ -48,6 +48,8 @@ class PosterImage extends ConsumerStatefulWidget { this.otherActions = const [], this.onPressed, this.onUserDataChanged, + this.primaryPosters = false, + this.onFocusChanged, super.key, }); @@ -56,15 +58,8 @@ class PosterImage extends ConsumerStatefulWidget { } class _PosterImageState extends ConsumerState { - late String currentTag = widget.heroTag == true ? widget.poster.id : UniqueKey().toString(); - bool hover = false; - + final tag = UniqueKey(); void pressedWidget(BuildContext context) async { - if (widget.heroTag == false) { - setState(() { - currentTag = widget.poster.id; - }); - } if (widget.onPressed != null) { widget.onPressed?.call(() async { await navigateToDetails(); @@ -78,7 +73,7 @@ class _PosterImageState extends ConsumerState { } Future navigateToDetails() async { - await widget.poster.navigateTo(context, ref: ref); + await widget.poster.navigateTo(context, ref: ref, tag: tag); } final posterRadius = FladderTheme.smallShape.borderRadius; @@ -87,302 +82,268 @@ class _PosterImageState extends ConsumerState { Widget build(BuildContext context) { final poster = widget.poster; final padding = const EdgeInsets.all(5); + return Hero( - tag: currentTag, - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (event) => setState(() => hover = true), - onExit: (event) => setState(() => hover = false), - child: Card( - elevation: 6, - color: Theme.of(context).colorScheme.secondaryContainer, - shape: RoundedRectangleBorder( - side: BorderSide( - width: 1.0, - color: Colors.white.withValues(alpha: 0.10), - ), - borderRadius: posterRadius, + tag: tag, + child: Card( + elevation: 6, + color: Theme.of(context).colorScheme.secondaryContainer, + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1.0, + color: Colors.white.withValues(alpha: 0.10), ), - child: Stack( - fit: StackFit.expand, - children: [ - FladderImage( - image: widget.poster.getPosters?.primary ?? widget.poster.getPosters?.backDrop?.lastOrNull, - placeHolder: PosterPlaceholder(item: widget.poster), - ), - if (poster.userData.progress > 0 && widget.poster.type == FladderItemType.book) - Align( - alignment: Alignment.topLeft, - child: Padding( - padding: padding, - child: Card( - child: Padding( - padding: const EdgeInsets.all(5.5), - child: Text( - context.localized.page((widget.poster as BookModel).currentPage), - style: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - fontSize: 12, - ), - ), + borderRadius: posterRadius, + ), + child: Stack( + fit: StackFit.expand, + children: [ + FladderImage( + image: widget.primaryPosters + ? widget.poster.images?.primary + : widget.poster.getPosters?.primary ?? widget.poster.getPosters?.backDrop?.lastOrNull, + placeHolder: PosterPlaceholder(item: widget.poster), + ), + if (poster.userData.progress > 0 && widget.poster.type == FladderItemType.book) + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: padding, + child: Card( + child: Padding( + padding: const EdgeInsets.all(5.5), + child: Text( + context.localized.page((widget.poster as BookModel).currentPage), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + ), ), ), ), ), - if (widget.selected == true) - Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.15), - border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary), - borderRadius: posterRadius, - ), - clipBehavior: Clip.hardEdge, - child: Stack( - alignment: Alignment.topCenter, - children: [ - Container( - color: Theme.of(context).colorScheme.primary, - width: double.infinity, - child: Padding( - padding: const EdgeInsets.all(2), - child: Text( - widget.poster.name, - maxLines: 2, - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith(color: Theme.of(context).colorScheme.onPrimary, fontWeight: FontWeight.bold), - ), - ), - ) - ], - ), + ), + if (widget.selected == true) + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.15), + border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary), + borderRadius: posterRadius, ), - Align( - alignment: Alignment.bottomCenter, - child: Column( - mainAxisSize: MainAxisSize.min, + clipBehavior: Clip.hardEdge, + child: Stack( + alignment: Alignment.topCenter, children: [ - if (widget.poster.userData.isFavourite) - const Row( - children: [ - StatusCard( - color: Colors.red, - child: Icon( - IconsaxPlusBold.heart, - size: 21, - color: Colors.red, - ), - ), - ], - ), - if ((poster.userData.progress > 0 && poster.userData.progress < 100) && - widget.poster.type != FladderItemType.book) ...{ - const SizedBox( - height: 4, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 3).copyWith(bottom: 3).add(padding), - child: Card( - color: Colors.transparent, - elevation: 3, - shadowColor: Colors.transparent, - child: LinearProgressIndicator( - minHeight: 7.5, - backgroundColor: Theme.of(context).colorScheme.onPrimary.withValues(alpha: 0.5), - value: poster.userData.progress / 100, - borderRadius: BorderRadius.circular(2), - ), + Container( + color: Theme.of(context).colorScheme.primary, + width: double.infinity, + child: Padding( + padding: const EdgeInsets.all(2), + child: Text( + widget.poster.name, + maxLines: 2, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(color: Theme.of(context).colorScheme.onPrimary, fontWeight: FontWeight.bold), ), ), - }, + ) ], ), ), - if (widget.inlineTitle) - IgnorePointer( - child: Align( - alignment: Alignment.topLeft, - child: Padding( - padding: const EdgeInsets.all(8), - child: Text( - widget.poster.title.maxLength(limitTo: 25), - style: - Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 20, fontWeight: FontWeight.bold), + Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.poster.userData.isFavourite) + const Row( + children: [ + StatusCard( + color: Colors.red, + child: Icon( + IconsaxPlusBold.heart, + size: 21, + color: Colors.red, + ), + ), + ], + ), + if ((poster.userData.progress > 0 && poster.userData.progress < 100) && + widget.poster.type != FladderItemType.book) ...{ + const SizedBox( + height: 4, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 3).copyWith(bottom: 3).add(padding), + child: Card( + color: Colors.transparent, + elevation: 3, + shadowColor: Colors.transparent, + child: LinearProgressIndicator( + minHeight: 7.5, + backgroundColor: Theme.of(context).colorScheme.onPrimary.withValues(alpha: 0.5), + value: poster.userData.progress / 100, + borderRadius: BorderRadius.circular(2), + ), ), ), + }, + ], + ), + ), + if (widget.inlineTitle) + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + widget.poster.title.maxLength(limitTo: 25), + style: Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 20, fontWeight: FontWeight.bold), ), ), - if ((widget.poster.unPlayedItemCount != null && widget.poster is SeriesModel) - || (widget.poster is MovieModel && !widget.poster.unWatched)) - IgnorePointer( - child: Align( - alignment: Alignment.topRight, - child: StatusCard( - color: Theme.of(context).colorScheme.primary, - useFittedBox: widget.poster.unPlayedItemCount != 0, - child: Padding( - padding: const EdgeInsets.all(6), - child: widget.poster.unPlayedItemCount != 0 - ? Container( - constraints: const BoxConstraints(minWidth: 16), - child: Text( - widget.poster.userData.unPlayedItemCount.toString(), - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - overflow: TextOverflow.visible, - fontSize: 14, - ), - ), - ) - : Icon( - Icons.check_rounded, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ), - ), - if (widget.poster.overview.runTime != null && - ((widget.poster is PhotoModel) && - (widget.poster as PhotoModel).internalType == FladderItemType.video)) ...{ - Align( + ), + if ((widget.poster.unPlayedItemCount != null && widget.poster is SeriesModel) || + (widget.poster is MovieModel && !widget.poster.unWatched)) + IgnorePointer( + child: Align( alignment: Alignment.topRight, - child: Padding( - padding: padding, - child: Card( - elevation: 5, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - widget.poster.overview.runTime.humanizeSmall ?? "", - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), + child: StatusCard( + color: Theme.of(context).colorScheme.primary, + useFittedBox: widget.poster.unPlayedItemCount != 0, + child: Padding( + padding: const EdgeInsets.all(6), + child: widget.poster.unPlayedItemCount != 0 + ? Container( + constraints: const BoxConstraints(minWidth: 16), + child: Text( + widget.poster.userData.unPlayedItemCount.toString(), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + overflow: TextOverflow.visible, + fontSize: 14, + ), + ), + ) + : Icon( + Icons.check_rounded, + size: 20, + color: Theme.of(context).colorScheme.primary, ), - const SizedBox(width: 2), - Icon( - Icons.play_arrow_rounded, - color: Theme.of(context).colorScheme.onSurface, - ), - ], - ), + ), + ), + ), + ), + if (widget.poster.overview.runTime != null && + ((widget.poster is PhotoModel) && + (widget.poster as PhotoModel).internalType == FladderItemType.video)) ...{ + Align( + alignment: Alignment.topRight, + child: Padding( + padding: padding, + child: Card( + elevation: 5, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + widget.poster.overview.runTime.humanizeSmall ?? "", + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 2), + Icon( + Icons.play_arrow_rounded, + color: Theme.of(context).colorScheme.onSurface, + ), + ], ), ), ), - ) - }, - //Desktop overlay - if (AdaptiveLayout.of(context).inputDevice != InputDevice.touch && - widget.poster.type != FladderItemType.person) - AnimatedOpacity( - opacity: hover ? 1 : 0, - duration: const Duration(milliseconds: 125), - child: Stack( - fit: StackFit.expand, - children: [ - //Hover color overlay - Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.55), - border: Border.all(width: 3, color: Theme.of(context).colorScheme.primary), - borderRadius: posterRadius, - )), - //Poster Button - Focus( - onFocusChange: (value) => setState(() => hover = value), - child: FlatButton( - onTap: () => pressedWidget(context), - onSecondaryTapDown: (details) async { - Offset localPosition = details.globalPosition; - RelativeRect position = RelativeRect.fromLTRB( - localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); - await showMenu( - context: context, - position: position, - items: widget.poster - .generateActions( - context, - ref, - exclude: widget.excludeActions, - otherActions: widget.otherActions, - onUserDataChanged: widget.onUserDataChanged, - onDeleteSuccesFully: widget.onItemRemoved, - onItemUpdated: widget.onItemUpdated, - ) - .popupMenuItems(useIcons: true), - ); - }, - ), - ), - //Play Button - if (widget.poster.playAble) - DisableFocus( - child: Align( - alignment: Alignment.center, - child: IconButton.filledTonal( - onPressed: () => widget.playVideo?.call(false), - icon: const Icon( - IconsaxPlusBold.play, - size: 32, - ), - ), - ), - ), - DisableFocus( - child: Align( - alignment: Alignment.bottomRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - PopupMenuButton( - tooltip: "Options", - icon: const Icon( - Icons.more_vert, - color: Colors.white, - ), - itemBuilder: (context) => widget.poster - .generateActions( - context, - ref, - exclude: widget.excludeActions, - otherActions: widget.otherActions, - onUserDataChanged: widget.onUserDataChanged, - onDeleteSuccesFully: widget.onItemRemoved, - onItemUpdated: widget.onItemUpdated, - ) - .popupMenuItems(useIcons: true), - ), - ], - ), - ), - ), - ], + ), + ) + }, + FocusButton( + onTap: () => pressedWidget(context), + onFocusChanged: widget.onFocusChanged, + onLongPress: () { + showBottomSheetPill( + context: context, + item: widget.poster, + content: (scrollContext, scrollController) => ListView( + shrinkWrap: true, + controller: scrollController, + children: widget.poster + .generateActions( + context, + ref, + exclude: widget.excludeActions, + otherActions: widget.otherActions, + onUserDataChanged: widget.onUserDataChanged, + onDeleteSuccesFully: widget.onItemRemoved, + onItemUpdated: widget.onItemUpdated, + ) + .listTileItems(scrollContext, useIcons: true), ), - ) - else - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => pressedWidget(context), - onLongPress: () { - showBottomSheetPill( - context: context, - item: widget.poster, - content: (scrollContext, scrollController) => ListView( - shrinkWrap: true, - controller: scrollController, - children: widget.poster + ); + }, + onSecondaryTapDown: (details) async { + Offset localPosition = details.globalPosition; + RelativeRect position = + RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); + await showMenu( + context: context, + position: position, + items: widget.poster + .generateActions( + context, + ref, + exclude: widget.excludeActions, + otherActions: widget.otherActions, + onUserDataChanged: widget.onUserDataChanged, + onDeleteSuccesFully: widget.onItemRemoved, + onItemUpdated: widget.onItemUpdated, + ) + .popupMenuItems(useIcons: true), + ); + }, + overlays: [ + //Poster Button + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) ...[ + // Play Button + if (widget.poster.playAble) + Align( + alignment: Alignment.center, + child: IconButton.filledTonal( + onPressed: () => widget.playVideo?.call(false), + icon: const Icon( + IconsaxPlusBold.play, + size: 32, + ), + ), + ), + Align( + alignment: Alignment.bottomRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PopupMenuButton( + tooltip: "Options", + icon: const Icon( + Icons.more_vert, + color: Colors.white, + ), + itemBuilder: (context) => widget.poster .generateActions( context, ref, @@ -392,14 +353,15 @@ class _PosterImageState extends ConsumerState { onDeleteSuccesFully: widget.onItemRemoved, onItemUpdated: widget.onItemUpdated, ) - .listTileItems(scrollContext, useIcons: true), + .popupMenuItems(useIcons: true), ), - ); - }, + ], + ), ), - ), - ], - ), + ], + ], + ), + ], ), ), ); diff --git a/lib/screens/shared/media/detailed_banner.dart b/lib/screens/shared/media/detailed_banner.dart new file mode 100644 index 0000000..98e88fe --- /dev/null +++ b/lib/screens/shared/media/detailed_banner.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/screens/details_screens/components/overview_header.dart'; +import 'package:fladder/screens/shared/media/poster_row.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/widgets/shared/ensure_visible.dart'; + +class DetailedBanner extends ConsumerStatefulWidget { + final List posters; + final Function(ItemBaseModel selected) onSelect; + const DetailedBanner({ + required this.posters, + required this.onSelect, + super.key, + }); + + @override + ConsumerState createState() => _DetailedBannerState(); +} + +class _DetailedBannerState extends ConsumerState { + late ItemBaseModel selectedPoster = widget.posters.first; + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final color = Theme.of(context).colorScheme.surface; + final stops = [0.05, 0.35, 0.65, 0.95]; + return Column( + children: [ + SizedBox( + width: double.infinity, + height: size.height * 0.50, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.85), + color.withValues(alpha: 0.75), + color.withValues(alpha: 0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Stack( + children: [ + ExcludeFocus( + child: Align( + alignment: Alignment.topRight, + child: AspectRatio( + aspectRatio: 1.7, + child: ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + colors: [ + Colors.white, + Colors.white, + Colors.white, + Colors.white.withAlpha(0), + ], + stops: stops, + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ).createShader(bounds); + }, + child: ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + colors: [ + Colors.white.withAlpha(0), + Colors.white, + Colors.white, + Colors.white, + ], + stops: stops, + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ).createShader(bounds); + }, + child: FladderImage( + image: selectedPoster.images?.primary, + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: FractionallySizedBox( + widthFactor: 0.5, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + spacing: 16, + children: [ + Flexible( + child: OverviewHeader( + name: selectedPoster.parentBaseModel.name, + subTitle: selectedPoster.label(context), + image: selectedPoster.getPosters, + logoAlignment: Alignment.centerLeft, + summary: selectedPoster.overview.summary, + productionYear: selectedPoster.overview.productionYear, + runTime: selectedPoster.overview.runTime, + genres: selectedPoster.overview.genreItems, + studios: selectedPoster.overview.studios, + officialRating: selectedPoster.overview.parentalRating, + communityRating: selectedPoster.overview.communityRating, + ), + ), + SizedBox( + height: size.height * 0.05, + ) + ], + ), + ), + ), + ], + ), + ), + ), + FocusProvider( + autoFocus: true, + child: PosterRow( + key: const Key("detailed-banner-row"), + primaryPosters: true, + label: context.localized.nextUp, + posters: widget.posters, + onFocused: (poster) { + context.ensureVisible( + alignment: 1.0, + ); + setState(() { + selectedPoster = poster; + }); + widget.onSelect(poster); + }, + ), + ) + ], + ); + } +} diff --git a/lib/screens/shared/media/episode_posters.dart b/lib/screens/shared/media/episode_posters.dart index 3caac9e..8178fa2 100644 --- a/lib/screens/shared/media/episode_posters.dart +++ b/lib/screens/shared/media/episode_posters.dart @@ -6,11 +6,10 @@ import 'package:fladder/models/items/episode_model.dart'; import 'package:fladder/models/syncing/sync_item.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/sync/sync_provider_helpers.dart'; -import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/syncing/sync_button.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; -import 'package:fladder/util/disable_keypad_focus.dart'; import 'package:fladder/util/fladder_image.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/shared/clickable_text.dart'; @@ -64,14 +63,14 @@ class _EpisodePosterState extends ConsumerState { EnumBox( current: selectedSeason != null ? "${context.localized.season(1)} $selectedSeason" : context.localized.all, itemBuilder: (context) => [ - PopupMenuItem( - child: Text(context.localized.all), - onTap: () => setState(() => selectedSeason = null), + ItemActionButton( + label: Text(context.localized.all), + action: () => setState(() => selectedSeason = null), ), ...episodesBySeason.entries.map( - (e) => PopupMenuItem( - child: Text("${context.localized.season(1)} ${e.key}"), - onTap: () { + (e) => ItemActionButton( + label: Text("${context.localized.season(1)} ${e.key}"), + action: () { setState(() => selectedSeason = e.key); }, ), @@ -84,7 +83,7 @@ class _EpisodePosterState extends ConsumerState { contentPadding: widget.contentPadding, startIndex: indexOfCurrent, items: episodes, - itemBuilder: (context, index) { + itemBuilder: (context, index, selected) { final episode = episodes[index]; final isCurrentEpisode = index == indexOfCurrent; return EpisodePoster( @@ -164,14 +163,43 @@ class EpisodePoster extends ConsumerWidget { child: Stack( fit: StackFit.expand, children: [ - FladderImage( - image: !episodeAvailable ? episode.parentImages?.primary : episode.images?.primary, - placeHolder: placeHolder, - blurOnly: !episodeAvailable - ? true - : ref.watch(clientSettingsProvider.select((value) => value.blurUpcomingEpisodes)) - ? blur - : false, + FocusButton( + onTap: onTap, + onLongPress: onLongPress, + onSecondaryTapDown: (details) async { + Offset localPosition = details.globalPosition; + RelativeRect position = + RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); + + await showMenu( + context: context, position: position, items: actions.popupMenuItems(useIcons: true)); + }, + child: FladderImage( + image: !episodeAvailable ? episode.parentImages?.primary : episode.images?.primary, + placeHolder: placeHolder, + blurOnly: !episodeAvailable + ? true + : ref.watch(clientSettingsProvider.select((value) => value.blurUpcomingEpisodes)) + ? blur + : false, + decodeHeight: 250, + ), + overlays: [ + if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && actions.isNotEmpty) + ExcludeFocus( + child: Align( + alignment: Alignment.bottomRight, + child: PopupMenuButton( + tooltip: context.localized.options, + icon: const Icon( + Icons.more_vert, + color: Colors.white, + ), + itemBuilder: (context) => actions.popupMenuItems(useIcons: true), + ), + ), + ), + ], ), if (!episodeAvailable) Align( @@ -236,36 +264,6 @@ class EpisodePoster extends ConsumerWidget { value: episode.userData.progress / 100, ), ), - LayoutBuilder( - builder: (context, constraints) { - return FlatButton( - onSecondaryTapDown: (details) async { - Offset localPosition = details.globalPosition; - RelativeRect position = RelativeRect.fromLTRB( - localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); - - await showMenu( - context: context, position: position, items: actions.popupMenuItems(useIcons: true)); - }, - onTap: onTap, - onLongPress: onLongPress, - ); - }, - ), - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && actions.isNotEmpty) - DisableFocus( - child: Align( - alignment: Alignment.bottomRight, - child: PopupMenuButton( - tooltip: context.localized.options, - icon: const Icon( - Icons.more_vert, - color: Colors.white, - ), - itemBuilder: (context) => actions.popupMenuItems(useIcons: true), - ), - ), - ), ], ), ), diff --git a/lib/screens/shared/media/media_banner.dart b/lib/screens/shared/media/media_banner.dart index ae4929a..7d77c1f 100644 --- a/lib/screens/shared/media/media_banner.dart +++ b/lib/screens/shared/media/media_banner.dart @@ -5,10 +5,10 @@ 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/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/media/banner_play_button.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/fladder_image.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/themes_data.dart'; @@ -99,9 +99,7 @@ class _MediaBannerState extends ConsumerState { surfaceTintColor: overlayColor, color: overlayColor, child: MouseRegion( - onEnter: (event) => setState(() => showControls = true), onHover: (event) => timer.reset(), - onExit: (event) => setState(() => showControls = false), child: Stack( fit: StackFit.expand, children: [ @@ -146,56 +144,52 @@ class _MediaBannerState extends ConsumerState { ], ), ), - child: Stack( - children: [ - SizedBox( - width: double.infinity, - height: double.infinity, - child: Padding( - padding: const EdgeInsets.all(1), - child: FladderImage( - fit: BoxFit.cover, - image: currentItem.bannerImage, - ), + child: FocusButton( + onTap: () => currentItem.navigateTo(context), + onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch + ? () async { + interacting = true; + final poster = currentItem; + showBottomSheetPill( + context: context, + item: poster, + content: (scrollContext, scrollController) => ListView( + shrinkWrap: true, + controller: scrollController, + children: poster + .generateActions(context, ref) + .listTileItems(scrollContext, useIcons: true), + ), + ); + interacting = false; + timer.reset(); + } + : null, + onSecondaryTapDown: AdaptiveLayout.of(context).inputDevice == InputDevice.touch + ? null + : (details) async { + Offset localPosition = details.globalPosition; + RelativeRect position = RelativeRect.fromLTRB( + localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy); + final poster = currentItem; + + await showMenu( + context: context, + position: position, + items: poster.generateActions(context, ref).popupMenuItems(useIcons: true), + ); + }, + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Padding( + padding: const EdgeInsets.all(1), + child: FladderImage( + fit: BoxFit.cover, + image: currentItem.bannerImage, ), ), - FlatButton( - onTap: () => currentItem.navigateTo(context), - onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch - ? () async { - interacting = true; - final poster = currentItem; - showBottomSheetPill( - context: context, - item: poster, - content: (scrollContext, scrollController) => ListView( - shrinkWrap: true, - controller: scrollController, - children: poster - .generateActions(context, ref) - .listTileItems(scrollContext, useIcons: true), - ), - ); - interacting = false; - timer.reset(); - } - : null, - onSecondaryTapDown: AdaptiveLayout.of(context).inputDevice == InputDevice.touch - ? null - : (details) async { - Offset localPosition = details.globalPosition; - RelativeRect position = RelativeRect.fromLTRB(localPosition.dx - 320, - localPosition.dy, localPosition.dx, localPosition.dy); - final poster = currentItem; - - await showMenu( - context: context, - position: position, - items: poster.generateActions(context, ref).popupMenuItems(useIcons: true), - ); - }, - ), - ], + ), ), ), ), diff --git a/lib/screens/shared/media/people_row.dart b/lib/screens/shared/media/people_row.dart index c8bf543..b4e6a9b 100644 --- a/lib/screens/shared/media/people_row.dart +++ b/lib/screens/shared/media/people_row.dart @@ -6,9 +6,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart'; import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/screens/details_screens/person_detail_screen.dart'; -import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.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/string_extensions.dart'; import 'package:fladder/widgets/shared/clickable_text.dart'; @@ -47,7 +47,7 @@ class PeopleRow extends ConsumerWidget { height: AdaptiveLayout.poster(context).size * 0.9, contentPadding: contentPadding, items: people, - itemBuilder: (context, index) { + itemBuilder: (context, index, selected) { final person = people[index]; return AspectRatio( aspectRatio: 0.6, @@ -63,19 +63,13 @@ class PeopleRow extends ConsumerWidget { transitionType: ContainerTransitionType.fadeThrough, openColor: Colors.transparent, tappable: false, - closedBuilder: (context, action) => Stack( - children: [ - Positioned.fill( - child: Card( - child: FladderImage( - image: person.image, - placeHolder: placeHolder(person.name), - fit: BoxFit.cover, - ), - ), - ), - FlatButton(onTap: () => action()), - ], + closedBuilder: (context, action) => FocusButton( + onTap: () => action(), + child: FladderImage( + image: person.image, + placeHolder: placeHolder(person.name), + fit: BoxFit.cover, + ), ), openBuilder: (context, action) => PersonDetailScreen( person: person, diff --git a/lib/screens/shared/media/poster_list_item.dart b/lib/screens/shared/media/poster_list_item.dart index 035082f..26a99e6 100644 --- a/lib/screens/shared/media/poster_list_item.dart +++ b/lib/screens/shared/media/poster_list_item.dart @@ -9,10 +9,12 @@ import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/fladder_image.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/shared/clickable_text.dart'; +import 'package:fladder/widgets/shared/ensure_visible.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; @@ -65,8 +67,13 @@ class PosterListItem extends ConsumerWidget { color: Theme.of(context).colorScheme.primary.withValues(alpha: selected == true ? 0.25 : 0), borderRadius: BorderRadius.circular(6), ), - child: InkWell( + child: FocusButton( onTap: () => pressedWidget(context), + onFocusChanged: (focus) { + if (focus) { + context.ensureVisible(); + } + }, onSecondaryTapDown: (details) async { Offset localPosition = details.globalPosition; RelativeRect position = diff --git a/lib/screens/shared/media/poster_row.dart b/lib/screens/shared/media/poster_row.dart index 90b25a2..7a2b4e0 100644 --- a/lib/screens/shared/media/poster_row.dart +++ b/lib/screens/shared/media/poster_row.dart @@ -3,53 +3,56 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/providers/arguments_provider.dart'; import 'package:fladder/screens/shared/media/poster_widget.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; +import 'package:fladder/widgets/shared/ensure_visible.dart'; import 'package:fladder/widgets/shared/horizontal_list.dart'; -class PosterRow extends ConsumerStatefulWidget { +class PosterRow extends ConsumerWidget { final List posters; final String label; final double? collectionAspectRatio; final Function()? onLabelClick; final EdgeInsets contentPadding; + final Function(ItemBaseModel focused)? onFocused; + final bool primaryPosters; const PosterRow({ required this.posters, this.contentPadding = const EdgeInsets.symmetric(horizontal: 16), required this.label, this.collectionAspectRatio, this.onLabelClick, + this.onFocused, + this.primaryPosters = false, super.key, }); @override - ConsumerState createState() => _PosterRowState(); -} - -class _PosterRowState extends ConsumerState { - late final controller = ScrollController(); - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final dominantRatio = widget.collectionAspectRatio ?? widget.posters.getMostCommonType.aspectRatio; + Widget build(BuildContext context, WidgetRef ref) { + final dominantRatio = primaryPosters ? 1.2 : collectionAspectRatio ?? posters.getMostCommonType.aspectRatio; return HorizontalList( - contentPadding: widget.contentPadding, - label: widget.label, - onLabelClick: widget.onLabelClick, + contentPadding: contentPadding, + label: label, + autoFocus: ref.read(argumentsStateProvider).htpcMode ? FocusProvider.autoFocusOf(context) : false, + onLabelClick: onLabelClick, dominantRatio: dominantRatio, - items: widget.posters, - itemBuilder: (context, index) { - final poster = widget.posters[index]; + items: posters, + onFocused: (index) { + if (onFocused != null) { + onFocused?.call(posters[index]); + } else { + context.ensureVisible(); + } + }, + itemBuilder: (context, index, selected) { + final poster = posters[index]; return PosterWidget( + key: Key(poster.id), poster: poster, aspectRatio: dominantRatio, - key: Key(poster.id), + primaryPosters: primaryPosters, ); }, ); diff --git a/lib/screens/shared/media/poster_widget.dart b/lib/screens/shared/media/poster_widget.dart index 669b3e6..2f2d86a 100644 --- a/lib/screens/shared/media/poster_widget.dart +++ b/lib/screens/shared/media/poster_widget.dart @@ -15,7 +15,6 @@ class PosterWidget extends ConsumerWidget { final ItemBaseModel poster; final Widget? subTitle; final bool? selected; - final bool? heroTag; final int maxLines; final double? aspectRatio; final bool inlineTitle; @@ -26,22 +25,27 @@ class PosterWidget extends ConsumerWidget { final Function(ItemBaseModel newItem)? onItemUpdated; final Function(ItemBaseModel oldItem)? onItemRemoved; final Function(VoidCallback action, ItemBaseModel item)? onPressed; - const PosterWidget( - {required this.poster, - this.subTitle, - this.maxLines = 3, - this.selected, - this.heroTag, - this.aspectRatio, - this.inlineTitle = false, - this.underTitle = true, - this.excludeActions = const {}, - this.otherActions = const [], - this.onUserDataChanged, - this.onItemUpdated, - this.onItemRemoved, - this.onPressed, - super.key}); + final bool primaryPosters; + final Function(bool focus)? onFocusChanged; + + const PosterWidget({ + required this.poster, + this.subTitle, + this.maxLines = 3, + this.selected, + this.aspectRatio, + this.inlineTitle = false, + this.underTitle = true, + this.excludeActions = const {}, + this.otherActions = const [], + this.onUserDataChanged, + this.onItemUpdated, + this.onItemRemoved, + this.onPressed, + this.primaryPosters = false, + this.onFocusChanged, + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -54,7 +58,6 @@ class PosterWidget extends ConsumerWidget { Expanded( child: PosterImage( poster: poster, - heroTag: heroTag ?? false, selected: selected, playVideo: (value) async => await poster.play(context, ref), inlineTitle: inlineTitle, @@ -64,67 +67,71 @@ class PosterWidget extends ConsumerWidget { onItemRemoved: onItemRemoved, onItemUpdated: onItemUpdated, onPressed: onPressed, + primaryPosters: primaryPosters, + onFocusChanged: onFocusChanged, ), ), if (!inlineTitle && underTitle) - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Flexible( - child: ClickableText( - onTap: AdaptiveLayout.viewSizeOf(context) != ViewSize.phone - ? () => poster.parentBaseModel.navigateTo(context) - : null, - text: poster.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ExcludeFocus( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: ClickableText( + onTap: AdaptiveLayout.viewSizeOf(context) != ViewSize.phone + ? () => poster.parentBaseModel.navigateTo(context) + : null, + text: poster.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (subTitle != null) ...[ - Flexible( - child: Opacity( - opacity: opacity, - child: subTitle!, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (subTitle != null) ...[ + Flexible( + child: Opacity( + opacity: opacity, + child: subTitle!, + ), + ), + ], + if (poster.subText?.isNotEmpty ?? false) + Flexible( + child: ClickableText( + opacity: opacity, + text: poster.subText ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ) + else + Flexible( + child: ClickableText( + opacity: opacity, + text: poster.subTextShort(context) ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), ), - ), ], - if (poster.subText?.isNotEmpty ?? false) - Flexible( - child: ClickableText( - opacity: opacity, - text: poster.subText ?? "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), - ), - ) - else - Flexible( - child: ClickableText( - opacity: opacity, - text: poster.subTextShort(context) ?? "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ], - ), - Flexible( - child: ClickableText( - opacity: opacity, - text: poster.subText?.isNotEmpty ?? false ? poster.subTextShort(context) ?? "" : "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), ), - ), - ].take(maxLines).toList(), + Flexible( + child: ClickableText( + opacity: opacity, + text: poster.subText?.isNotEmpty ?? false ? poster.subTextShort(context) ?? "" : "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ].take(maxLines).toList(), + ), ), ], ), diff --git a/lib/screens/shared/media/season_row.dart b/lib/screens/shared/media/season_row.dart index bf6ab79..212cba7 100644 --- a/lib/screens/shared/media/season_row.dart +++ b/lib/screens/shared/media/season_row.dart @@ -4,11 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/items/season_model.dart'; import 'package:fladder/providers/sync/sync_provider_helpers.dart'; -import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/syncing/sync_button.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; -import 'package:fladder/util/disable_keypad_focus.dart'; import 'package:fladder/util/fladder_image.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/shared/clickable_text.dart'; @@ -39,6 +38,7 @@ class SeasonsRow extends ConsumerWidget { itemBuilder: ( context, index, + selected, ) { final season = (seasons ?? [])[index]; return SeasonPoster( @@ -141,46 +141,46 @@ class SeasonPoster extends ConsumerWidget { ], ), ), - LayoutBuilder( - builder: (context, constraints) { - return FlatButton( - onSecondaryTapDown: (details) async { - Offset localPosition = details.globalPosition; - RelativeRect position = RelativeRect.fromLTRB( - localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); - await showMenu( - context: context, - position: position, - items: season.generateActions(context, ref).popupMenuItems(useIcons: true)); - }, - onTap: () => onSeasonPressed?.call(season), - onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch - ? () { - showBottomSheetPill( - context: context, - content: (context, scrollController) => ListView( - shrinkWrap: true, - controller: scrollController, - children: - season.generateActions(context, ref).listTileItems(context, useIcons: true), - ), - ); - } - : null, - ); - }, - ), - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) - DisableFocus( - child: Align( - alignment: Alignment.bottomRight, - child: PopupMenuButton( - tooltip: context.localized.options, - icon: const Icon(Icons.more_vert, color: Colors.white), - itemBuilder: (context) => season.generateActions(context, ref).popupMenuItems(useIcons: true), - ), - ), + Positioned.fill( + child: FocusButton( + onSecondaryTapDown: (details) async { + Offset localPosition = details.globalPosition; + RelativeRect position = RelativeRect.fromLTRB( + localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); + await showMenu( + context: context, + position: position, + items: season.generateActions(context, ref).popupMenuItems(useIcons: true)); + }, + onTap: () => onSeasonPressed?.call(season), + onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch + ? () { + showBottomSheetPill( + context: context, + content: (context, scrollController) => ListView( + shrinkWrap: true, + controller: scrollController, + children: season.generateActions(context, ref).listTileItems(context, useIcons: true), + ), + ); + } + : null, + overlays: [ + if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) + ExcludeFocus( + child: Align( + alignment: Alignment.bottomRight, + child: PopupMenuButton( + tooltip: context.localized.options, + icon: const Icon(Icons.more_vert, color: Colors.white), + itemBuilder: (context) => + season.generateActions(context, ref).popupMenuItems(useIcons: true), + ), + ), + ), + ], ), + ), ], ), ), diff --git a/lib/screens/shared/outlined_text_field.dart b/lib/screens/shared/outlined_text_field.dart index 7ff30be..99d962b 100644 --- a/lib/screens/shared/outlined_text_field.dart +++ b/lib/screens/shared/outlined_text_field.dart @@ -3,8 +3,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fladder/providers/arguments_provider.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart'; import 'package:fladder/theme.dart'; +import 'package:fladder/util/focus_provider.dart'; +import 'package:fladder/widgets/shared/ensure_visible.dart'; class OutlinedTextField extends ConsumerStatefulWidget { final String? label; @@ -24,6 +27,9 @@ class OutlinedTextField extends ConsumerStatefulWidget { final TextAlign textAlign; final TextInputType? keyboardType; final TextInputAction? textInputAction; + final InputDecoration? decoration; + final String? placeHolder; + final String? suffix; final String? errorText; final bool? enabled; @@ -46,6 +52,9 @@ class OutlinedTextField extends ConsumerStatefulWidget { this.keyboardType, this.textInputAction, this.errorText, + this.placeHolder, + this.decoration, + this.suffix, this.enabled, super.key, }); @@ -55,7 +64,26 @@ class OutlinedTextField extends ConsumerStatefulWidget { } class _OutlinedTextFieldState extends ConsumerState { - late FocusNode focusNode = widget.focusNode ?? FocusNode(); + late final FocusNode _textFocus = widget.focusNode ?? FocusNode(); + late final FocusNode _wrapperFocus = FocusNode() + ..addListener(() { + setState(() { + hasFocus = _wrapperFocus.hasFocus; + if (hasFocus) { + context.ensureVisible(); + } + }); + }); + + bool hasFocus = false; + + @override + void dispose() { + _textFocus.dispose(); + _wrapperFocus.dispose(); + super.dispose(); + } + bool _obscureText = true; void _toggle() { setState(() { @@ -65,101 +93,103 @@ class _OutlinedTextFieldState extends ConsumerState { Color getColor() { if (widget.errorText != null) return Theme.of(context).colorScheme.errorContainer; - return Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.25); + return Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.35); } @override Widget build(BuildContext context) { final isPasswordField = widget.keyboardType == TextInputType.visiblePassword; + final leanBackMode = ref.watch(argumentsStateProvider).leanBackMode; if (widget.autoFocus) { - focusNode.requestFocus(); + if (leanBackMode) { + _wrapperFocus.requestFocus(); + } else { + _textFocus.requestFocus(); + } } - focusNode.addListener( - () {}, - ); return Column( children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - decoration: BoxDecoration( - color: widget.fillColor ?? getColor(), - borderRadius: FladderTheme.defaultShape.borderRadius, - ), - ), - ), + AnimatedContainer( + duration: const Duration(milliseconds: 175), + decoration: BoxDecoration( + color: widget.decoration == null ? widget.fillColor ?? getColor() : null, + borderRadius: FladderTheme.smallShape.borderRadius, + border: BoxBorder.all( + width: 2, + color: hasFocus ? Theme.of(context).colorScheme.primaryFixed : Colors.transparent, ), - IgnorePointer( + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: IgnorePointer( ignoring: widget.enabled == false, - child: TextField( - controller: widget.controller, - onChanged: widget.onChanged, - focusNode: focusNode, - onTap: widget.onTap, - autofillHints: widget.autoFillHints, - keyboardType: widget.keyboardType, - autocorrect: widget.autocorrect, - onSubmitted: widget.onSubmitted, - textInputAction: widget.textInputAction, - obscureText: isPasswordField ? _obscureText : false, - style: widget.style, - maxLines: widget.maxLines, - inputFormatters: widget.inputFormatters, - textAlign: widget.textAlign, - decoration: InputDecoration( - border: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0), - width: widget.borderWidth, - ), + child: KeyboardListener( + focusNode: _wrapperFocus, + onKeyEvent: (KeyEvent event) { + if (event is KeyUpEvent && acceptKeys.contains(event.logicalKey)) { + if (_textFocus.hasFocus) { + _textFocus.unfocus(); + _wrapperFocus.requestFocus(); + } else if (_wrapperFocus.hasFocus) { + _textFocus.requestFocus(); + } + } + }, + child: ExcludeFocusTraversal( + child: TextField( + controller: widget.controller, + onChanged: widget.onChanged, + focusNode: _textFocus, + onTap: widget.onTap, + autofillHints: widget.autoFillHints, + keyboardType: widget.keyboardType, + autocorrect: widget.autocorrect, + onSubmitted: widget.onSubmitted != null + ? (value) { + widget.onSubmitted?.call(value); + Future.microtask(() async { + await Future.delayed(const Duration(milliseconds: 125)); + _wrapperFocus.requestFocus(); + }); + } + : null, + textInputAction: widget.textInputAction, + obscureText: isPasswordField ? _obscureText : false, + style: widget.style, + maxLines: widget.maxLines, + inputFormatters: widget.inputFormatters, + textAlign: widget.textAlign, + canRequestFocus: true, + decoration: widget.decoration ?? + InputDecoration( + border: InputBorder.none, + filled: widget.fillColor != null, + fillColor: widget.fillColor, + labelText: widget.label, + suffix: widget.suffix != null + ? Padding( + padding: const EdgeInsets.only(right: 6), + child: Text(widget.suffix!), + ) + : null, + hintText: widget.placeHolder, + // errorText: widget.errorText, + suffixIcon: isPasswordField + ? InkWell( + onTap: _toggle, + borderRadius: BorderRadius.circular(5), + child: Icon( + _obscureText ? Icons.visibility : Icons.visibility_off, + size: 16.0, + ), + ) + : null, + ), ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0), - width: widget.borderWidth, - ), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0), - width: widget.borderWidth, - ), - ), - errorBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0), - width: widget.borderWidth, - ), - ), - focusedErrorBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0), - width: widget.borderWidth, - ), - ), - filled: widget.fillColor != null, - fillColor: widget.fillColor, - labelText: widget.label, - // errorText: widget.errorText, - suffixIcon: isPasswordField - ? InkWell( - onTap: _toggle, - borderRadius: BorderRadius.circular(5), - child: Icon( - _obscureText ? Icons.visibility : Icons.visibility_off, - size: 16.0, - ), - ) - : null, ), ), ), - ], + ), ), AnimatedFadeSize( child: widget.errorText != null diff --git a/lib/screens/shared/passcode_input.dart b/lib/screens/shared/passcode_input.dart index 42731e3..cb166a2 100644 --- a/lib/screens/shared/passcode_input.dart +++ b/lib/screens/shared/passcode_input.dart @@ -4,8 +4,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart'; -import 'package:fladder/util/input_handler.dart'; -import 'package:fladder/util/list_padding.dart'; class PassCodeInput extends ConsumerStatefulWidget { final ValueChanged passCode; @@ -20,6 +18,18 @@ class _PassCodeInputState extends ConsumerState { final passCodeLength = 4; var currentPasscode = ""; + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_onKey); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_onKey); + super.dispose(); + } + bool _onKey(KeyEvent value) { if (value is KeyDownEvent) { final keyInt = int.tryParse(value.logicalKey.keyLabel); @@ -37,70 +47,68 @@ class _PassCodeInputState extends ConsumerState { @override Widget build(BuildContext context) { - return InputHandler( - onKeyEvent: (node, event) => _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored, - child: AlertDialog( - scrollable: true, - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate( - passCodeLength, - (index) => Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: SizedBox( - height: iconSize * 1.2, - width: iconSize * 1.2, - child: Card( - child: Transform.translate( - offset: const Offset(0, 5), - child: AnimatedFadeSize( - child: Text( - currentPasscode.length > index ? "*" : "", - style: Theme.of(context).textTheme.displayLarge?.copyWith(fontSize: 60), - ), + return AlertDialog( + scrollable: true, + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate( + passCodeLength, + (index) => Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: SizedBox( + height: iconSize * 1.2, + width: iconSize * 1.2, + child: Card( + child: Transform.translate( + offset: const Offset(0, 5), + child: AnimatedFadeSize( + child: Text( + currentPasscode.length > index ? "*" : "", + style: Theme.of(context).textTheme.displayLarge?.copyWith(fontSize: 60), ), ), ), ), ), ), - ).toList(), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.of([1, 2, 3]).map((e) => passCodeNumber(e)).toList(), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.of([4, 5, 6]).map((e) => passCodeNumber(e)).toList(), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.of([7, 8, 9]).map((e) => passCodeNumber(e)).toList(), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - backSpaceButton, - passCodeNumber(0), - clearAllButton, - ], - ) - ].addPadding(const EdgeInsets.symmetric(vertical: 8)), - ), + ), + ).toList(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.of([1, 2, 3]).map((e) => passCodeNumber(e)).toList(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.of([4, 5, 6]).map((e) => passCodeNumber(e)).toList(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.of([7, 8, 9]).map((e) => passCodeNumber(e)).toList(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + backSpaceButton, + passCodeNumber(0), + clearAllButton, + ], + ) + ], ), ); } Widget passCodeNumber(int value) { return IconButton.filledTonal( - onPressed: () async { + onPressed: () { addToPassCode(value.toString()); }, icon: Container( @@ -138,6 +146,7 @@ class _PassCodeInputState extends ConsumerState { Widget get clearAllButton { return IconButton.filled( + autofocus: true, style: ButtonStyle( backgroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer), iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer), diff --git a/lib/screens/shared/user_icon.dart b/lib/screens/shared/user_icon.dart index 47a9149..476411d 100644 --- a/lib/screens/shared/user_icon.dart +++ b/lib/screens/shared/user_icon.dart @@ -59,6 +59,8 @@ class UserIcon extends ConsumerWidget { imageUrl: user?.avatar ?? "", progressIndicatorBuilder: (context, url, progress) => placeHolder(), errorWidget: (context, url, error) => placeHolder(), + memCacheHeight: 128, + fit: BoxFit.cover, ), FlatButton( onTap: onTap, diff --git a/lib/screens/syncing/sync_list_item.dart b/lib/screens/syncing/sync_list_item.dart index ffd9829..94b7c3e 100644 --- a/lib/screens/syncing/sync_list_item.dart +++ b/lib/screens/syncing/sync_list_item.dart @@ -11,153 +11,144 @@ import 'package:fladder/screens/shared/default_alert_dialog.dart'; import 'package:fladder/screens/syncing/sync_item_details.dart'; import 'package:fladder/screens/syncing/sync_widgets.dart'; import 'package:fladder/screens/syncing/widgets/sync_progress_builder.dart'; -import 'package:fladder/screens/syncing/widgets/sync_status_overlay.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/size_formatting.dart'; -class SyncListItem extends ConsumerStatefulWidget { +class SyncListItem extends ConsumerWidget { final SyncedItem syncedItem; - const SyncListItem({required this.syncedItem, super.key}); + const SyncListItem({ + required this.syncedItem, + super.key, + }); @override - ConsumerState createState() => SyncListItemState(); -} - -class SyncListItemState extends ConsumerState { - @override - Widget build(BuildContext context) { - final syncedItem = widget.syncedItem; + Widget build(BuildContext context, WidgetRef ref) { final baseItem = syncedItem.itemModel; + print(FocusManager.instance.primaryFocus); return Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: SyncStatusOverlay( - syncedItem: syncedItem, - child: Card( - elevation: 1, - color: Theme.of(context).colorScheme.surfaceDim, - shadowColor: Colors.transparent, - child: Dismissible( - background: Container( - color: Theme.of(context).colorScheme.errorContainer, - child: const Padding( - padding: EdgeInsets.all(8.0), - child: Row( - children: [Icon(IconsaxPlusBold.trash)], - ), + child: Card( + elevation: 1, + color: Theme.of(context).colorScheme.surfaceDim, + shadowColor: Colors.transparent, + child: Dismissible( + key: Key(syncedItem.id), + background: Container( + color: Theme.of(context).colorScheme.errorContainer, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Row( + children: [Icon(IconsaxPlusBold.trash)], ), ), - key: Key(syncedItem.id), - direction: DismissDirection.startToEnd, - confirmDismiss: (direction) async { - await showDefaultAlertDialog( - context, - context.localized.deleteItem(baseItem?.detailedName(context) ?? ""), - context.localized.syncDeletePopupPermanent, - (context) async { - ref.read(syncProvider.notifier).removeSync(context, syncedItem); - Navigator.of(context).pop(); - return true; - }, - context.localized.delete, - (context) async { - Navigator.of(context).pop(); - }, - context.localized.cancel); - return false; - }, - child: LayoutBuilder( - builder: (context, constraints) { - return IntrinsicHeight( - child: InkWell( - onTap: () => baseItem?.navigateTo(context), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 16, - children: [ - ConstrainedBox( - constraints: BoxConstraints(maxHeight: 125, maxWidth: constraints.maxWidth * 0.2), - child: Card( - child: AspectRatio( - aspectRatio: baseItem?.primaryRatio ?? 1.0, - child: FladderImage( - image: baseItem?.getPosters?.primary, - fit: BoxFit.cover, - )), - ), - ), - Expanded( - child: FutureBuilder( - future: ref.read(syncProvider.notifier).getNestedChildren(syncedItem), - builder: (context, asyncSnapshot) { - final nestedChildren = asyncSnapshot.data ?? []; - return SyncProgressBuilder( - item: syncedItem, - children: nestedChildren, - builder: (context, combinedStream) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - Flexible( - child: Text( - baseItem?.detailedName(context) ?? "", - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Flexible( - child: SyncSubtitle( - syncItem: syncedItem, - children: nestedChildren, - ), - ), - Flexible( - child: Consumer( - builder: (context, ref, child) => SyncLabel( - label: context.localized.totalSize( - ref.watch(syncSizeProvider(syncedItem, nestedChildren)).byteFormat ?? - '--'), - status: combinedStream?.status ?? TaskStatus.notFound, - ), - ), - ), - if (combinedStream != null && combinedStream.hasDownload == true) - SyncProgressBar(item: syncedItem, task: combinedStream) - ], - ); - }, - ); - }, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Card( - elevation: 0, - shadowColor: Colors.transparent, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Text(baseItem != null ? baseItem.type.label(context) : ""), - )), - IconButton( - onPressed: () => showSyncItemDetails(context, syncedItem, ref), - icon: const Icon(IconsaxPlusLinear.more_square), - ), - ], - ), - ], + ), + direction: DismissDirection.startToEnd, + confirmDismiss: (direction) async { + await showDefaultAlertDialog( + context, + context.localized.deleteItem(baseItem?.detailedName(context) ?? ""), + context.localized.syncDeletePopupPermanent, + (context) async { + ref.read(syncProvider.notifier).removeSync(context, syncedItem); + Navigator.of(context).pop(); + return true; + }, + context.localized.delete, + (context) async { + Navigator.of(context).pop(); + }, + context.localized.cancel); + return false; + }, + child: FocusButton( + onTap: () => baseItem?.navigateTo(context), + onLongPress: () => showSyncItemDetails(context, syncedItem, ref), + child: ExcludeFocus( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 125, maxWidth: 512), + child: Card( + child: AspectRatio( + aspectRatio: baseItem?.primaryRatio ?? 1.0, + child: FladderImage( + image: baseItem?.getPosters?.primary, + fit: BoxFit.cover, + )), ), ), - ), - ); - }, + Expanded( + child: FutureBuilder( + future: ref.read(syncProvider.notifier).getNestedChildren(syncedItem), + builder: (context, asyncSnapshot) { + final nestedChildren = asyncSnapshot.data ?? []; + return SyncProgressBuilder( + item: syncedItem, + children: nestedChildren, + builder: (context, combinedStream) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Flexible( + child: Text( + baseItem?.detailedName(context) ?? "", + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Flexible( + child: SyncSubtitle( + syncItem: syncedItem, + children: nestedChildren, + ), + ), + Flexible( + child: Consumer( + builder: (context, ref, child) => SyncLabel( + label: context.localized.totalSize( + ref.watch(syncSizeProvider(syncedItem, nestedChildren)).byteFormat ?? '--'), + status: combinedStream?.status ?? TaskStatus.notFound, + ), + ), + ), + if (combinedStream != null && combinedStream.hasDownload == true) + SyncProgressBar(item: syncedItem, task: combinedStream) + ], + ); + }, + ); + }, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Card( + elevation: 0, + shadowColor: Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Text(baseItem != null ? baseItem.type.label(context) : ""), + )), + IconButton( + onPressed: () => showSyncItemDetails(context, syncedItem, ref), + icon: const Icon(IconsaxPlusLinear.more_square), + ), + ], + ), + ], + ), + ), ), ), ), diff --git a/lib/screens/syncing/synced_screen.dart b/lib/screens/syncing/synced_screen.dart index 92596d6..95d6686 100644 --- a/lib/screens/syncing/synced_screen.dart +++ b/lib/screens/syncing/synced_screen.dart @@ -5,7 +5,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; -import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/home_screen.dart'; @@ -13,10 +12,10 @@ import 'package:fladder/screens/shared/nested_scaffold.dart'; import 'package:fladder/screens/shared/nested_sliver_appbar.dart'; import 'package:fladder/screens/syncing/sync_list_item.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/sliver_list_padding.dart'; import 'package:fladder/widgets/navigation_scaffold/components/background_image.dart'; -import 'package:fladder/widgets/shared/pinch_poster_zoom.dart'; import 'package:fladder/widgets/shared/pull_to_refresh.dart'; @RoutePage() @@ -38,82 +37,82 @@ class _SyncedScreenState extends ConsumerState { onRefresh: () => ref.read(syncProvider.notifier).refresh(), child: NestedScaffold( background: BackgroundImage(images: items.map((value) => value.images).nonNulls.toList()), - body: PinchPosterZoom( - scaleDifference: (difference) => ref.read(clientSettingsProvider.notifier).addPosterSize(difference / 2), - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - controller: AdaptiveLayout.scrollOf(context, HomeTabs.sync), - slivers: [ - if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) - NestedSliverAppBar( - parent: context, - route: LibrarySearchRoute(), - ) - else - const DefaultSliverTopBadding(), - if (kDebugMode) - SliverToBoxAdapter( - child: Padding( - padding: padding, - child: Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - spacing: 12, - children: [ - ElevatedButton( - onPressed: () => ref.read(syncProvider.notifier).viewDatabase(context), - child: const Text("View Database"), - ), - ElevatedButton( - onPressed: () => ref.read(syncProvider.notifier).removeAllSyncedData(), - child: const Text("Clear drift database"), - ), - ], - ), - ), - ), - if (items.isNotEmpty) ...[ - SliverToBoxAdapter( - child: Padding( - padding: padding, - child: Text( - context.localized.syncedItems, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ), - SliverPadding( + body: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + controller: AdaptiveLayout.scrollOf(context, HomeTabs.sync), + slivers: [ + if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) + NestedSliverAppBar( + parent: context, + route: LibrarySearchRoute(), + ) + else + const DefaultSliverTopBadding(), + if (kDebugMode) + SliverToBoxAdapter( + child: Padding( padding: padding, - sliver: SliverList.builder( - itemBuilder: (context, index) { - final item = items[index]; - return SyncListItem(syncedItem: item); - }, - itemCount: items.length, - ), - ), - ] else ...[ - SliverFillRemaining( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + spacing: 12, children: [ - Text( - context.localized.noItemsSynced, - style: Theme.of(context).textTheme.titleMedium, + ElevatedButton( + onPressed: () => ref.read(syncProvider.notifier).viewDatabase(context), + child: const Text("View Database"), + ), + ElevatedButton( + onPressed: () => ref.read(syncProvider.notifier).removeAllSyncedData(), + child: const Text("Clear drift database"), ), - const SizedBox(width: 16), - const Icon( - IconsaxPlusLinear.cloud_cross, - ) ], ), - ) - ], - const DefautlSliverBottomPadding(), + ), + ), + if (items.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: padding, + child: Text( + context.localized.syncedItems, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + SliverPadding( + padding: padding, + sliver: SliverList.builder( + itemBuilder: (context, index) { + final item = items[index]; + return FocusProvider( + autoFocus: index == 0, + child: SyncListItem(syncedItem: item), + ); + }, + itemCount: items.length, + ), + ), + ] else ...[ + SliverFillRemaining( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.localized.noItemsSynced, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(width: 16), + const Icon( + IconsaxPlusLinear.cloud_cross, + ) + ], + ), + ) ], - ), + const DefautlSliverBottomPadding(), + ], ), ), ); diff --git a/lib/screens/video_player/components/video_player_chapters.dart b/lib/screens/video_player/components/video_player_chapters.dart index 09328ee..f5064d8 100644 --- a/lib/screens/video_player/components/video_player_chapters.dart +++ b/lib/screens/video_player/components/video_player_chapters.dart @@ -45,7 +45,7 @@ class VideoPlayerChapters extends ConsumerWidget { startIndex: chapters.indexOf(currentChapter ?? chapters.first), contentPadding: const EdgeInsets.symmetric(horizontal: 32), items: chapters.toList(), - itemBuilder: (context, index) { + itemBuilder: (context, index, selected) { final chapter = chapters[index]; final isCurrent = chapter == currentChapter; return Card( diff --git a/lib/screens/video_player/components/video_player_options_sheet.dart b/lib/screens/video_player/components/video_player_options_sheet.dart index 266286f..dd42899 100644 --- a/lib/screens/video_player/components/video_player_options_sheet.dart +++ b/lib/screens/video_player/components/video_player_options_sheet.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:collection/collection.dart'; -import 'package:iconsax_plus/iconsax_plus.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/items/episode_model.dart'; @@ -31,6 +31,7 @@ import 'package:fladder/util/refresh_state.dart'; import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; import 'package:fladder/widgets/shared/fladder_slider.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; import 'package:fladder/widgets/shared/spaced_list_tile.dart'; @@ -153,10 +154,9 @@ class _VideoOptionsMobileState extends ConsumerState { label: Text(context.localized.scale), current: videoSettings.videoFit.name.toUpperCaseSplit(), itemBuilder: (context) => BoxFit.values - .map((value) => PopupMenuItem( - value: value, - child: Text(value.name.toUpperCaseSplit()), - onTap: () => ref.read(videoPlayerSettingsProvider.notifier).setFitType(value), + .map((value) => ItemActionButton( + label: Text(value.name.toUpperCaseSplit()), + action: () => ref.read(videoPlayerSettingsProvider.notifier).setFitType(value), )) .toList(), ), diff --git a/lib/src/player_settings_helper.g.dart b/lib/src/player_settings_helper.g.dart new file mode 100644 index 0000000..3bbbfcf --- /dev/null +++ b/lib/src/player_settings_helper.g.dart @@ -0,0 +1,171 @@ +// Autogenerated from Pigeon (v26.0.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + + +enum SegmentType { + commercial, + preview, + recap, + intro, + outro, +} + +enum SegmentSkip { + ask, + skip, + none, +} + +class PlayerSettings { + PlayerSettings({ + required this.skipTypes, + required this.skipForward, + required this.skipBackward, + }); + + Map skipTypes; + + int skipForward; + + int skipBackward; + + List _toList() { + return [ + skipTypes, + skipForward, + skipBackward, + ]; + } + + Object encode() { + return _toList(); } + + static PlayerSettings decode(Object result) { + result as List; + return PlayerSettings( + skipTypes: (result[0] as Map?)!.cast(), + skipForward: result[1]! as int, + skipBackward: result[2]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlayerSettings || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is SegmentType) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is SegmentSkip) { + buffer.putUint8(130); + writeValue(buffer, value.index); + } else if (value is PlayerSettings) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : SegmentType.values[value]; + case 130: + final int? value = readValue(buffer) as int?; + return value == null ? null : SegmentSkip.values[value]; + case 131: + return PlayerSettings.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class PlayerSettingsPigeon { + /// Constructor for [PlayerSettingsPigeon]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + PlayerSettingsPigeon({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future sendPlayerSettings(PlayerSettings playerSettings) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.PlayerSettingsPigeon.sendPlayerSettings$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([playerSettings]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} diff --git a/lib/src/video_player_helper.g.dart b/lib/src/video_player_helper.g.dart new file mode 100644 index 0000000..e6e6c22 --- /dev/null +++ b/lib/src/video_player_helper.g.dart @@ -0,0 +1,1137 @@ +// Autogenerated from Pigeon (v26.0.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +List wrapResponse({Object? result, PlatformException? error, bool empty = false}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + + +enum MediaSegmentType { + commercial, + preview, + recap, + intro, + outro, +} + +class PlayableData { + PlayableData({ + required this.id, + required this.title, + this.subTitle, + this.logoUrl, + required this.description, + required this.startPosition, + required this.defaultAudioTrack, + required this.audioTracks, + required this.defaultSubtrack, + required this.subtitleTracks, + this.trickPlayModel, + required this.chapters, + required this.segments, + this.previousVideo, + this.nextVideo, + required this.url, + }); + + String id; + + String title; + + String? subTitle; + + String? logoUrl; + + String description; + + int startPosition; + + int defaultAudioTrack; + + List audioTracks; + + int defaultSubtrack; + + List subtitleTracks; + + TrickPlayModel? trickPlayModel; + + List chapters; + + List segments; + + String? previousVideo; + + String? nextVideo; + + String url; + + List _toList() { + return [ + id, + title, + subTitle, + logoUrl, + description, + startPosition, + defaultAudioTrack, + audioTracks, + defaultSubtrack, + subtitleTracks, + trickPlayModel, + chapters, + segments, + previousVideo, + nextVideo, + url, + ]; + } + + Object encode() { + return _toList(); } + + static PlayableData decode(Object result) { + result as List; + return PlayableData( + id: result[0]! as String, + title: result[1]! as String, + subTitle: result[2] as String?, + logoUrl: result[3] as String?, + description: result[4]! as String, + startPosition: result[5]! as int, + defaultAudioTrack: result[6]! as int, + audioTracks: (result[7] as List?)!.cast(), + defaultSubtrack: result[8]! as int, + subtitleTracks: (result[9] as List?)!.cast(), + trickPlayModel: result[10] as TrickPlayModel?, + chapters: (result[11] as List?)!.cast(), + segments: (result[12] as List?)!.cast(), + previousVideo: result[13] as String?, + nextVideo: result[14] as String?, + url: result[15]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlayableData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class MediaSegment { + MediaSegment({ + required this.type, + required this.name, + required this.start, + required this.end, + }); + + MediaSegmentType type; + + String name; + + int start; + + int end; + + List _toList() { + return [ + type, + name, + start, + end, + ]; + } + + Object encode() { + return _toList(); } + + static MediaSegment decode(Object result) { + result as List; + return MediaSegment( + type: result[0]! as MediaSegmentType, + name: result[1]! as String, + start: result[2]! as int, + end: result[3]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! MediaSegment || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class AudioTrack { + AudioTrack({ + required this.name, + required this.languageCode, + required this.codec, + required this.index, + required this.external, + this.url, + }); + + String name; + + String languageCode; + + String codec; + + int index; + + bool external; + + String? url; + + List _toList() { + return [ + name, + languageCode, + codec, + index, + external, + url, + ]; + } + + Object encode() { + return _toList(); } + + static AudioTrack decode(Object result) { + result as List; + return AudioTrack( + name: result[0]! as String, + languageCode: result[1]! as String, + codec: result[2]! as String, + index: result[3]! as int, + external: result[4]! as bool, + url: result[5] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AudioTrack || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class SubtitleTrack { + SubtitleTrack({ + required this.name, + required this.languageCode, + required this.codec, + required this.index, + required this.external, + this.url, + }); + + String name; + + String languageCode; + + String codec; + + int index; + + bool external; + + String? url; + + List _toList() { + return [ + name, + languageCode, + codec, + index, + external, + url, + ]; + } + + Object encode() { + return _toList(); } + + static SubtitleTrack decode(Object result) { + result as List; + return SubtitleTrack( + name: result[0]! as String, + languageCode: result[1]! as String, + codec: result[2]! as String, + index: result[3]! as int, + external: result[4]! as bool, + url: result[5] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SubtitleTrack || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class Chapter { + Chapter({ + required this.name, + required this.url, + required this.time, + }); + + String name; + + String url; + + int time; + + List _toList() { + return [ + name, + url, + time, + ]; + } + + Object encode() { + return _toList(); } + + static Chapter decode(Object result) { + result as List; + return Chapter( + name: result[0]! as String, + url: result[1]! as String, + time: result[2]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! Chapter || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class TrickPlayModel { + TrickPlayModel({ + required this.width, + required this.height, + required this.tileWidth, + required this.tileHeight, + required this.thumbnailCount, + required this.interval, + required this.images, + }); + + int width; + + int height; + + int tileWidth; + + int tileHeight; + + int thumbnailCount; + + int interval; + + List images; + + List _toList() { + return [ + width, + height, + tileWidth, + tileHeight, + thumbnailCount, + interval, + images, + ]; + } + + Object encode() { + return _toList(); } + + static TrickPlayModel decode(Object result) { + result as List; + return TrickPlayModel( + width: result[0]! as int, + height: result[1]! as int, + tileWidth: result[2]! as int, + tileHeight: result[3]! as int, + thumbnailCount: result[4]! as int, + interval: result[5]! as int, + images: (result[6] as List?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! TrickPlayModel || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class StartResult { + StartResult({ + this.resultValue, + }); + + String? resultValue; + + List _toList() { + return [ + resultValue, + ]; + } + + Object encode() { + return _toList(); } + + static StartResult decode(Object result) { + result as List; + return StartResult( + resultValue: result[0] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! StartResult || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class PlaybackState { + PlaybackState({ + required this.position, + required this.buffered, + required this.duration, + required this.playing, + required this.buffering, + required this.completed, + required this.failed, + }); + + int position; + + int buffered; + + int duration; + + bool playing; + + bool buffering; + + bool completed; + + bool failed; + + List _toList() { + return [ + position, + buffered, + duration, + playing, + buffering, + completed, + failed, + ]; + } + + Object encode() { + return _toList(); } + + static PlaybackState decode(Object result) { + result as List; + return PlaybackState( + position: result[0]! as int, + buffered: result[1]! as int, + duration: result[2]! as int, + playing: result[3]! as bool, + buffering: result[4]! as bool, + completed: result[5]! as bool, + failed: result[6]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlaybackState || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is MediaSegmentType) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is PlayableData) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is MediaSegment) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is AudioTrack) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is SubtitleTrack) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is Chapter) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is TrickPlayModel) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else if (value is StartResult) { + buffer.putUint8(136); + writeValue(buffer, value.encode()); + } else if (value is PlaybackState) { + buffer.putUint8(137); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : MediaSegmentType.values[value]; + case 130: + return PlayableData.decode(readValue(buffer)!); + case 131: + return MediaSegment.decode(readValue(buffer)!); + case 132: + return AudioTrack.decode(readValue(buffer)!); + case 133: + return SubtitleTrack.decode(readValue(buffer)!); + case 134: + return Chapter.decode(readValue(buffer)!); + case 135: + return TrickPlayModel.decode(readValue(buffer)!); + case 136: + return StartResult.decode(readValue(buffer)!); + case 137: + return PlaybackState.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class NativeVideoActivity { + /// Constructor for [NativeVideoActivity]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NativeVideoActivity({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future launchActivity() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.launchActivity$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as StartResult?)!; + } + } + + Future disposeActivity() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.disposeActivity$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future isLeanBackEnabled() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.NativeVideoActivity.isLeanBackEnabled$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } +} + +class VideoPlayerApi { + /// Constructor for [VideoPlayerApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + VideoPlayerApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future sendPlayableModel(PlayableData playableData) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.sendPlayableModel$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([playableData]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + + Future open(String url, bool play) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.open$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, play]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future setLooping(bool looping) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setLooping$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([looping]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Sets the volume, with 0.0 being muted and 1.0 being full volume. + Future setVolume(double volume) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setVolume$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([volume]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Sets the playback speed as a multiple of normal speed. + Future setPlaybackSpeed(double speed) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.setPlaybackSpeed$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([speed]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future play() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.play$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Pauses playback if the video is currently playing. + Future pause() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.pause$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// Seeks to the given playback position, in milliseconds. + Future seekTo(int position) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.seekTo$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([position]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future stop() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerApi.stop$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} + +abstract class VideoPlayerListenerCallback { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + void onPlaybackStateChanged(PlaybackState state); + + static void setUp(VideoPlayerListenerCallback? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged was null.'); + final List args = (message as List?)!; + final PlaybackState? arg_state = (args[0] as PlaybackState?); + assert(arg_state != null, + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerListenerCallback.onPlaybackStateChanged was null, expected non-null PlaybackState.'); + try { + api.onPlaybackStateChanged(arg_state!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} + +abstract class VideoPlayerControlsCallback { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + void loadNextVideo(); + + void loadPreviousVideo(); + + void onStop(); + + void swapSubtitleTrack(int value); + + void swapAudioTrack(int value); + + static void setUp(VideoPlayerControlsCallback? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadNextVideo$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + api.loadNextVideo(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.loadPreviousVideo$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + api.loadPreviousVideo(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.onStop$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + api.onStop(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack was null.'); + final List args = (message as List?)!; + final int? arg_value = (args[0] as int?); + assert(arg_value != null, + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapSubtitleTrack was null, expected non-null int.'); + try { + api.swapSubtitleTrack(arg_value!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack was null.'); + final List args = (message as List?)!; + final int? arg_value = (args[0] as int?); + assert(arg_value != null, + 'Argument for dev.flutter.pigeon.nl_jknaapen_fladder.video.VideoPlayerControlsCallback.swapAudioTrack was null, expected non-null int.'); + try { + api.swapAudioTrack(arg_value!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} diff --git a/lib/stubs/web/lib_mdk_web.dart b/lib/stubs/web/lib_mdk_web.dart index e2d1143..d9ac719 100644 --- a/lib/stubs/web/lib_mdk_web.dart +++ b/lib/stubs/web/lib_mdk_web.dart @@ -23,7 +23,7 @@ class LibMDK extends BasePlayer { Future dispose() async {} @override - Future open(String url, bool play) async {} + Future loadVideo(String url, bool play) async {} void setState(PlayerState state) {} @@ -34,6 +34,10 @@ class LibMDK extends BasePlayer { @override Future play() async {} + + @override + Future open(BuildContext context) async {} + @override Future playOrPause() async {} diff --git a/lib/theme.dart b/lib/theme.dart index a50d2d6..c410eea 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -37,6 +37,15 @@ class FladderTheme { static ThemeData theme(ColorScheme? colorScheme, DynamicSchemeVariant dynamicSchemeVariant) { final ColorScheme? scheme = generateDynamicColourSchemes(colorScheme, dynamicSchemeVariant); + final buttonState = WidgetStateProperty.resolveWith( + (states) { + return BorderSide( + width: 2, + color: states.contains(WidgetState.focused) ? Colors.white.withValues(alpha: 0.65) : Colors.transparent, + ); + }, + ); + final textTheme = FladderFonts.rubikTextTheme( const TextTheme(), ); @@ -61,7 +70,6 @@ class FladderTheme { floatingActionButtonTheme: FloatingActionButtonThemeData( backgroundColor: scheme?.secondaryContainer, foregroundColor: scheme?.onSecondaryContainer, - shape: defaultShape, ), snackBarTheme: SnackBarThemeData( backgroundColor: scheme?.secondary, @@ -90,11 +98,6 @@ class FladderTheme { }), trackOutlineWidth: const WidgetStatePropertyAll(1), ), - iconButtonTheme: IconButtonThemeData( - style: ButtonStyle( - shape: WidgetStatePropertyAll(defaultShape), - ), - ), navigationBarTheme: const NavigationBarThemeData(), dialogTheme: DialogThemeData(shape: defaultShape), scrollbarTheme: ScrollbarThemeData( @@ -130,7 +133,7 @@ class FladderTheme { dividerTheme: DividerThemeData( indent: 6, endIndent: 6, - color: scheme?.onSurface.withAlpha(125), + color: scheme?.onSurface.withAlpha(30), ), segmentedButtonTheme: SegmentedButtonThemeData( style: ButtonStyle( @@ -145,9 +148,36 @@ class FladderTheme { side: const WidgetStatePropertyAll(BorderSide.none), ), ), - elevatedButtonTheme: ElevatedButtonThemeData(style: ButtonStyle(shape: WidgetStatePropertyAll(defaultShape))), - filledButtonTheme: FilledButtonThemeData(style: ButtonStyle(shape: WidgetStatePropertyAll(defaultShape))), - outlinedButtonTheme: OutlinedButtonThemeData(style: ButtonStyle(shape: WidgetStatePropertyAll(defaultShape))), + iconButtonTheme: IconButtonThemeData( + style: ButtonStyle( + shape: WidgetStatePropertyAll(smallShape), + side: buttonState, + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + shape: WidgetStatePropertyAll(smallShape), + side: buttonState, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + shape: WidgetStatePropertyAll(smallShape), + side: buttonState, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: ButtonStyle( + shape: WidgetStatePropertyAll(smallShape), + side: buttonState, + ), + ), + textButtonTheme: TextButtonThemeData( + style: ButtonStyle( + shape: WidgetStatePropertyAll(smallShape), + side: buttonState, + ), + ), textTheme: textTheme.copyWith( titleMedium: textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, diff --git a/lib/util/adaptive_layout/adaptive_layout.dart b/lib/util/adaptive_layout/adaptive_layout.dart index 2ed0751..91ba889 100644 --- a/lib/util/adaptive_layout/adaptive_layout.dart +++ b/lib/util/adaptive_layout/adaptive_layout.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/settings/home_settings_model.dart'; +import 'package:fladder/providers/arguments_provider.dart'; import 'package:fladder/providers/settings/home_settings_provider.dart'; import 'package:fladder/screens/home_screen.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout_model.dart'; @@ -15,12 +16,14 @@ import 'package:fladder/util/resolution_checker.dart'; enum InputDevice { touch, pointer, + dpad, } enum ViewSize { phone, tablet, - desktop; + desktop, + television; const ViewSize(); @@ -28,6 +31,7 @@ enum ViewSize { ViewSize.phone => context.localized.phone, ViewSize.tablet => context.localized.tablet, ViewSize.desktop => context.localized.desktop, + ViewSize.television => context.localized.television, }; bool operator >(ViewSize other) => index > other.index; @@ -174,12 +178,20 @@ class _AdaptiveLayoutBuilderState extends ConsumerState { @override Widget build(BuildContext context) { - final acceptedLayouts = ref.watch(homeSettingsProvider.select((value) => value.screenLayouts)); - final acceptedViewSizes = ref.watch(homeSettingsProvider.select((value) => value.layoutStates)); + final arguments = ref.watch(argumentsStateProvider); + final htpcMode = arguments.htpcMode; + final acceptedLayouts = + htpcMode ? {LayoutMode.dual} : ref.watch(homeSettingsProvider.select((value) => value.screenLayouts)); + final acceptedViewSizes = + htpcMode ? {ViewSize.television} : ref.watch(homeSettingsProvider.select((value) => value.layoutStates)); final selectedViewSize = selectAvailableOrSmaller(viewSize, acceptedViewSizes, ViewSize.values); final selectedLayoutMode = selectAvailableOrSmaller(layoutMode, acceptedLayouts, LayoutMode.values); - final input = (isDesktop || kIsWeb) ? InputDevice.pointer : InputDevice.touch; + final input = htpcMode + ? InputDevice.dpad + : (isDesktop || kIsWeb) + ? InputDevice.pointer + : InputDevice.touch; final posterDefaults = const PosterDefaults(size: 350, ratio: 0.55); @@ -195,8 +207,10 @@ class _AdaptiveLayoutBuilderState extends ConsumerState { posterDefaults: posterDefaults, ); + final mediaQuery = MediaQuery.of(context); + return MediaQuery( - data: MediaQuery.of(context).copyWith( + data: mediaQuery.copyWith( padding: isDesktop || kIsWeb ? const EdgeInsets.only(top: defaultTitleBarHeight, bottom: 16) : null, viewPadding: isDesktop || kIsWeb ? const EdgeInsets.only(top: defaultTitleBarHeight, bottom: 16) : null, ), @@ -211,9 +225,14 @@ class _AdaptiveLayoutBuilderState extends ConsumerState { posterDefaults: posterDefaults, ), child: Builder( - builder: (context) => ResolutionChecker( - child: widget.adaptiveLayout == null ? DebugBanner(child: widget.child(context)) : widget.child(context), - ), + builder: (context) => isDesktop + ? ResolutionChecker( + child: + widget.adaptiveLayout == null ? DebugBanner(child: widget.child(context)) : widget.child(context), + ) + : widget.adaptiveLayout == null + ? DebugBanner(child: widget.child(context)) + : widget.child(context), ), ), ); diff --git a/lib/util/custom_cache_manager.dart b/lib/util/custom_cache_manager.dart index 76429e6..8e4b70e 100644 --- a/lib/util/custom_cache_manager.dart +++ b/lib/util/custom_cache_manager.dart @@ -6,7 +6,7 @@ class CustomCacheManager { Config( key, stalePeriod: const Duration(days: 3), - maxNrOfCacheObjects: 250, + maxNrOfCacheObjects: 256, fileService: HttpFileService(), ), ); diff --git a/lib/util/disable_keypad_focus.dart b/lib/util/disable_keypad_focus.dart deleted file mode 100644 index 384f21b..0000000 --- a/lib/util/disable_keypad_focus.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; - -class DisableFocus extends StatelessWidget { - final Widget child; - final bool canRequestFocus; - final bool skipTraversal; - final bool descendantsAreFocusable; - final bool descendantsAreTraversable; - const DisableFocus({ - required this.child, - super.key, - this.canRequestFocus = false, - this.skipTraversal = true, - this.descendantsAreFocusable = false, - this.descendantsAreTraversable = false, - }); - - @override - Widget build(BuildContext context) { - return Focus( - canRequestFocus: canRequestFocus, - skipTraversal: skipTraversal, - descendantsAreFocusable: descendantsAreFocusable, - descendantsAreTraversable: descendantsAreTraversable, - child: child, - ); - } -} diff --git a/lib/util/fladder_image.dart b/lib/util/fladder_image.dart index 94d30a0..733ac89 100644 --- a/lib/util/fladder_image.dart +++ b/lib/util/fladder_image.dart @@ -18,6 +18,7 @@ class FladderImage extends ConsumerWidget { final AlignmentGeometry? alignment; final bool disableBlur; final bool blurOnly; + final int? decodeHeight; const FladderImage({ required this.image, this.frameBuilder, @@ -29,6 +30,7 @@ class FladderImage extends ConsumerWidget { this.alignment, this.disableBlur = false, this.blurOnly = false, + this.decodeHeight = 400, super.key, }); @@ -48,20 +50,23 @@ class FladderImage extends ConsumerWidget { Image( image: BlurHashImage( newImage.hash, - decodingHeight: 24, - decodingWidth: 24, + decodingHeight: 16, + decodingWidth: 16, ), fit: blurFit ?? fit, + height: 16, ), if (!blurOnly && imageProvider != null) FadeInImage( placeholder: MemoryImage(kTransparentImage), fit: fit, placeholderFit: fit, - excludeFromSemantics: true, alignment: alignment ?? Alignment.center, imageErrorBuilder: imageErrorBuilder, - image: imageProvider, + image: ResizeImage( + imageProvider, + height: decodeHeight, + ), ) ], ); diff --git a/lib/util/focus_provider.dart b/lib/util/focus_provider.dart new file mode 100644 index 0000000..399b53e --- /dev/null +++ b/lib/util/focus_provider.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:fladder/screens/shared/flat_button.dart'; +import 'package:fladder/theme.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart'; + +final acceptKeys = { + LogicalKeyboardKey.space, + LogicalKeyboardKey.enter, + LogicalKeyboardKey.accept, + LogicalKeyboardKey.select, + LogicalKeyboardKey.gameButtonA, +}; + +class FocusProvider extends InheritedWidget { + final bool hasFocus; + final bool autoFocus; + final FocusNode? focusNode; + + const FocusProvider({ + super.key, + this.hasFocus = false, + this.autoFocus = false, + this.focusNode, + required super.child, + }); + + static bool of(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType(); + return widget?.hasFocus ?? false; + } + + static bool autoFocusOf(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType(); + return widget?.autoFocus ?? false; + } + + static FocusNode? focusNodeOf(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType(); + return widget?.focusNode; + } + + @override + bool updateShouldNotify(FocusProvider oldWidget) { + return oldWidget.hasFocus != hasFocus; + } +} + +class FocusButton extends StatefulWidget { + final Widget? child; + final List overlays; + final Function()? onTap; + final Function()? onLongPress; + final Function(TapDownDetails)? onSecondaryTapDown; + final bool darkOverlay; + final Function(bool focus)? onFocusChanged; + + const FocusButton({ + this.child, + this.overlays = const [], + this.onTap, + this.onLongPress, + this.onSecondaryTapDown, + this.darkOverlay = true, + this.onFocusChanged, + super.key, + }); + + @override + State createState() => FocusButtonState(); +} + +class FocusButtonState extends State { + bool onHover = false; + Timer? _longPressTimer; + bool _longPressTriggered = false; + bool _keyDownActive = false; + + static const Duration _kLongPressTimeout = Duration(milliseconds: 500); + + KeyEventResult _handleKey(FocusNode node, KeyEvent event) { + if (!node.hasFocus) return KeyEventResult.ignored; + + if (acceptKeys.contains(event.logicalKey)) { + if (event is KeyDownEvent) { + if (_keyDownActive) return KeyEventResult.ignored; + _keyDownActive = true; + _startLongPressTimer(); + } else if (event is KeyUpEvent) { + if (!_keyDownActive) return KeyEventResult.ignored; + if (_longPressTriggered) { + _resetKeyState(); + + return KeyEventResult.ignored; + } + _cancelLongPressTimer(); + _keyDownActive = false; + widget.onTap?.call(); + } + } + return KeyEventResult.ignored; + } + + void _startLongPressTimer() { + _longPressTriggered = false; + _longPressTimer?.cancel(); + _longPressTimer = Timer(_kLongPressTimeout, () { + _longPressTriggered = true; + widget.onLongPress?.call(); + _resetKeyState(); + }); + } + + void _cancelLongPressTimer() { + _longPressTimer?.cancel(); + _longPressTimer = null; + } + + void _resetKeyState() { + _cancelLongPressTimer(); + _keyDownActive = false; + _longPressTriggered = false; + } + + @override + void dispose() { + _resetKeyState(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final focusNode = FocusProvider.focusNodeOf(context); + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (event) => setState(() => onHover = true), + onExit: (event) => setState(() => onHover = false), + hitTestBehavior: HitTestBehavior.translucent, + child: Focus( + focusNode: focusNode, + onFocusChange: (value) { + widget.onFocusChanged?.call(value); + if (value) { + lastMainFocus = focusNode; + } + setState(() { + onHover = value; + }); + }, + onKeyEvent: _handleKey, + child: ExcludeFocus( + child: FlatButton( + onTap: widget.onTap, + onSecondaryTapDown: widget.onSecondaryTapDown, + onLongPress: widget.onLongPress, + child: Stack( + children: [ + ClipRRect( + borderRadius: FladderTheme.smallShape.borderRadius, + child: widget.child, + ), + Positioned.fill( + child: AnimatedOpacity( + opacity: onHover ? 1 : 0, + duration: const Duration(milliseconds: 125), + child: Container( + decoration: BoxDecoration( + color: widget.darkOverlay ? Colors.black.withValues(alpha: 0.35) : Colors.transparent, + border: Border.all(width: 3, color: Theme.of(context).colorScheme.primaryFixed), + borderRadius: FladderTheme.smallShape.borderRadius, + ), + child: Stack( + children: widget.overlays, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/util/input_handler.dart b/lib/util/input_handler.dart index 379e2ed..da21454 100644 --- a/lib/util/input_handler.dart +++ b/lib/util/input_handler.dart @@ -42,6 +42,7 @@ class _InputHandlerState extends ConsumerState> { return Focus( autofocus: widget.autoFocus, focusNode: focusNode, + skipTraversal: true, onFocusChange: (value) { if (!focusNode.hasFocus && widget.autoFocus) { focusNode.requestFocus(); diff --git a/lib/util/item_base_model/play_item_helpers.dart b/lib/util/item_base_model/play_item_helpers.dart index 08c3233..bc5a44e 100644 --- a/lib/util/item_base_model/play_item_helpers.dart +++ b/lib/util/item_base_model/play_item_helpers.dart @@ -17,7 +17,6 @@ import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/book_viewer/book_viewer_screen.dart'; import 'package:fladder/screens/shared/fladder_snackbar.dart'; -import 'package:fladder/screens/video_player/video_player.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/list_extensions.dart'; import 'package:fladder/util/localization_helper.dart'; @@ -75,9 +74,11 @@ Future _playVideo( return; } + final actualStartPosition = startPosition ?? await current.startDuration() ?? Duration.zero; + final loadedCorrectly = await ref.read(videoPlayerProvider.notifier).loadPlaybackItem( current, - startPosition: startPosition, + actualStartPosition, ); if (!loadedCorrectly) { @@ -93,22 +94,16 @@ Future _playVideo( ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.fullScreen)); - if (context.mounted) { - await Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (context) => const VideoPlayer(), - ), - ); - if (AdaptiveLayout.of(context).isDesktop) { - fullScreenHelper.closeFullScreen(ref); - } - - if (context.mounted) { - await context.refreshData(); - } - - onPlayerExit?.call(); + await ref.read(videoPlayerProvider.notifier).openPlayer(context); + if (AdaptiveLayout.of(context).isDesktop) { + fullScreenHelper.closeFullScreen(ref); } + + if (context.mounted) { + await context.refreshData(); + } + + onPlayerExit?.call(); } extension BookBaseModelExtension on BookModel? { diff --git a/lib/widgets/media_query_scaler.dart b/lib/widgets/media_query_scaler.dart new file mode 100644 index 0000000..ca7d0f7 --- /dev/null +++ b/lib/widgets/media_query_scaler.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class MediaQueryScaler extends StatelessWidget { + final Widget child; + final bool enable; + final double scale; + + const MediaQueryScaler({ + required this.child, + required this.enable, + this.scale = 1.35, + super.key, + }); + + @override + Widget build(BuildContext context) { + if (!enable) return child; + final mediaQuery = MediaQuery.of(context); + final screenSize = MediaQuery.sizeOf(context) * scale; + + final scaledMedia = mediaQuery.copyWith( + navigationMode: NavigationMode.directional, + size: screenSize, + padding: mediaQuery.padding * scale, + viewInsets: mediaQuery.viewInsets * scale, + viewPadding: mediaQuery.viewPadding * scale, + devicePixelRatio: mediaQuery.devicePixelRatio * scale, + ); + + return FittedBox( + alignment: Alignment.center, + child: SizedBox( + width: screenSize.width, + height: screenSize.height, + child: MediaQuery( + data: scaledMedia, + child: child, + ), + ), + ); + } +} diff --git a/lib/widgets/navigation_scaffold/components/adaptive_fab.dart b/lib/widgets/navigation_scaffold/components/adaptive_fab.dart index bd3d744..8af9ba9 100644 --- a/lib/widgets/navigation_scaffold/components/adaptive_fab.dart +++ b/lib/widgets/navigation_scaffold/components/adaptive_fab.dart @@ -32,8 +32,11 @@ class AdaptiveFab { padding: const EdgeInsets.symmetric(horizontal: 6), child: FilledButton.tonal( onPressed: onPressed, + style: FilledButton.styleFrom( + padding: const EdgeInsets.all(16), + ), child: Row( - spacing: 24, + spacing: 16, children: [ child, Flexible(child: Text(title)), diff --git a/lib/widgets/navigation_scaffold/components/background_image.dart b/lib/widgets/navigation_scaffold/components/background_image.dart index 5b4b29a..793b154 100644 --- a/lib/widgets/navigation_scaffold/components/background_image.dart +++ b/lib/widgets/navigation_scaffold/components/background_image.dart @@ -31,7 +31,7 @@ class _BackgroundImageState extends ConsumerState { @override void didUpdateWidget(covariant BackgroundImage oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.items.length != widget.items.length || oldWidget.images.length != widget.images.length) { + if (!oldWidget.items.equals(widget.items)) { updateItems(); } } diff --git a/lib/widgets/navigation_scaffold/components/destination_model.dart b/lib/widgets/navigation_scaffold/components/destination_model.dart index 28f53ca..f024ea5 100644 --- a/lib/widgets/navigation_scaffold/components/destination_model.dart +++ b/lib/widgets/navigation_scaffold/components/destination_model.dart @@ -81,10 +81,12 @@ class DestinationModel { ); } - NavigationButton toNavigationButton(bool selected, bool horizontal, bool expanded, {Widget? customIcon}) { + NavigationButton toNavigationButton(bool selected, bool horizontal, bool expanded, + {bool navFocusNode = false, Widget? customIcon}) { return NavigationButton( label: label, selected: selected, + navFocusNode: navFocusNode, onPressed: action, horizontal: horizontal, expanded: expanded, diff --git a/lib/widgets/navigation_scaffold/components/floating_player_bar.dart b/lib/widgets/navigation_scaffold/components/floating_player_bar.dart index a473529..fc05304 100644 --- a/lib/widgets/navigation_scaffold/components/floating_player_bar.dart +++ b/lib/widgets/navigation_scaffold/components/floating_player_bar.dart @@ -25,6 +25,7 @@ double floatingPlayerHeight(BuildContext context) => switch (AdaptiveLayout.view ViewSize.phone => 75, ViewSize.tablet => 85, ViewSize.desktop => 95, + ViewSize.television => 105, }; class FloatingPlayerBar extends ConsumerStatefulWidget { diff --git a/lib/widgets/navigation_scaffold/components/navigation_body.dart b/lib/widgets/navigation_scaffold/components/navigation_body.dart index bdc6f78..a5d1a99 100644 --- a/lib/widgets/navigation_scaffold/components/navigation_body.dart +++ b/lib/widgets/navigation_scaffold/components/navigation_body.dart @@ -64,25 +64,28 @@ class _NavigationBodyState extends ConsumerState { child: widget.child, ); - return switch (AdaptiveLayout.layoutOf(context)) { - ViewSize.phone => paddedChild(), - ViewSize.tablet => hasOverlay - ? SideNavigationBar( - currentIndex: widget.currentIndex, - destinations: widget.destinations, - currentLocation: widget.currentLocation, - child: paddedChild(), - scaffoldKey: widget.drawerKey, - ) - : paddedChild(), - ViewSize.desktop => SideNavigationBar( - currentIndex: widget.currentIndex, - destinations: widget.destinations, - currentLocation: widget.currentLocation, - child: paddedChild(), - scaffoldKey: widget.drawerKey, - ) - }; + return FocusTraversalGroup( + policy: GlobalFallbackTraversalPolicy(fallbackNode: navBarNode), + child: switch (AdaptiveLayout.layoutOf(context)) { + ViewSize.phone => paddedChild(), + ViewSize.tablet => hasOverlay + ? SideNavigationBar( + currentIndex: widget.currentIndex, + destinations: widget.destinations, + currentLocation: widget.currentLocation, + child: paddedChild(), + scaffoldKey: widget.drawerKey, + ) + : paddedChild(), + ViewSize.desktop || ViewSize.television => SideNavigationBar( + currentIndex: widget.currentIndex, + destinations: widget.destinations, + currentLocation: widget.currentLocation, + child: paddedChild(), + scaffoldKey: widget.drawerKey, + ) + }, + ); } MediaQueryData semiNestedPadding(BuildContext context, bool hasOverlay) { @@ -92,3 +95,28 @@ class _NavigationBodyState extends ConsumerState { ); } } + +FocusNode? lastMainFocus; + +class GlobalFallbackTraversalPolicy extends ReadingOrderTraversalPolicy { + final FocusNode fallbackNode; + + GlobalFallbackTraversalPolicy({required this.fallbackNode}) : super(); + + @override + bool inDirection(FocusNode currentNode, TraversalDirection direction) { + lastMainFocus = null; + final handled = super.inDirection(currentNode, direction); + if (!handled && direction == TraversalDirection.left) { + lastMainFocus = currentNode; + + if (fallbackNode.canRequestFocus && fallbackNode.context?.mounted == true) { + final cb = FocusTraversalPolicy.defaultTraversalRequestFocusCallback; + cb(fallbackNode); + return true; + } + } + + return handled; + } +} diff --git a/lib/widgets/navigation_scaffold/components/navigation_button.dart b/lib/widgets/navigation_scaffold/components/navigation_button.dart index 95b83b4..92c83a3 100644 --- a/lib/widgets/navigation_scaffold/components/navigation_button.dart +++ b/lib/widgets/navigation_scaffold/components/navigation_button.dart @@ -3,12 +3,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/side_navigation_bar.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; class NavigationButton extends ConsumerStatefulWidget { final String? label; final Widget selectedIcon; final Widget icon; + final bool navFocusNode; final bool horizontal; final bool expanded; final Function()? onPressed; @@ -21,6 +23,7 @@ class NavigationButton extends ConsumerStatefulWidget { required this.label, required this.selectedIcon, required this.icon, + this.navFocusNode = false, this.horizontal = false, this.expanded = false, this.onPressed, @@ -48,6 +51,7 @@ class _NavigationButtonState extends ConsumerState { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: ElevatedButton( + focusNode: widget.navFocusNode ? navBarNode : null, onHover: (value) => setState(() => showPopupButton = value), style: ButtonStyle( elevation: const WidgetStatePropertyAll(0), @@ -64,95 +68,97 @@ class _NavigationButtonState extends ConsumerState { })), onPressed: widget.onPressed, onLongPress: widget.onLongPress, - child: widget.horizontal - ? Padding( - padding: widget.customIcon != null - ? EdgeInsetsGeometry.zero - : const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - child: SizedBox( - height: widget.customIcon != null ? 60 : 35, - child: Row( - spacing: 4, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 250), - height: widget.selected ? 16 : 0, - margin: const EdgeInsets.only(top: 1.5), - width: 6, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: widget.selected && !widget.expanded ? 1 : 0), - ), - ), - widget.customIcon ?? - AnimatedSwitcher( - duration: widget.duration, - child: widget.selected ? widget.selectedIcon : widget.icon, - ), - const SizedBox(width: 6), - if (widget.horizontal && widget.expanded) ...[ - if (widget.label != null) - Expanded( - child: ConstrainedBox( - constraints: const BoxConstraints(minWidth: 80), - child: Text( - widget.label!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - if (widget.trailing.isNotEmpty) - AnimatedOpacity( - duration: const Duration(milliseconds: 125), - opacity: showPopupButton ? 1 : 0, - child: PopupMenuButton( - tooltip: context.localized.options, - iconColor: foreGroundColor, - iconSize: 18, - itemBuilder: (context) => widget.trailing.popupMenuItems(useIcons: true), - ), - ) - ], - ], - ), - ), - ) - : Padding( - padding: widget.customIcon != null ? EdgeInsetsGeometry.zero : const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - spacing: 8, + child: ExcludeFocusTraversal( + child: widget.horizontal + ? Padding( + padding: widget.customIcon != null + ? EdgeInsetsGeometry.zero + : const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + child: SizedBox( + height: widget.customIcon != null ? 60 : 35, + child: Row( + spacing: 4, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 250), + height: widget.selected ? 16 : 0, + margin: const EdgeInsets.only(top: 1.5), + width: 6, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: widget.selected && !widget.expanded ? 1 : 0), + ), + ), widget.customIcon ?? AnimatedSwitcher( duration: widget.duration, child: widget.selected ? widget.selectedIcon : widget.icon, ), - if (widget.label != null && widget.horizontal && widget.expanded) - Flexible(child: Text(widget.label!)) + const SizedBox(width: 6), + if (widget.horizontal && widget.expanded) ...[ + if (widget.label != null) + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 80), + child: Text( + widget.label!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (widget.trailing.isNotEmpty) + AnimatedOpacity( + duration: const Duration(milliseconds: 125), + opacity: showPopupButton ? 1 : 0, + child: PopupMenuButton( + tooltip: context.localized.options, + iconColor: foreGroundColor, + iconSize: 18, + itemBuilder: (context) => widget.trailing.popupMenuItems(useIcons: true), + ), + ) + ], ], ), - AnimatedContainer( - duration: const Duration(milliseconds: 250), - margin: EdgeInsets.only(top: widget.selected ? 4 : 0), - height: widget.selected ? 6 : 0, - width: widget.selected ? 14 : 0, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context).colorScheme.primary.withValues(alpha: widget.selected ? 1 : 0), + ), + ) + : Padding( + padding: widget.customIcon != null ? EdgeInsetsGeometry.zero : const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + spacing: 8, + children: [ + widget.customIcon ?? + AnimatedSwitcher( + duration: widget.duration, + child: widget.selected ? widget.selectedIcon : widget.icon, + ), + if (widget.label != null && widget.horizontal && widget.expanded) + Flexible(child: Text(widget.label!)) + ], ), - ), - ], + AnimatedContainer( + duration: const Duration(milliseconds: 250), + margin: EdgeInsets.only(top: widget.selected ? 4 : 0), + height: widget.selected ? 6 : 0, + width: widget.selected ? 14 : 0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.primary.withValues(alpha: widget.selected ? 1 : 0), + ), + ), + ], + ), ), - ), + ), ), ); } diff --git a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart index b277c70..94ae4dd 100644 --- a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart +++ b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart @@ -20,12 +20,15 @@ import 'package:fladder/util/fladder_image.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart'; import 'package:fladder/widgets/navigation_scaffold/components/navigation_button.dart'; import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart'; import 'package:fladder/widgets/shared/custom_tooltip.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; +final navBarNode = FocusNode(); + class SideNavigationBar extends ConsumerStatefulWidget { final int currentIndex; final List destinations; @@ -53,7 +56,7 @@ class _SideNavigationBarState extends ConsumerState { final views = ref.watch(viewsProvider.select((value) => value.views)); final usePostersForLibrary = ref.watch(clientSettingsProvider.select((value) => value.usePosterForLibrary)); - final expandedWidth = 250.0; + final expandedWidth = 200.0; final padding = MediaQuery.paddingOf(context); final collapsedWidth = 90 + padding.left; @@ -64,6 +67,9 @@ class _SideNavigationBarState extends ConsumerState { final fullScreenChildRoute = fullScreenRoutes.contains(context.router.current.name); + final hasOverlay = AdaptiveLayout.layoutModeOf(context) == LayoutMode.dual || + homeRoutes.any((element) => element.name.contains(context.router.current.name)); + return Stack( children: [ AdaptiveLayoutBuilder( @@ -73,15 +79,16 @@ class _SideNavigationBarState extends ConsumerState { ), child: (context) => widget.child, ), - IgnorePointer( - ignoring: fullScreenChildRoute, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 250), - opacity: !fullScreenChildRoute ? 1 : 0, - child: Container( - color: Theme.of(context).colorScheme.surface.withValues(alpha: shouldExpand ? 0.95 : 0.85), - width: shouldExpand ? expandedWidth : collapsedWidth, - child: MouseRegion( + FocusTraversalGroup( + policy: _RailTraversalPolicy(), + child: IgnorePointer( + ignoring: !hasOverlay || fullScreenChildRoute, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: !fullScreenChildRoute ? 1 : 0, + child: Container( + color: Theme.of(context).colorScheme.surface.withValues(alpha: shouldExpand ? 0.95 : 0.85), + width: shouldExpand ? expandedWidth : collapsedWidth, child: Padding( key: const Key('navigation_rail'), padding: padding.copyWith(right: 0, top: isDesktop ? padding.top : null), @@ -111,9 +118,12 @@ class _SideNavigationBarState extends ConsumerState { ), ), if (largeBar) ...[ - AnimatedFadeSize( - duration: const Duration(milliseconds: 250), - child: shouldExpand ? actionButton(context).extended : actionButton(context).normal, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4).copyWith(bottom: expandedSideBar ? 10 : 0), + child: AnimatedFadeSize( + duration: const Duration(milliseconds: 250), + child: shouldExpand ? actionButton(context).extended : actionButton(context).normal, + ), ), ], Expanded( @@ -137,6 +147,7 @@ class _SideNavigationBarState extends ConsumerState { child: destination.toNavigationButton( widget.currentIndex == index, true, + navFocusNode: index == 0, shouldExpand, ), ), @@ -204,6 +215,7 @@ class _SideNavigationBarState extends ConsumerState { : view.collectionType.iconOutlined, ), ), + decodeHeight: 64, ), ), ) @@ -273,6 +285,7 @@ class _SideNavigationBarState extends ConsumerState { e.collectionType.iconOutlined, ), ), + decodeHeight: 64, ), ), ), @@ -299,7 +312,7 @@ class _SideNavigationBarState extends ConsumerState { selectedIcon: const Icon(IconsaxPlusBold.setting_3), horizontal: true, expanded: shouldExpand, - icon: const SettingsUserIcon(), + icon: const ExcludeFocusTraversal(child: SettingsUserIcon()), onPressed: () { if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) { context.router.push(const SettingsRoute()); @@ -332,3 +345,64 @@ class _SideNavigationBarState extends ConsumerState { ); } } + +class _RailTraversalPolicy extends ReadingOrderTraversalPolicy { + _RailTraversalPolicy(); + + @override + bool inDirection(FocusNode currentNode, TraversalDirection direction) { + if (direction == TraversalDirection.left) { + return false; + } + if (direction == TraversalDirection.right) { + if (lastMainFocus != null && _isLaidOut(lastMainFocus!)) { + lastMainFocus!.requestFocus(); + return true; + } else { + return super.inDirection(currentNode, direction); + } + } + if (direction == TraversalDirection.up || direction == TraversalDirection.down) { + final scope = currentNode.enclosingScope; + if (scope == null) { + return false; + } + + final candidates = scope.traversalDescendants + .where((n) => n.canRequestFocus && FocusTraversalGroup.maybeOfNode(n) == this && _isLaidOut(n)) + .toList(); + + if (candidates.isEmpty) return false; + + final sorted = sortDescendants(candidates, currentNode).toList(); + + var index = sorted.indexOf(currentNode); + if (index == -1) { + index = direction == TraversalDirection.down ? -1 : sorted.length; + } + + final nextIndex = direction == TraversalDirection.down ? index + 1 : index - 1; + + if (nextIndex < 0 || nextIndex >= sorted.length) { + return true; + } + + requestFocusCallback(sorted[nextIndex]); + return true; + } + return super.inDirection(currentNode, direction); + } +} + +bool _isLaidOut(FocusNode node) { + final ro = node.context?.findRenderObject(); + return ro is RenderBox && ro.hasSize; +} + +bool isNodeInCurrentRoute(FocusNode node) { + if (!node.canRequestFocus) return false; + if (node.context == null) return false; + + final nearestScope = FocusScope.of(node.context!); + return nearestScope.hasFocus || nearestScope.isFirstFocus; +} diff --git a/lib/widgets/shared/ensure_visible.dart b/lib/widgets/shared/ensure_visible.dart new file mode 100644 index 0000000..9ce7c56 --- /dev/null +++ b/lib/widgets/shared/ensure_visible.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +extension EnsureVisibleHelper on BuildContext { + Future ensureVisible({ + Duration duration = const Duration(milliseconds: 300), + double? alignment, + Curve curve = Curves.fastOutSlowIn, + }) { + return Scrollable.ensureVisible( + this, + duration: duration, + alignment: alignment ?? 0.5, + curve: curve, + ); + } +} diff --git a/lib/widgets/shared/enum_selection.dart b/lib/widgets/shared/enum_selection.dart index d661159..101754f 100644 --- a/lib/widgets/shared/enum_selection.dart +++ b/lib/widgets/shared/enum_selection.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/focus_provider.dart'; +import 'package:fladder/widgets/shared/ensure_visible.dart'; +import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; class EnumBox extends StatelessWidget { final String current; - final List> Function(BuildContext context) itemBuilder; + final List Function(BuildContext context) itemBuilder; const EnumBox({required this.current, required this.itemBuilder, super.key}); @@ -15,7 +17,7 @@ class EnumBox extends StatelessWidget { final textStyle = Theme.of(context).textTheme.titleMedium; const padding = EdgeInsets.symmetric(horizontal: 12, vertical: 6); final itemList = itemBuilder(context); - final useBottomSheet = AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone; + final useBottomSheet = AdaptiveLayout.inputDeviceOf(context) != InputDevice.pointer; final labelWidget = Padding( padding: padding, @@ -46,29 +48,39 @@ class EnumBox extends StatelessWidget { ), ); return Card( - color: Theme.of(context).colorScheme.primaryContainer, + color: itemList.length > 1 ? Theme.of(context).colorScheme.primaryContainer : Colors.transparent, shadowColor: Colors.transparent, elevation: 0, child: useBottomSheet - ? FlatButton( + ? FocusButton( child: labelWidget, - onTap: () => showBottomSheetPill( - context: context, - content: (context, scrollController) => ListView( - shrinkWrap: true, - controller: scrollController, - children: [ - const SizedBox(height: 6), - ...itemBuilder(context), - ], - ), - ), + darkOverlay: false, + onFocusChanged: (value) { + if (value) { + context.ensureVisible(); + } + }, + onTap: itemList.length > 1 + ? () => showBottomSheetPill( + context: context, + content: (context, scrollController) => ListView( + shrinkWrap: true, + controller: scrollController, + children: [ + const SizedBox(height: 6), + ...itemList.map( + (e) => e.toListItem(context), + ), + ], + ), + ) + : null, ) : PopupMenuButton( tooltip: '', shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), enabled: itemList.length > 1, - itemBuilder: itemBuilder, + itemBuilder: (context) => itemList.map((e) => e.toPopupMenuItem()).toList(), padding: padding, child: labelWidget, ), @@ -79,7 +91,7 @@ class EnumBox extends StatelessWidget { class EnumSelection extends StatelessWidget { final Text label; final String current; - final List> Function(BuildContext context) itemBuilder; + final List Function(BuildContext context) itemBuilder; const EnumSelection({ super.key, required this.label, diff --git a/lib/widgets/shared/grid_focus_traveler.dart b/lib/widgets/shared/grid_focus_traveler.dart new file mode 100644 index 0000000..4fbd2ec --- /dev/null +++ b/lib/widgets/shared/grid_focus_traveler.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fladder/util/focus_provider.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/side_navigation_bar.dart'; + +class GridFocusTraveler extends ConsumerStatefulWidget { + final int currentIndex; + final int itemCount; + final int crossAxisCount; + final Function(BuildContext context, int selectedIndex, int index) itemBuilder; + final SliverGridDelegate gridDelegate; + + const GridFocusTraveler({ + this.currentIndex = 0, + required this.itemCount, + required this.crossAxisCount, + required this.itemBuilder, + required this.gridDelegate, + super.key, + }); + + @override + ConsumerState createState() => _GridFocusTravelerState(); +} + +class _GridFocusTravelerState extends ConsumerState { + late int selectedIndex = widget.currentIndex; + + late final List _focusNodes; + + @override + void initState() { + super.initState(); + _focusNodes = List.generate(widget.itemCount, (index) => FocusNode()); + _focusNodes.mapIndexed( + (index, element) { + element.addListener(() { + if (element.hasFocus) { + setState(() { + selectedIndex = index; + }); + } + }); + }, + ); + + if (!FocusProvider.autoFocusOf(context)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNodes.firstOrNull?.requestFocus(); + }); + } + } + + @override + void didUpdateWidget(GridFocusTraveler oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.itemCount != oldWidget.itemCount) { + for (var node in _focusNodes) { + node.dispose(); + } + _focusNodes = List.generate(widget.itemCount, (index) => FocusNode()); + if (selectedIndex >= widget.itemCount) { + selectedIndex = widget.itemCount - 1; + if (selectedIndex >= 0) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNodes[selectedIndex].requestFocus(); + }); + } + } + } + } + + @override + void dispose() { + for (var node in _focusNodes) { + node.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FocusTraversalGroup( + policy: GridFocusTravelerPolicy( + navBarNode: navBarNode, + nodes: _focusNodes, + crossAxisCount: widget.crossAxisCount, + onChanged: (value) { + selectedIndex = value; + _focusNodes[value].requestFocus(); + }, + ), + child: SliverGrid.builder( + gridDelegate: widget.gridDelegate, + itemCount: widget.itemCount, + itemBuilder: (context, index) { + return FocusProvider( + focusNode: _focusNodes[index], + child: Builder( + builder: (context) => widget.itemBuilder(context, selectedIndex, index), + ), + ); + }, + ), + ); + } +} + +class GridFocusTravelerPolicy extends ReadingOrderTraversalPolicy { + /// The complete list of FocusNodes for the grid. + final List nodes; + + /// The number of items in each row. + final int crossAxisCount; + + /// Callback to notify the parent which node index should be focused next. + final Function(int value) onChanged; + + /// The navigation bar node to focus when navigating left from the first column. + final FocusNode navBarNode; + + GridFocusTravelerPolicy({ + required this.nodes, + required this.crossAxisCount, + required this.onChanged, + required this.navBarNode, + }); + + @override + bool inDirection(FocusNode currentNode, TraversalDirection direction) { + final int current = nodes.indexOf(currentNode); + if (current == -1) { + return super.inDirection(currentNode, direction); + } + + final int itemCount = nodes.length; + final int row = current ~/ crossAxisCount; + final int col = current % crossAxisCount; + final int rowCount = (itemCount / crossAxisCount).ceil(); + int? next; + + switch (direction) { + case TraversalDirection.left: + if (col > 0) { + next = current - 1; + } + break; + + case TraversalDirection.right: + if (col < crossAxisCount - 1 && current + 1 < itemCount) { + next = current + 1; + } + break; + + case TraversalDirection.up: + if (row > 0) { + next = current - crossAxisCount; + } + break; + + case TraversalDirection.down: + if (row < rowCount - 1) { + final int candidate = current + crossAxisCount; + if (candidate < itemCount) { + next = candidate; + } + } + break; + } + + if (next != null) { + onChanged(next); + return true; + } + + if (direction == TraversalDirection.left && col == 0) { + navBarNode.requestFocus(); + return true; + } + + return super.inDirection(currentNode, direction); + } +} diff --git a/lib/widgets/shared/horizontal_list.dart b/lib/widgets/shared/horizontal_list.dart index 2661749..3a49d74 100644 --- a/lib/widgets/shared/horizontal_list.dart +++ b/lib/widgets/shared/horizontal_list.dart @@ -1,4 +1,4 @@ -import 'dart:math'; +import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -7,26 +7,32 @@ import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; -import 'package:fladder/util/disable_keypad_focus.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/sticky_header_text.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/side_navigation_bar.dart'; +import 'package:fladder/widgets/shared/ensure_visible.dart'; class HorizontalList extends ConsumerStatefulWidget { + final bool autoFocus; final String? label; final List titleActions; final Function()? onLabelClick; final String? subtext; final List items; final int? startIndex; - final Widget Function(BuildContext context, int index) itemBuilder; + final Widget Function(BuildContext context, int index, int selected) itemBuilder; + final Function(int index)? onFocused; final bool scrollToEnd; final EdgeInsets contentPadding; final double? dominantRatio; final double? height; final bool shrinkWrap; const HorizontalList({ + this.autoFocus = false, required this.items, required this.itemBuilder, + this.onFocused, this.startIndex, this.height, this.label, @@ -45,44 +51,100 @@ class HorizontalList extends ConsumerStatefulWidget { } class _HorizontalListState extends ConsumerState { + final FocusNode parentNode = FocusNode(); + late int currentIndex = 0; final GlobalKey _firstItemKey = GlobalKey(); final ScrollController _scrollController = ScrollController(); final contentPadding = 8.0; double? contentWidth; double? _firstItemWidth; + bool hasFocus = false; + + late List _focusNodes; @override void initState() { super.initState(); - _measureFirstItem(scrollTo: true); + _initFocusNodes(); + _measureFirstItem(); } - @override - void dispose() { - super.dispose(); - } - - void _measureFirstItem({bool scrollTo = false}) { + void _measureFirstItem() { + if (_firstItemWidth != null) return; WidgetsBinding.instance.addPostFrameCallback((_) { - if (widget.startIndex != null) { - final context = _firstItemKey.currentContext; - if (context != null) { - final box = context.findRenderObject() as RenderBox; - _firstItemWidth = box.size.width; - if (scrollTo) { - _scrollToPosition(widget.startIndex!); - } - } + final context = _firstItemKey.currentContext; + if (context != null) { + final box = context.findRenderObject() as RenderBox; + _firstItemWidth = box.size.width; + _scrollToPosition(widget.startIndex ?? 0); } }); } - void _scrollToPosition(int index) { - final offset = index * _firstItemWidth! + index * contentPadding; - _scrollController.animateTo( - offset, + void _initFocusNodes() { + _focusNodes = List.generate(widget.items.length, (i) { + final node = FocusNode(); + node.addListener(() { + if (node.hasFocus) { + _scrollToPosition(i); + if (widget.onFocused != null) { + widget.onFocused?.call(i); + } else { + context.ensureVisible(); + } + } + }); + return node; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.autoFocus) { + _focusNodes[currentIndex].requestFocus(); + context.ensureVisible(); + } + }); + } + + @override + void dispose() { + for (var node in _focusNodes) { + node.dispose(); + } + parentNode.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(HorizontalList oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.items.length != oldWidget.items.length) { + for (var node in _focusNodes) { + node.dispose(); + } + _initFocusNodes(); + + if (currentIndex >= widget.items.length) { + currentIndex = widget.items.isEmpty ? 0 : widget.items.length - 1; + } + + if (widget.items.isNotEmpty && parentNode.hasFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNodes[currentIndex].requestFocus(); + }); + } + } + } + + Future _scrollToPosition(int index) async { + if (_firstItemWidth == null) return; + + final offset = index * (_firstItemWidth! + contentPadding); + final clamped = math.min(offset, _scrollController.position.maxScrollExtent); + + await _scrollController.animateTo( + clamped, duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, + curve: Curves.fastOutSlowIn, ); } @@ -90,34 +152,35 @@ class _HorizontalListState extends ConsumerState { _scrollController.animateTo( 0, duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, + curve: Curves.fastOutSlowIn, ); } - void _scrollToEnd() { + Future _scrollToEnd() async { + final offset = (_firstItemWidth ?? 200) * widget.items.length + 200; _scrollController.animateTo( - (_firstItemWidth ?? 200) * widget.items.length + 200, + math.min(offset, _scrollController.position.maxScrollExtent), duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, + curve: Curves.fastOutSlowIn, ); } - int getFirstVisibleIndex() { - if (widget.startIndex == null) return 0; - if (!_scrollController.hasClients || _firstItemWidth == null) return 0; - return (_scrollController.offset / _firstItemWidth!).floor().clamp(0, widget.items.length - 1); - } - @override Widget build(BuildContext context) { final hasPointer = AdaptiveLayout.of(context).inputDevice == InputDevice.pointer; - final content = Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DisableFocus( - child: Padding( + return Focus( + focusNode: parentNode, + onFocusChange: (value) { + if (value) { + _focusNodes[currentIndex].requestFocus(); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( padding: widget.contentPadding, child: Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -128,18 +191,22 @@ class _HorizontalListState extends ConsumerState { children: [ if (widget.label != null) Flexible( - child: StickyHeaderText( - label: widget.label ?? "", - onClick: widget.onLabelClick, + child: ExcludeFocus( + child: StickyHeaderText( + label: widget.label ?? "", + onClick: widget.onLabelClick, + ), ), ), if (widget.subtext != null) Flexible( - child: Opacity( - opacity: 0.5, - child: Text( - widget.subtext!, - style: Theme.of(context).textTheme.titleMedium, + child: ExcludeFocus( + child: Opacity( + opacity: 0.5, + child: Text( + widget.subtext!, + style: Theme.of(context).textTheme.titleMedium, + ), ), ), ), @@ -148,89 +215,135 @@ class _HorizontalListState extends ConsumerState { ), ), if (widget.items.length > 1) - Card( - elevation: 5, - color: Theme.of(context).colorScheme.surface, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (hasPointer) - GestureDetector( - onLongPress: () => _scrollToStart(), - child: IconButton( + ExcludeFocus( + child: Card( + elevation: 5, + color: Theme.of(context).colorScheme.surface, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (hasPointer) + GestureDetector( + onLongPress: () => _scrollToStart(), + child: IconButton( + onPressed: () { + _scrollController.animateTo( + _scrollController.offset + -(MediaQuery.of(context).size.width / 1.75), + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut); + }, + icon: const Icon( + IconsaxPlusLinear.arrow_left_1, + size: 20, + )), + ), + if (widget.startIndex != null) + IconButton( + tooltip: "Scroll to current", onPressed: () { - _scrollController.animateTo( - _scrollController.offset + -(MediaQuery.of(context).size.width / 1.75), - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut); - }, - icon: const Icon( - IconsaxPlusLinear.arrow_left_1, - size: 20, - )), - ), - if (widget.startIndex != null) - IconButton( - tooltip: "Scroll to current", - onPressed: () { - if (_firstItemWidth != null && widget.startIndex != null) { _scrollToPosition(widget.startIndex!); - } - }, - icon: const Icon( - Icons.circle, - size: 16, - )), - if (hasPointer) - GestureDetector( - onLongPress: () => _scrollToEnd(), - child: IconButton( - onPressed: () { - _scrollController.animateTo( - _scrollController.offset + (MediaQuery.of(context).size.width / 1.75), - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut); }, icon: const Icon( - IconsaxPlusLinear.arrow_right_3, - size: 20, + Icons.circle, + size: 16, )), - ), - ], + if (hasPointer) + GestureDetector( + onLongPress: () => _scrollToEnd(), + child: IconButton( + onPressed: () { + _scrollController.animateTo( + _scrollController.offset + (MediaQuery.of(context).size.width / 1.75), + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut); + }, + icon: const Icon( + IconsaxPlusLinear.arrow_right_3, + size: 20, + )), + ), + ], + ), ), ), ].addPadding(const EdgeInsets.symmetric(horizontal: 6)), ), ), - ), - const SizedBox(height: 8), - SizedBox( - height: widget.height ?? - ((AdaptiveLayout.poster(context).size * - ref.watch(clientSettingsProvider.select((value) => value.posterSize))) / - pow((widget.dominantRatio ?? 1.0), 0.55)) * - 0.72, - child: ListView.separated( - controller: _scrollController, - scrollDirection: Axis.horizontal, - padding: widget.contentPadding, - itemBuilder: (context, index) => index == getFirstVisibleIndex() - ? Container( - key: _firstItemKey, - child: widget.itemBuilder(context, index), - ) - : widget.itemBuilder(context, index), - separatorBuilder: (context, index) => SizedBox(width: contentPadding), - itemCount: widget.items.length, + const SizedBox(height: 8), + SizedBox( + height: widget.height ?? + ((AdaptiveLayout.poster(context).size * + ref.watch(clientSettingsProvider.select((value) => value.posterSize))) / + math.pow((widget.dominantRatio ?? 1.0), 0.55)) * + 0.72, + child: FocusTraversalGroup( + policy: HorizontalRailFocus( + parentNode: parentNode, + nodes: _focusNodes, + onChanged: (value) { + currentIndex = value; + _focusNodes[value].requestFocus(); + }), + child: ExcludeFocusTraversal( + child: ListView.separated( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: widget.contentPadding, + itemBuilder: (context, index) { + return FocusProvider( + focusNode: _focusNodes[index], + hasFocus: hasFocus && index == currentIndex, + key: index == 0 ? _firstItemKey : null, + child: widget.itemBuilder(context, index, hasFocus ? currentIndex : -1), + ); + }, + separatorBuilder: (context, index) => SizedBox(width: contentPadding), + itemCount: widget.items.length, + ), + ), + ), ), - ), - ], + ], + ), ); - return widget.startIndex == null - ? content - : LayoutBuilder(builder: (context, constraints) { - _measureFirstItem(); - return content; - }); + } +} + +class HorizontalRailFocus extends WidgetOrderTraversalPolicy { + final FocusNode parentNode; + final List nodes; + final Function(int value) onChanged; + HorizontalRailFocus({ + required this.parentNode, + required this.nodes, + required this.onChanged, + }); + + @override + bool inDirection(FocusNode currentNode, TraversalDirection direction) { + // Find the index of the currently focused node + final int current = nodes.indexWhere((node) => node.hasFocus); + // If nothing is focused, default to 0 + final int currentIndex = current == -1 ? 0 : current; + + if (direction == TraversalDirection.left) { + if (currentIndex <= 0) { + navBarNode.requestFocus(); + return true; + } else { + onChanged(math.max(currentIndex - 1, 0)); + return true; + } + } else if (direction == TraversalDirection.right) { + if (currentIndex >= nodes.length - 1) { + // Corrected boundary check + return super.inDirection(parentNode, direction); + } else { + onChanged(math.min(currentIndex + 1, nodes.length - 1)); + return true; + } + } + parentNode.requestFocus(); + return super.inDirection(parentNode, direction); } } diff --git a/lib/widgets/shared/item_actions.dart b/lib/widgets/shared/item_actions.dart index c267f4c..a1da600 100644 --- a/lib/widgets/shared/item_actions.dart +++ b/lib/widgets/shared/item_actions.dart @@ -33,21 +33,25 @@ class ItemActionDivider extends ItemAction { } class ItemActionButton extends ItemAction { + final bool selected; final Widget? icon; final Widget? label; final FutureOr Function()? action; ItemActionButton({ + this.selected = false, this.icon, this.label, this.action, }); ItemActionButton copyWith({ + bool? selected, Widget? icon, Widget? label, Future Function()? action, }) { return ItemActionButton( + selected: selected ?? this.selected, icon: icon ?? this.icon, label: label ?? this.label, action: action ?? this.action, @@ -93,14 +97,19 @@ class ItemActionButton extends ItemAction { @override Widget toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true}) { + final foregroundColor = + selected ? Theme.of(context).colorScheme.onPrimaryContainer : Theme.of(context).colorScheme.onSurface; return ElevatedButton( + autofocus: selected, style: ButtonStyle( - backgroundColor: const WidgetStatePropertyAll(Colors.transparent), + backgroundColor: WidgetStatePropertyAll( + selected ? Theme.of(context).colorScheme.primaryContainer : Colors.transparent, + ), padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 12)), minimumSize: const WidgetStatePropertyAll(Size(50, 50)), elevation: const WidgetStatePropertyAll(0), - foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onSurface), - iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onSurface), + foregroundColor: WidgetStatePropertyAll(foregroundColor), + iconColor: WidgetStatePropertyAll(foregroundColor), ), onPressed: () { if (shouldPop) { @@ -113,7 +122,7 @@ class ItemActionButton extends ItemAction { builder: (context) { return Theme( data: ThemeData( - iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface), + iconTheme: IconThemeData(color: foregroundColor), ), child: Row( children: [ @@ -134,10 +143,13 @@ extension ItemActionExtension on List { List popupMenuItems({bool useIcons = false}) => map((e) => e.toPopupMenuItem(useIcons: useIcons)) .whereNotIndexed((index, element) => (index == 0 && element is PopupMenuDivider)) .toList(); + List menuItemButtonItems() => map((e) => e.toMenuItemButton()).whereNotIndexed((index, element) => (index == 0 && element is Divider)).toList(); - List listTileItems(BuildContext context, {bool useIcons = false, bool shouldPop = true}) => - map((e) => e.toListItem(context, useIcons: useIcons, shouldPop: shouldPop)) - .whereNotIndexed((index, element) => (index == 0 && element is Divider)) - .toList(); + + List listTileItems(BuildContext context, {bool useIcons = false, bool shouldPop = true}) { + return map((e) => e.toListItem(context, useIcons: useIcons, shouldPop: shouldPop)) + .whereNotIndexed((index, element) => (index == 0 && element is Divider)) + .toList(); + } } diff --git a/lib/widgets/shared/selectable_icon_button.dart b/lib/widgets/shared/selectable_icon_button.dart index 09635e7..8e3bdba 100644 --- a/lib/widgets/shared/selectable_icon_button.dart +++ b/lib/widgets/shared/selectable_icon_button.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart'; import 'package:fladder/util/refresh_state.dart'; +import 'package:fladder/widgets/shared/ensure_visible.dart'; class SelectableIconButton extends ConsumerStatefulWidget { final FutureOr Function() onPressed; @@ -33,6 +34,7 @@ class SelectableIconButton extends ConsumerStatefulWidget { class _SelectableIconButtonState extends ConsumerState { bool loading = false; + bool focused = false; @override Widget build(BuildContext context) { const duration = Duration(milliseconds: 250); @@ -51,6 +53,16 @@ class _SelectableIconButtonState extends ConsumerState { widget.iconColor ?? (widget.selected ? Theme.of(context).colorScheme.onPrimary : null)), padding: const WidgetStatePropertyAll(EdgeInsets.zero), ), + onFocusChange: (value) { + setState(() { + focused = value; + }); + if (value) { + context.ensureVisible( + alignment: 1.0, + ); + } + }, onPressed: loading ? null : () async { diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 53faf19..603e11e 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -14,17 +14,20 @@ import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; +import 'package:fladder/providers/arguments_provider.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/src/video_player_helper.g.dart' hide PlaybackState; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/wrappers/players/base_player.dart'; import 'package:fladder/wrappers/players/lib_mdk.dart' if (dart.library.html) 'package:fladder/stubs/web/lib_mdk_web.dart'; import 'package:fladder/wrappers/players/lib_mpv.dart'; +import 'package:fladder/wrappers/players/native_player.dart'; import 'package:fladder/wrappers/players/player_states.dart'; -class MediaControlsWrapper extends BaseAudioHandler { +class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerControlsCallback { MediaControlsWrapper({required this.ref}); BasePlayer? _player; @@ -49,11 +52,12 @@ class MediaControlsWrapper extends BaseAudioHandler { List subscriptions = []; SMTCWindows? smtc; - bool initMediaControls = false; + bool initializedWrapper = false; Future init() async { - if (!initMediaControls) { - initMediaControls = true; + if (!initializedWrapper) { + initializedWrapper = true; + VideoPlayerControlsCallback.setUp(this); await AudioService.init( builder: () => this, config: const AudioServiceConfig( @@ -69,10 +73,13 @@ class MediaControlsWrapper extends BaseAudioHandler { ); } - final player = switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) { - PlayerOptions.libMDK => LibMDK(), - PlayerOptions.libMPV => LibMPV(), - }; + final player = ref.read(argumentsStateProvider).leanBackMode + ? NativePlayer() + : switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) { + PlayerOptions.libMDK => LibMDK(), + PlayerOptions.libMPV => LibMPV(), + PlayerOptions.nativePlayer => NativePlayer(), + }; setup(player); } @@ -93,7 +100,15 @@ class MediaControlsWrapper extends BaseAudioHandler { _subscribePlayer(); } - Future open(String url, bool play) async => _player?.open(url, play); + Future loadVideo(PlaybackModel model, Duration startPosition, bool play) async { + if (_player is NativePlayer) { + final context = ref.read(localizationContextProvider); + await (_player as NativePlayer).sendPlaybackDataToNative(context, model, startPosition); + } + return _player?.loadVideo(model.media?.url ?? "", play); + } + + Future openPlayer(BuildContext context) async => _player?.open(context); void _subscribePlayer() { if (Platform.isWindows && !kIsWeb) { @@ -313,4 +328,46 @@ class MediaControlsWrapper extends BaseAudioHandler { _player?.setSpeed(speed); return super.setSpeed(speed); } + + //Native player calls + // + // + @override + void loadNextVideo() async { + final nextVideo = ref.read(playBackModel.select((value) => value?.nextVideo)); + final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering)); + if (nextVideo != null && !buffering) ref.read(playbackModelHelper).loadNewVideo(nextVideo); + } + + @override + void loadPreviousVideo() async { + final previousVideo = ref.read(playBackModel.select((value) => value?.previousVideo)); + final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering)); + if (previousVideo != null && !buffering) ref.read(playbackModelHelper).loadNewVideo(previousVideo); + } + + @override + void onStop() => stop(); + + @override + void swapAudioTrack(int value) async { + final playbackModel = ref.read(playBackModel); + final newModel = await playbackModel?.setAudio( + playbackModel.audioStreams?.firstWhere((element) => element.index == value), this); + ref.read(playBackModel.notifier).update((state) => newModel); + if (newModel != null) { + await ref.read(playbackModelHelper).shouldReload(newModel); + } + } + + @override + void swapSubtitleTrack(int value) async { + final playbackModel = ref.read(playBackModel); + final newModel = await playbackModel?.setSubtitle( + playbackModel.subStreams?.firstWhere((element) => element.index == value), this); + ref.read(playBackModel.notifier).update((state) => newModel); + if (newModel != null) { + await ref.read(playbackModelHelper).shouldReload(newModel); + } + } } diff --git a/lib/wrappers/players/base_player.dart b/lib/wrappers/players/base_player.dart index bb975e7..5ee7862 100644 --- a/lib/wrappers/players/base_player.dart +++ b/lib/wrappers/players/base_player.dart @@ -23,7 +23,8 @@ abstract class BasePlayer { GlobalKey? controlsKey, }); Future dispose(); - Future open(String url, bool play); + Future open(BuildContext context); + Future loadVideo(String url, bool play); Future seek(Duration position); Future play(); Future setVolume(double volume); diff --git a/lib/wrappers/players/lib_mdk.dart b/lib/wrappers/players/lib_mdk.dart index 920af32..8ee1b8a 100644 --- a/lib/wrappers/players/lib_mdk.dart +++ b/lib/wrappers/players/lib_mdk.dart @@ -10,6 +10,7 @@ import 'package:video_player/video_player.dart'; import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; +import 'package:fladder/screens/video_player/video_player.dart' as video_screen; import 'package:fladder/wrappers/players/base_player.dart'; import 'package:fladder/wrappers/players/player_states.dart'; @@ -40,7 +41,7 @@ class LibMDK extends BasePlayer { } @override - Future open(String url, bool play) async { + Future loadVideo(String url, bool play) async { if (_controller != null) { _controller?.dispose(); } @@ -95,6 +96,13 @@ class LibMDK extends BasePlayer { }); } + @override + Future open(BuildContext context) async => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (context) => const video_screen.VideoPlayer(), + ), + ); + @override Future pause() async => _controller?.pause(); @override diff --git a/lib/wrappers/players/lib_mpv.dart b/lib/wrappers/players/lib_mpv.dart index c18f4e5..d02ce4c 100644 --- a/lib/wrappers/players/lib_mpv.dart +++ b/lib/wrappers/players/lib_mpv.dart @@ -13,6 +13,7 @@ import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/settings/subtitle_settings_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/settings/subtitle_settings_provider.dart'; +import 'package:fladder/screens/video_player/video_player.dart' as video_screen; import 'package:fladder/util/subtitle_position_calculator.dart'; import 'package:fladder/wrappers/players/base_player.dart'; import 'package:fladder/wrappers/players/player_states.dart'; @@ -82,11 +83,18 @@ class LibMPV extends BasePlayer { } @override - Future open(String url, bool play) async { + Future loadVideo(String url, bool play) async { await _player?.open(mpv.Media(url), play: play); return setState(lastState.update(buffering: true)); } + @override + Future open(BuildContext context) async => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (context) => const video_screen.VideoPlayer(), + ), + ); + List get subTracks => _player?.state.tracks.subtitle ?? []; mpv.SubtitleTrack get subtitleTrack => _player?.state.track.subtitle ?? mpv.SubtitleTrack.no(); diff --git a/lib/wrappers/players/native_player.dart b/lib/wrappers/players/native_player.dart new file mode 100644 index 0000000..8016374 --- /dev/null +++ b/lib/wrappers/players/native_player.dart @@ -0,0 +1,167 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:fladder/models/items/media_streams_model.dart'; +import 'package:fladder/models/playback/playback_model.dart'; +import 'package:fladder/models/settings/video_player_settings.dart'; +import 'package:fladder/src/video_player_helper.g.dart'; +import 'package:fladder/wrappers/players/base_player.dart'; +import 'package:fladder/wrappers/players/player_states.dart'; + +class NativePlayer extends BasePlayer implements VideoPlayerListenerCallback { + final player = VideoPlayerApi(); + + @override + Future dispose() async { + return NativeVideoActivity().disposeActivity(); + } + + @override + Future init(VideoPlayerSettingsModel settings) async => VideoPlayerListenerCallback.setUp(this); + + @override + Future loop(bool loop) { + return player.setLooping(loop); + } + + @override + Future loadVideo(String url, bool play) async => player.open(url, play); + + @override + Future open(BuildContext newContext) async => NativeVideoActivity().launchActivity(); + + @override + Future pause() { + return player.pause(); + } + + @override + Future play() => player.play(); + + @override + Future playOrPause() async { + return; + } + + @override + Future seek(Duration position) { + return player.seekTo(position.inMilliseconds); + } + + @override + Future setAudioTrack(AudioStreamModel? model, PlaybackModel playbackModel) async { + return 0; + } + + @override + Future setSpeed(double speed) async {} + + @override + Future setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async { + return 0; + } + + @override + Future setVolume(double volume) async { + return player.setVolume(volume); + } + + @override + Future stop() async { + return player.stop(); + } + + @override + Widget? subtitles(bool showOverlay, {GlobalKey>? controlsKey}) => null; + + @override + Widget? videoWidget(Key key, BoxFit fit) => null; + + @override + void onPlaybackStateChanged(PlaybackState state) { + lastState = lastState.update( + playing: state.playing, + position: Duration(milliseconds: state.position), + buffer: Duration(milliseconds: state.buffered), + buffering: state.buffering, + ); + _stateController.add(lastState); + } + + final StreamController _stateController = StreamController.broadcast(); + + @override + Stream get stateStream => _stateController.stream; + + Future sendPlaybackDataToNative( + BuildContext? context, + PlaybackModel model, + Duration startPosition, + ) async { + final playableData = PlayableData( + id: model.item.id, + title: model.item.title, + subTitle: context != null ? model.item.label(context) : "", + logoUrl: model.item.getPosters?.logo?.path, + startPosition: startPosition.inMilliseconds, + description: model.item.overview.summary, + defaultAudioTrack: model.mediaStreams?.defaultAudioStreamIndex ?? 1, + nextVideo: model.nextVideo?.name, + previousVideo: model.previousVideo?.name, + audioTracks: model.audioStreams + ?.map( + (audio) => AudioTrack( + name: audio.displayTitle, + languageCode: audio.language, + codec: audio.codec, + index: audio.index, + external: false, + ), + ) + .toList() ?? + [], + defaultSubtrack: model.mediaStreams?.defaultSubStreamIndex ?? 1, + subtitleTracks: model.subStreams + ?.map( + (sub) => SubtitleTrack( + name: sub.displayTitle, + languageCode: sub.language, + codec: sub.codec, + index: sub.index, + external: sub.isExternal, + url: sub.url, + ), + ) + .toList() ?? + [], + segments: model.mediaSegments?.segments + .map( + (e) => MediaSegment( + type: MediaSegmentType.values.firstWhere((element) => element.name == e.type.name), + name: context != null ? e.type.label(context) : e.type.name, + start: e.start.inMilliseconds, + end: e.end.inMilliseconds, + ), + ) + .toList() ?? + [], + trickPlayModel: model.trickPlay != null + ? TrickPlayModel( + width: model.trickPlay!.width, + height: model.trickPlay!.height, + tileWidth: model.trickPlay!.tileWidth, + tileHeight: model.trickPlay!.tileHeight, + thumbnailCount: model.trickPlay!.thumbnailCount, + interval: model.trickPlay!.interval.inMilliseconds, + images: model.trickPlay?.images ?? []) + : null, + chapters: model.chapters + ?.map((e) => Chapter(name: e.name, url: e.imageUrl, time: e.startPosition.inMilliseconds)) + .toList() ?? + [], + url: model.media?.url ?? "", + ); + player.sendPlayableModel(playableData); + } +} diff --git a/pigeons/player_settings_pigeon.dart b/pigeons/player_settings_pigeon.dart new file mode 100644 index 0000000..13a3d04 --- /dev/null +++ b/pigeons/player_settings_pigeon.dart @@ -0,0 +1,43 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/src/player_settings_helper.g.dart', + dartOptions: DartOptions(), + kotlinOut: 'android/app/src/main/kotlin/nl/jknaapen/fladder/api/PlayerSettingsHelper.g.kt', + kotlinOptions: KotlinOptions( + includeErrorClass: false, + ), + dartPackageName: 'nl_jknaapen_fladder.settings', + ), +) +class PlayerSettings { + final Map skipTypes; + final int skipForward; + final int skipBackward; + + const PlayerSettings({ + required this.skipTypes, + required this.skipForward, + required this.skipBackward, + }); +} + +enum SegmentType { + commercial, + preview, + recap, + intro, + outro, +} + +enum SegmentSkip { + ask, + skip, + none, +} + +@HostApi() +abstract class PlayerSettingsPigeon { + void sendPlayerSettings(PlayerSettings playerSettings); +} diff --git a/pigeons/video_player.dart b/pigeons/video_player.dart new file mode 100644 index 0000000..042dfa2 --- /dev/null +++ b/pigeons/video_player.dart @@ -0,0 +1,215 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/src/video_player_helper.g.dart', + dartOptions: DartOptions(), + kotlinOut: 'android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt', + kotlinOptions: KotlinOptions(), + dartPackageName: 'nl_jknaapen_fladder.video', + ), +) +class PlayableData { + final String id; + final String title; + final String? subTitle; + final String? logoUrl; + final String description; + final int startPosition; + final int defaultAudioTrack; + final List audioTracks; + final int defaultSubtrack; + final List subtitleTracks; + final TrickPlayModel? trickPlayModel; + final List chapters; + final List segments; + final String? previousVideo; + final String? nextVideo; + final String url; + + PlayableData({ + required this.id, + required this.title, + this.subTitle, + this.logoUrl, + required this.description, + required this.startPosition, + required this.defaultAudioTrack, + this.audioTracks = const [], + required this.defaultSubtrack, + this.subtitleTracks = const [], + this.trickPlayModel, + this.chapters = const [], + this.segments = const [], + this.previousVideo, + this.nextVideo, + required this.url, + }); +} + +enum MediaSegmentType { + commercial, + preview, + recap, + intro, + outro, +} + +class MediaSegment { + final MediaSegmentType type; + final String name; + final int start; + final int end; + + const MediaSegment({ + required this.type, + required this.name, + required this.start, + required this.end, + }); +} + +class AudioTrack { + final String name; + final String languageCode; + final String codec; + final int index; + final bool external; + final String? url; + + const AudioTrack({ + required this.name, + required this.languageCode, + required this.codec, + required this.index, + required this.external, + required this.url, + }); +} + +class SubtitleTrack { + final String name; + final String languageCode; + final String codec; + final int index; + final bool external; + final String? url; + + const SubtitleTrack({ + required this.name, + required this.languageCode, + required this.codec, + required this.index, + required this.external, + required this.url, + }); +} + +class Chapter { + final String name; + final String url; + // Duration in milliseconds + final int time; + + const Chapter({ + required this.name, + required this.url, + required this.time, + }); +} + +class TrickPlayModel { + final int width; + final int height; + final int tileWidth; + final int tileHeight; + final int thumbnailCount; + //Duration in milliseconds + final int interval; + final List images; + + const TrickPlayModel({ + required this.width, + required this.height, + required this.tileWidth, + required this.tileHeight, + required this.thumbnailCount, + required this.interval, + this.images = const [], + }); +} + +class StartResult { + String? resultValue; +} + +@HostApi() +abstract class NativeVideoActivity { + @async + StartResult launchActivity(); + void disposeActivity(); + + bool isLeanBackEnabled(); +} + +@HostApi() +abstract class VideoPlayerApi { + bool sendPlayableModel(PlayableData playableData); + + void open(String url, bool play); + + void setLooping(bool looping); + + /// Sets the volume, with 0.0 being muted and 1.0 being full volume. + void setVolume(double volume); + + /// Sets the playback speed as a multiple of normal speed. + void setPlaybackSpeed(double speed); + + void play(); + + /// Pauses playback if the video is currently playing. + void pause(); + + /// Seeks to the given playback position, in milliseconds. + void seekTo(int position); + + void stop(); +} + +class PlaybackState { + //Milliseconds + final int position; + //Milliseconds + final int buffered; + //Milliseconds + final int duration; + final bool playing; + final bool buffering; + final bool completed; + final bool failed; + + const PlaybackState({ + required this.position, + required this.buffered, + required this.duration, + required this.playing, + required this.buffering, + required this.completed, + required this.failed, + }); +} + +@FlutterApi() +abstract class VideoPlayerListenerCallback { + void onPlaybackStateChanged(PlaybackState state); +} + +@FlutterApi() +abstract class VideoPlayerControlsCallback { + void loadNextVideo(); + void loadPreviousVideo(); + void onStop(); + void swapSubtitleTrack(int value); + void swapAudioTrack(int value); +} diff --git a/pubspec.lock b/pubspec.lock index 284ed1a..0114fb8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1445,6 +1445,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + pigeon: + dependency: "direct dev" + description: + name: pigeon + sha256: "2073deca15d22f1a0f5862cc6edc9e8660b3be7fd94f03b49101db71a5316b0f" + url: "https://pub.dev" + source: hosted + version: "26.0.1" platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e66ace5..94dc592 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -188,6 +188,7 @@ dev_dependencies: dart_mappable_builder: ^4.5.0 auto_route_generator: ^10.2.3 icons_launcher: ^3.0.2 + pigeon: ^26.0.1 flutter: # The following line ensures that the Material Icons font is