Переглянути джерело

detail improvements

detail improvements

detail improvements

detail improvements

detail improvements

detail improvements

detail improvements

detail improvements

detail improvements
Milan Jurkulak 3 роки тому
батько
коміт
46469c4309
40 змінених файлів з 1131 додано та 212 видалено
  1. 8 4
      app/src/main/AndroidManifest.xml
  2. 18 0
      app/src/main/kotlin/org/mjdev/tvapp/data/local/Movie.kt
  3. 24 100
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/DetailScreen.kt
  4. 35 8
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/IPTVScreen.kt
  5. 62 0
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/IPTVViewModel.kt
  6. BIN
      app/src/main/res/drawable/placeholder.png
  7. 2 0
      tvlib/build.gradle.kts
  8. 11 7
      tvlib/src/main/AndroidManifest.xml
  9. 2 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ComposeExt.kt
  10. 5 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/GlobalExt.kt
  11. 30 2
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ModifierExt.kt
  12. 1 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/StringExt.kt
  13. 2 4
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/brush/BrushBuilder.kt
  14. 31 11
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/AudioItem.kt
  15. 47 2
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/CachingCursor.kt
  16. 38 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/CachingCursorListIterator.kt
  17. 24 3
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/PhotoItem.kt
  18. 31 11
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/VideoItem.kt
  19. 37 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/other/Observed.kt
  20. 13 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/interfaces/ItemWithDate.kt
  21. 7 6
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/button/Button.kt
  22. 6 3
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/card/ItemCard.kt
  23. 6 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/card/PhotoCard.kt
  24. 0 3
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/carousel/CarouselCard.kt
  25. 13 3
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/complex/FocusableCard.kt
  26. 217 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/Gallery.kt
  27. 127 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/ItemInfo.kt
  28. 2 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/FadingPhotoImage.kt
  29. 30 10
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/ImageAny.kt
  30. 57 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/ImageBackground.kt
  31. 28 13
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/PhotoImage.kt
  32. 62 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/immersivelist/ImmersiveList.kt
  33. 70 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/immersivelist/ImmersiveListBackgroundScope.kt
  34. 30 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/immersivelist/ImmersiveListScope.kt
  35. 3 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/text/AutoHideEmptyText.kt
  36. 22 16
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/ErrorMessage.kt
  37. 0 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/TVRow.kt
  38. 18 0
      tvlib/src/main/res/drawable/broken_image.xml
  39. BIN
      tvlib/src/main/res/drawable/person.png
  40. 12 0
      tvlib/src/main/res/drawable/person.xml

+ 8 - 4
app/src/main/AndroidManifest.xml

@@ -15,16 +15,18 @@
 
 
     <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
 
     <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
     <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
 
-    <uses-permission
-        android:name="android.permission.READ_EXTERNAL_STORAGE"
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
         tools:ignore="ScopedStorage" />
     <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
     <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
     <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
+        tools:ignore="ScopedStorage" />
 
     <uses-permission
         android:name="android.permission.AUTHENTICATE_ACCOUNTS"
@@ -97,8 +99,9 @@
 
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
-                <category android:name="android.intent.category.APP_BROWSER"/>
-                <category android:name="android.intent.category.DEFAULT"/>
+
+                <category android:name="android.intent.category.APP_BROWSER" />
+                <category android:name="android.intent.category.DEFAULT" />
             </intent-filter>
 
             <intent-filter>
@@ -113,6 +116,7 @@
 
                 <category android:name="android.intent.category.DEFAULT" />
                 <category android:name="android.intent.category.BROWSABLE" />
+
                 <data android:scheme="http" />
                 <data android:scheme="https" />
             </intent-filter>

+ 18 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/local/Movie.kt

@@ -76,4 +76,22 @@ class Movie :
             studio = value
         }
 
+    override fun equals(other: Any?): Boolean {
+        if (other == null || other !is Movie) return false
+        return uri == other.uri
+    }
+
+    override fun hashCode(): Int {
+        var result = id.hashCode()
+        result = 31 * result + (category?.hashCode() ?: 0)
+        result = 31 * result + (studio?.hashCode() ?: 0)
+        result = 31 * result + (country?.hashCode() ?: 0)
+        result = 31 * result + isNsfw.hashCode()
+        result = 31 * result + (title?.hashCode() ?: 0)
+        result = 31 * result + (description?.hashCode() ?: 0)
+        result = 31 * result + (uri?.hashCode() ?: 0)
+        result = 31 * result + (subtitle?.hashCode() ?: 0)
+        return result
+    }
+
 }

+ 24 - 100
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/DetailScreen.kt

@@ -6,50 +6,28 @@
  * w: https://mjdev.org
  */
 
+@file:Suppress("MoveVariableDeclarationIntoWhen")
+
 package org.mjdev.tvapp.ui.screens
 
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Info
 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.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.layout.ContentScale
 import androidx.navigation.navArgument
-import kotlinx.coroutines.delay
 import org.mjdev.tvapp.R
+import org.mjdev.tvapp.data.local.Movie
+import org.mjdev.tvapp.viewmodel.IPTVViewModel
 import org.mjdev.tvlib.annotations.TvPreview
+import org.mjdev.tvlib.extensions.HiltExt.appViewModel
+import org.mjdev.tvlib.interfaces.ItemAudio
 import org.mjdev.tvlib.interfaces.ItemPhoto
-import org.mjdev.tvlib.interfaces.ItemWithBackground
-import org.mjdev.tvlib.interfaces.ItemWithDescription
-import org.mjdev.tvlib.interfaces.ItemWithImage
-import org.mjdev.tvlib.interfaces.ItemWithSubtitle
-import org.mjdev.tvlib.interfaces.ItemWithTitle
+import org.mjdev.tvlib.interfaces.ItemVideo
 import org.mjdev.tvlib.navigation.AnyType
 import org.mjdev.tvlib.screen.Screen
-import org.mjdev.tvlib.ui.components.image.FadingPhotoImage
-import org.mjdev.tvlib.ui.components.image.ImageAny
-import org.mjdev.tvlib.ui.components.text.AutoHideEmptyText
-import org.mjdev.tvlib.ui.theme.ThemeHelper.themeProvider
+import org.mjdev.tvlib.ui.components.gallery.Gallery
 
 class DetailScreen : Screen() {
 
@@ -72,80 +50,26 @@ class DetailScreen : Screen() {
     @Composable
     override fun ComposeScreen() {
 
+        val viewModel: IPTVViewModel = appViewModel { context ->
+            IPTVViewModel.mockIPTVViewModel(context)
+        }
+
         val data: Any? = args[data]
 
-        val photo = (data as? ItemPhoto)?.uri
-        val backgroundImage = (data as? ItemWithBackground<*>)?.background
-        val image = (data as? ItemWithImage<*>)?.image
-        val title = (data as? ItemWithTitle<*>)?.title
-        val subtitle = (data as? ItemWithSubtitle<*>)?.subtitle
-        val description = (data as? ItemWithDescription<*>)?.description
-        var infoVisible by remember { mutableStateOf(true) }
-
-        Box(
-            modifier = Modifier
-                .fillMaxSize()
-                .pointerInput(Unit) {
-                    detectTapGestures {
-                        infoVisible = !infoVisible
-                    }
-                },
-            contentAlignment = Alignment.BottomCenter,
-        ) {
-            FadingPhotoImage(
-                modifier = Modifier
-                    .fillMaxSize()
-                    .background(Color.Black, RectangleShape),
-                initialImage = backgroundImage ?: photo ?: image,
-                contrast = 4f,
-                alpha = 0.6f,
-                brightness = -255f,
-                contentScale = ContentScale.Crop
-            )
-            ImageAny(
-                src = photo ?: backgroundImage ?: image,
-                contentDescription = null,
-                contentScale = ContentScale.Fit,
-                modifier = Modifier.fillMaxSize()
-            )
-            AnimatedVisibility(
-                visible = infoVisible,
-                enter = fadeIn(),
-                exit = fadeOut(),
-            ) {
-                Column(
-                    modifier = Modifier
-                        .fillMaxWidth()
-                        .alpha(0.8f)
-                        .background(themeProvider.detailTextBackgroundColor)
-                        .padding(
-                            vertical = themeProvider.detailTextPaddingVertical,
-                            horizontal = themeProvider.detailTextPaddingHorizontal
-                        )
-                ) {
-                    AutoHideEmptyText(
-                        text = title,
-                        style = themeProvider.detailsTitleTextStyle,
-                        color = themeProvider.detailsTextColor
-                    )
-                    AutoHideEmptyText(
-                        text = subtitle,
-                        style = themeProvider.detailsTextStyle,
-                        color = themeProvider.detailsTextColor
-                    )
-                    AutoHideEmptyText(
-                        text = description,
-                        style = themeProvider.detailsTextStyle,
-                        color = themeProvider.detailsTextColor
-                    )
-                }
-            }
-            LaunchedEffect(infoVisible) {
-                delay(2000)
-                infoVisible = false
-            }
+        val dataList: List<Any?> = when (data) {
+            is ItemAudio -> viewModel.localAudioCursor
+            is ItemVideo -> viewModel.localVideoCursor
+            is ItemPhoto -> viewModel.localPhotoCursor
+            is Movie -> viewModel.movieList()
+            else -> listOf(data)
         }
 
+        Gallery (
+            modifier = Modifier.fillMaxSize(),
+            list = dataList,
+            index = dataList.indexOf(data)
+        )
+
     }
 
 }

+ 35 - 8
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/IPTVScreen.kt

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Tv
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -22,8 +23,14 @@ import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.navigation.navArgument
 import org.mjdev.tvapp.R
+import org.mjdev.tvapp.data.local.Movie
+import org.mjdev.tvapp.viewmodel.IPTVViewModel
 import org.mjdev.tvlib.annotations.TvPreview
+import org.mjdev.tvlib.extensions.HiltExt.appViewModel
 import org.mjdev.tvlib.extensions.StringExt.parseUri
+import org.mjdev.tvlib.interfaces.ItemAudio
+import org.mjdev.tvlib.interfaces.ItemPhoto
+import org.mjdev.tvlib.interfaces.ItemVideo
 import org.mjdev.tvlib.interfaces.ItemWithTitle
 import org.mjdev.tvlib.interfaces.ItemWithUri
 import org.mjdev.tvlib.navigation.AnyType
@@ -51,19 +58,39 @@ class IPTVScreen : Screen() {
     @Composable
     override fun ComposeScreen() {
 
+        val viewModel: IPTVViewModel = appViewModel { context ->
+            IPTVViewModel.mockIPTVViewModel(context)
+        }
+
         val data: Any? = remember { args[data] }
 
-        val mediaUri: String? = when (data) {
+        val dataList = remember {
+            when (data) {
+                is ItemAudio -> viewModel.localAudioCursor.asList()
+                is ItemVideo -> viewModel.localVideoCursor.asList()
+                is ItemPhoto -> viewModel.localPhotoCursor.asList()
+                is Movie -> viewModel.movieList()
+                else -> listOf(data)
+            }
+        }
+
+        val currentIndex = remember {
+            mutableIntStateOf(dataList.indexOf(data))
+        }
+
+        val currentData = dataList[currentIndex.intValue]
+
+        val mediaUri: String? = when (currentData) {
             null -> null
-            is ItemWithUri<*> -> data.uri.toString()
-            is String -> data.toString()
-            is URL -> data.toString()
-            is Uri -> data.toString()
-            else -> data.toString()
+            is ItemWithUri<*> -> currentData.uri.toString()
+            is String -> currentData.toString()
+            is URL -> currentData.toString()
+            is Uri -> currentData.toString()
+            else -> currentData.toString()
         }
 
-        val mediaTitle: String? = when (data) {
-            is ItemWithTitle<*> -> data.title.toString()
+        val mediaTitle: String? = when (currentData) {
+            is ItemWithTitle<*> -> currentData.title.toString()
             else -> null
         }
 

+ 62 - 0
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/IPTVViewModel.kt

@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.viewmodel
+
+import android.content.Context
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.runBlocking
+import org.mjdev.tvlib.helpers.cursor.AudioCursor
+import org.mjdev.tvlib.helpers.cursor.PhotoCursor
+import org.mjdev.tvlib.helpers.cursor.VideoCursor
+import org.mjdev.tvlib.network.NetworkConnectivityService
+import org.mjdev.tvlib.viewmodel.BaseViewModel
+import org.mjdev.tvapp.database.DAO
+import org.mjdev.tvapp.repository.IMovieRepository
+import org.mjdev.tvapp.repository.MovieRepository
+import org.mjdev.tvlib.network.NetworkConnectivityServiceImpl
+import javax.inject.Inject
+
+@HiltViewModel
+class IPTVViewModel @Inject constructor() : BaseViewModel() {
+
+    @Inject
+    lateinit var movieRepository: IMovieRepository
+
+    @Inject
+    lateinit var networkInfo: NetworkConnectivityService
+
+    @Inject
+    lateinit var localAudioCursor: AudioCursor
+
+    @Inject
+    lateinit var localVideoCursor: VideoCursor
+
+    @Inject
+    lateinit var localPhotoCursor: PhotoCursor
+
+    fun movieList() = runBlocking {
+        movieRepository.getMovies().getOrNull() ?: emptyList()
+    }
+
+    companion object {
+
+        @Suppress("unused")
+        fun mockIPTVViewModel(
+            context: Context
+        ): IPTVViewModel = IPTVViewModel().apply {
+            movieRepository = MovieRepository(DAO(context))
+            networkInfo = NetworkConnectivityServiceImpl(context)
+            localAudioCursor = AudioCursor(context)
+            localVideoCursor = VideoCursor(context)
+            localPhotoCursor = PhotoCursor(context)
+        }
+
+    }
+
+}

BIN
app/src/main/res/drawable/placeholder.png


+ 2 - 0
tvlib/build.gradle.kts

@@ -154,6 +154,8 @@ dependencies {
     implementation("androidx.palette:palette-ktx:1.0.0")
     // lottie
     implementation("com.airbnb.android:lottie-compose:6.1.0")
+    // exif info
+    implementation("androidx.exifinterface:exifinterface:1.3.6")
     // constraints
     constraints {
         implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.0")?.apply {

+ 11 - 7
tvlib/src/main/AndroidManifest.xml

@@ -1,17 +1,16 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
+<?xml version="1.0" encoding="utf-8"?><!--
   ~ Copyright (c) Milan Jurkulák 2023.
   ~  Contact:
   ~  e: mimoccc@gmail.com
   ~  e: mj@mjdev.org
   ~  w: https://mjdev.org
   -->
+<manifest xmlns:tools="http://schemas.android.com/tools"
+    xmlns:android="http://schemas.android.com/apk/res/android">
 
-<manifest xmlns:android="http://schemas.android.com/apk/res/android">
-
-<!--    <permission-->
-<!--        android:name="android.permission.QUERY_ALL_PACKAGES"-->
-<!--        tools:ignore="ReservedSystemPermission" />-->
+    <!--    <permission-->
+    <!--        android:name="android.permission.QUERY_ALL_PACKAGES"-->
+    <!--        tools:ignore="ReservedSystemPermission" />-->
 
     <queries>
         <intent>
@@ -19,4 +18,9 @@
         </intent>
     </queries>
 
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
+        tools:ignore="ScopedStorage" />
+
 </manifest>

+ 2 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ComposeExt.kt

@@ -64,11 +64,12 @@ object ComposeExt {
         get() = currentValue == DrawerValue.Closed
 
     @Composable
-    inline fun <reified T> textFrom(text: T?): String = when (text) {
+    fun <T> textFrom(text: T?): String = when (text) {
         null -> ""
         is Unit -> ""
         is Int -> LocalContext.current.getString(text)
         is String -> text
+        is MutableState<*> -> textFrom(text.value)
         else -> text.toString()
     }
 

+ 5 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/GlobalExt.kt

@@ -9,11 +9,16 @@
 package org.mjdev.tvlib.extensions
 
 import android.os.Handler
+import androidx.compose.runtime.MutableState
 import timber.log.Timber
 
 @Suppress("unused")
 object GlobalExt {
 
+    fun MutableState<Boolean>.toggle() {
+        value = !value
+    }
+
     suspend fun <E> runSafe(block: suspend () -> E): Result<E> = try {
         block.invoke().let { result ->
             Result.success(result)

+ 30 - 2
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ModifierExt.kt

@@ -9,8 +9,11 @@
 package org.mjdev.tvlib.extensions
 
 import android.annotation.SuppressLint
+import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.relocation.BringIntoViewResponder
+import androidx.compose.foundation.relocation.bringIntoViewResponder
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.Stable
@@ -24,6 +27,7 @@ import androidx.compose.ui.focus.FocusState
 import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.focus.onFocusChanged
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.SolidColor
@@ -31,10 +35,10 @@ import androidx.compose.ui.graphics.drawscope.Fill
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.graphics.lerp
 import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.unit.dp
 import kotlinx.coroutines.delay
-import org.mjdev.tvlib.BuildConfig
 import timber.log.Timber
 import java.lang.Float.min
 
@@ -42,7 +46,7 @@ import java.lang.Float.min
 object ModifierExt {
 
     // used to debug library
-    val isDebug = false
+    const val isDebug = false
 
     fun Modifier.conditional(
         condition: Boolean,
@@ -148,4 +152,28 @@ object ModifierExt {
         }
     }
 
+    @OptIn(ExperimentalFoundationApi::class)
+    fun Modifier.bringIntoViewIfChildrenAreFocused(): Modifier = composed(
+        inspectorInfo = debugInspectorInfo { name = "bringIntoViewIfChildrenAreFocused" },
+        factory = {
+            var myRect: Rect = Rect.Zero
+            this
+                .onSizeChanged {
+                    myRect = Rect(Offset.Zero, Offset(it.width.toFloat(), it.height.toFloat()))
+                }
+                .bringIntoViewResponder(
+                    remember {
+                        object : BringIntoViewResponder {
+                            @ExperimentalFoundationApi
+                            override fun calculateRectForParent(localRect: Rect): Rect = myRect
+
+                            @ExperimentalFoundationApi
+                            override suspend fun bringChildIntoView(localRect: () -> Rect?) {
+                            }
+                        }
+                    }
+                )
+        }
+    )
+
 }

+ 1 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/StringExt.kt

@@ -14,6 +14,6 @@ object StringExt {
 
     fun String.asException() = Exception(this)
 
-    fun String.parseUri() = Uri.parse(this)
+    fun String.parseUri(): Uri = Uri.parse(this)
 
 }

+ 2 - 4
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/brush/BrushBuilder.kt

@@ -28,7 +28,7 @@ class BrushBuilder {
     private var end: Float = Float.POSITIVE_INFINITY
     private var tileMode: TileMode = TileMode.Clamp
 
-    fun build(): Brush? = when (type) {
+    fun build(): Brush = when (type) {
         Vertical -> {
             Brush.verticalGradient(
                 colorStops = colorStops,
@@ -38,7 +38,7 @@ class BrushBuilder {
             )
         }
 
-        Horizontal -> {
+        else -> {
             Brush.horizontalGradient(
                 colorStops = colorStops,
                 startX = start,
@@ -46,8 +46,6 @@ class BrushBuilder {
                 tileMode = tileMode
             )
         }
-
-        else -> null
     }
 
     fun type(type: Int): BrushBuilder = apply {

+ 31 - 11
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/AudioItem.kt

@@ -9,13 +9,17 @@
 package org.mjdev.tvlib.helpers.cursor
 
 import android.database.Cursor
+import android.media.MediaMetadataRetriever
 import android.net.Uri
 import android.provider.MediaStore
 import org.mjdev.tvlib.extensions.CursorExt.asMap
 import org.mjdev.tvlib.interfaces.ItemAudio
+import org.mjdev.tvlib.interfaces.ItemWithDate
+import org.mjdev.tvlib.interfaces.ItemWithDescription
 import org.mjdev.tvlib.interfaces.ItemWithImage
 import org.mjdev.tvlib.interfaces.ItemWithTitle
 import org.mjdev.tvlib.interfaces.ItemWithUri
+import timber.log.Timber
 import java.io.Serializable
 
 @Suppress("unused")
@@ -24,28 +28,46 @@ class AudioItem() :
     ItemAudio,
     ItemWithTitle<String>,
     ItemWithImage<Any>,
-    ItemWithUri<String> {
+    ItemWithUri<String>,
+    ItemWithDescription<String?>,
+    ItemWithDate {
 
     override var title: String? = null
-
     override var uri: String? = null
-
     override var image: Any? = null
+    override var date: String? = null
+    override var description: String? = null
 
     constructor(c: Cursor) : this() {
         c.asMap(VideoItem.MEDIA_PROJECTION).forEach { vp ->
             when (vp.key) {
                 MediaStore.Audio.Media.TITLE -> title = vp.value.toString()
-                MediaStore.Audio.Media._ID -> uri = Uri.withAppendedPath(
-                    MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
-                    vp.value.toString()
-                ).toString()
-
-                MediaStore.Video.Media.DATA -> image = vp.value.toString()
+                MediaStore.Audio.Media.DATA -> {
+                    uri = vp.value.toString()
+                    image = vp.value.toString()
+                }
+            }
+        }
+        try {
+            MediaMetadataRetriever().apply {
+                setDataSource(uri)
+            }.also { mmr ->
+                date = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)
             }
+        } catch (e: Exception) {
+            Timber.e(e)
         }
     }
 
+    override fun equals(other: Any?): Boolean {
+        if (other == null || other !is AudioItem) return false
+        return uri == other.uri
+    }
+
+    override fun hashCode(): Int {
+        return uri?.hashCode() ?: 0
+    }
+
     companion object {
 
         const val SORT_ORDER_DATE_DESC: String = MediaStore.Audio.Media.DATE_ADDED + " DESC"
@@ -57,8 +79,6 @@ class AudioItem() :
             MediaStore.Audio.Media._ID,
             MediaStore.Audio.Media.TITLE,
             MediaStore.Audio.Media.DATA,
-            MediaStore.Audio.Media.DATE_ADDED,
-            // todo more columns
         )
 
     }

+ 47 - 2
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/CachingCursor.kt

@@ -40,9 +40,9 @@ open class CachingCursor(
     selectionArgs: Array<String>? = null,
     sortOrder: String? = null,
     private val prefetchItems: Int = 8,
-    private val cacheItems: Int = 256,
+    private var cacheItems: Int = -1,
     private val transform: (Cursor) -> Any? = { it },
-) : Cursor {
+) : Cursor, List<Any?> {
 
     private val resolver: ContentResolver by lazy {
         context.contentResolver
@@ -75,6 +75,9 @@ open class CachingCursor(
     val isNotEmpty: Boolean get() = count > 0
 
     init {
+        if (cacheItems == -1) {
+            cacheItems = cursor?.count ?: 0
+        }
         (0..prefetchItems).forEach { idx -> getData(idx) }
     }
 
@@ -282,6 +285,48 @@ open class CachingCursor(
     override fun respond(b: Bundle?): Bundle? =
         cursor?.respond(b)
 
+    override val size: Int
+        get() = count
+
+    override fun get(index: Int): Any? = _get(index)
+    override fun isEmpty(): Boolean = (size == 0)
+
+    override fun iterator(): Iterator<Any?> =
+        CachingCursorListIterator(this)
+
+    override fun listIterator(): ListIterator<Any?> =
+        CachingCursorListIterator(this)
+
+    override fun listIterator(index: Int): ListIterator<Any?> =
+        CachingCursorListIterator(this, index)
+
+    override fun subList(fromIndex: Int, toIndex: Int): List<Any?> =
+        (fromIndex..toIndex).map { idx -> get(idx) }
+
+    override fun lastIndexOf(element: Any?): Int {
+        var curr: Int = size - 1
+        while ((this[curr] != element) && (curr > 0)) {
+            curr--
+        }
+        return curr
+    }
+
+    override fun indexOf(element: Any?): Int {
+        var curr = 0
+        while ((this[curr] != element) && (curr < size)) {
+            curr++
+        }
+        return if (curr == size) -1 else curr
+    }
+
+    override fun containsAll(elements: Collection<Any?>): Boolean {
+        TODO("Not yet implemented")
+    }
+
+    override fun contains(element: Any?): Boolean {
+        TODO("Not yet implemented")
+    }
+
     data class CachedCursorItem(
         val idx: Int,
         val cached: Long,

+ 38 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/CachingCursorListIterator.kt

@@ -0,0 +1,38 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.helpers.cursor
+
+@Suppress("CanBeParameter")
+class CachingCursorListIterator(
+    private val cachingCursor: CachingCursor,
+    private val index: Int = 0
+) : ListIterator<Any?> {
+
+    private var idx = index
+
+    override fun hasNext(): Boolean = cachingCursor.size > (idx - 1)
+
+    override fun hasPrevious(): Boolean = idx > -1
+
+    override fun next(): Any? {
+        val ret = cachingCursor[idx]
+        idx = nextIndex()
+        return ret
+    }
+
+    override fun nextIndex(): Int = idx + 1
+
+    override fun previous(): Any? {
+        idx = previousIndex()
+        return cachingCursor[idx]
+    }
+
+    override fun previousIndex(): Int = if ((idx - 1) < 0) 0 else idx - 1
+
+}

+ 24 - 3
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/PhotoItem.kt

@@ -11,11 +11,15 @@ package org.mjdev.tvlib.helpers.cursor
 import android.database.Cursor
 import android.net.Uri
 import android.provider.MediaStore
+import androidx.exifinterface.media.ExifInterface
 import org.mjdev.tvlib.extensions.CursorExt.asMap
 import org.mjdev.tvlib.interfaces.ItemPhoto
 import org.mjdev.tvlib.interfaces.ItemWithBackground
+import org.mjdev.tvlib.interfaces.ItemWithDate
+import org.mjdev.tvlib.interfaces.ItemWithDescription
 import org.mjdev.tvlib.interfaces.ItemWithImage
 import org.mjdev.tvlib.interfaces.ItemWithTitle
+import timber.log.Timber
 import java.io.Serializable
 
 @Suppress("unused")
@@ -24,12 +28,16 @@ class PhotoItem() :
     ItemPhoto,
     ItemWithTitle<String>,
     ItemWithImage<Any>,
-    ItemWithBackground<Any?> {
+    ItemWithBackground<Any?>,
+    ItemWithDescription<String?>,
+    ItemWithDate {
 
     override var title: String? = null
     override var uri: String? = null
     override var image: Any? = null
     override var background: Any? = null
+    override var date: String? = null
+    override var description: String? = null
 
     constructor(c: Cursor) : this() {
         c.asMap(VideoItem.MEDIA_PROJECTION).forEach { vp ->
@@ -41,11 +49,26 @@ class PhotoItem() :
                 ).toString()
 
                 MediaStore.Images.Media.DATA -> {
+                    uri = vp.value.toString()
                     image = vp.value.toString()
                     background = vp.value.toString()
                 }
             }
         }
+        try {
+            date = ExifInterface(image.toString()).getAttribute(ExifInterface.TAG_DATETIME)
+        } catch (e: Exception) {
+            Timber.e(e)
+        }
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (other == null || other !is PhotoItem) return false
+        return uri == other.uri
+    }
+
+    override fun hashCode(): Int {
+        return uri?.hashCode() ?: 0
     }
 
     companion object {
@@ -59,8 +82,6 @@ class PhotoItem() :
             MediaStore.Images.Media._ID,
             MediaStore.Images.Media.TITLE,
             MediaStore.Images.Media.DATA,
-            MediaStore.Images.Media.DATE_ADDED,
-            // todo more columns
         )
 
     }

+ 31 - 11
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/VideoItem.kt

@@ -9,13 +9,17 @@
 package org.mjdev.tvlib.helpers.cursor
 
 import android.database.Cursor
+import android.media.MediaMetadataRetriever
 import android.net.Uri
 import android.provider.MediaStore
 import org.mjdev.tvlib.extensions.CursorExt.asMap
 import org.mjdev.tvlib.interfaces.ItemVideo
+import org.mjdev.tvlib.interfaces.ItemWithDate
+import org.mjdev.tvlib.interfaces.ItemWithDescription
 import org.mjdev.tvlib.interfaces.ItemWithImage
 import org.mjdev.tvlib.interfaces.ItemWithTitle
 import org.mjdev.tvlib.interfaces.ItemWithUri
+import timber.log.Timber
 import java.io.Serializable
 
 @Suppress("unused")
@@ -24,28 +28,46 @@ class VideoItem() :
     ItemVideo,
     ItemWithTitle<String>,
     ItemWithImage<Any>,
-    ItemWithUri<String> {
+    ItemWithUri<String>,
+    ItemWithDescription<String?>,
+    ItemWithDate {
 
     override var title: String? = null
-
     override var uri: String? = null
-
     override var image: Any? = null
+    override var date: String? = null
+    override var description: String? = null
 
     constructor(c: Cursor) : this() {
         c.asMap(MEDIA_PROJECTION).forEach { vp ->
             when (vp.key) {
                 MediaStore.Video.Media.TITLE -> title = vp.value.toString()
-                MediaStore.Video.Media._ID -> uri = Uri.withAppendedPath(
-                    MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
-                    vp.value.toString()
-                ).toString()
-
-                MediaStore.Video.Media.DATA -> image = vp.value.toString()
+                MediaStore.Video.Media.DATA -> {
+                    uri = vp.value.toString()
+                    image = vp.value.toString()
+                }
+            }
+        }
+        try {
+            MediaMetadataRetriever().apply {
+                setDataSource(uri)
+            }.also { mmr ->
+                date = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)
             }
+        } catch (e: Exception) {
+            Timber.e(e)
         }
     }
 
+    override fun equals(other: Any?): Boolean {
+        if (other == null || other !is VideoItem) return false
+        return uri == other.uri
+    }
+
+    override fun hashCode(): Int {
+        return uri?.hashCode() ?: 0
+    }
+
     companion object {
 
         const val SORT_ORDER_DATE_DESC: String = MediaStore.Video.Media.DATE_ADDED + " DESC"
@@ -57,8 +79,6 @@ class VideoItem() :
             MediaStore.Video.Media._ID,
             MediaStore.Video.Media.TITLE,
             MediaStore.Video.Media.DATA,
-            MediaStore.Video.Media.DATE_ADDED
-            // todo more columns
         )
 
     }

+ 37 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/other/Observed.kt

@@ -0,0 +1,37 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:Suppress("unused")
+
+package org.mjdev.tvlib.helpers.other
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+
+open class Observed<T>(
+    initial: T
+) : MutableState<T> {
+
+    private var _value by mutableStateOf(initial)
+
+    override var value: T = initial
+        get() = _value
+        set(value) {
+            _value = value
+            field = value
+        }
+
+    override fun component1(): T = value
+
+    override fun component2(): (T) -> Unit = {
+        value = it
+    }
+
+}

+ 13 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/interfaces/ItemWithDate.kt

@@ -0,0 +1,13 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.interfaces
+
+interface ItemWithDate {
+    var date: String?
+}

+ 7 - 6
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/button/Button.kt

@@ -11,6 +11,7 @@ package org.mjdev.tvlib.ui.components.button
 import android.annotation.SuppressLint
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.shape.RoundedCornerShape
@@ -44,7 +45,6 @@ import org.mjdev.tvlib.ui.components.text.TextAny
 @Composable
 fun Button(
     text: Any? = "test",
-    onClick: () -> Unit = {},
     modifier: Modifier = Modifier,
     fontSize: TextUnit = 24.sp,
     fontWeight: FontWeight = FontWeight.Bold,
@@ -60,16 +60,17 @@ fun Button(
     focusState: MutableState<FocusState?> = rememberFocusState(text),
     content: @Composable RowScope.() -> Unit = {
         TextAny(text = text)
-    }
+    },
+    onClick: () -> Unit = {},
 ) {
 
     Button(
         modifier = modifier
             .recomposeHighlighter()
-//            .touchable(
-//                focusState = focusState,
-//            )
-            .border(BorderStroke(borderSize, borderColor), shape),
+            .border(BorderStroke(borderSize, borderColor), shape)
+            .clickable {
+               onClick()
+            },
         glow = glow,
         scale = scale,
         onClick = onClick,

+ 6 - 3
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/card/ItemCard.kt

@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.MutableState
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.FocusState
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.ContentScale
@@ -28,6 +29,7 @@ import androidx.tv.material3.CardScale
 import androidx.tv.material3.CardShape
 import androidx.tv.material3.ExperimentalTvMaterial3Api
 import org.mjdev.tvlib.extensions.ComposeExt.computeCardWidth
+import org.mjdev.tvlib.extensions.ComposeExt.rememberFocusRequester
 import org.mjdev.tvlib.extensions.ComposeExt.rememberFocusState
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 import org.mjdev.tvlib.interfaces.ItemWithDescription
@@ -49,7 +51,6 @@ fun ItemCard(
     colors: CardColors = CardDefaults.colors(),
     border: CardBorder = CardDefaults.colorFocusBorder(Color.Green),
     glow: CardGlow = CardDefaults.colorFocusGlow(Color.Green),
-    placeholder: @Composable () -> Unit = {},
     aspectRatio: Float = 16f / 9f,
     imageRenderer: @Composable () -> Unit = {
         ImageAny(
@@ -57,11 +58,12 @@ fun ItemCard(
             src = (item as? ItemWithImage<*>)?.image,
             contentDescription = (item as? ItemWithDescription<*>)?.description?.toString(),
             contentScale = contentScale,
-            placeholder = placeholder
         )
     },
     cardWidth: Dp = computeCardWidth(),
     focusState: MutableState<FocusState?> = rememberFocusState(item),
+    focusRequester: FocusRequester = rememberFocusRequester(item),
+    focused: Boolean = false,
     titlePadding: PaddingValues = PaddingValues(8.dp),
     onFocus: (item: Any?) -> Unit = {},
     onClick: (item: Any?) -> Unit = {},
@@ -69,6 +71,8 @@ fun ItemCard(
     modifier = modifier.recomposeHighlighter(),
     item = item,
     focusState = focusState,
+    focusRequester = focusRequester,
+    focused = focused,
     contentScale = contentScale,
     aspectRatio = aspectRatio,
     textColor = textColor,
@@ -79,7 +83,6 @@ fun ItemCard(
     glow = glow,
     cardWidth = cardWidth,
     imageRenderer = imageRenderer,
-    placeholder = placeholder,
     titlePadding = titlePadding,
     onFocus = onFocus,
     onClick = onClick,

+ 6 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/card/PhotoCard.kt

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.ui.Modifier
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.MutableState
+import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.FocusState
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.ContentScale
@@ -25,6 +26,7 @@ import androidx.tv.material3.CardDefaults
 import androidx.tv.material3.CardScale
 import androidx.tv.material3.ExperimentalTvMaterial3Api
 import org.mjdev.tvlib.extensions.ComposeExt.computeCardWidth
+import org.mjdev.tvlib.extensions.ComposeExt.rememberFocusRequester
 import org.mjdev.tvlib.extensions.ComposeExt.rememberFocusState
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 import org.mjdev.tvlib.interfaces.ItemWithDescription
@@ -41,6 +43,8 @@ fun PhotoCard(
     contentScale: ContentScale = ContentScale.Crop,
     textColor: Color = Color.White,
     focusState: MutableState<FocusState?> = rememberFocusState(item),
+    focusRequester: FocusRequester = rememberFocusRequester(item),
+    focused: Boolean = false,
     @FloatRange(from = 0.0, to = 10.0)
     contrast: Float = 5f,
     @FloatRange(from = -255.0, to = 255.0)
@@ -69,11 +73,12 @@ fun PhotoCard(
         contentScale = contentScale,
         textColor = textColor,
         focusState = focusState,
+        focusRequester = focusRequester,
+        focused = focused,
         aspectRatio = aspectRatio,
         cardWidth = cardWidth,
         scale = scale,
         imageRenderer = imageRenderer,
-        placeholder = placeholder,
         titlePadding = titlePadding,
         onFocus = onFocus,
         onClick = onClick,

+ 0 - 3
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/carousel/CarouselCard.kt

@@ -41,7 +41,6 @@ fun CarouselCard(
     contentScale: ContentScale = ContentScale.Crop,
     scale: CardScale = CardScale.None,
     focusState: MutableState<FocusState?> = rememberFocusState(item),
-    placeholder: @Composable () -> Unit = {}, // todo
     imageRenderer: @Composable () -> Unit = {
         val image = (item as? ItemWithImage<*>)?.image
         val background = (item as? ItemWithBackground<*>)?.background
@@ -50,7 +49,6 @@ fun CarouselCard(
             src = background ?: image,
             contentDescription = (item as? ItemWithDescription<*>)?.description?.toString(),
             contentScale = contentScale,
-            placeholder = placeholder
         )
     },
     titlePadding: PaddingValues = PaddingValues(8.dp, 12.dp, 8.dp, 12.dp),
@@ -69,7 +67,6 @@ fun CarouselCard(
         contentScale = contentScale,
         scale = scale,
         focusState = focusState,
-        placeholder = placeholder,
         imageRenderer = imageRenderer,
         titlePadding = titlePadding,
         onFocus = onFocus,

+ 13 - 3
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/complex/FocusableCard.kt

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.MutableState
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.FocusRequester
@@ -55,6 +56,7 @@ import org.mjdev.tvlib.ui.components.card.colorFocusBorder
 import org.mjdev.tvlib.ui.components.card.colorFocusGlow
 import org.mjdev.tvlib.ui.components.image.ImageAny
 import org.mjdev.tvlib.ui.components.text.AutoHideEmptyText
+import timber.log.Timber
 
 @SuppressLint("ModifierParameter")
 @OptIn(ExperimentalTvMaterial3Api::class)
@@ -72,21 +74,20 @@ fun FocusableCard(
     colors: CardColors = CardDefaults.colors(),
     border: CardBorder = CardDefaults.colorFocusBorder(Color.Green),
     glow: CardGlow = CardDefaults.colorFocusGlow(Color.Green),
-    placeholder: @Composable () -> Unit = {},
     imageRenderer: @Composable () -> Unit = {
         ImageAny(
             modifier = Modifier.fillMaxSize(),
             src = (item as? ItemWithImage<*>)?.image,
             contentDescription = (item as? ItemWithDescription<*>)?.description?.toString(),
             contentScale = contentScale,
-            placeholder = placeholder
         )
     },
     focusRequester: FocusRequester = rememberFocusRequester(item),
     focusState: MutableState<FocusState?> = rememberFocusState(item),
+    focused: Boolean = false,
     onFocus: (item: Any?) -> Unit = {},
     onFocusChange: (state: FocusState) -> Unit = { state ->
-        if (state.isFocused) {
+        if (state.isFocused || state.hasFocus) {
             onFocus(item)
         }
     },
@@ -172,4 +173,13 @@ fun FocusableCard(
             onClick(item)
         },
     )
+    LaunchedEffect(focused) {
+        try {
+            if (focused) {
+                focusRequester.requestFocus()
+            }
+        } catch (e: Exception) {
+            Timber.e(e)
+        }
+    }
 }

+ 217 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/Gallery.kt

@@ -0,0 +1,217 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.gallery
+
+import android.view.Gravity
+import android.view.KeyEvent.ACTION_DOWN
+import android.view.KeyEvent.KEYCODE_DPAD_DOWN
+import android.view.KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableIntState
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.lazy.list.TvLazyListState
+import androidx.tv.foundation.lazy.list.itemsIndexed
+import androidx.tv.foundation.lazy.list.rememberTvLazyListState
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import kotlinx.coroutines.delay
+import org.mjdev.tvlib.extensions.GlobalExt.toggle
+import org.mjdev.tvlib.interfaces.ItemPhoto
+import org.mjdev.tvlib.interfaces.ItemWithBackground
+import org.mjdev.tvlib.interfaces.ItemWithImage
+import org.mjdev.tvlib.ui.components.card.PhotoCard
+import org.mjdev.tvlib.ui.components.image.ImageAny
+import org.mjdev.tvlib.ui.components.immersivelist.ImmersiveList
+import org.mjdev.tvlib.ui.components.image.ImageBackground
+import org.mjdev.tvlib.ui.components.tv.TVRow
+import timber.log.Timber
+
+@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalAnimationApi::class)
+@Composable
+fun Gallery(
+    modifier: Modifier = Modifier,
+    list: List<Any?> = listOf(),
+    index: Int = 0,
+    createColorBrush: (
+        color: Color,
+        gravity: Int
+    ) -> Brush = { color, g ->
+        Brush.verticalGradient(
+            when (g) {
+                Gravity.TOP -> listOf(
+                    color,
+                    color,
+                    color,
+                    color.copy(alpha = 0.8f),
+                    color.copy(alpha = 0.5f),
+                    Color.Transparent
+                )
+
+                else -> listOf(
+                    Color.Transparent,
+                    color.copy(alpha = 0.5f),
+                    color.copy(alpha = 0.8f),
+                    color,
+                    color,
+                    color,
+                )
+            }
+        )
+    },
+    infoVisible: MutableState<Boolean> = remember { mutableStateOf(true) },
+    currentItemIndex: MutableIntState = remember { mutableIntStateOf(index) },
+    focusRequester: FocusRequester = FocusRequester(),
+    imageFromItem: (index: Int) -> Any? = { idx ->
+        list[idx].let { data ->
+            val photo = (data as? ItemPhoto)?.uri
+            val image = (data as? ItemWithImage<*>)?.image
+            val background = (data as? ItemWithBackground<*>)?.background
+            photo ?: image ?: background
+        }
+    },
+    handleKey: (event: KeyEvent) -> Boolean = { ev ->
+        val action = ev.nativeKeyEvent.action
+        if (action == ACTION_DOWN) {
+            val code = ev.nativeKeyEvent.keyCode
+            if (infoVisible.value) {
+                infoVisible.value = true
+                if ((code == KEYCODE_DPAD_DOWN) || (code == KEYCODE_SYSTEM_NAVIGATION_DOWN)) {
+                    infoVisible.value = false
+                    true
+                } else {
+                    false
+                }
+            } else {
+                infoVisible.value = true
+                true
+            }
+        } else {
+            false
+        }
+    },
+    listState: TvLazyListState = rememberTvLazyListState(),
+) {
+    Box(
+        modifier = modifier.background(Color.Black, RectangleShape),
+        contentAlignment = Alignment.BottomCenter,
+    ) {
+        ImageBackground(
+            modifier = modifier,
+            image = imageFromItem(currentItemIndex.intValue),
+        )
+        ImmersiveList(
+            modifier = Modifier.fillMaxSize(),
+            currentItemIndex = currentItemIndex,
+            listAlignment = Alignment.BottomStart,
+            background = { _, _ ->
+                Box(
+                    modifier = Modifier.fillMaxSize()
+                ) {
+                    ImageAny(
+                        src = imageFromItem(currentItemIndex.intValue),
+                        contentDescription = null,
+                        contentScale = ContentScale.Fit,
+                        modifier = Modifier
+                            .fillMaxSize()
+                            .focusable()
+                            .focusRequester(focusRequester)
+                            .pointerInput(Unit) {
+                                detectTapGestures {
+                                    infoVisible.toggle()
+                                }
+                            }
+                            .onKeyEvent { ev -> handleKey(ev) }
+                    )
+                    ItemInfo(
+                        src = list[currentItemIndex.intValue],
+                        infoVisible = infoVisible,
+                        createColorBrush = createColorBrush
+                    )
+                }
+            }
+        ) {
+            AnimatedVisibility(
+                visible = infoVisible.value,
+                enter = fadeIn() + slideInVertically(initialOffsetY = { it / 2 }),
+                exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 2 }),
+            ) {
+                ImageBackground(
+                    modifier = Modifier.fillMaxWidth(),
+                    image = imageFromItem(currentItemIndex.intValue),
+                    transform = { color -> createColorBrush(color, Gravity.BOTTOM) }
+                ) {
+                    Spacer(modifier = Modifier.height(32.dp))
+                    TVRow(
+                        horizontalArrangement = Arrangement.spacedBy(16.dp),
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(16.dp),
+                        contentPadding = 16.dp,
+                        state = listState
+                    ) {
+                        itemsIndexed(list) { index, item ->
+                            PhotoCard(
+                                modifier = Modifier
+                                    .immersiveListItem(index)
+                                    .onKeyEvent { ev -> handleKey(ev) },
+                                item = item,
+                                focused = (currentItemIndex.intValue == index),
+                            )
+                        }
+                    }
+                    Spacer(modifier = Modifier.height(32.dp))
+                }
+                if (transition.currentState != transition.targetState) {
+                    if (!infoVisible.value) {
+                        try {
+                            focusRequester.requestFocus()
+                        } catch (e: Exception) {
+                            Timber.e(e)
+                        }
+                    }
+                }
+            }
+        }
+        LaunchedEffect(currentItemIndex.intValue) {
+            delay(5000)
+            infoVisible.value = false
+        }
+    }
+}

+ 127 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/ItemInfo.kt

@@ -0,0 +1,127 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.gallery
+
+import android.view.Gravity
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.mjdev.tvlib.annotations.TvPreview
+import org.mjdev.tvlib.interfaces.ItemPhoto
+import org.mjdev.tvlib.interfaces.ItemWithBackground
+import org.mjdev.tvlib.interfaces.ItemWithDate
+import org.mjdev.tvlib.interfaces.ItemWithDescription
+import org.mjdev.tvlib.interfaces.ItemWithImage
+import org.mjdev.tvlib.interfaces.ItemWithTitle
+import org.mjdev.tvlib.ui.components.text.AutoHideEmptyText
+import org.mjdev.tvlib.ui.components.image.ImageBackground
+
+@TvPreview
+@Composable
+fun ItemInfo(
+    src: Any? = null,
+    infoVisible: MutableState<Boolean> = remember { mutableStateOf(true) },
+    createColorBrush: (
+        color: Color,
+        gravity: Int
+    ) -> Brush = { color, g ->
+        Brush.verticalGradient(
+            when (g) {
+                Gravity.TOP -> listOf(
+                    color,
+                    color,
+                    color,
+                    color.copy(alpha = 0.8f),
+                    color.copy(alpha = 0.5f),
+                    Color.Transparent
+                )
+
+                else -> listOf(
+                    Color.Transparent,
+                    color.copy(alpha = 0.5f),
+                    color.copy(alpha = 0.8f),
+                    color,
+                    color,
+                    color,
+                )
+            }
+        )
+    },
+    imageFromItem: () -> Any? = {
+        val photo = (src as? ItemPhoto)?.uri
+        val image = (src as? ItemWithImage<*>)?.image
+        val background = (src as? ItemWithBackground<*>)?.background
+        photo ?: image ?: background
+    },
+    titleFromItem: () -> Any? = {
+        (src as? ItemWithTitle<*>)?.title
+    },
+    dateFromItem: () -> Any? = {
+        (src as? ItemWithDate)?.date
+    },
+    detailsFromItem: () -> Any? = {
+        (src as? ItemWithDescription<*>)?.description
+    },
+) {
+    AnimatedVisibility(
+        visible = infoVisible.value,
+        enter = fadeIn(),
+        exit = fadeOut(),
+    ) {
+        Box(
+            modifier = Modifier.fillMaxHeight(),
+            contentAlignment = Alignment.TopStart
+        ) {
+            ImageBackground(
+                modifier = Modifier.fillMaxWidth(),
+                image = imageFromItem(),
+                transform = { color -> createColorBrush(color, Gravity.TOP) }
+            ) {
+                Column(
+                    modifier = Modifier.padding(16.dp, 0.dp, 0.dp, 16.dp)
+                ) {
+                    Spacer(modifier = Modifier.height(16.dp))
+                    AutoHideEmptyText(
+                        text = titleFromItem(),
+                        color = Color.White,
+                        fontSize = 24.sp
+                    )
+                    AutoHideEmptyText(
+                        text = dateFromItem(),
+                        color = Color.White,
+                        fontSize = 16.sp
+                    )
+                    AutoHideEmptyText(
+                        text = detailsFromItem(),
+                        color = Color.White,
+                        fontSize = 12.sp
+                    )
+                    Spacer(modifier = Modifier.height(16.dp))
+                }
+            }
+        }
+    }
+}

+ 2 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/FadingPhotoImage.kt

@@ -56,7 +56,8 @@ fun FadingPhotoImage(
     val fadingImage = remember { mutableStateOf(initialImage) }
     Crossfade(
         fadingImage.value,
-        animationSpec = tween(animationDuration)
+        animationSpec = tween(animationDuration),
+        label = "FadingImage"
     ) { targetState ->
         PhotoImage(
             modifier = modifier.blur(radius = blurRadius),

+ 30 - 10
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/ImageAny.kt

@@ -36,19 +36,17 @@ import org.mjdev.tvlib.extensions.DrawableExt.asImageBitmap
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 import java.net.URL
 
-// todo placeholder
 @SuppressLint("ModifierParameter")
 @Preview
 @Composable
 fun ImageAny(
     modifier: Modifier = Modifier,
-    src: Any? = R.drawable.person,
+    src: Any? = null,
     contentDescription: String? = null,
     alignment: Alignment = Alignment.Center,
     contentScale: ContentScale = ContentScale.Fit,
     alpha: Float = DefaultAlpha,
     colorFilter: ColorFilter? = null,
-    placeholder: @Composable () -> Unit = {}, // todo on error
 ) = BoxWithConstraints(
     modifier = modifier
 ) {
@@ -59,7 +57,17 @@ fun ImageAny(
 
     when (src) {
 
-        null, Unit -> placeholder()
+        null, Unit -> {
+            Image(
+                painterResource(R.drawable.broken_image),
+                contentDescription,
+                modifier.recomposeHighlighter(),
+                alignment,
+                contentScale,
+                alpha,
+                colorFilter
+            )
+        }
 
         Color -> Image(
             ColorDrawable((src as Color).toArgb()).asImageBitmap(width, height),
@@ -122,9 +130,13 @@ fun ImageAny(
         )
 
         is URL -> CoilImage(
-            imageLoader = { imageLoader },
+            imageLoader = {
+                imageLoader
+            },
             modifier = modifier.recomposeHighlighter(),
-            imageModel = { src },
+            imageModel = {
+                src
+            },
             imageOptions = ImageOptions(
                 contentScale = contentScale,
                 alignment = alignment,
@@ -135,9 +147,13 @@ fun ImageAny(
         )
 
         is Uri -> CoilImage(
-            imageLoader = { imageLoader },
+            imageLoader = {
+                imageLoader
+            },
             modifier = modifier.recomposeHighlighter(),
-            imageModel = { src },
+            imageModel = {
+                src
+            },
             imageOptions = ImageOptions(
                 contentScale = contentScale,
                 alignment = alignment,
@@ -148,9 +164,13 @@ fun ImageAny(
         )
 
         is String -> CoilImage(
-            imageLoader = { imageLoader },
+            imageLoader = {
+                imageLoader
+            },
             modifier = modifier.recomposeHighlighter(),
-            imageModel = { src },
+            imageModel = {
+                src
+            },
             imageOptions = ImageOptions(
                 contentScale = contentScale,
                 alignment = alignment,

+ 57 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/ImageBackground.kt

@@ -0,0 +1,57 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.image
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.graphics.drawable.toBitmap
+import coil.request.ImageRequest
+import org.mjdev.tvlib.extensions.BitmapExt.majorColor
+import org.mjdev.tvlib.extensions.ComposeExt.rememberImageLoader
+import org.mjdev.tvlib.extensions.ModifierExt.conditional
+
+@Composable
+fun ImageBackground(
+    modifier: Modifier = Modifier,
+    image: Any? = null,
+    transform: ((color: Color) -> Brush)? = null,
+    contentAlignment: Alignment = Alignment.TopStart,
+    content: @Composable () -> Unit = {}
+) {
+    val context = LocalContext.current
+    val imageLoader = rememberImageLoader()
+    val bckColor = remember { mutableStateOf(Color.Transparent) }
+    Box(
+        modifier = modifier
+            .conditional(transform == null) {
+                background(bckColor.value, RectangleShape)
+            }
+            .conditional(transform != null) {
+                background(transform!!.invoke(bckColor.value), RectangleShape)
+            },
+        contentAlignment = contentAlignment
+    ) {
+        content()
+    }
+    LaunchedEffect(image) {
+        bckColor.value = imageLoader.execute(
+            ImageRequest.Builder(context).data(image).build()
+        ).drawable?.toBitmap()?.majorColor(Color.Black) ?: Color.Black
+    }
+}

+ 28 - 13
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/PhotoImage.kt

@@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
 import androidx.core.graphics.drawable.toDrawable
 import com.skydoves.landscapist.ImageOptions
 import com.skydoves.landscapist.coil.CoilImage
+import com.skydoves.landscapist.coil.CoilImageState
 import org.mjdev.tvlib.R
 import org.mjdev.tvlib.extensions.BitmapExt.majorColor
 import org.mjdev.tvlib.extensions.ComposeExt.isEditMode
@@ -41,14 +42,20 @@ import org.mjdev.tvlib.extensions.ComposeExt.rememberImageLoader
 import org.mjdev.tvlib.extensions.DrawableExt.asPhoto
 import org.mjdev.tvlib.extensions.ModifierExt.conditional
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
+import timber.log.Timber
 
 @SuppressLint("ModifierParameter")
 @Preview
 @Composable
 fun PhotoImage(
-    src: Any? = R.drawable.person,
-    placeholder: @Composable () -> Unit = {},
+    src: Any? = null,
     modifier: Modifier = Modifier,
+    placeholder: @Composable () -> Unit = {
+        ImageAny(
+            modifier = modifier,
+            src = R.drawable.broken_image
+        )
+    },
     alignment: Alignment = Alignment.Center,
     contentScale: ContentScale = ContentScale.Crop,
     alpha: Float = DefaultAlpha,
@@ -63,13 +70,25 @@ fun PhotoImage(
     shape: Shape = RoundedCornerShape(roundCornerSize),
     colorFilter: ColorFilter? = null,
     contentDescription: String? = null,
+    onImageStateChanged: (state: CoilImageState) -> Unit = {}
 ) {
     val isEdit = isEditMode()
     val context = LocalContext.current
     val imageLoader = rememberImageLoader()
+    val imageOptions = ImageOptions(
+        contentScale = contentScale,
+        alignment = alignment,
+        contentDescription = contentDescription,
+        alpha = alpha,
+        colorFilter = colorFilter,
+    )
     CoilImage(
-        imageLoader = { imageLoader },
-        imageModel = { src },
+        imageLoader = {
+            imageLoader
+        },
+        imageModel = {
+            src
+        },
         modifier = modifier
             .recomposeHighlighter()
             .conditional(isEdit) {
@@ -79,17 +98,13 @@ fun PhotoImage(
                 BorderStroke(borderSize, borderColor),
                 shape
             ),
-        imageOptions = ImageOptions(
-            contentScale = contentScale,
-            alignment = alignment,
-            contentDescription = contentDescription,
-            alpha = alpha,
-            colorFilter = colorFilter,
-        ),
-        failure = {
+        imageOptions = imageOptions,
+        failure = { failure ->
+            Timber.e(failure.reason)
             placeholder()
         },
-        onImageStateChanged = {
+        onImageStateChanged = { state ->
+            onImageStateChanged.invoke(state)
         },
         success = { state, _ ->
             val bitmap = state.imageBitmap?.asAndroidBitmap()

+ 62 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/immersivelist/ImmersiveList.kt

@@ -0,0 +1,62 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.immersivelist
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableIntState
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvlib.extensions.ModifierExt.bringIntoViewIfChildrenAreFocused
+
+@Suppress("IllegalExperimentalApiUsage")
+@OptIn(ExperimentalComposeUiApi::class)
+@ExperimentalTvMaterial3Api
+@Composable
+fun ImmersiveList(
+    background: @Composable ImmersiveListBackgroundScope.(index: Int, listHasFocus: Boolean) -> Unit,
+    modifier: Modifier = Modifier,
+    listAlignment: Alignment = Alignment.BottomEnd,
+    currentItemIndex: MutableIntState = remember { mutableIntStateOf(0) },
+    listHasFocus: MutableState<Boolean> = remember { mutableStateOf(false) },
+    list: @Composable ImmersiveListScope.() -> Unit,
+) {
+    Box(
+        modifier.bringIntoViewIfChildrenAreFocused()
+    ) {
+        ImmersiveListBackgroundScope(
+            this
+        ).background(
+            currentItemIndex.intValue,
+            listHasFocus.value
+        )
+        val focusManager = LocalFocusManager.current
+        Box(
+            Modifier
+                .align(listAlignment)
+                .onFocusChanged { focusState ->
+                    listHasFocus.value = focusState.hasFocus
+                }
+        ) {
+            ImmersiveListScope { idx ->
+                currentItemIndex.intValue = idx
+                focusManager.moveFocus(FocusDirection.Enter)
+            }.list()
+        }
+    }
+}

+ 70 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/immersivelist/ImmersiveListBackgroundScope.kt

@@ -0,0 +1,70 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.immersivelist
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.ImmersiveListDefaults
+
+@Immutable
+@ExperimentalTvMaterial3Api
+class ImmersiveListBackgroundScope internal constructor(boxScope: BoxScope) : BoxScope by boxScope {
+
+    @Suppress("unused")
+    @Composable
+    fun AnimatedVisibility(
+        visible: Boolean,
+        modifier: Modifier = Modifier,
+        enter: EnterTransition = ImmersiveListDefaults.EnterTransition,
+        exit: ExitTransition = ImmersiveListDefaults.ExitTransition,
+        label: String = "AnimatedVisibility",
+        content: @Composable AnimatedVisibilityScope.() -> Unit
+    ) {
+        androidx.compose.animation.AnimatedVisibility(
+            visible,
+            modifier,
+            enter,
+            exit,
+            label,
+            content
+        )
+    }
+
+    @Suppress("unused")
+    @Composable
+    fun AnimatedContent(
+        targetState: Int,
+        modifier: Modifier = Modifier,
+        transitionSpec: AnimatedContentTransitionScope<Int>.() -> ContentTransform = {
+            ImmersiveListDefaults.EnterTransition.togetherWith(ImmersiveListDefaults.ExitTransition)
+        },
+        contentAlignment: Alignment = Alignment.TopStart,
+        label: String = "AnimatedContent",
+        content: @Composable AnimatedVisibilityScope.(targetState: Int) -> Unit
+    ) {
+        androidx.compose.animation.AnimatedContent(
+            targetState,
+            modifier,
+            transitionSpec,
+            contentAlignment,
+            content = content,
+            label = label
+        )
+    }
+}

+ 30 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/immersivelist/ImmersiveListScope.kt

@@ -0,0 +1,30 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.immersivelist
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+
+@Immutable
+@ExperimentalTvMaterial3Api
+class ImmersiveListScope internal constructor(
+    private val onFocused: (Int) -> Unit
+) {
+
+    fun Modifier.immersiveListItem(index: Int): Modifier {
+        return this then onFocusChanged {
+            if (it.isFocused) {
+                onFocused(index)
+            }
+        }
+    }
+
+}

+ 3 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/text/AutoHideEmptyText.kt

@@ -24,6 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.TextUnit
 import androidx.tv.material3.ExperimentalTvMaterial3Api
 import androidx.tv.material3.LocalTextStyle
+import org.mjdev.tvlib.extensions.ComposeExt.textFrom
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 
 @OptIn(ExperimentalTvMaterial3Api::class)
@@ -48,7 +49,8 @@ fun AutoHideEmptyText(
     onTextLayout: (TextLayoutResult) -> Unit = {},
     style: TextStyle = LocalTextStyle.current
 ) {
-    if ((text != null) && (text.toString().isNotEmpty())) {
+    val textAny = textFrom(text)
+    if (textAny.isNotEmpty()) {
         TextAny(
             text,
             modifier.recomposeHighlighter(),

+ 22 - 16
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/ErrorMessage.kt

@@ -37,6 +37,7 @@ import org.mjdev.tvlib.ui.components.button.Button
 import org.mjdev.tvlib.R
 import org.mjdev.tvlib.ui.components.text.TextAny
 
+@Suppress("LocalVariableName")
 @OptIn(ExperimentalTvMaterial3Api::class)
 @TvPreview
 @Composable
@@ -58,11 +59,14 @@ fun ErrorMessage(
 ) {
 
     Box(
-        modifier = Modifier.fillMaxWidth().recomposeHighlighter()
+        modifier = Modifier
+            .fillMaxWidth()
+            .recomposeHighlighter()
     ) {
 
         ConstraintLayout(
-            modifier = Modifier.recomposeHighlighter()
+            modifier = Modifier
+                .recomposeHighlighter()
                 .fillMaxWidth()
                 .padding(contentPadding)
                 .background(backgroundColor, RoundedCornerShape(roundSize))
@@ -79,7 +83,8 @@ fun ErrorMessage(
             }
 
             TextAny(
-                modifier = Modifier.recomposeHighlighter()
+                modifier = Modifier
+                    .recomposeHighlighter()
                     .wrapContentSize()
                     .constrainAs(_title) {
                         top.linkTo(parent.top)
@@ -94,7 +99,8 @@ fun ErrorMessage(
             )
 
             BasicText(
-                modifier = Modifier.recomposeHighlighter()
+                modifier = Modifier
+                    .recomposeHighlighter()
                     .wrapContentSize()
                     .constrainAs(_message) {
                         top.linkTo(parent.top)
@@ -113,36 +119,36 @@ fun ErrorMessage(
                 maxLines = 4,
                 text =
                 error?.message ?: (stringResource(id = R.string.error_unknown) +
-                    "\nType: " +
-                    (error?.javaClass?.simpleName ?: Exception::class.simpleName))
+                        "\nType: " +
+                        (error?.javaClass?.simpleName ?: Exception::class.simpleName))
             )
 
             if (dismissible && (cancelText != null)) {
 
                 Button(
-                    modifier = Modifier.recomposeHighlighter()
+                    modifier = Modifier
+                        .recomposeHighlighter()
                         .padding(4.dp)
                         .constrainAs(_button) {
                             top.linkTo(parent.top, margin = 4.dp)
                             end.linkTo(parent.end, margin = roundSize)
                             bottom.linkTo(parent.bottom, margin = 4.dp)
                         },
-                    contentPadding=PaddingValues(
+                    contentPadding = PaddingValues(
                         8.dp,
                         4.dp
                     ),
                     containerColor = Color.Red,
                     onClick = {
                         onDismiss()
+                    },
+                    content = {
+                        TextAny(
+                            text = cancelText,
+                            color = color
+                        )
                     }
-                ) {
-
-                    TextAny(
-                        text = cancelText,
-                        color = color
-                    )
-
-                }
+                )
 
             }
 

+ 0 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/TVRow.kt

@@ -24,7 +24,6 @@ import androidx.tv.foundation.lazy.list.rememberTvLazyListState
 import org.mjdev.tvlib.annotations.TvPreview
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 
-// todo : scroll horizontally by touch
 @TvPreview
 @Composable
 fun TVRow(

+ 18 - 0
tvlib/src/main/res/drawable/broken_image.xml

@@ -0,0 +1,18 @@
+<!--
+  ~  Copyright (c) Milan Jurkulák 2023.
+  ~  Contact:
+  ~  e: mimoccc@gmail.com
+  ~  e: mj@mjdev.org
+  ~  w: https://mjdev.org
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="48dp"
+    android:height="48dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/black"
+      android:pathData="M180,840Q156,840 138,822Q120,804 120,780L120,180Q120,156 138,138Q156,120 180,120L780,120Q804,120 822,138Q840,156 840,180L840,780Q840,804 822,822Q804,840 780,840L180,840ZM223,526L395,354L565,524L736,353L780,397L780,180Q780,180 780,180Q780,180 780,180L180,180Q180,180 180,180Q180,180 180,180L180,483L223,526ZM180,780L780,780Q780,780 780,780Q780,780 780,780L780,482L736,438L565,609L395,439L223,611L180,568L180,780Q180,780 180,780Q180,780 180,780ZM180,780L180,780Q180,780 180,780Q180,780 180,780L180,482L180,542L180,457.1L180,397L180,180Q180,180 180,180Q180,180 180,180L180,180Q180,180 180,180Q180,180 180,180L180,483L180,483L180,568L180,568L180,780Q180,780 180,780Q180,780 180,780Z"/>
+</vector>

BIN
tvlib/src/main/res/drawable/person.png


+ 12 - 0
tvlib/src/main/res/drawable/person.xml

@@ -0,0 +1,12 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:width="48dp"
+    android:height="48dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/black"
+      android:pathData="M222,705Q285,661 347,637.5Q409,614 480,614Q551,614 613.5,637.5Q676,661 739,705Q783,651 801.5,596Q820,541 820,480Q820,335 722.5,237.5Q625,140 480,140Q335,140 237.5,237.5Q140,335 140,480Q140,541 159,596Q178,651 222,705ZM479.81,510Q422,510 382.5,470.31Q343,430.63 343,372.81Q343,315 382.69,275.5Q422.37,236 480.19,236Q538,236 577.5,275.69Q617,315.37 617,373.19Q617,431 577.31,470.5Q537.63,510 479.81,510ZM480.47,880Q398,880 325,848.5Q252,817 197.5,762.5Q143,708 111.5,635.23Q80,562.47 80,479.73Q80,397 111.5,324.5Q143,252 197.5,197.5Q252,143 324.77,111.5Q397.53,80 480.27,80Q563,80 635.5,111.5Q708,143 762.5,197.5Q817,252 848.5,324.53Q880,397.06 880,479.53Q880,562 848.5,635Q817,708 762.5,762.5Q708,817 635.47,848.5Q562.94,880 480.47,880ZM480,820Q535,820 587.5,804Q640,788 691,748Q640,712 587,693Q534,674 480,674Q426,674 373,693Q320,712 269,748Q320,788 372.5,804Q425,820 480,820ZM480,450Q514,450 535.5,428.5Q557,407 557,373Q557,339 535.5,317.5Q514,296 480,296Q446,296 424.5,317.5Q403,339 403,373Q403,407 424.5,428.5Q446,450 480,450ZM480,373Q480,373 480,373Q480,373 480,373Q480,373 480,373Q480,373 480,373Q480,373 480,373Q480,373 480,373Q480,373 480,373Q480,373 480,373ZM480,747Q480,747 480,747Q480,747 480,747Q480,747 480,747Q480,747 480,747Q480,747 480,747Q480,747 480,747Q480,747 480,747Q480,747 480,747Z"
+      tools:ignore="VectorPath" />
+</vector>