feat: Android TV support (#503)

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

2
.vscode/launch.json vendored
View file

@ -77,7 +77,6 @@
"name": "Web",
"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",

17
.vscode/tasks.json vendored
View file

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

2
android/.gitignore vendored
View file

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

View file

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

View file

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

View file

@ -1,7 +1,77 @@
package nl.jknaapen.fladder
import io.flutter.embedding.android.FlutterFragmentActivity
import com.ryanheise.audioservice.AudioServiceFragmentActivity;
import NativeVideoActivity
import PlayerSettingsPigeon
import StartResult
import VideoPlayerApi
import VideoPlayerControlsCallback
import VideoPlayerListenerCallback
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import com.ryanheise.audioservice.AudioServiceFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import nl.jknaapen.fladder.objects.PlayerSettingsObject
import nl.jknaapen.fladder.objects.VideoPlayerObject
import nl.jknaapen.fladder.utility.leanBackEnabled
class MainActivity: AudioServiceFragmentActivity () {
class MainActivity : AudioServiceFragmentActivity(), NativeVideoActivity {
private lateinit var videoPlayerLauncher: ActivityResultLauncher<Intent>
private var videoPlayerCallback: ((Result<StartResult>) -> Unit)? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val videoPlayerHost = VideoPlayerObject
NativeVideoActivity.setUp(
flutterEngine.dartExecutor.binaryMessenger,
this
)
VideoPlayerApi.setUp(
flutterEngine.dartExecutor.binaryMessenger,
videoPlayerHost.implementation
)
videoPlayerHost.videoPlayerListener =
VideoPlayerListenerCallback(flutterEngine.dartExecutor.binaryMessenger)
videoPlayerHost.videoPlayerControls =
VideoPlayerControlsCallback(flutterEngine.dartExecutor.binaryMessenger)
PlayerSettingsPigeon.setUp(
flutterEngine.dartExecutor.binaryMessenger,
api = PlayerSettingsObject
)
videoPlayerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val callback = videoPlayerCallback
videoPlayerCallback = null
val startResult = if (result.resultCode == RESULT_OK) {
StartResult(resultValue = result.data?.getStringExtra("result") ?: "Finished")
} else {
StartResult(resultValue = "Cancelled")
}
callback?.invoke(Result.success(startResult))
}
}
override fun launchActivity(callback: (Result<StartResult>) -> Unit) {
try {
videoPlayerCallback = callback
val intent = Intent(this, VideoPlayerActivity::class.java)
videoPlayerLauncher.launch(intent)
} catch (e: Exception) {
e.printStackTrace()
callback(Result.failure(e))
}
}
override fun disposeActivity() {
VideoPlayerObject.currentActivity?.finish()
}
override fun isLeanBackEnabled(): Boolean = leanBackEnabled(applicationContext)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 581 KiB

Before After
Before After

Binary file not shown.

View file

@ -679,6 +679,12 @@ class FakeJellyfinOpenApi extends JellyfinOpenApi {
),
);
}
@override
Future<chopper.Response<BrandingOptions>> brandingConfigurationGet() async => chopper.Response(
FakeHelper.fakeCorrectResponse,
const BrandingOptions(loginDisclaimer: "Test server"),
);
}
class FakeHelper {

View file

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

View file

@ -29,6 +29,7 @@ import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/routes/auto_router.dart';
import 'package:fladder/routes/auto_router.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<String> 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<Main> 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<Main> with WindowListener, WidgetsBinding
final language = ref.watch(clientSettingsProvider
.select((value) => value.selectedLocale ?? WidgetsBinding.instance.platformDispatcher.locale));
final scrollBehaviour = const MaterialScrollBehavior();
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
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<Main> 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<Main> with WindowListener, WidgetsBinding
routerConfig: autoRouter.config(),
),
);
}),
},
);
}
}

View file

@ -164,7 +164,7 @@ class ItemBaseModel with ItemBaseModelMappable {
}
}
Future<void> navigateTo(BuildContext context, {WidgetRef? ref}) async {
Future<void> navigateTo(BuildContext context, {WidgetRef? ref, Object? tag}) async {
switch (this) {
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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,26 +1,34 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/credentials_model.dart';
class LoginScreenModel {
final List<AccountModel> 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<AccountModel>? 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<AccountModel> accounts,
@Default(LoginScreenType.users) LoginScreenType screen,
ServerLoginModel? serverLoginModel,
String? errorMessage,
@Default(false) bool hasBaseUrl,
@Default(false) bool loading,
}) = _LoginScreenModel;
}
@Freezed(copyWith: true)
abstract class ServerLoginModel with _$ServerLoginModel {
factory ServerLoginModel({
required CredentialsModel tempCredentials,
@Default([]) List<AccountModel> accounts,
String? serverMessage,
@Default(false) bool hasQuickConnect,
}) = _ServerLoginModel;
}

View file

@ -0,0 +1,774 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'login_screen_model.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$LoginScreenModel {
List<AccountModel> get accounts;
LoginScreenType get screen;
ServerLoginModel? get serverLoginModel;
String? get errorMessage;
bool get hasBaseUrl;
bool get loading;
/// Create a copy of LoginScreenModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$LoginScreenModelCopyWith<LoginScreenModel> get copyWith =>
_$LoginScreenModelCopyWithImpl<LoginScreenModel>(
this as LoginScreenModel, _$identity);
@override
String toString() {
return 'LoginScreenModel(accounts: $accounts, screen: $screen, serverLoginModel: $serverLoginModel, errorMessage: $errorMessage, hasBaseUrl: $hasBaseUrl, loading: $loading)';
}
}
/// @nodoc
abstract mixin class $LoginScreenModelCopyWith<$Res> {
factory $LoginScreenModelCopyWith(
LoginScreenModel value, $Res Function(LoginScreenModel) _then) =
_$LoginScreenModelCopyWithImpl;
@useResult
$Res call(
{List<AccountModel> accounts,
LoginScreenType screen,
ServerLoginModel? serverLoginModel,
String? errorMessage,
bool hasBaseUrl,
bool loading});
$ServerLoginModelCopyWith<$Res>? get serverLoginModel;
}
/// @nodoc
class _$LoginScreenModelCopyWithImpl<$Res>
implements $LoginScreenModelCopyWith<$Res> {
_$LoginScreenModelCopyWithImpl(this._self, this._then);
final LoginScreenModel _self;
final $Res Function(LoginScreenModel) _then;
/// Create a copy of LoginScreenModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? accounts = null,
Object? screen = null,
Object? serverLoginModel = freezed,
Object? errorMessage = freezed,
Object? hasBaseUrl = null,
Object? loading = null,
}) {
return _then(_self.copyWith(
accounts: null == accounts
? _self.accounts
: accounts // ignore: cast_nullable_to_non_nullable
as List<AccountModel>,
screen: null == screen
? _self.screen
: screen // ignore: cast_nullable_to_non_nullable
as LoginScreenType,
serverLoginModel: freezed == serverLoginModel
? _self.serverLoginModel
: serverLoginModel // ignore: cast_nullable_to_non_nullable
as ServerLoginModel?,
errorMessage: freezed == errorMessage
? _self.errorMessage
: errorMessage // ignore: cast_nullable_to_non_nullable
as String?,
hasBaseUrl: null == hasBaseUrl
? _self.hasBaseUrl
: hasBaseUrl // ignore: cast_nullable_to_non_nullable
as bool,
loading: null == loading
? _self.loading
: loading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
/// Create a copy of LoginScreenModel
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ServerLoginModelCopyWith<$Res>? get serverLoginModel {
if (_self.serverLoginModel == null) {
return null;
}
return $ServerLoginModelCopyWith<$Res>(_self.serverLoginModel!, (value) {
return _then(_self.copyWith(serverLoginModel: value));
});
}
}
/// Adds pattern-matching-related methods to [LoginScreenModel].
extension LoginScreenModelPatterns on LoginScreenModel {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>(
TResult Function(_LoginScreenModel value)? $default, {
required TResult orElse(),
}) {
final _that = this;
switch (_that) {
case _LoginScreenModel() when $default != null:
return $default(_that);
case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs
TResult map<TResult extends Object?>(
TResult Function(_LoginScreenModel value) $default,
) {
final _that = this;
switch (_that) {
case _LoginScreenModel():
return $default(_that);
case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>(
TResult? Function(_LoginScreenModel value)? $default,
) {
final _that = this;
switch (_that) {
case _LoginScreenModel() when $default != null:
return $default(_that);
case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>(
TResult Function(
List<AccountModel> accounts,
LoginScreenType screen,
ServerLoginModel? serverLoginModel,
String? errorMessage,
bool hasBaseUrl,
bool loading)?
$default, {
required TResult orElse(),
}) {
final _that = this;
switch (_that) {
case _LoginScreenModel() when $default != null:
return $default(_that.accounts, _that.screen, _that.serverLoginModel,
_that.errorMessage, _that.hasBaseUrl, _that.loading);
case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs
TResult when<TResult extends Object?>(
TResult Function(
List<AccountModel> accounts,
LoginScreenType screen,
ServerLoginModel? serverLoginModel,
String? errorMessage,
bool hasBaseUrl,
bool loading)
$default,
) {
final _that = this;
switch (_that) {
case _LoginScreenModel():
return $default(_that.accounts, _that.screen, _that.serverLoginModel,
_that.errorMessage, _that.hasBaseUrl, _that.loading);
case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>(
TResult? Function(
List<AccountModel> accounts,
LoginScreenType screen,
ServerLoginModel? serverLoginModel,
String? errorMessage,
bool hasBaseUrl,
bool loading)?
$default,
) {
final _that = this;
switch (_that) {
case _LoginScreenModel() when $default != null:
return $default(_that.accounts, _that.screen, _that.serverLoginModel,
_that.errorMessage, _that.hasBaseUrl, _that.loading);
case _:
return null;
}
}
}
/// @nodoc
class _LoginScreenModel implements LoginScreenModel {
_LoginScreenModel(
{final List<AccountModel> accounts = const [],
this.screen = LoginScreenType.users,
this.serverLoginModel,
this.errorMessage,
this.hasBaseUrl = false,
this.loading = false})
: _accounts = accounts;
final List<AccountModel> _accounts;
@override
@JsonKey()
List<AccountModel> get accounts {
if (_accounts is EqualUnmodifiableListView) return _accounts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_accounts);
}
@override
@JsonKey()
final LoginScreenType screen;
@override
final ServerLoginModel? serverLoginModel;
@override
final String? errorMessage;
@override
@JsonKey()
final bool hasBaseUrl;
@override
@JsonKey()
final bool loading;
/// Create a copy of LoginScreenModel
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$LoginScreenModelCopyWith<_LoginScreenModel> get copyWith =>
__$LoginScreenModelCopyWithImpl<_LoginScreenModel>(this, _$identity);
@override
String toString() {
return 'LoginScreenModel(accounts: $accounts, screen: $screen, serverLoginModel: $serverLoginModel, errorMessage: $errorMessage, hasBaseUrl: $hasBaseUrl, loading: $loading)';
}
}
/// @nodoc
abstract mixin class _$LoginScreenModelCopyWith<$Res>
implements $LoginScreenModelCopyWith<$Res> {
factory _$LoginScreenModelCopyWith(
_LoginScreenModel value, $Res Function(_LoginScreenModel) _then) =
__$LoginScreenModelCopyWithImpl;
@override
@useResult
$Res call(
{List<AccountModel> accounts,
LoginScreenType screen,
ServerLoginModel? serverLoginModel,
String? errorMessage,
bool hasBaseUrl,
bool loading});
@override
$ServerLoginModelCopyWith<$Res>? get serverLoginModel;
}
/// @nodoc
class __$LoginScreenModelCopyWithImpl<$Res>
implements _$LoginScreenModelCopyWith<$Res> {
__$LoginScreenModelCopyWithImpl(this._self, this._then);
final _LoginScreenModel _self;
final $Res Function(_LoginScreenModel) _then;
/// Create a copy of LoginScreenModel
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$Res call({
Object? accounts = null,
Object? screen = null,
Object? serverLoginModel = freezed,
Object? errorMessage = freezed,
Object? hasBaseUrl = null,
Object? loading = null,
}) {
return _then(_LoginScreenModel(
accounts: null == accounts
? _self._accounts
: accounts // ignore: cast_nullable_to_non_nullable
as List<AccountModel>,
screen: null == screen
? _self.screen
: screen // ignore: cast_nullable_to_non_nullable
as LoginScreenType,
serverLoginModel: freezed == serverLoginModel
? _self.serverLoginModel
: serverLoginModel // ignore: cast_nullable_to_non_nullable
as ServerLoginModel?,
errorMessage: freezed == errorMessage
? _self.errorMessage
: errorMessage // ignore: cast_nullable_to_non_nullable
as String?,
hasBaseUrl: null == hasBaseUrl
? _self.hasBaseUrl
: hasBaseUrl // ignore: cast_nullable_to_non_nullable
as bool,
loading: null == loading
? _self.loading
: loading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
/// Create a copy of LoginScreenModel
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ServerLoginModelCopyWith<$Res>? get serverLoginModel {
if (_self.serverLoginModel == null) {
return null;
}
return $ServerLoginModelCopyWith<$Res>(_self.serverLoginModel!, (value) {
return _then(_self.copyWith(serverLoginModel: value));
});
}
}
/// @nodoc
mixin _$ServerLoginModel {
CredentialsModel get tempCredentials;
List<AccountModel> get accounts;
String? get serverMessage;
bool get hasQuickConnect;
/// Create a copy of ServerLoginModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ServerLoginModelCopyWith<ServerLoginModel> get copyWith =>
_$ServerLoginModelCopyWithImpl<ServerLoginModel>(
this as ServerLoginModel, _$identity);
@override
String toString() {
return 'ServerLoginModel(tempCredentials: $tempCredentials, accounts: $accounts, serverMessage: $serverMessage, hasQuickConnect: $hasQuickConnect)';
}
}
/// @nodoc
abstract mixin class $ServerLoginModelCopyWith<$Res> {
factory $ServerLoginModelCopyWith(
ServerLoginModel value, $Res Function(ServerLoginModel) _then) =
_$ServerLoginModelCopyWithImpl;
@useResult
$Res call(
{CredentialsModel tempCredentials,
List<AccountModel> accounts,
String? serverMessage,
bool hasQuickConnect});
}
/// @nodoc
class _$ServerLoginModelCopyWithImpl<$Res>
implements $ServerLoginModelCopyWith<$Res> {
_$ServerLoginModelCopyWithImpl(this._self, this._then);
final ServerLoginModel _self;
final $Res Function(ServerLoginModel) _then;
/// Create a copy of ServerLoginModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? tempCredentials = null,
Object? accounts = null,
Object? serverMessage = freezed,
Object? hasQuickConnect = null,
}) {
return _then(_self.copyWith(
tempCredentials: null == tempCredentials
? _self.tempCredentials
: tempCredentials // ignore: cast_nullable_to_non_nullable
as CredentialsModel,
accounts: null == accounts
? _self.accounts
: accounts // ignore: cast_nullable_to_non_nullable
as List<AccountModel>,
serverMessage: freezed == serverMessage
? _self.serverMessage
: serverMessage // ignore: cast_nullable_to_non_nullable
as String?,
hasQuickConnect: null == hasQuickConnect
? _self.hasQuickConnect
: hasQuickConnect // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [ServerLoginModel].
extension ServerLoginModelPatterns on ServerLoginModel {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>(
TResult Function(_ServerLoginModel value)? $default, {
required TResult orElse(),
}) {
final _that = this;
switch (_that) {
case _ServerLoginModel() when $default != null:
return $default(_that);
case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs
TResult map<TResult extends Object?>(
TResult Function(_ServerLoginModel value) $default,
) {
final _that = this;
switch (_that) {
case _ServerLoginModel():
return $default(_that);
case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>(
TResult? Function(_ServerLoginModel value)? $default,
) {
final _that = this;
switch (_that) {
case _ServerLoginModel() when $default != null:
return $default(_that);
case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>(
TResult Function(
CredentialsModel tempCredentials,
List<AccountModel> accounts,
String? serverMessage,
bool hasQuickConnect)?
$default, {
required TResult orElse(),
}) {
final _that = this;
switch (_that) {
case _ServerLoginModel() when $default != null:
return $default(_that.tempCredentials, _that.accounts,
_that.serverMessage, _that.hasQuickConnect);
case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs
TResult when<TResult extends Object?>(
TResult Function(
CredentialsModel tempCredentials,
List<AccountModel> accounts,
String? serverMessage,
bool hasQuickConnect)
$default,
) {
final _that = this;
switch (_that) {
case _ServerLoginModel():
return $default(_that.tempCredentials, _that.accounts,
_that.serverMessage, _that.hasQuickConnect);
case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>(
TResult? Function(
CredentialsModel tempCredentials,
List<AccountModel> accounts,
String? serverMessage,
bool hasQuickConnect)?
$default,
) {
final _that = this;
switch (_that) {
case _ServerLoginModel() when $default != null:
return $default(_that.tempCredentials, _that.accounts,
_that.serverMessage, _that.hasQuickConnect);
case _:
return null;
}
}
}
/// @nodoc
class _ServerLoginModel implements ServerLoginModel {
_ServerLoginModel(
{required this.tempCredentials,
final List<AccountModel> accounts = const [],
this.serverMessage,
this.hasQuickConnect = false})
: _accounts = accounts;
@override
final CredentialsModel tempCredentials;
final List<AccountModel> _accounts;
@override
@JsonKey()
List<AccountModel> get accounts {
if (_accounts is EqualUnmodifiableListView) return _accounts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_accounts);
}
@override
final String? serverMessage;
@override
@JsonKey()
final bool hasQuickConnect;
/// Create a copy of ServerLoginModel
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ServerLoginModelCopyWith<_ServerLoginModel> get copyWith =>
__$ServerLoginModelCopyWithImpl<_ServerLoginModel>(this, _$identity);
@override
String toString() {
return 'ServerLoginModel(tempCredentials: $tempCredentials, accounts: $accounts, serverMessage: $serverMessage, hasQuickConnect: $hasQuickConnect)';
}
}
/// @nodoc
abstract mixin class _$ServerLoginModelCopyWith<$Res>
implements $ServerLoginModelCopyWith<$Res> {
factory _$ServerLoginModelCopyWith(
_ServerLoginModel value, $Res Function(_ServerLoginModel) _then) =
__$ServerLoginModelCopyWithImpl;
@override
@useResult
$Res call(
{CredentialsModel tempCredentials,
List<AccountModel> accounts,
String? serverMessage,
bool hasQuickConnect});
}
/// @nodoc
class __$ServerLoginModelCopyWithImpl<$Res>
implements _$ServerLoginModelCopyWith<$Res> {
__$ServerLoginModelCopyWithImpl(this._self, this._then);
final _ServerLoginModel _self;
final $Res Function(_ServerLoginModel) _then;
/// Create a copy of ServerLoginModel
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$Res call({
Object? tempCredentials = null,
Object? accounts = null,
Object? serverMessage = freezed,
Object? hasQuickConnect = null,
}) {
return _then(_ServerLoginModel(
tempCredentials: null == tempCredentials
? _self.tempCredentials
: tempCredentials // ignore: cast_nullable_to_non_nullable
as CredentialsModel,
accounts: null == accounts
? _self._accounts
: accounts // ignore: cast_nullable_to_non_nullable
as List<AccountModel>,
serverMessage: freezed == serverMessage
? _self.serverMessage
: serverMessage // ignore: cast_nullable_to_non_nullable
as String?,
hasQuickConnect: null == hasQuickConnect
? _self.hasQuickConnect
: hasQuickConnect // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View file

@ -137,7 +137,7 @@ class PlaybackModelHelper {
oldModel: currentModel,
);
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);
}
}
}

View file

@ -8,12 +8,14 @@ abstract class ArgumentsModel with _$ArgumentsModel {
factory ArgumentsModel({
@Default(false) bool htpcMode,
@Default(false) bool leanBackMode,
}) = _ArgumentsModel;
factory ArgumentsModel.fromArguments(List<String> arguments) {
factory ArgumentsModel.fromArguments(List<String> arguments, bool leanBackEnabled) {
arguments = arguments.map((e) => e.trim()).toList();
return ArgumentsModel(
htpcMode: arguments.contains('--htpc'),
htpcMode: arguments.contains('--htpc') || leanBackEnabled,
leanBackMode: leanBackEnabled,
);
}
}

View file

@ -15,10 +15,11 @@ T _$identity<T>(T value) => value;
/// @nodoc
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 extends Object?>(
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 extends Object?>(
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 extends Object?>(
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)';
}
}

View file

@ -40,7 +40,8 @@ T selectAvailableOrSmaller<T>(T value, Set<T> availableOptions, List<T> 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'
};
}

View file

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

View file

@ -127,11 +127,17 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
enum PlayerOptions {
libMDK,
libMPV;
libMPV,
nativePlayer;
const PlayerOptions();
static Iterable<PlayerOptions> get available => kIsWeb ? {PlayerOptions.libMPV} : PlayerOptions.values;
static Iterable<PlayerOptions> get available => kIsWeb
? {PlayerOptions.libMPV}
: switch (defaultTargetPlatform) {
TargetPlatform.android => PlayerOptions.values,
_ => {PlayerOptions.libMDK, PlayerOptions.libMPV},
};
static PlayerOptions get platformDefaults {
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",
};
}

View file

@ -82,6 +82,7 @@ const _$BoxFitEnumMap = {
const _$PlayerOptionsEnumMap = {
PlayerOptions.libMDK: 'libMDK',
PlayerOptions.libMPV: 'libMPV',
PlayerOptions.nativePlayer: 'nativePlayer',
};
const _$DeviceOrientationEnumMap = {

View file

@ -37,10 +37,14 @@ class JellyRequest implements Interceptor {
FutureOr<Response<BodyType>> intercept<BodyType>(Chain<BodyType> 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<BodyType> response = await chain.proceed(
applyHeaders(

View file

@ -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<AuthNotifier, LoginScreenModel>((ref) {
return AuthNotifier(ref);
});
class AuthNotifier extends StateNotifier<LoginScreenModel> {
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<Response<List<AccountModel>>?> 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<void> initModel(BuildContext newContext) async {
context ??= newContext;
ref.read(userProvider.notifier).clear();
final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts();
ref.read(lockScreenActiveProvider.notifier).update((state) => true);
if (FladderConfig.baseUrl != null) {
final url = FladderConfig.baseUrl;
state = state.copyWith(
hasBaseUrl: true,
);
if (url != null) {
await setServer(url);
}
return response.copyWith(body: []);
}
state = state.copyWith(
accounts: currentAccounts,
screen: currentAccounts.isEmpty ? LoginScreenType.login : LoginScreenType.users,
);
}
Future<void> _fetchServerInfo(String url) async {
try {
final newCredentials = CredentialsModel.createNewCredentials().copyWith(server: url.rtrim('/'));
final newLoginModel = ServerLoginModel(tempCredentials: newCredentials);
state = state.copyWith(
serverLoginModel: newLoginModel,
loading: true,
);
final publicUsers = (await getPublicUsers())?.body ?? [];
final quickConnectStatus = (await api.quickConnectEnabled()).body ?? false;
final branding = await api.getBranding();
final serverResponse = await api.systemInfoPublicGet();
state = state.copyWith(
screen: quickConnectStatus ? LoginScreenType.code : LoginScreenType.login,
serverLoginModel: newLoginModel.copyWith(
tempCredentials: newCredentials.copyWith(
serverName: serverResponse.body?.serverName,
serverId: serverResponse.body?.id,
),
accounts: publicUsers,
hasQuickConnect: quickConnectStatus,
serverMessage: branding.body?.loginDisclaimer,
),
loading: false,
);
} catch (e) {
return null;
state = state.copyWith(
errorMessage: context?.localized.invalidUrl,
loading: false,
);
if (context != null) {
fladderSnackbar(context!, title: context!.localized.unableToConnectHost);
}
}
}
String? _parseUrl(String url) {
if (url.isEmpty) {
return null;
}
if (!Uri.parse(url).isAbsolute) {
return context?.localized.invalidUrl;
}
if (!url.startsWith('https://') && !url.startsWith('http://')) {
return context?.localized.invalidUrlDesc;
}
return null;
}
Future<Response<List<AccountModel>>?> getPublicUsers() async {
try {
state = state.copyWith(loading: true);
final credentials = state.serverLoginModel?.tempCredentials;
if (credentials == null) return null;
var response = await api.usersPublicGet(credentials);
if (response.isSuccessful && response.body != null) {
var models = response.body ?? [];
return response.copyWith(body: models.toList());
}
state = state.copyWith(
serverLoginModel: state.serverLoginModel?.copyWith(
accounts: response.body ?? [],
),
);
return response.copyWith(body: []);
} catch (e) {
return null;
} finally {
state = state.copyWith(loading: false);
}
}
Future<Response<AccountModel>?> authenticateUsingSecret(String secret) async {
clearAllProviders();
var response = await api.quickConnectAuthenticate(secret);
return _createAccountModel(response);
}
Future<Response<AccountModel>?> authenticateByName(String userName, String password) async {
state = state.copyWith(loading: true);
clearAllProviders();
var response = await api.usersAuthenticateByNamePost(userName: userName, password: password);
CredentialsModel credentials = state.tempCredentials;
return _createAccountModel(response);
}
Future<Response<AccountModel>?> _createAccountModel(Response<AuthenticationResult> response) async {
CredentialsModel? credentials = state.serverLoginModel?.tempCredentials;
if (credentials == null) return null;
if (response.isSuccessful && (response.body?.accessToken?.isNotEmpty ?? false)) {
var serverResponse = await api.systemInfoPublicGet();
credentials = credentials.copyWith(
@ -68,16 +160,21 @@ class AuthNotifier extends StateNotifier<LoginScreenModel> {
);
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<Response?> 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<LoginScreenModel> {
ref.read(libraryScreenProvider.notifier).clear();
}
void setServer(String server) {
Future<void> setServer(String server) async {
final url = (state.hasBaseUrl ? FladderConfig.baseUrl : server);
if (url == null || server.isEmpty) return;
final isUrlValid = _parseUrl(url);
state = state.copyWith(
tempCredentials: state.tempCredentials.copyWith(server: server),
errorMessage: isUrlValid,
serverLoginModel: null,
);
if (isUrlValid == null) {
await _fetchServerInfo(url);
}
}
List<AccountModel> getSavedAccounts() {
@ -113,4 +217,27 @@ class AuthNotifier extends StateNotifier<LoginScreenModel> {
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,
);
}
}
}

View file

@ -26,9 +26,16 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
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<HomeModel> {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
mediaTypes: [MediaType.video],
enableTotalRecordCount: false,
@ -48,7 +57,7 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
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<HomeModel> {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
mediaTypes: [MediaType.audio],
enableTotalRecordCount: false,
@ -68,7 +79,7 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
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<HomeModel> {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
mediaTypes: [MediaType.book],
enableTotalRecordCount: false,
@ -87,7 +100,6 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
}
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<HomeModel> {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
);

View file

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

View file

View file

@ -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<Response<bool>> quickConnectEnabled() async => api.quickConnectEnabledGet();
Future<Response<BrandingOptions>> getBranding() async => api.brandingConfigurationGet();
Future<Response<dynamic>> deleteItem(String itemId) => api.itemsItemIdDelete(itemId: itemId);
Future<UserConfiguration?> _updateUserConfiguration(UserConfiguration newUserConfiguration) async {
@ -1161,6 +1163,22 @@ class JellyService {
);
return _updateUserConfiguration(updated);
}
Future<Response<QuickConnectResult>> quickConnectInitiate() async {
return api.quickConnectInitiatePost();
}
Future<Response<QuickConnectResult>> quickConnectConnectGet({
String? secret,
}) async {
return api.quickConnectConnectGet(secret: secret);
}
Future<Response<AuthenticationResult>> quickConnectAuthenticate(String secret) async {
return api.usersAuthenticateWithQuickConnectPost(
body: QuickConnectDto(secret: secret),
);
}
}
extension ParsedMap on Map<String, dynamic> {

View file

@ -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<VideoPlayerSettingsProviderNotifier, VideoPlayerSettingsModel>((ref) {
@ -30,6 +33,30 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier<VideoPlayerSetti
if (!oldState.playerSame(value)) {
ref.read(videoPlayerProvider.notifier).init();
}
final userData = ref.read(userProvider);
pigeon.PlayerSettingsPigeon().sendPlayerSettings(
pigeon.PlayerSettings(
skipTypes: value.segmentSkipSettings.map(
(key, value) => MapEntry(
switch (key) {
MediaSegmentType.unknown => pigeon.SegmentType.intro,
MediaSegmentType.commercial => pigeon.SegmentType.commercial,
MediaSegmentType.preview => pigeon.SegmentType.preview,
MediaSegmentType.recap => pigeon.SegmentType.recap,
MediaSegmentType.outro => pigeon.SegmentType.outro,
MediaSegmentType.intro => pigeon.SegmentType.intro,
},
switch (value) {
SegmentSkip.none => pigeon.SegmentSkip.none,
SegmentSkip.askToSkip => pigeon.SegmentSkip.ask,
SegmentSkip.skip => pigeon.SegmentSkip.skip,
},
),
),
skipBackward: (userData?.userSettings?.skipBackDuration ?? const Duration(seconds: 15)).inMilliseconds,
skipForward: (userData?.userSettings?.skipForwardDuration ?? const Duration(seconds: 30)).inMilliseconds,
),
);
}
void setScreenBrightness(double? value) async {

View file

@ -55,10 +55,30 @@ class SharedUtility {
}
Future<bool?> addAccount(AccountModel account) async {
return await saveAccounts(getAccounts()
..add(account.copyWith(
lastUsed: DateTime.now(),
)));
final newAccount = account.copyWith(
lastUsed: DateTime.now(),
);
List<AccountModel> accounts = getAccounts().toList();
if (accounts.any((element) => element.sameIdentity(newAccount))) {
accounts = accounts
.map(
(e) => e.sameIdentity(newAccount)
? e.copyWith(
credentials: newAccount.credentials,
lastUsed: newAccount.lastUsed,
)
: e,
)
.toList();
} else {
accounts = [
...accounts,
newAccount,
];
}
return await saveAccounts(accounts);
}
Future<bool?> removeAccount(AccountModel? account) async {

View file

@ -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<MediaControlsWrapper> {
mediaState.update(
(state) => state.playing == event ? state : state.copyWith(playing: event),
);
final currentState = playbackState;
ref.read(playBackModel)?.updatePlaybackPosition(currentState.position, playbackState.playing, ref);
}
Future<void> updatePosition(Duration event) async {
@ -105,7 +109,7 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
}
}
Future<bool> loadPlaybackItem(PlaybackModel model, {Duration? startPosition}) async {
Future<bool> loadPlaybackItem(PlaybackModel model, Duration startPosition) async {
await state.stop();
mediaState
.update((state) => state.copyWith(state: VideoPlayerState.fullScreen, buffering: true, errorPlaying: false));
@ -114,13 +118,13 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
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<MediaControlsWrapper> {
mediaState.update((state) => state.copyWith(errorPlaying: true));
return false;
}
Future<void> openPlayer(BuildContext context) async => state.openPlayer(context);
}

View file

@ -65,6 +65,7 @@ class ViewsNotifier extends StateNotifier<ViewsModel> {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
],
);
return e.copyWith(recentlyAdded: recents.body?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList());

View file

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/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<AutoRoute> 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<AutoRoute> detailsRoutes = [
final List<AutoRoute> _defaultRoutes = [
AutoRoute(page: SplashRoute.page, path: '/splash'),
AutoRoute(page: LoginRoute.page, path: '/login'),
AutoRoute(page: LoginRoute.page, path: '/login', maintainState: false),
];
final List<AutoRoute> _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;
}
}

View file

@ -93,11 +93,12 @@ class DetailsRoute extends _i18.PageRouteInfo<DetailsRouteArgs> {
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<DetailsRouteArgs> {
final args = data.argsAs<DetailsRouteArgs>(
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

View file

@ -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<ConsumerStatefulWidget> createState() => _DetailsScreenState();
@ -66,7 +72,7 @@ class _DetailsScreenState extends ConsumerState<DetailsScreen> {
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),

View file

@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
import 'package:fladder/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<void> 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)),
))

View file

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

View file

@ -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<DashboardScreen> {
late final Timer _timer;
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
final textController = TextEditingController();
ItemBaseModel? selectedPoster;
@override
void initState() {
super.initState();
@ -70,6 +76,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
@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<DashboardScreen> {
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<DashboardScreen> {
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<DashboardScreen> {
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<DashboardScreen> {
...[
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(),
],
),

View file

@ -5,17 +5,26 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/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<ItemBaseModel> posters;
const HomeBannerWidget({required this.posters, super.key});
final Function(ItemBaseModel selected) onSelect;
const HomeBannerWidget({
required this.posters,
required this.onSelect,
super.key,
});
@override
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(),
};
}

View file

@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/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<T> extends StatelessWidget {
final Text label;
final String current;
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
final List<ItemAction> Function(BuildContext context) itemBuilder;
const _StreamOptionSelect({
required this.label,
required this.current,
@ -124,47 +114,14 @@ class _StreamOptionSelect<T> 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,
),
),
),

View file

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

View file

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

View file

@ -22,6 +22,8 @@ import 'package:fladder/util/item_base_model/play_item_helpers.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/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<MovieDetailScreen> {
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<MovieDetailScreen> {
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) {

View file

@ -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<SeriesDetailScreen> {
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<SeriesDetailScreen> {
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!,

View file

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

View file

@ -13,13 +13,13 @@ import 'package:fladder/providers/library_screen_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/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<ItemActionButton> 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,
)
],
);
},
);

View file

@ -352,49 +352,27 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
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: <PopupMenuEntry>[
PopupMenuItem(
child: Text(librarySearchResults.nestedCurrentItem?.type.label(context) ??
context.localized.library(0))),
itemCountWidget.toPopupMenuItem(useIcons: true),
refreshAction.toPopupMenuItem(useIcons: true),
itemViewAction.toPopupMenuItem(useIcons: true),
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

View file

@ -149,16 +149,19 @@ class _LibraryFilterChipsState extends ConsumerState<LibraryFilterChips> {
),
];
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(),
),
);
}

View file

@ -23,11 +23,14 @@ import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/shared/media/poster_list_item.dart';
import 'package:fladder/screens/shared/media/poster_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<LibraryViewTypes>((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<ItemBaseModel> items) {
return SliverGrid.builder(
final width = MediaQuery.of(context).size.width;
final cellWidth = (width / posterSize).floorToDouble();
final crossAxisCount = ((width / cellWidth).floor()).clamp(2, 10);
return GridFocusTraveler(
itemCount: items.length,
crossAxisCount: crossAxisCount,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
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),

View file

@ -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<SuggestionSearchBar> {
),
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<SuggestionSearchBar> {
isEmpty = value.isEmpty;
});
},
placeHolder: widget.title ?? "${context.localized.search}...",
decoration: InputDecoration(
hintText: widget.title ?? "${context.localized.search}...",
prefixIcon: const Icon(IconsaxPlusLinear.search_normal),

View file

@ -0,0 +1,140 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:async/async.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
Future<void> openLoginCodeDialog(
BuildContext context, {
required QuickConnectResult quickConnectInfo,
required Function(BuildContext context, String secret) onAuthenticated,
}) {
return showDialog(
context: context,
builder: (context) => LoginCodeDialog(
quickConnectInfo: quickConnectInfo,
onAuthenticated: onAuthenticated,
),
);
}
class LoginCodeDialog extends ConsumerStatefulWidget {
final QuickConnectResult quickConnectInfo;
final Function(BuildContext context, String secret) onAuthenticated;
const LoginCodeDialog({
required this.quickConnectInfo,
required this.onAuthenticated,
super.key,
});
@override
ConsumerState<LoginCodeDialog> createState() => _LoginCodeDialogState();
}
class _LoginCodeDialogState extends ConsumerState<LoginCodeDialog> {
late QuickConnectResult quickConnectInfo = widget.quickConnectInfo;
RestartableTimer? timer;
@override
void initState() {
super.initState();
createTimer();
}
@override
void dispose() {
timer?.cancel();
super.dispose();
}
void createTimer() {
timer?.cancel();
timer = RestartableTimer(const Duration(seconds: 1), () async {
final result = await ref.read(jellyApiProvider).quickConnectConnectGet(
secret: quickConnectInfo.secret,
);
final newSecret = result.body?.secret;
if (result.isSuccessful && result.body?.authenticated == true && newSecret != null) {
widget.onAuthenticated.call(context, newSecret);
} else {
timer?.reset();
}
});
}
@override
Widget build(BuildContext context) {
final code = quickConnectInfo.code;
final serverName = ref.watch(authProvider.select((value) => value.serverLoginModel?.tempCredentials.serverName));
return Dialog(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: [
Text(
serverName?.isNotEmpty == true
? "${context.localized.quickConnectTitle} - $serverName"
: context.localized.quickConnectTitle,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const Divider(),
ListView(
shrinkWrap: true,
children: [
if (code != null) ...[
Text(
context.localized.quickConnectEnterCodeDescription,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
IntrinsicWidth(
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
code,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
wordSpacing: 8,
letterSpacing: 8,
),
textAlign: TextAlign.center,
semanticsLabel: code,
),
),
),
),
],
FilledButton(
onPressed: () async {
final response = await ref.read(jellyApiProvider).quickConnectInitiate();
if (response.isSuccessful && response.body != null) {
setState(() {
quickConnectInfo = response.body!;
});
createTimer();
}
},
child: Text(
context.localized.refresh,
),
)
].addInBetween(const SizedBox(height: 16)),
)
],
),
),
);
}
}

View file

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package: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<LoginScreen> {
List<AccountModel> users = const [];
bool loading = false;
String? invalidUrl = "";
bool startCheckingForErrors = false;
bool addingNewUser = false;
bool editingUsers = false;
late final TextEditingController serverTextController = TextEditingController(text: '');
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<void> handleLogin(AccountModel user) async {
await ref.read(authProvider.notifier).switchUser();
await ref.read(sharedUtilityProvider).updateAccountInfo(user.copyWith(
lastUsed: DateTime.now(),
));
ref.read(userProvider.notifier).updateUser(user.copyWith(lastUsed: DateTime.now()));
loggedInGoToHome();
}
void loggedInGoToHome() {
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
if (context.mounted) {
context.router.replaceAll([const DashboardRoute()]);
}
}
Future<Null> Function()? get enterCredentialsTryLogin => emptyFields()
? null
: () async {
serverTextController.text = serverTextController.text.rtrim('/');
ref.read(authProvider.notifier).setServer(serverTextController.text.rtrim('/'));
final response = await ref.read(authProvider.notifier).authenticateByName(
usernameController.text,
passwordController.text,
);
if (response?.isSuccessful == false) {
fladderSnackbar(context,
title:
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
} else if (response?.body != null) {
loggedInGoToHome();
}
};
bool emptyFields() => usernameController.text.isEmpty;
void retrieveListOfUsers() async {
serverTextController.text = serverTextController.text.rtrim('/');
ref.read(authProvider.notifier).setServer(serverTextController.text);
setState(() => loading = true);
final response = await ref.read(authProvider.notifier).getPublicUsers();
if ((response == null || response.isSuccessful == false) && context.mounted) {
fladderSnackbar(context, title: response?.base.reasonPhrase ?? context.localized.unableToConnectHost);
setState(() => startCheckingForErrors = true);
}
if (response?.body?.isEmpty == true) {
await Future.delayed(const Duration(seconds: 1));
}
setState(() {
users = response?.body ?? [];
loading = false;
});
}
Widget addUserFields(List<AccountModel> accounts, bool authLoading) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (accounts.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8.0),
child: IconButton.filledTonal(
onPressed: () {
setState(() {
addingNewUser = false;
loading = false;
startCheckingForErrors = false;
serverTextController.text = "";
usernameController.text = "";
passwordController.text = "";
invalidUrl = "";
});
ref.read(authProvider.notifier).setServer("");
},
icon: const Icon(
IconsaxPlusLinear.arrow_left_2,
),
),
),
if (FladderConfig.baseUrl == null) ...[
Flexible(
child: OutlinedTextField(
controller: serverTextController,
onChanged: _parseUrl,
onSubmitted: (value) => retrieveListOfUsers(),
autoFillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
autocorrect: false,
textInputAction: TextInputAction.go,
label: context.localized.server,
errorText: (invalidUrl == null || serverTextController.text.isEmpty || !startCheckingForErrors)
? null
: invalidUrl,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Tooltip(
message: context.localized.retrievePublicListOfUsers,
waitDuration: const Duration(seconds: 1),
child: IconButton.filled(
onPressed: () => retrieveListOfUsers(),
icon: const Icon(
IconsaxPlusLinear.refresh,
),
),
),
),
],
],
),
AnimatedFadeSize(
child: invalidUrl == null
? Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (loading || users.isNotEmpty)
AnimatedFadeSize(
duration: const Duration(milliseconds: 250),
child: loading
? CircularProgressIndicator(key: UniqueKey(), strokeCap: StrokeCap.round)
: LoginUserGrid(
users: users,
onPressed: (value) {
usernameController.text = value.name;
passwordController.text = "";
focusNode.requestFocus();
setState(() {});
},
),
),
AutofillGroup(
child: Column(
children: [
OutlinedTextField(
controller: usernameController,
autoFillHints: const [AutofillHints.username],
textInputAction: TextInputAction.next,
autocorrect: false,
onChanged: (value) => setState(() {}),
label: context.localized.userName,
),
OutlinedTextField(
controller: passwordController,
autoFillHints: const [AutofillHints.password],
keyboardType: TextInputType.visiblePassword,
focusNode: focusNode,
autocorrect: false,
textInputAction: TextInputAction.send,
onSubmitted: (value) => enterCredentialsTryLogin?.call(),
onChanged: (value) => setState(() {}),
label: context.localized.password,
),
FilledButton(
onPressed: enterCredentialsTryLogin,
child: authLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.inversePrimary,
strokeCap: StrokeCap.round),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.localized.login),
const SizedBox(width: 8),
const Icon(IconsaxPlusBold.send_1),
],
),
),
].addPadding(const EdgeInsets.symmetric(vertical: 4)),
),
),
].addPadding(const EdgeInsets.symmetric(vertical: 10)),
)
: DiscoverServersWidget(
serverCredentials: accounts.map((e) => e.credentials).toList(),
onPressed: (server) {
serverTextController.text = server.address;
_parseUrl(server.address);
retrieveListOfUsers();
},
),
)
].addPadding(const EdgeInsets.symmetric(vertical: 8)),
);
}
}

View file

@ -0,0 +1,315 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/login/lock_screen.dart';
import 'package:fladder/screens/login/login_code_dialog.dart';
import 'package:fladder/screens/login/login_user_grid.dart';
import 'package:fladder/screens/login/widgets/discover_servers_widget.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/screens/shared/outlined_text_field.dart';
import 'package:fladder/screens/shared/passcode_input.dart';
import 'package:fladder/util/auth_service.dart';
import 'package:fladder/util/localization_helper.dart';
class LoginScreenCredentials extends ConsumerStatefulWidget {
const LoginScreenCredentials({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _LoginScreenCredentialsState();
}
class _LoginScreenCredentialsState extends ConsumerState<LoginScreenCredentials> {
late final TextEditingController serverTextController = TextEditingController(text: '');
final usernameController = TextEditingController();
final passwordController = TextEditingController();
final FocusNode focusNode = FocusNode();
bool loggingIn = false;
@override
Widget build(BuildContext context) {
final existingUsers = ref.watch(authProvider.select((value) => value.accounts));
final otherCredentials = existingUsers.map((e) => e.credentials).toList();
final serverCredentials = ref.watch(authProvider.select((value) => value.serverLoginModel));
final users = serverCredentials?.accounts ?? [];
final provider = ref.read(authProvider.notifier);
final loading = ref.watch(authProvider.select((value) => value.loading));
final hasBaseUrl = ref.watch(authProvider.select((value) => value.hasBaseUrl));
final urlError = ref.watch(authProvider.select((value) => value.errorMessage));
final hasQuickConnect = ref.watch(authProvider.select((value) => value.serverLoginModel?.hasQuickConnect ?? false));
ref.listen(
authProvider.select((value) => value.serverLoginModel),
(previous, next) {
if (next?.tempCredentials.server.isNotEmpty == true) {
serverTextController.text = next?.tempCredentials.server ?? "";
}
},
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 16,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 8,
children: [
if (existingUsers.isNotEmpty)
IconButton.filledTonal(
onPressed: () => provider.goUserSelect(),
iconSize: 28,
icon: const Icon(
IconsaxPlusLinear.arrow_left_2,
),
),
if (!hasBaseUrl)
Flexible(
child: OutlinedTextField(
controller: serverTextController,
onChanged: (value) => provider.tryParseUrl(value),
onSubmitted: (value) => provider.setServer(value),
autoFillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
autocorrect: false,
textInputAction: TextInputAction.go,
label: context.localized.server,
errorText: urlError,
),
),
Tooltip(
message: context.localized.retrievePublicListOfUsers,
waitDuration: const Duration(seconds: 1),
child: IconButton.filled(
onPressed: () => provider.setServer(serverTextController.text),
iconSize: 28,
icon: const Icon(
IconsaxPlusLinear.refresh,
),
),
),
],
),
if (serverCredentials == null)
DiscoverServersWidget(
serverCredentials: otherCredentials,
onPressed: (info) => provider.setServer(info.address),
)
else ...[
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 16,
children: [
if (loading || users.isNotEmpty)
AnimatedFadeSize(
duration: const Duration(milliseconds: 250),
child: loading
? CircularProgressIndicator(key: UniqueKey(), strokeCap: StrokeCap.round)
: LoginUserGrid(
users: users,
onPressed: (value) {
usernameController.text = value.name;
passwordController.text = "";
focusNode.requestFocus();
setState(() {});
},
),
),
AutofillGroup(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
Flexible(
child: OutlinedTextField(
controller: usernameController,
autoFillHints: const [AutofillHints.username],
textInputAction: TextInputAction.next,
autocorrect: false,
onChanged: (value) => setState(() {}),
label: context.localized.userName,
),
),
Flexible(
child: OutlinedTextField(
controller: passwordController,
autoFillHints: const [AutofillHints.password],
keyboardType: TextInputType.visiblePassword,
focusNode: focusNode,
autocorrect: false,
textInputAction: TextInputAction.send,
onSubmitted: (value) => enterCredentialsTryLogin?.call(),
onChanged: (value) => setState(() {}),
label: context.localized.password,
),
),
const Divider(
indent: 32,
endIndent: 32,
),
FilledButton(
onPressed: enterCredentialsTryLogin,
child: loggingIn
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.inversePrimary, strokeCap: StrokeCap.round),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.localized.login),
const SizedBox(width: 8),
const Icon(IconsaxPlusBold.send_1),
],
),
),
if (hasQuickConnect)
FilledButton(
onPressed: () async {
final result = await ref.read(jellyApiProvider).quickConnectInitiate();
if (result.body != null) {
await openLoginCodeDialog(
context,
quickConnectInfo: result.body!,
onAuthenticated: (context, secret) async {
context.pop();
if (secret.isNotEmpty) {
await loginUsingSecret(secret);
}
},
);
} else {
fladderSnackbar(context, title: context.localized.quickConnectPostFailed);
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.localized.quickConnectLoginUsingCode),
const SizedBox(width: 8),
const Icon(IconsaxPlusBold.scan_barcode),
],
),
),
],
),
),
if (serverCredentials.serverMessage?.isEmpty == false) ...[
const Divider(),
Text(
serverCredentials.serverMessage ?? "",
style: Theme.of(context).textTheme.titleLarge,
),
],
],
),
],
],
);
}
Future<void> Function()? get enterCredentialsTryLogin => emptyFields() ? null : () => loginUsingCredentials();
Future<void> loginUsingCredentials() async {
setState(() {
loggingIn = true;
});
final response = await ref.read(authProvider.notifier).authenticateByName(
usernameController.text,
passwordController.text,
);
if (response?.isSuccessful == false) {
fladderSnackbar(context,
title:
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
} else if (response?.body != null) {
loggedInGoToHome(context, ref);
}
setState(() {
loggingIn = false;
});
}
Future<void> loginUsingSecret(String secret) async {
setState(() {
loggingIn = true;
});
final response = await ref.read(authProvider.notifier).authenticateUsingSecret(secret);
if (response?.isSuccessful == false) {
fladderSnackbar(context,
title:
"(${response?.base.statusCode}) ${response?.base.reasonPhrase ?? context.localized.somethingWentWrongPasswordCheck}");
} else if (response?.body != null) {
loggedInGoToHome(context, ref);
}
setState(() {
loggingIn = false;
});
}
bool emptyFields() => usernameController.text.isEmpty;
}
void loggedInGoToHome(BuildContext context, WidgetRef ref) {
ref.read(lockScreenActiveProvider.notifier).update((state) => false);
if (context.mounted) {
context.router.replaceAll([const DashboardRoute()]);
}
}
Future<void> _handleLogin(BuildContext context, AccountModel user, WidgetRef ref) async {
await ref.read(authProvider.notifier).switchUser();
await ref.read(sharedUtilityProvider).updateAccountInfo(user.copyWith(
lastUsed: DateTime.now(),
));
ref.read(userProvider.notifier).updateUser(user.copyWith(lastUsed: DateTime.now()));
loggedInGoToHome(context, ref);
}
void tapLoggedInAccount(BuildContext context, AccountModel user, WidgetRef ref) async {
Future<void> loginFunction() => _handleLogin(context, user, ref);
switch (user.authMethod) {
case Authentication.autoLogin:
loginFunction();
break;
case Authentication.biometrics:
final authenticated = await AuthService.authenticateUser(context, user);
if (authenticated) {
loginFunction();
}
break;
case Authentication.passcode:
if (context.mounted) {
showPassCodeDialog(context, (newPin) {
if (newPin == user.localPin) {
loginFunction();
} else {
fladderSnackbar(context, title: context.localized.incorrectPinTryAgain);
}
});
}
break;
case Authentication.none:
loginFunction();
break;
}
}

View file

@ -6,9 +6,9 @@ import 'package:reorderable_grid/reorderable_grid.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/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,
);
},
),
);
}

View file

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

View file

@ -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<String, dynamic> fields;
@ -63,14 +66,14 @@ class _EditGeneralState extends ConsumerState<EditFields> {
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<EditFields> {
.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<EditFields> {
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<EditFields> {
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)),
),

View file

@ -9,6 +9,7 @@ import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
Future<void> showRefreshPopup(BuildContext context, String itemId, String itemName) async {
return showDialog(
@ -69,10 +70,9 @@ class _RefreshPopupDialogState extends ConsumerState<RefreshPopupDialog> {
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;
}),
))

View file

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

Some files were not shown because too many files have changed in this diff Show more