浏览代码

images glide

Milan Jurkulak 3 年之前
父节点
当前提交
4c5f32d40e
共有 37 个文件被更改,包括 2273 次插入 和 1062 次删除
  1. 1 1
      app/build.gradle.kts
  2. 48 66
      app/src/main/kotlin/org/mjdev/tvapp/app/Application.kt
  3. 3 1
      app/src/main/kotlin/org/mjdev/tvapp/data/local/TVChannel.kt
  4. 17 16
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/IPTVScreen.kt
  5. 20 9
      tvlib/build.gradle.kts
  6. 5 8
      tvlib/src/main/kotlin/org/mjdev/tvlib/activity/ComposableActivity.kt
  7. 1 2
      tvlib/src/main/kotlin/org/mjdev/tvlib/application/TvApplication.kt
  8. 120 64
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ComposeExt.kt
  9. 1 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/MediaItemExt.kt
  10. 108 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/ApplicationGlideApp.kt
  11. 72 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/LoaderTarget.kt
  12. 166 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/OkHttpUrlLoaderEx.kt
  13. 91 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/layout/LayoutInflaterDrawable.kt
  14. 123 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/layout/LayoutLoaderFactory.kt
  15. 30 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/layout/LayoutResId.kt
  16. 138 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/qrcode/BarcodeDrawable.kt
  17. 112 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/qrcode/BarcodeEncoder.kt
  18. 93 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/qrcode/QRCodeLoaderFactory.kt
  19. 13 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/qrcode/QRCodeRes.kt
  20. 70 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/svg/SvgDecoder.kt
  21. 40 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/svg/SvgDrawableTranscoder.kt
  22. 50 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/svg/SvgSoftwareLayerSetter.kt
  23. 1 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/UserAgentInterceptor.kt
  24. 275 275
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/coil/AlbumArtDecoder.kt
  25. 45 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/media/ItemType.kt
  26. 1 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/audiopreview/dailymotion/DailyMotionVideoView.kt
  27. 239 239
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/card/PreviewCard.kt
  28. 66 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/BoxWithControls.kt
  29. 117 191
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/Gallery.kt
  30. 77 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/ImmersiveInnerList.kt
  31. 43 66
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/ItemInfo.kt
  32. 17 13
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/icon/IconAny.kt
  33. 26 40
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/ImageAny.kt
  34. 14 38
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/ImageBackground.kt
  35. 4 9
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/PhotoImage.kt
  36. 3 2
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/immersivelist/ImmersiveList.kt
  37. 23 20
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/media/MediaPlayerContainer.kt

+ 1 - 1
app/build.gradle.kts

@@ -240,7 +240,7 @@ dependencies {
     // lottie
     implementation("com.airbnb.android:lottie-compose:6.1.0")
     // oauth
-    implementation("com.auth0.android:auth0:2.10.1")
+    implementation("com.auth0.android:auth0:2.10.2")
     implementation("com.auth0.android:jwtdecode:2.0.2")
     // widgets
     implementation("androidx.glance:glance-appwidget:1.0.0")

+ 48 - 66
app/src/main/kotlin/org/mjdev/tvapp/app/Application.kt

@@ -8,78 +8,60 @@
 
 package org.mjdev.tvapp.app
 
-import android.os.Build
-import coil.ImageLoader
-import coil.decode.GifDecoder
-import coil.decode.ImageDecoderDecoder
-import coil.decode.SvgDecoder
-import coil.decode.VideoFrameDecoder
-import coil.disk.DiskCache
-import coil.request.CachePolicy
 import dagger.hilt.android.HiltAndroidApp
-import okhttp3.Cache
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
-import org.mjdev.tvapp.sync.SyncService
-import org.mjdev.tvapp.sync.SyncService.Companion.createSyncAccount
 import org.mjdev.tvlib.application.TvApplication
-import org.mjdev.tvlib.extensions.GlobalExt.postDelayed
-import org.mjdev.tvlib.helpers.coil.AlbumArtDecoder
-import org.mjdev.tvlib.network.CacheInterceptor
-import java.io.File
 
 @HiltAndroidApp
 class Application : TvApplication() {
 
-    private val imageLoader by lazy {
-        ImageLoader.Builder(this)
-            .allowHardware(false)
-//            .allowRgb565(true)
-            .addLastModifiedToFileCacheKey(false)
-            .crossfade(false)
-            .diskCachePolicy(CachePolicy.ENABLED)
-            .memoryCachePolicy(CachePolicy.ENABLED)
-            .respectCacheHeaders(false)
-            .networkObserverEnabled(false)
-            .okHttpClient {
-                OkHttpClient.Builder()
-                    .cache(
-                        Cache(
-                            directory = File(
-                                cacheDir,
-                                "http_cache"
-                            ),
-                            maxSize = 1024L * 1024L * 1024L
-                        )
-                    )
-                    .addNetworkInterceptor(HttpLoggingInterceptor().apply {
-                        level = HttpLoggingInterceptor.Level.BODY
-                    })
-                    .addNetworkInterceptor(CacheInterceptor())
-                    .build()
-            }
-            .diskCache {
-                DiskCache.Builder()
-                    .directory(cacheDir.resolve("image_cache"))
-                    .maxSizePercent(0.7)
-                    .build()
-            }
-            .components {
-                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
-                    add(ImageDecoderDecoder.Factory())
-                } else {
-                    add(GifDecoder.Factory())
-                }
-                add(AlbumArtDecoder.Factory())
-                add(VideoFrameDecoder.Factory())
-                add(SvgDecoder.Factory())
-            }.build()
-    }
+//    private val imageLoader by lazy {
+//        ImageLoader.Builder(this)
+//            .allowHardware(false)
+////            .allowRgb565(true)
+//            .addLastModifiedToFileCacheKey(false)
+//            .crossfade(false)
+//            .diskCachePolicy(CachePolicy.ENABLED)
+//            .memoryCachePolicy(CachePolicy.ENABLED)
+//            .respectCacheHeaders(false)
+//            .networkObserverEnabled(false)
+//            .okHttpClient {
+//                OkHttpClient.Builder()
+//                    .cache(
+//                        Cache(
+//                            directory = File(
+//                                cacheDir,
+//                                "http_cache"
+//                            ),
+//                            maxSize = 1024L * 1024L * 1024L
+//                        )
+//                    )
+//                    .addNetworkInterceptor(HttpLoggingInterceptor().apply {
+//                        level = HttpLoggingInterceptor.Level.BODY
+//                    })
+//                    .addNetworkInterceptor(CacheInterceptor())
+//                    .build()
+//            }
+//            .diskCache {
+//                DiskCache.Builder()
+//                    .directory(cacheDir.resolve("image_cache"))
+//                    .maxSizePercent(0.7)
+//                    .build()
+//            }
+//            .components {
+//                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+//                    add(ImageDecoderDecoder.Factory())
+//                } else {
+//                    add(GifDecoder.Factory())
+//                }
+//                add(AlbumArtDecoder.Factory())
+//                add(VideoFrameDecoder.Factory())
+//                add(SvgDecoder.Factory())
+//            }.build()
+//    }
 
-    override fun onCreate() {
-        super.onCreate()
+//    override fun onCreate() {
+//        super.onCreate()
+//    }
 
-    }
-
-    override fun createImageLoader(): ImageLoader = imageLoader
+//    override fun createImageLoader(): ImageLoader = imageLoader
 }

+ 3 - 1
app/src/main/kotlin/org/mjdev/tvapp/data/local/TVChannel.kt

@@ -14,6 +14,7 @@ import io.objectbox.annotation.Entity
 import io.objectbox.annotation.Id
 import org.mjdev.tvapp.data.remote.Channel
 import org.mjdev.tvapp.data.remote.Stream
+import org.mjdev.tvlib.interfaces.ItemVideo
 import org.mjdev.tvlib.interfaces.ItemWithDescription
 import org.mjdev.tvlib.interfaces.ItemWithId
 import org.mjdev.tvlib.interfaces.ItemWithImage
@@ -32,7 +33,8 @@ class TVChannel() :
     ItemWithSubtitle<String>,
     ItemWithImage<String>,
     ItemWithUri<String>,
-    ItemWithDescription<String> {
+    ItemWithDescription<String>,
+    ItemVideo {
 
     constructor(stream: Stream, channel: Channel?) : this() {
 

+ 17 - 16
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/IPTVScreen.kt

@@ -8,15 +8,12 @@
 
 package org.mjdev.tvapp.ui.screens
 
-import androidx.compose.foundation.background
 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.remember
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.media3.common.MediaItem
 import androidx.navigation.navArgument
@@ -63,21 +60,25 @@ class IPTVScreen : Screen() {
             viewModel.mediaItemsFor(data)
         }
 
-        val startIndex = dataList.indexOf { item ->
-            item.uri.contentEquals(data.uri)
+        val index = remember(data) {
+            dataList.indexOf<Any?> { item -> item.uri == data.uri }
         }
 
-        val state = rememberMediaPlayerState(
-            items = dataList,
-            itemToPlay = startIndex
-        )
-
-        MediaPlayerContainer(
-            modifier = Modifier
-                .fillMaxSize()
-                .background(Color.Black, RectangleShape),
-            state = state
-        )
+//        Gallery (
+//            modifier = Modifier.fillMaxSize(),
+//            list = dataList,
+//            index = index,
+//            customContentViewer = { _, type, _ ->
+                MediaPlayerContainer(
+                    modifier = Modifier.fillMaxSize(),
+//                    visible = (type == ItemType.Video || type == ItemType.Audio),
+                    state = rememberMediaPlayerState(
+                        items = dataList,
+                        itemToPlay = index
+                    )
+                )
+//            }
+//        )
 
     }
 

+ 20 - 9
tvlib/build.gradle.kts

@@ -105,6 +105,7 @@ dependencies {
     // base libs
     implementation("androidx.core:core-ktx:1.12.0")
     implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
     // reflect / debug purposes only
     implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.10")
     // window manager
@@ -133,16 +134,26 @@ dependencies {
     // moshi json
     implementation("com.squareup.moshi:moshi:1.15.0")
     implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
+    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
     ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
     // sandwich
     implementation("com.github.skydoves:sandwich:1.3.9")
     // image loading
-    implementation("io.coil-kt:coil-base:2.4.0")
-    implementation("io.coil-kt:coil-gif:2.4.0")
-    implementation("io.coil-kt:coil-svg:2.4.0")
-    implementation("io.coil-kt:coil-video:2.4.0")
-    implementation("io.coil-kt:coil-compose:2.4.0")
-    implementation("com.github.skydoves:landscapist-coil:2.2.10")
+//    implementation("io.coil-kt:coil-base:2.4.0")
+//    implementation("io.coil-kt:coil-gif:2.4.0")
+//    implementation("io.coil-kt:coil-svg:2.4.0")
+//    implementation("io.coil-kt:coil-video:2.4.0")
+//    implementation("io.coil-kt:coil-compose:2.4.0")
+//    implementation("com.github.skydoves:landscapist-coil:2.2.10")
+    implementation("com.github.bumptech.glide:okhttp3-integration:4.15.1")
+    implementation("com.github.skydoves:landscapist-glide:2.2.10")
+    implementation("com.github.skydoves:landscapist-transformation:2.2.10")
+    implementation("com.github.skydoves:landscapist-palette:2.2.10")
+    implementation("com.github.skydoves:landscapist-placeholder:2.2.10")
+    // zxing
+    implementation("com.google.zxing:core:3.5.2")
+    // svg
+    implementation("com.caverock:androidsvg-aar:1.4")
     // debug
     implementation("com.jakewharton.timber:timber:5.0.1")
     // dagger core
@@ -198,9 +209,9 @@ dependencies {
     // dynamic theme
     implementation("com.google.android.material:material:1.10.0")
     // stripe payments
-    implementation ("com.stripe:stripe-android:20.32.1")
-    implementation ("com.stripe:stripecardscan:20.32.1")
-    implementation ("com.stripe:financial-connections:20.32.1")
+    implementation("com.stripe:stripe-android:20.32.1")
+    implementation("com.stripe:stripecardscan:20.32.1")
+    implementation("com.stripe:financial-connections:20.32.1")
     // yt
     // ...
     // constraints

+ 5 - 8
tvlib/src/main/kotlin/org/mjdev/tvlib/activity/ComposableActivity.kt

@@ -30,12 +30,9 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
 import androidx.tv.material3.MaterialTheme
 import androidx.tv.material3.NonInteractiveSurfaceDefaults
 import androidx.tv.material3.Surface
-import coil.ImageLoader
 import com.github.anrwatchdog.ANRWatchDog
 import org.mjdev.tvlib.BuildConfig
 import org.mjdev.tvlib.annotations.Previews
-import org.mjdev.tvlib.application.TvApplication
-import org.mjdev.tvlib.extensions.ComposeExt.createImageLoader
 import org.mjdev.tvlib.extensions.ModifierExt.swipeGestures
 import org.mjdev.tvlib.extensions.NavExt.navControllerEx
 import org.mjdev.tvlib.extensions.NavGraphBuilderExt.screen
@@ -76,9 +73,9 @@ open class ComposableActivity : ComponentActivity() {
             }
     }
 
-    val imageLoader: ImageLoader by lazy {
-        createImageLoader(this)
-    }
+//    val imageLoader: ImageLoader by lazy {
+//        createImageLoader(this)
+//    }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         Timber.d("Activity ${this::class.simpleName} created.")
@@ -182,7 +179,7 @@ open class ComposableActivity : ComponentActivity() {
         }
     }
 
-    open fun createImageLoader(): ImageLoader? =
-        (application as? TvApplication)?.createImageLoader()
+//    open fun createImageLoader(): ImageLoader? =
+//        (application as? TvApplication)?.createImageLoader()
 
 }

+ 1 - 2
tvlib/src/main/kotlin/org/mjdev/tvlib/application/TvApplication.kt

@@ -11,7 +11,6 @@
 package org.mjdev.tvlib.application
 
 import android.app.Application
-import coil.ImageLoader
 import com.google.android.material.color.DynamicColors
 
 open class TvApplication : Application() {
@@ -20,5 +19,5 @@ open class TvApplication : Application() {
         DynamicColors.applyToActivitiesIfAvailable(this)
     }
 
-    open fun createImageLoader(): ImageLoader? = null
+//    open fun createImageLoader(): ImageLoader? = null
 }

+ 120 - 64
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ComposeExt.kt

@@ -17,7 +17,7 @@ import android.content.res.Configuration
 import android.content.res.Resources
 import android.graphics.Bitmap
 import android.graphics.drawable.BitmapDrawable
-import android.os.Build
+import android.view.Gravity
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
@@ -25,35 +25,37 @@ import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.State
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.onKeyEvent
 import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalInspectionMode
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
+import androidx.core.graphics.drawable.toBitmap
+import androidx.media3.common.MediaItem
 import androidx.tv.material3.DrawerState
 import androidx.tv.material3.DrawerValue
 import androidx.tv.material3.ExperimentalTvMaterial3Api
-import coil.ImageLoader
-import coil.decode.GifDecoder
-import coil.decode.ImageDecoderDecoder
-import coil.decode.SvgDecoder
-import coil.decode.VideoFrameDecoder
-import coil.disk.DiskCache
-import coil.request.CachePolicy
+import com.bumptech.glide.Glide
 import kotlinx.coroutines.CoroutineScope
-import okhttp3.Cache
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
-import org.mjdev.tvlib.activity.ComposableActivity
-import org.mjdev.tvlib.extensions.ContextExt.activity
-import org.mjdev.tvlib.helpers.coil.AlbumArtDecoder
-import org.mjdev.tvlib.network.CacheInterceptor
-import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.mjdev.tvlib.extensions.BitmapExt.majorColor
+import org.mjdev.tvlib.extensions.MediaItemExt.uri
+import org.mjdev.tvlib.helpers.media.ItemType
+import org.mjdev.tvlib.interfaces.ItemPhoto
+import org.mjdev.tvlib.interfaces.ItemWithBackground
+import org.mjdev.tvlib.interfaces.ItemWithImage
+import timber.log.Timber
 
 @Suppress("MemberVisibilityCanBePrivate")
 object ComposeExt {
@@ -188,59 +190,113 @@ object ComposeExt {
         resources: Resources = LocalContext.current.resources
     ): BitmapDrawable = BitmapDrawable(resources, this)
 
-    @SuppressLint("RememberReturnType", "ComposableNaming")
-    @Composable
-    fun rememberImageLoader(): ImageLoader {
-        val context = LocalContext.current
-        val activity = runCatching { context.activity<ComposableActivity>() }.getOrNull()
-        val cachedImageLoader = activity?.imageLoader
-        return cachedImageLoader ?: remember(context) {
-            activity?.createImageLoader() ?: createImageLoader(context)
-        }
+    fun Modifier.onKey(
+        keyCode: Int,
+        action: Int = android.view.KeyEvent.ACTION_DOWN,
+        block: () -> Unit
+    ): Modifier = this then onKeyEvent { ev ->
+        if (ev.nativeKeyEvent.action == action) {
+            if (ev.nativeKeyEvent.keyCode == keyCode) {
+                block()
+                true
+            } else false
+        } else false
     }
 
-    fun createImageLoader(context: Context) = ImageLoader.Builder(context)
-        .allowHardware(false)
-        .allowRgb565(true)
-        .addLastModifiedToFileCacheKey(false)
-        .crossfade(false)
-        .diskCachePolicy(CachePolicy.ENABLED)
-        .memoryCachePolicy(CachePolicy.DISABLED)
-        .respectCacheHeaders(false)
-        .networkObserverEnabled(true)
-        .okHttpClient {
-            OkHttpClient.Builder()
-                .cache(
-                    Cache(
-                        directory = File(
-                            context.cacheDir,
-                            "http_cache"
-                        ),
-                        maxSize = 1024L * 1024L * 1024L
-                    )
-                )
-                .addNetworkInterceptor(HttpLoggingInterceptor().apply {
-                    level = HttpLoggingInterceptor.Level.BODY
-                })
-                .addNetworkInterceptor(CacheInterceptor())
-                .build()
-        }
-        .diskCache {
-            DiskCache.Builder()
-                .directory(context.cacheDir.resolve("image_cache"))
-                .maxSizePercent(0.7)
-                .build()
+    fun createVerticalColorBrush(
+        color: Color,
+        gravity: Int
+    ): Brush = Brush.verticalGradient(
+        when (gravity) {
+            Gravity.TOP -> listOf(
+                color,
+                color,
+                color,
+                color.copy(alpha = 0.8f),
+                color.copy(alpha = 0.5f),
+                Color.Transparent
+            )
+
+            Gravity.BOTTOM -> listOf(
+                Color.Transparent,
+                color.copy(alpha = 0.5f),
+                color.copy(alpha = 0.8f),
+                color,
+                color,
+                color,
+            )
+
+            else -> listOf(color)
         }
-        .components {
-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
-                add(ImageDecoderDecoder.Factory())
+    )
+
+    // todo mediaItem only
+    @Composable
+    fun rememberImageFromItem(image: Any?): State<Any?> = remember(image) {
+        derivedStateOf {
+            if (image is MediaItem) {
+                image.uri
             } else {
-                add(GifDecoder.Factory())
+                val photo = (image as? ItemPhoto)?.uri
+                val img = (image as? ItemWithImage<*>)?.image
+                val background = (image as? ItemWithBackground<*>)?.background
+                val color = image as? Color
+                val string = image.toString()
+                color ?: photo ?: img ?: background ?: string
             }
-            add(AlbumArtDecoder.Factory())
-            add(VideoFrameDecoder.Factory())
-            add(SvgDecoder.Factory())
         }
-        .build()
+    }
+
+    @Composable
+    fun <T> produceStateInCoroutine(
+        initialValue: T,
+        key: Any? = null,
+        block: suspend () -> T
+    ) = produceState(
+        initialValue = initialValue,
+        key1 = key
+    ) {
+        withContext(Dispatchers.IO) {
+            value = block()
+        }
+    }
+
+    @Composable
+    fun <T> rememberDerivedState(
+        key: Any? = Unit,
+        initialValue: T,
+        block: suspend () -> T
+    ) = produceStateInCoroutine(initialValue, key, block)
+
+    @Composable
+    fun <T> rememberDerivedState(
+        initialValue: T,
+        block: suspend () -> T
+    ) = produceStateInCoroutine(initialValue, initialValue, block)
+
+    // todo mediaItem only
+    @Composable
+    fun rememberColorFromImage(
+        image: Any?,
+        initialColor: Color = Color.Black,
+        context: Context = LocalContext.current,
+        onError: (error: Throwable) -> Unit = { error -> Timber.e(error) }
+    ): State<Color> = rememberDerivedState(image, initialColor) {
+        runCatching {
+            Glide
+                .with(context)
+                .load(image)
+                .submit()
+                .get()
+                .toBitmap()
+                .majorColor(Color.Transparent)
+        }.onFailure { e ->
+            onError(e)
+        }.getOrNull() ?: initialColor
+    }
+
+    // todo more types
+    @Composable
+    fun remberItemType(item: Any?): ItemType = remember(item) { ItemType(item) }
 
 }

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

@@ -56,6 +56,7 @@ object MediaItemExt {
             else -> MediaMetadata.MEDIA_TYPE_MIXED
         }
 
+    // todo : MediaItem
     val Any?.imageUrl: Any?
         get() {
             val photo = (this as? ItemPhoto)?.uri

+ 108 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/ApplicationGlideApp.kt

@@ -0,0 +1,108 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.glide
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.PictureDrawable
+import android.util.Log
+import com.bumptech.glide.Glide
+import com.bumptech.glide.GlideBuilder
+import com.bumptech.glide.Registry
+import com.bumptech.glide.annotation.GlideModule
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool
+import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
+import com.bumptech.glide.load.engine.cache.LruResourceCache
+import com.bumptech.glide.load.engine.cache.MemorySizeCalculator
+import com.bumptech.glide.load.model.GlideUrl
+import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
+import com.bumptech.glide.module.AppGlideModule
+import com.bumptech.glide.request.RequestOptions
+import com.caverock.androidsvg.SVG
+import org.mjdev.tvlib.BuildConfig
+import org.mjdev.tvlib.glide.sources.layout.LayoutLoaderFactory
+import org.mjdev.tvlib.glide.sources.layout.LayoutResId
+import org.mjdev.tvlib.glide.sources.svg.SvgDecoder
+import org.mjdev.tvlib.glide.sources.svg.SvgDrawableTranscoder
+import java.io.InputStream
+
+/**
+ * Custom loader for images & predefined settings
+ * */
+@GlideModule
+class ApplicationGlideApp : AppGlideModule() {
+    /**
+     * Cache size, maximal images cache on device
+     */
+    private val cacheSize256MegaBytes: Long = 256L * 1024L * 1024L
+
+    /**
+     * Apply custom image loader options
+     * @param context Context
+     * @param builder GlideBuilder
+     */
+    override fun applyOptions(context: Context, builder: GlideBuilder) {
+        builder.apply {
+            val calculator = MemorySizeCalculator.Builder(context).build()
+
+            val customMemoryCacheSize = calculator.memoryCacheSize
+            val customBitmapPoolSize = calculator.bitmapPoolSize
+
+            builder.setMemoryCache(LruResourceCache(customMemoryCacheSize.toLong()))
+            builder.setBitmapPool(LruBitmapPool(customBitmapPoolSize.toLong()))
+
+            setDiskCache(InternalCacheDiskCacheFactory(context, cacheSize256MegaBytes))
+
+            if (BuildConfig.DEBUG)
+                setLogLevel(Log.VERBOSE)
+            else
+                setLogLevel(Log.ERROR)
+
+            setDefaultRequestOptions(
+                RequestOptions()
+                    .dontAnimate()
+                    .dontTransform()
+                    .skipMemoryCache(true)
+                    .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
+                    .downsample(DownsampleStrategy.DEFAULT)
+                    .encodeFormat(Bitmap.CompressFormat.PNG)
+                    .encodeQuality(100)
+//                    .placeholder(EMPTY_DRAWABLE)
+//                    .error(EMPTY_DRAWABLE)
+//                    .fallback(EMPTY_DRAWABLE)
+            )
+        }
+    }
+
+    /**
+     * Register application custom components
+     * @param context Context
+     * @param glide Glide
+     * @param registry Registry
+     */
+    override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
+        super.registerComponents(context, glide, registry)
+        // call registration
+        registry.apply {
+            // All urls
+            replace(
+                GlideUrl::class.java,
+                InputStream::class.java,
+                OkHttpUrlLoaderEx.Factory(context)
+            )
+            // LayoutRes support (create images from layout res Id)
+            prepend(LayoutResId::class.java, Drawable::class.java, LayoutLoaderFactory(context))
+            // SVG support
+            register(SVG::class.java, PictureDrawable::class.java, SvgDrawableTranscoder())
+            append(InputStream::class.java, SVG::class.java, SvgDecoder())
+        }
+    }
+}

+ 72 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/LoaderTarget.kt

@@ -0,0 +1,72 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.glide
+
+import android.graphics.drawable.Drawable
+import com.bumptech.glide.request.target.CustomTarget
+import com.bumptech.glide.request.transition.Transition
+
+/**
+ * Custom Glide simplified load target
+ * @property onLoadStarted Function1<[@kotlin.ParameterName] Drawable?, Unit>? on start handler
+ * @property onLoadFinished Function1<[@kotlin.ParameterName] Drawable?, Unit>? on finish handler
+ * @constructor
+ */
+class LoaderTarget(
+        width: Int = SIZE_ORIGINAL,
+        height: Int = SIZE_ORIGINAL,
+        val onLoadStarted: ((drawable: Drawable?) -> Unit)? = null,
+        val onLoadFinished: ((drawable: Drawable?) -> Unit)? = null
+) : CustomTarget<Drawable>(
+        if (width > 0) width else if (height > 0) height else SIZE_ORIGINAL,
+        if (height > 0) height else if (width > 0) width else SIZE_ORIGINAL
+) {
+    constructor(
+            onLoadStarted: ((drawable: Drawable?) -> Unit)? = null,
+            onLoadFinished: ((drawable: Drawable?) -> Unit)? = null
+    ) : this(
+            SIZE_ORIGINAL,
+            SIZE_ORIGINAL,
+            onLoadStarted,
+            onLoadFinished
+    )
+
+    /**
+     * Load start handler
+     * @param placeholder Drawable?
+     */
+    override fun onLoadStarted(placeholder: Drawable?) {
+        onLoadStarted?.invoke(placeholder)
+    }
+
+    /**
+     * Load sucessfully finished handler
+     * @param resource Drawable
+     * @param transition Transition<in Drawable>?
+     */
+    override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
+        onLoadFinished?.invoke(resource)
+    }
+
+    /**
+     * Load stopped or cleared handler
+     * @param placeholder Drawable?
+     */
+    override fun onLoadCleared(placeholder: Drawable?) {
+        onLoadFinished?.invoke(placeholder)
+    }
+
+    /**
+     * Load failed handler
+     * @param errorDrawable Drawable?
+     */
+    override fun onLoadFailed(errorDrawable: Drawable?) {
+        onLoadFinished?.invoke(errorDrawable)
+    }
+}

+ 166 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/OkHttpUrlLoaderEx.kt

@@ -0,0 +1,166 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.glide
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.annotation.Keep
+import com.bumptech.glide.integration.okhttp3.OkHttpStreamFetcher
+import com.bumptech.glide.load.Options
+import com.bumptech.glide.load.model.GlideUrl
+import com.bumptech.glide.load.model.ModelLoader
+import com.bumptech.glide.load.model.ModelLoader.LoadData
+import com.bumptech.glide.load.model.ModelLoaderFactory
+import com.bumptech.glide.load.model.MultiModelLoaderFactory
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import org.mjdev.tvlib.BuildConfig
+import java.io.InputStream
+import java.util.concurrent.TimeUnit
+
+/**
+ * Custom URL loader
+ * This loader implements image load debugging availability
+ * @property factory Factory
+ * @property nonDebugClient OkHttpClient
+ * @property debugClient OkHttpClient?
+ * @property client OkHttpClient
+ * @property context Context
+ * @constructor
+ */
+@Keep
+class OkHttpUrlLoaderEx(
+    private val factory: Factory
+) : ModelLoader<GlideUrl, InputStream>, SharedPreferences.OnSharedPreferenceChangeListener {
+    private val nonDebugClient: OkHttpClient = OkHttpClient.Builder().apply {
+        readTimeout(30, TimeUnit.SECONDS)
+        writeTimeout(30, TimeUnit.SECONDS)
+        connectTimeout(30, TimeUnit.SECONDS)
+        followRedirects(true)
+        followSslRedirects(true)
+    }.build()
+
+    /**
+     * Client that make requests debug info
+     */
+    private var debugClient: OkHttpClient? = null
+
+    /**
+     * Standard client, not making any info
+     */
+    private val client: OkHttpClient?
+        get() = if (BuildConfig.DEBUG) debugClient else nonDebugClient
+
+    /**
+     * Context to be used
+     */
+    val context: Context get() = factory.context
+
+    init {
+//        context.sharedPrefs.preferences.registerOnSharedPreferenceChangeListener(this)
+        initialize()
+    }
+
+    /**
+     * Initialize loader
+     */
+    private fun initialize() {
+        debugClient = OkHttpClient.Builder().apply {
+            readTimeout(30, TimeUnit.SECONDS)
+            writeTimeout(30, TimeUnit.SECONDS)
+            connectTimeout(30, TimeUnit.SECONDS)
+            followRedirects(true)
+            followSslRedirects(true)
+            addInterceptor(
+                HttpLoggingInterceptor().setLevel(
+                    HttpLoggingInterceptor.Level.HEADERS
+                )
+            )
+//            addInterceptor(factory.chuckInterceptor)
+        }.build()
+    }
+
+    /**
+     * De-initialize loader
+     */
+    fun teardown() {
+//        context.sharedPrefs.preferences.unregisterOnSharedPreferenceChangeListener(this)
+    }
+
+    /**
+     * Preferences change handler, to enable/disable debugging
+     * @param sharedPreferences SharedPreferences
+     * @param key String
+     */
+    override fun onSharedPreferenceChanged(
+        sharedPreferences: SharedPreferences?,
+        key: String?
+    ) {
+//        if (key == context.sharedPrefs.getName(R.id.settings_log_http_include_images)) {
+//            initialize()
+//        }
+    }
+
+    /**
+     * Checker function, see glide library details
+     * @param url GlideUrl
+     * @return Boolean
+     */
+    override fun handles(url: GlideUrl): Boolean {
+        return true
+    }
+
+    /**
+     * Prepare loading
+     * @param model GlideUrl
+     * @param width Int
+     * @param height Int
+     * @param options Options
+     * @return LoadData<InputStream?>
+     */
+    override fun buildLoadData(
+        model: GlideUrl,
+        width: Int,
+        height: Int,
+        options: Options
+    ): LoadData<InputStream?> {
+        return LoadData(model, OkHttpStreamFetcher(client, model))
+    }
+
+    /**
+     * Custom factory
+     * @property context Context
+     * @property factory OkHttpUrlLoaderEx?
+     * @constructor
+     */
+    class Factory(val context: Context) : ModelLoaderFactory<GlideUrl, InputStream> {
+        var factory: OkHttpUrlLoaderEx? = null
+
+        /**
+         * Prepare builder
+         * @param multiFactory MultiModelLoaderFactory
+         * @return ModelLoader<GlideUrl, InputStream>
+         */
+        @Synchronized
+        override fun build(
+            multiFactory: MultiModelLoaderFactory
+        ): ModelLoader<GlideUrl, InputStream> {
+            factory = factory ?: OkHttpUrlLoaderEx(this)
+            return factory!!
+        }
+
+        /**
+         * Destroy builder
+         */
+        override fun teardown() {
+            factory?.teardown()
+            factory = null
+        }
+    }
+}

+ 91 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/layout/LayoutInflaterDrawable.kt

@@ -0,0 +1,91 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.glide.sources.layout
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.ColorFilter
+import android.graphics.Paint
+import android.graphics.PixelFormat
+import android.graphics.drawable.Drawable
+import android.view.LayoutInflater
+import android.view.View
+import androidx.annotation.LayoutRes
+
+/**
+ * Custom drawable that inflate view to itself
+ * Used for loading image error customized replacement
+ * @property context Context
+ * @property resId Int
+ * @property paint Paint
+ * @constructor
+ */
+@Suppress("unused")
+class LayoutInflaterDrawable(
+    val context: Context,
+    @LayoutRes val resId: Int
+) : Drawable() {
+    private var paint: Paint = Paint()
+
+    init {
+        paint.isAntiAlias = true
+    }
+
+    override fun draw(canvas: Canvas) {
+        val displaySize = context.resources.displayMetrics
+        var width = bounds.width()
+        var height = bounds.height()
+        if ((width == 0 && height == 0)) {
+            width = intrinsicWidth
+            height = intrinsicHeight
+        }
+        if ((width == 0 && height == 0)) {
+            width = displaySize.widthPixels
+            height = displaySize.heightPixels
+        }
+        resId.let {
+            if (it == View.NO_ID)
+                View(context)
+            else
+                LayoutInflater.from(context).inflate(resId, null, false)
+        }.let { v ->
+            v.measure(
+                    View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+                    View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)
+            )
+            v.layout(0, 0, v.measuredWidth, v.measuredHeight)
+            v.background?.draw(canvas)
+            v.draw(canvas)
+        }
+    }
+
+    override fun setAlpha(alpha: Int) {
+        paint.alpha = alpha
+    }
+
+    override fun setColorFilter(colorFilter: ColorFilter?) {
+        paint.colorFilter = colorFilter
+    }
+
+    @Deprecated("Deprecated in Java",
+        ReplaceWith(
+            "PixelFormat.TRANSLUCENT",
+            "android.graphics.PixelFormat"
+        )
+    )
+    override fun getOpacity(): Int {
+        return PixelFormat.TRANSLUCENT
+    }
+
+    companion object {
+        fun Int.toDrawable(context: Context): Drawable {
+            return LayoutInflaterDrawable(context, this)
+        }
+    }
+}

+ 123 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/layout/LayoutLoaderFactory.kt

@@ -0,0 +1,123 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.glide.sources.layout
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import com.bumptech.glide.Priority
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.Options
+import com.bumptech.glide.load.data.DataFetcher
+import com.bumptech.glide.load.model.ModelLoader
+import com.bumptech.glide.load.model.ModelLoaderFactory
+import com.bumptech.glide.load.model.MultiModelLoaderFactory
+import com.bumptech.glide.signature.ObjectKey
+
+/**
+ * Inflate view to a drawable thru glide library.
+ * Useful for custom drawables.
+ * Usage:
+ * GlideApp.with(context).load(LayoutResId(R.layout.layout_file)).into(target)
+ * @property context Context
+ * @property layoutLoader LayoutLoader?
+ * @constructor
+ */
+class LayoutLoaderFactory(val context: Context) : ModelLoaderFactory<LayoutResId?, Drawable?> {
+    private var layoutLoader: LayoutLoader? = null
+
+    /**
+     * Prepare loader
+     * @param multiFactory MultiModelLoaderFactory
+     * @return ModelLoader<LayoutResId?, Drawable?>
+     */
+    override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<LayoutResId?, Drawable?> {
+        if (layoutLoader == null) {
+            layoutLoader = LayoutLoader(context)
+        }
+        return layoutLoader!!
+    }
+
+    /**
+     * Destroy loader
+     */
+    override fun teardown() {
+        layoutLoader = null
+    }
+
+    /**
+     * Custom class for layout to drawable conversion
+     * @property context Context
+     * @constructor
+     */
+    private class LayoutLoader(val context: Context) : ModelLoader<LayoutResId?, Drawable?> {
+        /**
+         * Handling indicator see glide library description
+         * @param model LayoutResId
+         * @return Boolean
+         */
+        override fun handles(model: LayoutResId): Boolean {
+            return true
+        }
+
+        /**
+         * Build loader
+         * @param model LayoutResId
+         * @param width Int
+         * @param height Int
+         * @param options Options
+         * @return ModelLoader.LoadData<Drawable?>
+         */
+        override fun buildLoadData(model: LayoutResId, width: Int, height: Int, options: Options): ModelLoader.LoadData<Drawable?> {
+            return ModelLoader.LoadData(ObjectKey(model.toString()), LayoutFetcher(context, model, width, height))
+        }
+
+        /**
+         * Custom drawable fetcher
+         * @property context Context
+         * @property model LayoutResId
+         * @property width Int
+         * @property height Int
+         * @constructor
+         */
+        class LayoutFetcher(val context: Context, val model: LayoutResId, var width: Int = 0, var height: Int = 0) : DataFetcher<Drawable?> {
+            override fun cleanup() {}
+            override fun cancel() {}
+
+            /**
+             * Type of data returned
+             * @return Class<Drawable?>
+             */
+            @Suppress("UNCHECKED_CAST")
+            override fun getDataClass(): Class<Drawable?> {
+                return Drawable::class.java as Class<Drawable?>
+            }
+
+            /**
+             * Image source
+             * @return DataSource
+             */
+            override fun getDataSource(): DataSource {
+                return DataSource.LOCAL
+            }
+
+            /**
+             * Load image data
+             * @param priority Priority
+             * @param callback DataCallback<in Drawable?>
+             */
+            override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Drawable?>) {
+                try {
+                    callback.onDataReady(LayoutInflaterDrawable(context, model.resId))
+                } catch (e: Exception) {
+                    callback.onLoadFailed(e)
+                }
+            }
+        }
+    }
+}

+ 30 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/layout/LayoutResId.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.glide.sources.layout
+
+import android.view.View
+import androidx.annotation.LayoutRes
+
+/**
+ * Inflate view to a drawable thru glide library.
+ * Useful for custom drawables.
+ * Usage:
+ * GlideApp.with(context).load(LayoutResId(R.layout.layout_file)).into(target)
+ * @property resId Int
+ * @constructor
+ */
+class LayoutResId(@LayoutRes val resId: Int = View.NO_ID) {
+    /**
+     * return string representation
+     * @return String
+     */
+    override fun toString(): String {
+        return resId.toString()
+    }
+}

+ 138 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/qrcode/BarcodeDrawable.kt

@@ -0,0 +1,138 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.glide.sources.qrcode
+
+import android.graphics.*
+import android.graphics.drawable.Drawable
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.WriterException
+import timber.log.Timber
+
+@Suppress("MemberVisibilityCanBePrivate", "unused")
+class BarcodeDrawable(
+    val text: String,
+    val format: BarcodeFormat,
+    val size: Int = -1,
+    private val foregroundColor: Int = BLACK,
+    val backgroundColor: Int = WHITE
+) : Drawable() {
+    private var paint: Paint = Paint()
+
+    init {
+        paint.isAntiAlias = true
+    }
+
+    override fun getIntrinsicWidth(): Int {
+        return if (size > 0) size else super.getIntrinsicWidth()
+    }
+
+    override fun getIntrinsicHeight(): Int {
+        return if (size > 0) size else super.getIntrinsicHeight()
+    }
+
+    override fun draw(canvas: Canvas) {
+        generateBarcode(
+            text,
+            bounds.width(),
+            bounds.height(),
+            format,
+            foregroundColor,
+            backgroundColor
+        )?.also { bmp ->
+            canvas.drawBitmap(bmp, null, bounds, paint)
+            bmp.recycle()
+        }
+    }
+
+    override fun setAlpha(alpha: Int) {
+        paint.alpha = alpha
+    }
+
+    @Deprecated("Deprecated in Java",
+        ReplaceWith(
+            "PixelFormat.OPAQUE",
+            "android.graphics.PixelFormat"
+        )
+    )
+    override fun getOpacity(): Int {
+        return PixelFormat.OPAQUE
+    }
+
+    override fun setColorFilter(colorFilter: ColorFilter?) {
+        paint.colorFilter = colorFilter
+    }
+
+    private fun generateBarcode(
+        text: String?,
+        width: Int = size,
+        height: Int = size,
+        barcodeFormat: BarcodeFormat = BarcodeFormat.QR_CODE,
+        foregroundColor: Int = BLACK,
+        backgroundColor: Int = WHITE
+    ): Bitmap? {
+        return try {
+            BarcodeEncoder.encodeBitmap(
+                contents = text,
+                format = barcodeFormat,
+                width = if (width > 0) width else DEFAULT_WIDTH,
+                height = if (height > 0) height else DEFAULT_HEIGHT,
+                backgroundColor = backgroundColor,
+                foregroundColor = foregroundColor,
+            )
+        } catch (e: WriterException) {
+            Timber.e(e)
+            null
+        }
+    }
+
+    companion object {
+
+        /**
+         * Size of EAN8
+         */
+        const val LENGTH_EAN_8 = 8
+
+        /**
+         * Size of EAN13
+         */
+        const val LENGTH_EAN_13 = 13
+
+        /**
+         * Default width of drawable when not present
+         */
+        const val DEFAULT_WIDTH = 578
+
+        /**
+         * Default height of drawable when not present
+         */
+        const val DEFAULT_HEIGHT = 342
+
+        /**
+         * Guess type of requested barcode
+         * @param text String?
+         * @return BarcodeFormat
+         */
+        fun guessType(text: String?): BarcodeFormat {
+            return if (text == null) {
+                BarcodeFormat.CODE_128
+            } else {
+                when (text.length) {
+                    LENGTH_EAN_8 -> BarcodeFormat.EAN_8
+                    LENGTH_EAN_13 -> BarcodeFormat.EAN_13
+                    else -> BarcodeFormat.CODE_128
+                }
+            }
+        }
+
+        const val BLACK = BarcodeEncoder.BLACK
+        const val WHITE = BarcodeEncoder.WHITE
+
+    }
+
+}

+ 112 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/qrcode/BarcodeEncoder.kt

@@ -0,0 +1,112 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.glide.sources.qrcode
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.EncodeHintType
+import com.google.zxing.MultiFormatWriter
+import com.google.zxing.WriterException
+import com.google.zxing.common.BitMatrix
+
+@Suppress("unused")
+object BarcodeEncoder {
+
+    private fun createBitmap(
+        matrix: BitMatrix,
+        backgroundColor: Int = WHITE,
+        foregroundColor: Int = BLACK
+    ): Bitmap {
+        val width = matrix.width
+        val height = matrix.height
+        val pixels = IntArray(width * height)
+        for (y in 0 until height) {
+            val offset = y * width
+            for (x in 0 until width) {
+                pixels[offset + x] = if (matrix[x, y]) foregroundColor else backgroundColor
+            }
+        }
+        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+        bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
+        return bitmap
+    }
+
+    @Throws(WriterException::class)
+    private fun encode(contents: String?, format: BarcodeFormat?, width: Int, height: Int): BitMatrix {
+        return try {
+            MultiFormatWriter().encode(contents, format, width, height)
+        } catch (e: WriterException) {
+            throw e
+        } catch (e: Exception) {
+            throw WriterException(e)
+        }
+    }
+
+    @Throws(WriterException::class)
+    private fun encode(
+        contents: String?,
+        format: BarcodeFormat?,
+        width: Int,
+        height: Int,
+        hints: Map<EncodeHintType?, *>?
+    ): BitMatrix {
+        return try {
+            MultiFormatWriter().encode(contents, format, width, height, hints)
+        } catch (e: WriterException) {
+            throw e
+        } catch (e: Exception) {
+            throw WriterException(e)
+        }
+    }
+
+    @Throws(WriterException::class)
+    fun encodeBitmap(
+        contents: String?,
+        format: BarcodeFormat?,
+        width: Int,
+        height: Int,
+        backgroundColor: Int = WHITE,
+        foregroundColor: Int = Color.BLACK,
+    ): Bitmap {
+        return createBitmap(
+            matrix = encode(contents, format, width, height),
+            foregroundColor = foregroundColor,
+            backgroundColor = backgroundColor
+        )
+    }
+
+    @Throws(WriterException::class)
+    fun encodeBitmap(
+        contents: String?,
+        format: BarcodeFormat?,
+        width: Int,
+        height: Int,
+        backgroundColor: Int = WHITE,
+        foregroundColor: Int = Color.BLACK,
+        hints: Map<EncodeHintType?, *>?,
+    ): Bitmap {
+        return createBitmap(
+            matrix = encode(
+                contents,
+                format,
+                width,
+                height,
+                hints
+            ),
+            backgroundColor = backgroundColor,
+            foregroundColor = foregroundColor
+        )
+    }
+
+    const val WHITE = Color.WHITE
+
+    const val BLACK = Color.BLACK
+
+}

+ 93 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/qrcode/QRCodeLoaderFactory.kt

@@ -0,0 +1,93 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.glide.sources.qrcode
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import com.bumptech.glide.Priority
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.Options
+import com.bumptech.glide.load.data.DataFetcher
+import com.bumptech.glide.load.model.ModelLoader
+import com.bumptech.glide.load.model.ModelLoaderFactory
+import com.bumptech.glide.load.model.MultiModelLoaderFactory
+import com.bumptech.glide.signature.ObjectKey
+import com.google.zxing.BarcodeFormat
+
+@Suppress("unused")
+class QRCodeLoaderFactory(
+    val context: Context
+) : ModelLoaderFactory<QRCodeRes?, Drawable?> {
+    private var loader: QRCodeLoader? = null
+
+    override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<QRCodeRes?, Drawable?> {
+        if (loader == null) {
+            loader = QRCodeLoader(context)
+        }
+        return loader!!
+    }
+
+    override fun teardown() {
+        loader = null
+    }
+
+    private class QRCodeLoader(
+        val context: Context
+    ) : ModelLoader<QRCodeRes?, Drawable?> {
+        override fun handles(model: QRCodeRes): Boolean = true
+
+        override fun buildLoadData(
+            model: QRCodeRes,
+            width: Int,
+            height: Int,
+            options: Options
+        ): ModelLoader.LoadData<Drawable?> {
+            return ModelLoader.LoadData(
+                ObjectKey(model.toString()),
+                QRCodeFetcher(context, model, width, height)
+            )
+        }
+
+        class QRCodeFetcher(
+            val context: Context,
+            val model: QRCodeRes,
+            var width: Int = 0,
+            var height: Int = 0
+        ) : DataFetcher<Drawable?> {
+            override fun cleanup() {}
+            override fun cancel() {}
+
+            override fun getDataClass(): Class<Drawable?> {
+                @Suppress("UNCHECKED_CAST")
+                return Drawable::class.java as Class<Drawable?>
+            }
+
+            override fun getDataSource(): DataSource {
+                return DataSource.LOCAL
+            }
+
+            override fun loadData(
+                priority: Priority,
+                callback: DataFetcher.DataCallback<in Drawable?>
+            ) {
+                try {
+                    callback.onDataReady(
+                        BarcodeDrawable(
+                            model.toString(),
+                            BarcodeFormat.QR_CODE
+                        )
+                    )
+                } catch (e: Exception) {
+                    callback.onLoadFailed(e)
+                }
+            }
+        }
+    }
+
+}

+ 13 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/qrcode/QRCodeRes.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.glide.sources.qrcode
+
+open class QRCodeRes(val data: String) {
+    override fun toString(): String = data
+}

+ 70 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/svg/SvgDecoder.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.glide.sources.svg
+
+import com.bumptech.glide.load.Options
+import com.bumptech.glide.load.ResourceDecoder
+import com.bumptech.glide.load.engine.Resource
+import com.bumptech.glide.load.resource.SimpleResource
+import com.bumptech.glide.request.target.Target
+import com.caverock.androidsvg.SVG
+import com.caverock.androidsvg.SVGParseException
+import java.io.IOException
+import java.io.InputStream
+import java.io.InputStreamReader
+
+/**
+ * Decodes an SVG internal representation from an [InputStream].
+ */
+class SvgDecoder : ResourceDecoder<InputStream, SVG> {
+    /**
+     * Handles indicator, see glide lib documentation
+     * @param source InputStream
+     * @param options Options
+     * @return Boolean
+     */
+    override fun handles(source: InputStream, options: Options): Boolean {
+        return isSVG(source)
+    }
+
+    /**
+     * Check if model represents svg file
+     * @param source InputStream
+     * @return Boolean
+     */
+    private fun isSVG(source: InputStream): Boolean {
+        return InputStreamReader(source).readText().contains("<svg")
+    }
+
+    /**
+     * Decode svg to bitmap
+     * @param source InputStream
+     * @param width Int
+     * @param height Int
+     * @param options Options
+     * @return Resource<SVG>
+     * @throws IOException
+     */
+    @Throws(IOException::class)
+    override fun decode(
+        source: InputStream,
+        width: Int,
+        height: Int,
+        options: Options
+    ): Resource<SVG> {
+        return try {
+            val svg = SVG.getFromInputStream(source)
+            if (width != Target.SIZE_ORIGINAL) svg.documentWidth = width.toFloat()
+            if (height != Target.SIZE_ORIGINAL) svg.documentHeight = height.toFloat()
+            SimpleResource(svg)
+        } catch (ex: SVGParseException) {
+            throw IOException("Cannot load SVG from stream", ex)
+        }
+    }
+}

+ 40 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/svg/SvgDrawableTranscoder.kt

@@ -0,0 +1,40 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.glide.sources.svg
+
+import android.graphics.Picture
+import android.graphics.drawable.PictureDrawable
+import androidx.annotation.Keep
+import com.bumptech.glide.load.Options
+import com.bumptech.glide.load.engine.Resource
+import com.bumptech.glide.load.resource.SimpleResource
+import com.bumptech.glide.load.resource.transcode.ResourceTranscoder
+import com.caverock.androidsvg.SVG
+
+/**
+ * Convert the [SVG]'s internal representation to an Android-compatible one ([Picture]).
+ */
+@Keep
+class SvgDrawableTranscoder : ResourceTranscoder<SVG?, PictureDrawable?> {
+    /**
+     * Transcode data
+     * @param toTranscode Resource<SVG?>
+     * @param options Options
+     * @return Resource<PictureDrawable?>
+     */
+    override fun transcode(
+            toTranscode: Resource<SVG?>,
+            options: Options
+    ): Resource<PictureDrawable?> {
+        val svg: SVG = toTranscode.get()
+        val picture: Picture = svg.renderToPicture()
+        val drawable = PictureDrawable(picture)
+        return SimpleResource(drawable)
+    }
+}

+ 50 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/svg/SvgSoftwareLayerSetter.kt

@@ -0,0 +1,50 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.glide.sources.svg
+
+import android.graphics.drawable.PictureDrawable
+import android.widget.ImageView
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.ImageViewTarget
+import com.bumptech.glide.request.target.Target
+
+/**
+ * Listener which updates the [ImageView] to be software rendered, because [ ]
+ * /[Picture][android.graphics.Picture] can't render on a
+ * hardware backed [Canvas][android.graphics.Canvas].
+ */
+@Suppress("unused")
+class SvgSoftwareLayerSetter : RequestListener<PictureDrawable?> {
+
+    override fun onLoadFailed(
+        e: GlideException?,
+        model: Any?,
+        target: Target<PictureDrawable?>,
+        isFirstResource: Boolean
+    ): Boolean {
+        val view = (target as ImageViewTarget<*>).view
+        view.setLayerType(ImageView.LAYER_TYPE_NONE, null)
+        return false
+    }
+
+    override fun onResourceReady(
+        resource: PictureDrawable,
+        model: Any,
+        target: Target<PictureDrawable?>?,
+        dataSource: DataSource,
+        isFirstResource: Boolean
+    ): Boolean {
+        val view = (target as ImageViewTarget<*>).view
+        view.setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null)
+        return false
+    }
+
+}

+ 1 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/coil/UserAgentInterceptor.kt → tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/UserAgentInterceptor.kt

@@ -5,7 +5,7 @@
  *  e: mj@mjdev.org
  *  w: https://mjdev.org
  */
-package org.mjdev.tvlib.helpers.coil
+package org.mjdev.tvlib.helpers
 
 import okhttp3.Interceptor
 import okhttp3.Request

+ 275 - 275
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/coil/AlbumArtDecoder.kt

@@ -1,275 +1,275 @@
-/*
- *  Copyright (c) Milan Jurkulák 2023.
- *  Contact:
- *  e: mimoccc@gmail.com
- *  e: mj@mjdev.org
- *  w: https://mjdev.org
- */
-
-package org.mjdev.tvlib.helpers.coil
-
-import android.graphics.BitmapFactory
-import android.media.MediaMetadataRetriever
-import android.util.Xml
-import androidx.core.graphics.drawable.toDrawable
-import coil.ImageLoader
-import coil.annotation.ExperimentalCoilApi
-import coil.decode.AssetMetadata
-import coil.decode.ContentMetadata
-import coil.decode.DecodeResult
-import coil.decode.Decoder
-import coil.decode.ImageSource
-import coil.decode.ResourceMetadata
-import coil.fetch.SourceResult
-import coil.request.Options
-import okhttp3.Cache
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import org.mjdev.tvlib.network.CacheInterceptor
-import org.xmlpull.v1.XmlPullParser
-import timber.log.Timber
-import java.io.BufferedInputStream
-import java.io.File
-import java.io.InputStream
-import okhttp3.logging.HttpLoggingInterceptor
-
-@Suppress("PrivatePropertyName")
-class AlbumArtDecoder(
-    private val source: ImageSource,
-    private val options: Options
-) : Decoder {
-
-    private val USER_AGENT =
-        "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0"
-
-    private val context get() = options.context
-
-    private val httpCache by lazy {
-        Cache(
-            directory = File(
-                context.cacheDir,
-                "http_cache"
-            ),
-            maxSize = 1024L * 1024L * 1024L
-        )
-    }
-
-    private val userAgentInterceptor by lazy {
-        UserAgentInterceptor(USER_AGENT)
-    }
-
-    private val cacheInterceptor by lazy {
-        CacheInterceptor()
-    }
-
-    private val httpLoggingInterceptor by lazy {
-        HttpLoggingInterceptor().apply {
-            level = HttpLoggingInterceptor.Level.BODY
-        }
-    }
-
-    private val httpClient: OkHttpClient by lazy {
-        OkHttpClient.Builder().apply {
-            addNetworkInterceptor(userAgentInterceptor)
-            addNetworkInterceptor(cacheInterceptor)
-            addNetworkInterceptor(httpLoggingInterceptor)
-            cache(httpCache)
-        }.build()
-    }
-
-    private fun call(url: String) = httpClient.newCall(
-        Request.Builder().url(url).build()
-    ).execute()
-
-    override suspend fun decode() = MediaMetadataRetriever().use { retriever ->
-        retriever.setDataSource(source)
-        var rawData = try {
-            retriever.embeddedPicture
-        } catch (e: Exception) {
-            Timber.e(e)
-            null
-        }
-        if (rawData == null) {
-            var albumName = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM)
-            val artistName = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
-            if (albumName == null && artistName == null) {
-                albumName = source.file().name.substringBeforeLast(".")
-            }
-            rawData = downloadAlbumArt(albumName, artistName)
-        }
-        checkNotNull(rawData) {
-            "Failed to decode embedded album art picture."
-        }
-        val rawBitmap = BitmapFactory.decodeByteArray(rawData, 0, rawData.size)
-        DecodeResult(
-            drawable = rawBitmap.toDrawable(options.context.resources),
-            isSampled = true
-        )
-    }
-
-    private fun downloadAlbumArt(albumName: String?, artistName: String?): ByteArray? {
-        var releaseGroupID: String? = null
-        var musicSearchUrl = "http://www.musicbrainz.org/ws/2/release-group?query=$albumName"
-        if (artistName != null) {
-            musicSearchUrl += " AND artist:$artistName"
-        }
-        musicSearchUrl = musicSearchUrl.replace(" ".toRegex(), "%20")
-        val musicSearchIs = retrieveXMLFromURL(musicSearchUrl)
-        if (musicSearchIs != null) {
-            try {
-                releaseGroupID = parse(musicSearchIs)
-            } catch (e: Exception) {
-                Timber.e(e)
-            }
-        }
-        if (releaseGroupID != null) {
-            val url = "http://coverartarchive.org/release-group/$releaseGroupID/front"
-            val ins = try {
-                val response = call(url)
-                if (response.code == 200) {
-                    response.body.byteStream()
-                } else {
-                    Timber.e(response.message)
-                    null
-                }
-            } catch (e: Exception) {
-                Timber.e(e)
-                null
-            }
-            return BufferedInputStream(ins).readBytes()
-        } else {
-            return null
-        }
-    }
-
-    private fun retrieveXMLFromURL(url: String): InputStream? = try {
-        val response = call(url)
-        if (response.code == 200) {
-            response.body.byteStream()
-        } else {
-            Timber.e(response.message)
-            null
-        }
-    } catch (e: Exception) {
-        Timber.e(e)
-        null
-    }
-
-    @Throws(Exception::class)
-    fun parse(ins: InputStream): String? = ins.use { inst ->
-        val parser = Xml.newPullParser()
-        parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
-        parser.setInput(inst, null)
-        parser.nextTag()
-        readFeed(parser)
-    }
-
-    @Throws(Exception::class)
-    private fun readFeed(parser: XmlPullParser): String? {
-        var releaseGroupID: String? = null
-        val ns: String? = null
-        parser.require(XmlPullParser.START_TAG, ns, "metadata")
-        while (parser.next() != XmlPullParser.END_TAG) {
-            if (parser.eventType != XmlPullParser.START_TAG) {
-                continue
-            }
-            val name = parser.name
-            Timber.i(name)
-            if (name == "release-group-list") {
-                releaseGroupID = readReleaseGroupID(parser)
-                break
-            } else {
-                skip(parser)
-            }
-        }
-        return releaseGroupID
-    }
-
-    @Throws(Exception::class)
-    private fun readReleaseGroupID(parser: XmlPullParser): String? {
-        var releaseGroupID: String? = null
-        val ns: String? = null
-        parser.require(XmlPullParser.START_TAG, ns, "release-group-list")
-        val numOfReleaseGroups = parser.getAttributeValue("", "count").toInt()
-        if (numOfReleaseGroups == 0) {
-            throw Exception("No release id in stream.")
-        }
-        Timber.i(parser.name)
-        while (parser.next() != XmlPullParser.END_TAG) {
-            if (parser.eventType != XmlPullParser.START_TAG) {
-                Timber.i(parser.name)
-                continue
-            }
-            val name = parser.name
-            Timber.i(name)
-            if (name == "release-group") {
-                releaseGroupID = parser.getAttributeValue("", "id")
-                Timber.i(releaseGroupID)
-                break
-            } else {
-                skip(parser)
-            }
-        }
-        return releaseGroupID
-    }
-
-    @Throws(Exception::class)
-    private fun skip(parser: XmlPullParser) {
-        check(parser.eventType == XmlPullParser.START_TAG)
-        var depth = 1
-        while (depth != 0) {
-            when (parser.next()) {
-                XmlPullParser.END_TAG -> depth--
-                XmlPullParser.START_TAG -> depth++
-            }
-        }
-    }
-
-    @OptIn(ExperimentalCoilApi::class)
-    private fun MediaMetadataRetriever.setDataSource(source: ImageSource) {
-        when (val metadata = source.metadata) {
-            is AssetMetadata -> {
-                options.context.assets.openFd(metadata.filePath).use {
-                    setDataSource(it.fileDescriptor, it.startOffset, it.length)
-                }
-            }
-
-            is ContentMetadata -> {
-                setDataSource(options.context, metadata.uri)
-            }
-
-            is ResourceMetadata -> {
-                setDataSource("android.resource://${metadata.packageName}/${metadata.resId}")
-            }
-
-            else -> {
-                setDataSource(source.file().toFile().path)
-            }
-        }
-    }
-
-    class Factory : Decoder.Factory {
-
-        override fun create(
-            result: SourceResult,
-            options: Options,
-            imageLoader: ImageLoader
-        ): Decoder? {
-            return if (!isApplicable(result.mimeType)) {
-                null
-            } else {
-                AlbumArtDecoder(result.source, options)
-            }
-        }
-
-        private fun isApplicable(mimeType: String?): Boolean {
-            return mimeType != null && mimeType.startsWith("audio/")
-        }
-
-        override fun equals(other: Any?) = other is Factory
-
-        override fun hashCode() = javaClass.hashCode()
-
-    }
-
-}
+///*
+// *  Copyright (c) Milan Jurkulák 2023.
+// *  Contact:
+// *  e: mimoccc@gmail.com
+// *  e: mj@mjdev.org
+// *  w: https://mjdev.org
+// */
+//
+//package org.mjdev.tvlib.helpers.coil
+//
+//import android.graphics.BitmapFactory
+//import android.media.MediaMetadataRetriever
+//import android.util.Xml
+//import androidx.core.graphics.drawable.toDrawable
+//import coil.ImageLoader
+//import coil.annotation.ExperimentalCoilApi
+//import coil.decode.AssetMetadata
+//import coil.decode.ContentMetadata
+//import coil.decode.DecodeResult
+//import coil.decode.Decoder
+//import coil.decode.ImageSource
+//import coil.decode.ResourceMetadata
+//import coil.fetch.SourceResult
+//import coil.request.Options
+//import okhttp3.Cache
+//import okhttp3.OkHttpClient
+//import okhttp3.Request
+//import org.mjdev.tvlib.network.CacheInterceptor
+//import org.xmlpull.v1.XmlPullParser
+//import timber.log.Timber
+//import java.io.BufferedInputStream
+//import java.io.File
+//import java.io.InputStream
+//import okhttp3.logging.HttpLoggingInterceptor
+//
+//@Suppress("PrivatePropertyName")
+//class AlbumArtDecoder(
+//    private val source: ImageSource,
+//    private val options: Options
+//) : Decoder {
+//
+//    private val USER_AGENT =
+//        "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0"
+//
+//    private val context get() = options.context
+//
+//    private val httpCache by lazy {
+//        Cache(
+//            directory = File(
+//                context.cacheDir,
+//                "http_cache"
+//            ),
+//            maxSize = 1024L * 1024L * 1024L
+//        )
+//    }
+//
+//    private val userAgentInterceptor by lazy {
+//        UserAgentInterceptor(USER_AGENT)
+//    }
+//
+//    private val cacheInterceptor by lazy {
+//        CacheInterceptor()
+//    }
+//
+//    private val httpLoggingInterceptor by lazy {
+//        HttpLoggingInterceptor().apply {
+//            level = HttpLoggingInterceptor.Level.BODY
+//        }
+//    }
+//
+//    private val httpClient: OkHttpClient by lazy {
+//        OkHttpClient.Builder().apply {
+//            addNetworkInterceptor(userAgentInterceptor)
+//            addNetworkInterceptor(cacheInterceptor)
+//            addNetworkInterceptor(httpLoggingInterceptor)
+//            cache(httpCache)
+//        }.build()
+//    }
+//
+//    private fun call(url: String) = httpClient.newCall(
+//        Request.Builder().url(url).build()
+//    ).execute()
+//
+//    override suspend fun decode() = MediaMetadataRetriever().use { retriever ->
+//        retriever.setDataSource(source)
+//        var rawData = try {
+//            retriever.embeddedPicture
+//        } catch (e: Exception) {
+//            Timber.e(e)
+//            null
+//        }
+//        if (rawData == null) {
+//            var albumName = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM)
+//            val artistName = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
+//            if (albumName == null && artistName == null) {
+//                albumName = source.file().name.substringBeforeLast(".")
+//            }
+//            rawData = downloadAlbumArt(albumName, artistName)
+//        }
+//        checkNotNull(rawData) {
+//            "Failed to decode embedded album art picture."
+//        }
+//        val rawBitmap = BitmapFactory.decodeByteArray(rawData, 0, rawData.size)
+//        DecodeResult(
+//            drawable = rawBitmap.toDrawable(options.context.resources),
+//            isSampled = true
+//        )
+//    }
+//
+//    private fun downloadAlbumArt(albumName: String?, artistName: String?): ByteArray? {
+//        var releaseGroupID: String? = null
+//        var musicSearchUrl = "http://www.musicbrainz.org/ws/2/release-group?query=$albumName"
+//        if (artistName != null) {
+//            musicSearchUrl += " AND artist:$artistName"
+//        }
+//        musicSearchUrl = musicSearchUrl.replace(" ".toRegex(), "%20")
+//        val musicSearchIs = retrieveXMLFromURL(musicSearchUrl)
+//        if (musicSearchIs != null) {
+//            try {
+//                releaseGroupID = parse(musicSearchIs)
+//            } catch (e: Exception) {
+//                Timber.e(e)
+//            }
+//        }
+//        if (releaseGroupID != null) {
+//            val url = "http://coverartarchive.org/release-group/$releaseGroupID/front"
+//            val ins = try {
+//                val response = call(url)
+//                if (response.code == 200) {
+//                    response.body.byteStream()
+//                } else {
+//                    Timber.e(response.message)
+//                    null
+//                }
+//            } catch (e: Exception) {
+//                Timber.e(e)
+//                null
+//            }
+//            return BufferedInputStream(ins).readBytes()
+//        } else {
+//            return null
+//        }
+//    }
+//
+//    private fun retrieveXMLFromURL(url: String): InputStream? = try {
+//        val response = call(url)
+//        if (response.code == 200) {
+//            response.body.byteStream()
+//        } else {
+//            Timber.e(response.message)
+//            null
+//        }
+//    } catch (e: Exception) {
+//        Timber.e(e)
+//        null
+//    }
+//
+//    @Throws(Exception::class)
+//    fun parse(ins: InputStream): String? = ins.use { inst ->
+//        val parser = Xml.newPullParser()
+//        parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+//        parser.setInput(inst, null)
+//        parser.nextTag()
+//        readFeed(parser)
+//    }
+//
+//    @Throws(Exception::class)
+//    private fun readFeed(parser: XmlPullParser): String? {
+//        var releaseGroupID: String? = null
+//        val ns: String? = null
+//        parser.require(XmlPullParser.START_TAG, ns, "metadata")
+//        while (parser.next() != XmlPullParser.END_TAG) {
+//            if (parser.eventType != XmlPullParser.START_TAG) {
+//                continue
+//            }
+//            val name = parser.name
+//            Timber.i(name)
+//            if (name == "release-group-list") {
+//                releaseGroupID = readReleaseGroupID(parser)
+//                break
+//            } else {
+//                skip(parser)
+//            }
+//        }
+//        return releaseGroupID
+//    }
+//
+//    @Throws(Exception::class)
+//    private fun readReleaseGroupID(parser: XmlPullParser): String? {
+//        var releaseGroupID: String? = null
+//        val ns: String? = null
+//        parser.require(XmlPullParser.START_TAG, ns, "release-group-list")
+//        val numOfReleaseGroups = parser.getAttributeValue("", "count").toInt()
+//        if (numOfReleaseGroups == 0) {
+//            throw Exception("No release id in stream.")
+//        }
+//        Timber.i(parser.name)
+//        while (parser.next() != XmlPullParser.END_TAG) {
+//            if (parser.eventType != XmlPullParser.START_TAG) {
+//                Timber.i(parser.name)
+//                continue
+//            }
+//            val name = parser.name
+//            Timber.i(name)
+//            if (name == "release-group") {
+//                releaseGroupID = parser.getAttributeValue("", "id")
+//                Timber.i(releaseGroupID)
+//                break
+//            } else {
+//                skip(parser)
+//            }
+//        }
+//        return releaseGroupID
+//    }
+//
+//    @Throws(Exception::class)
+//    private fun skip(parser: XmlPullParser) {
+//        check(parser.eventType == XmlPullParser.START_TAG)
+//        var depth = 1
+//        while (depth != 0) {
+//            when (parser.next()) {
+//                XmlPullParser.END_TAG -> depth--
+//                XmlPullParser.START_TAG -> depth++
+//            }
+//        }
+//    }
+//
+//    @OptIn(ExperimentalCoilApi::class)
+//    private fun MediaMetadataRetriever.setDataSource(source: ImageSource) {
+//        when (val metadata = source.metadata) {
+//            is AssetMetadata -> {
+//                options.context.assets.openFd(metadata.filePath).use {
+//                    setDataSource(it.fileDescriptor, it.startOffset, it.length)
+//                }
+//            }
+//
+//            is ContentMetadata -> {
+//                setDataSource(options.context, metadata.uri)
+//            }
+//
+//            is ResourceMetadata -> {
+//                setDataSource("android.resource://${metadata.packageName}/${metadata.resId}")
+//            }
+//
+//            else -> {
+//                setDataSource(source.file().toFile().path)
+//            }
+//        }
+//    }
+//
+//    class Factory : Decoder.Factory {
+//
+//        override fun create(
+//            result: SourceResult,
+//            options: Options,
+//            imageLoader: ImageLoader
+//        ): Decoder? {
+//            return if (!isApplicable(result.mimeType)) {
+//                null
+//            } else {
+//                AlbumArtDecoder(result.source, options)
+//            }
+//        }
+//
+//        private fun isApplicable(mimeType: String?): Boolean {
+//            return mimeType != null && mimeType.startsWith("audio/")
+//        }
+//
+//        override fun equals(other: Any?) = other is Factory
+//
+//        override fun hashCode() = javaClass.hashCode()
+//
+//    }
+//
+//}

+ 45 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/media/ItemType.kt

@@ -0,0 +1,45 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.helpers.media
+
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import org.mjdev.tvlib.extensions.MediaItemExt.mediaType
+import org.mjdev.tvlib.interfaces.ItemAudio
+import org.mjdev.tvlib.interfaces.ItemPhoto
+import org.mjdev.tvlib.interfaces.ItemVideo
+
+enum class ItemType {
+    Undefined,
+    Audio,
+    Video,
+    Photo;
+
+    companion object {
+
+        // todo more types
+        operator fun invoke(item: Any?): ItemType {
+            return if (item == null) Undefined
+            else when (item) {
+                is ItemAudio -> Audio
+                is ItemVideo -> Video
+                is ItemPhoto -> Photo
+                is MediaItem -> when (item.liveConfiguration.mediaType) {
+                    MediaMetadata.MEDIA_TYPE_MUSIC -> Audio
+                    MediaMetadata.MEDIA_TYPE_MOVIE -> Video
+                    MediaMetadata.MEDIA_TYPE_ALBUM -> Photo
+                    else -> Undefined
+                }
+                else -> Undefined
+            }
+        }
+
+    }
+
+}

+ 1 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/audiopreview/dailymotion/DailyMotionVideoView.kt

@@ -27,7 +27,7 @@ import okhttp3.Cache
 import okhttp3.OkHttpClient
 import okhttp3.Request
 import okhttp3.logging.HttpLoggingInterceptor
-import org.mjdev.tvlib.helpers.coil.UserAgentInterceptor
+import org.mjdev.tvlib.helpers.UserAgentInterceptor
 import org.mjdev.tvlib.network.CacheInterceptor
 import org.mjdev.tvlib.ui.components.audiopreview.IPreviewEngine
 import timber.log.Timber

+ 239 - 239
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/card/PreviewCard.kt

@@ -1,239 +1,239 @@
-/*
- * 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.card
-
-import android.graphics.Bitmap
-import android.media.MediaMetadataRetriever
-import androidx.compose.animation.Crossfade
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.runtime.*
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalLifecycleOwner
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.viewinterop.AndroidView
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import androidx.media3.common.C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
-import androidx.media3.common.MediaItem
-import androidx.media3.common.Player
-import androidx.media3.common.Player.Listener
-import androidx.media3.common.Player.REPEAT_MODE_ONE
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.datasource.DataSource
-import androidx.media3.datasource.DefaultHttpDataSource
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.source.ClippingMediaSource
-import androidx.media3.exoplayer.source.MediaSource
-import androidx.media3.exoplayer.source.ProgressiveMediaSource
-import androidx.media3.ui.AspectRatioFrameLayout
-import androidx.media3.ui.PlayerView
-import androidx.tv.material3.ExperimentalTvMaterial3Api
-import coil.compose.AsyncImage
-import coil.compose.rememberAsyncImagePainter
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import org.mjdev.tvlib.annotations.Previews
-import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
-import org.mjdev.tvlib.extensions.ModifierExt.tvAspectRatio
-
-// todo
-@OptIn(ExperimentalTvMaterial3Api::class)
-@Suppress("unused")
-@UnstableApi
-@Previews
-@Composable
-fun PreviewCard(
-    modifier: Modifier = Modifier,
-    videoUrl: String = "",
-    aspectRatio: Float? = 16f / 9f,
-    hasFocus: Boolean = false,
-    clipStartPosition: Long = 2000000,
-    clipEndPosition: Long = 8000000,
-    thumbnailFrame: Long = 5000000,
-    thumbnailUrl: String? = null
-) {
-    Card(
-        modifier = modifier.tvAspectRatio(aspectRatio).recomposeHighlighter()
-    ) {
-        val context = LocalContext.current
-        var currentlyPlaying by remember(videoUrl) { mutableStateOf(false) }
-        var isBuffering by remember(videoUrl) { mutableStateOf(true) }
-        var thumbnailBitmap by rememberSaveable(videoUrl) {
-            mutableStateOf<Bitmap?>(null)
-        }
-        val exoPlayer = remember(videoUrl) {
-            ExoPlayer.Builder(context).build().apply {
-                repeatMode = REPEAT_MODE_ONE
-                volume = 0f
-                videoScalingMode = VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
-            }
-        }
-        exoPlayer.addListener(object : Listener {
-            override fun onPlaybackStateChanged(playbackState: Int) {
-                super.onPlaybackStateChanged(playbackState)
-                currentlyPlaying = playbackState == Player.STATE_READY
-            }
-        })
-        LaunchedEffect(key1 = videoUrl) {
-            val dataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory()
-            val mediaSource: MediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
-                .createMediaSource(MediaItem.fromUri(videoUrl))
-            val clip = ClippingMediaSource(mediaSource, clipStartPosition, clipEndPosition)
-            exoPlayer.setMediaSource(clip)
-            exoPlayer.prepare()
-            exoPlayer.playWhenReady = true
-            exoPlayer.addListener(object : Listener {
-                override fun onPlaybackStateChanged(playbackState: Int) {
-                    super.onPlaybackStateChanged(playbackState)
-                    isBuffering = playbackState == Player.STATE_BUFFERING
-                }
-            })
-        }
-        val lifecycleOwner by rememberUpdatedState(LocalLifecycleOwner.current)
-        DisposableEffect(lifecycleOwner) {
-            val lifecycle = lifecycleOwner.lifecycle
-            val observer = LifecycleEventObserver { _, event ->
-                when (event) {
-                    Lifecycle.Event.ON_PAUSE -> {
-                        exoPlayer.playWhenReady = false
-                    }
-
-                    Lifecycle.Event.ON_RESUME -> {
-                        exoPlayer.playWhenReady = true
-                    }
-
-                    Lifecycle.Event.ON_DESTROY -> {
-                        exoPlayer.run {
-                            stop()
-                            release()
-                        }
-                    }
-
-                    else -> {}
-                }
-            }
-            lifecycle.addObserver(observer)
-            onDispose {
-                lifecycle.removeObserver(observer)
-            }
-        }
-        Crossfade(targetState = hasFocus, animationSpec = tween(durationMillis = 700),
-            label = ""
-        ) { focused ->
-            if (focused) {
-                Box {
-                    AndroidView(
-                        modifier = Modifier
-                            .fillMaxSize(),
-                        factory = {
-                            PlayerView(context).apply {
-                                player = exoPlayer
-                                useController = false
-                                resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
-                                controllerAutoShow = false
-                                setKeepContentOnPlayerReset(true)
-                                setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
-                            }
-                        }
-                    )
-                    if (isBuffering) {
-                        PreviewThumbnail(
-                            thumbnailUrl,
-                            thumbnailBitmap,
-                            thumbnailFrame,
-                            videoUrl,
-                            true
-                        ) {
-                            thumbnailBitmap = it
-                        }
-                    }
-                }
-            } else {
-                PreviewThumbnail(thumbnailUrl, thumbnailBitmap, thumbnailFrame, videoUrl, false) {
-                    thumbnailBitmap = it
-                }
-            }
-        }
-    }
-}
-
-@Previews
-@Composable
-fun PreviewThumbnail(
-    thumbnail: String? = "",
-    thumbnailBitmap: Bitmap? = null,
-    thumbnailFrame: Long = 0,
-    videoUrl: String = "",
-    isLoadingVideo: Boolean = false,
-    onBitmapLoaded: (bitmap: Bitmap?) -> Unit = {}
-) {
-    if (thumbnail == null && thumbnailBitmap == null) {
-        LaunchedEffect(videoUrl) {
-            withContext(Dispatchers.IO) {
-                MediaMetadataRetriever().apply {
-                    setDataSource(videoUrl, HashMap())
-                }.also {
-                    val result = it.getFrameAtTime(
-                        thumbnailFrame,
-                        MediaMetadataRetriever.OPTION_CLOSEST_SYNC
-                    )
-                    onBitmapLoaded(result)
-                    it.release()
-                }
-            }
-        }
-        Box(
-            modifier = Modifier
-                .fillMaxSize()
-                .background(Color.Black)
-                .border(2.dp, Color.White.copy(alpha = 0.4f)),
-            contentAlignment = Alignment.Center
-        ) {
-            CircularProgressIndicator()
-        }
-    } else {
-        Box(
-            modifier = Modifier
-                .fillMaxSize(),
-            contentAlignment = Alignment.Center
-        ) {
-            if (thumbnailBitmap != null) {
-                Image(
-                    painter = rememberAsyncImagePainter(thumbnailBitmap),
-                    contentDescription = null,
-                    contentScale = ContentScale.Crop,
-                    alignment = Alignment.Center,
-                    modifier = Modifier.fillMaxSize()
-                )
-            } else {
-                AsyncImage(
-                    model = thumbnail,
-                    contentDescription = null,
-                    contentScale = ContentScale.Crop,
-                    alignment = Alignment.Center,
-                    modifier = Modifier.fillMaxSize()
-                )
-            }
-            if (isLoadingVideo) {
-                CircularProgressIndicator(strokeWidth = 4.dp)
-            }
-        }
-    }
-}
+///*
+// * 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.card
+//
+//import android.graphics.Bitmap
+//import android.media.MediaMetadataRetriever
+//import androidx.compose.animation.Crossfade
+//import androidx.compose.animation.core.tween
+//import androidx.compose.foundation.Image
+//import androidx.compose.foundation.background
+//import androidx.compose.foundation.border
+//import androidx.compose.foundation.layout.Box
+//import androidx.compose.foundation.layout.fillMaxSize
+//import androidx.compose.material3.CircularProgressIndicator
+//import androidx.compose.runtime.*
+//import androidx.compose.runtime.saveable.rememberSaveable
+//import androidx.compose.ui.Alignment
+//import androidx.compose.ui.Modifier
+//import androidx.compose.ui.graphics.Color
+//import androidx.compose.ui.layout.ContentScale
+//import androidx.compose.ui.platform.LocalContext
+//import androidx.compose.ui.platform.LocalLifecycleOwner
+//import androidx.compose.ui.unit.dp
+//import androidx.compose.ui.viewinterop.AndroidView
+//import androidx.lifecycle.Lifecycle
+//import androidx.lifecycle.LifecycleEventObserver
+//import androidx.media3.common.C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
+//import androidx.media3.common.MediaItem
+//import androidx.media3.common.Player
+//import androidx.media3.common.Player.Listener
+//import androidx.media3.common.Player.REPEAT_MODE_ONE
+//import androidx.media3.common.util.UnstableApi
+//import androidx.media3.datasource.DataSource
+//import androidx.media3.datasource.DefaultHttpDataSource
+//import androidx.media3.exoplayer.ExoPlayer
+//import androidx.media3.exoplayer.source.ClippingMediaSource
+//import androidx.media3.exoplayer.source.MediaSource
+//import androidx.media3.exoplayer.source.ProgressiveMediaSource
+//import androidx.media3.ui.AspectRatioFrameLayout
+//import androidx.media3.ui.PlayerView
+//import androidx.tv.material3.ExperimentalTvMaterial3Api
+//import coil.compose.AsyncImage
+//import coil.compose.rememberAsyncImagePainter
+//import kotlinx.coroutines.Dispatchers
+//import kotlinx.coroutines.withContext
+//import org.mjdev.tvlib.annotations.Previews
+//import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
+//import org.mjdev.tvlib.extensions.ModifierExt.tvAspectRatio
+//
+//// todo
+//@OptIn(ExperimentalTvMaterial3Api::class)
+//@Suppress("unused")
+//@UnstableApi
+//@Previews
+//@Composable
+//fun PreviewCard(
+//    modifier: Modifier = Modifier,
+//    videoUrl: String = "",
+//    aspectRatio: Float? = 16f / 9f,
+//    hasFocus: Boolean = false,
+//    clipStartPosition: Long = 2000000,
+//    clipEndPosition: Long = 8000000,
+//    thumbnailFrame: Long = 5000000,
+//    thumbnailUrl: String? = null
+//) {
+//    Card(
+//        modifier = modifier.tvAspectRatio(aspectRatio).recomposeHighlighter()
+//    ) {
+//        val context = LocalContext.current
+//        var currentlyPlaying by remember(videoUrl) { mutableStateOf(false) }
+//        var isBuffering by remember(videoUrl) { mutableStateOf(true) }
+//        var thumbnailBitmap by rememberSaveable(videoUrl) {
+//            mutableStateOf<Bitmap?>(null)
+//        }
+//        val exoPlayer = remember(videoUrl) {
+//            ExoPlayer.Builder(context).build().apply {
+//                repeatMode = REPEAT_MODE_ONE
+//                volume = 0f
+//                videoScalingMode = VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
+//            }
+//        }
+//        exoPlayer.addListener(object : Listener {
+//            override fun onPlaybackStateChanged(playbackState: Int) {
+//                super.onPlaybackStateChanged(playbackState)
+//                currentlyPlaying = playbackState == Player.STATE_READY
+//            }
+//        })
+//        LaunchedEffect(key1 = videoUrl) {
+//            val dataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory()
+//            val mediaSource: MediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
+//                .createMediaSource(MediaItem.fromUri(videoUrl))
+//            val clip = ClippingMediaSource(mediaSource, clipStartPosition, clipEndPosition)
+//            exoPlayer.setMediaSource(clip)
+//            exoPlayer.prepare()
+//            exoPlayer.playWhenReady = true
+//            exoPlayer.addListener(object : Listener {
+//                override fun onPlaybackStateChanged(playbackState: Int) {
+//                    super.onPlaybackStateChanged(playbackState)
+//                    isBuffering = playbackState == Player.STATE_BUFFERING
+//                }
+//            })
+//        }
+//        val lifecycleOwner by rememberUpdatedState(LocalLifecycleOwner.current)
+//        DisposableEffect(lifecycleOwner) {
+//            val lifecycle = lifecycleOwner.lifecycle
+//            val observer = LifecycleEventObserver { _, event ->
+//                when (event) {
+//                    Lifecycle.Event.ON_PAUSE -> {
+//                        exoPlayer.playWhenReady = false
+//                    }
+//
+//                    Lifecycle.Event.ON_RESUME -> {
+//                        exoPlayer.playWhenReady = true
+//                    }
+//
+//                    Lifecycle.Event.ON_DESTROY -> {
+//                        exoPlayer.run {
+//                            stop()
+//                            release()
+//                        }
+//                    }
+//
+//                    else -> {}
+//                }
+//            }
+//            lifecycle.addObserver(observer)
+//            onDispose {
+//                lifecycle.removeObserver(observer)
+//            }
+//        }
+//        Crossfade(targetState = hasFocus, animationSpec = tween(durationMillis = 700),
+//            label = ""
+//        ) { focused ->
+//            if (focused) {
+//                Box {
+//                    AndroidView(
+//                        modifier = Modifier
+//                            .fillMaxSize(),
+//                        factory = {
+//                            PlayerView(context).apply {
+//                                player = exoPlayer
+//                                useController = false
+//                                resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
+//                                controllerAutoShow = false
+//                                setKeepContentOnPlayerReset(true)
+//                                setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
+//                            }
+//                        }
+//                    )
+//                    if (isBuffering) {
+//                        PreviewThumbnail(
+//                            thumbnailUrl,
+//                            thumbnailBitmap,
+//                            thumbnailFrame,
+//                            videoUrl,
+//                            true
+//                        ) {
+//                            thumbnailBitmap = it
+//                        }
+//                    }
+//                }
+//            } else {
+//                PreviewThumbnail(thumbnailUrl, thumbnailBitmap, thumbnailFrame, videoUrl, false) {
+//                    thumbnailBitmap = it
+//                }
+//            }
+//        }
+//    }
+//}
+//
+//@Previews
+//@Composable
+//fun PreviewThumbnail(
+//    thumbnail: String? = "",
+//    thumbnailBitmap: Bitmap? = null,
+//    thumbnailFrame: Long = 0,
+//    videoUrl: String = "",
+//    isLoadingVideo: Boolean = false,
+//    onBitmapLoaded: (bitmap: Bitmap?) -> Unit = {}
+//) {
+//    if (thumbnail == null && thumbnailBitmap == null) {
+//        LaunchedEffect(videoUrl) {
+//            withContext(Dispatchers.IO) {
+//                MediaMetadataRetriever().apply {
+//                    setDataSource(videoUrl, HashMap())
+//                }.also {
+//                    val result = it.getFrameAtTime(
+//                        thumbnailFrame,
+//                        MediaMetadataRetriever.OPTION_CLOSEST_SYNC
+//                    )
+//                    onBitmapLoaded(result)
+//                    it.release()
+//                }
+//            }
+//        }
+//        Box(
+//            modifier = Modifier
+//                .fillMaxSize()
+//                .background(Color.Black)
+//                .border(2.dp, Color.White.copy(alpha = 0.4f)),
+//            contentAlignment = Alignment.Center
+//        ) {
+//            CircularProgressIndicator()
+//        }
+//    } else {
+//        Box(
+//            modifier = Modifier
+//                .fillMaxSize(),
+//            contentAlignment = Alignment.Center
+//        ) {
+//            if (thumbnailBitmap != null) {
+//                Image(
+//                    painter = rememberAsyncImagePainter(thumbnailBitmap),
+//                    contentDescription = null,
+//                    contentScale = ContentScale.Crop,
+//                    alignment = Alignment.Center,
+//                    modifier = Modifier.fillMaxSize()
+//                )
+//            } else {
+//                AsyncImage(
+//                    model = thumbnail,
+//                    contentDescription = null,
+//                    contentScale = ContentScale.Crop,
+//                    alignment = Alignment.Center,
+//                    modifier = Modifier.fillMaxSize()
+//                )
+//            }
+//            if (isLoadingVideo) {
+//                CircularProgressIndicator(strokeWidth = 4.dp)
+//            }
+//        }
+//    }
+//}

+ 66 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/BoxWithControls.kt

@@ -0,0 +1,66 @@
+/*
+ *  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.annotation.SuppressLint
+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.Color
+import org.mjdev.tvlib.extensions.ComposeExt.onKey
+import org.mjdev.tvlib.extensions.GlobalExt.toggle
+import org.mjdev.tvlib.extensions.ModifierExt.swipeGestures
+import org.mjdev.tvlib.ui.components.image.ImageColoredBackground
+
+@SuppressLint("InlinedApi")
+@Composable
+fun BoxWithControls(
+    modifier: Modifier = Modifier,
+    src: Any? = null,
+    contentAlignment: Alignment = Alignment.BottomCenter,
+    controlsState: MutableState<Boolean> = remember { mutableStateOf(false) },
+    onTap: () -> Unit = { controlsState.toggle() },
+    controls: @Composable (
+        src: Any?,
+        bckColor: Color,
+        controlsState: MutableState<Boolean>
+    ) -> Unit = { _, _, _ -> },
+    content: @Composable (
+        src: Any?,
+        bckColor: Color,
+        controlsState: MutableState<Boolean>
+    ) -> Unit
+) = ImageColoredBackground(
+    modifier = modifier
+        .swipeGestures(
+            onTap = { onTap() }
+        )
+        .onKey(android.view.KeyEvent.KEYCODE_DPAD_DOWN) {
+            controlsState.value = false
+        }
+        .onKey(android.view.KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN) {
+            controlsState.value = false
+        }
+        .onKey(android.view.KeyEvent.KEYCODE_DPAD_UP) {
+            controlsState.value = true
+        }
+        .onKey(android.view.KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP) {
+            controlsState.value = true
+        },
+    image = src,
+    contentAlignment = contentAlignment,
+) { bckColor ->
+    content(src, bckColor, controlsState)
+    if (controlsState.value) {
+        controls(src, bckColor, controlsState)
+    }
+}

+ 117 - 191
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/Gallery.kt

@@ -8,19 +8,6 @@
 
 package org.mjdev.tvlib.ui.components.gallery
 
-import android.view.Gravity
-import android.view.KeyEvent.ACTION_DOWN
-import android.view.KeyEvent.KEYCODE_DPAD_CENTER
-import android.view.KeyEvent.KEYCODE_DPAD_DOWN
-import android.view.KeyEvent.KEYCODE_ENTER
-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.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -37,15 +24,12 @@ import androidx.compose.runtime.SideEffect
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
 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.layout.ContentScale
 import androidx.compose.ui.unit.dp
 import androidx.tv.foundation.lazy.list.TvLazyListState
@@ -54,64 +38,36 @@ import androidx.tv.foundation.lazy.list.rememberTvLazyListState
 import androidx.tv.material3.ExperimentalTvMaterial3Api
 import kotlinx.coroutines.delay
 import org.mjdev.tvlib.annotations.Previews
+import org.mjdev.tvlib.extensions.ComposeExt.remberItemType
+import org.mjdev.tvlib.extensions.ComposeExt.rememberDerivedState
 import org.mjdev.tvlib.extensions.ComposeExt.rememberFocusRequester
+import org.mjdev.tvlib.extensions.ComposeExt.rememberImageFromItem
 import org.mjdev.tvlib.extensions.GlobalExt.toggle
 import org.mjdev.tvlib.extensions.ModifierExt.swipeGestures
-import org.mjdev.tvlib.interfaces.ItemPhoto
-import org.mjdev.tvlib.interfaces.ItemWithBackground
-import org.mjdev.tvlib.interfaces.ItemWithImage
+import org.mjdev.tvlib.helpers.media.ItemType
 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)
+@OptIn(ExperimentalTvMaterial3Api::class)
 @Previews
 @Composable
 fun Gallery(
     modifier: Modifier = Modifier,
     list: List<Any?> = listOf(Unit),
     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) },
+    delayedHide: Long = 5000,
+    controlsState: MutableState<Boolean> = remember { mutableStateOf(true) },
     currentItemIndex: MutableIntState = remember { mutableIntStateOf(index) },
     focusRequester: FocusRequester = rememberFocusRequester(),
-    imageScaleType: MutableState<ContentScale> = remember { mutableStateOf(ContentScale.Crop) },
-    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
-        }
+    imageScaleType: MutableState<ContentScale> = rememberSaveable(
+        saver = contentScaleSaver
+    ) {
+        mutableStateOf(ContentScale.Crop)
     },
+    listState: TvLazyListState = rememberTvLazyListState(),
     switchImageScale: () -> Unit = {
         imageScaleType.value = when (imageScaleType.value) {
             ContentScale.Fit -> ContentScale.Crop
@@ -119,166 +75,136 @@ fun Gallery(
             else -> ContentScale.Fit
         }
     },
-    handleKey: (event: KeyEvent) -> Boolean = { ev ->
-        val action = ev.nativeKeyEvent.action
-        if (action == ACTION_DOWN) {
-            val code = ev.nativeKeyEvent.keyCode
-            if ((code == KEYCODE_ENTER) || (code == KEYCODE_DPAD_CENTER)) {
-                switchImageScale()
-                true
-            } else 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(),
-    customOverlay: @Composable (item: Any?) -> Unit = {}
+    customContentViewer: @Composable (
+        src: Any?,
+        type: ItemType,
+        list: List<Any?>
+    ) -> Unit = { _, _, _ -> },
 ) {
-    val initialized = remember { mutableStateOf(false) }
-    val imageState: MutableState<Any?> = remember { mutableStateOf(Color.DarkGray) }
+    val initialized = remember(list, index) { mutableStateOf(false) }
+
     val nextItem: () -> Unit = {
         if (currentItemIndex.intValue < (list.size - 1)) {
             currentItemIndex.intValue += 1
-            imageState.value = list[currentItemIndex.intValue]
         }
+        controlsState.value = true
     }
     val prevItem: () -> Unit = {
         if (currentItemIndex.intValue > 0) {
             currentItemIndex.intValue -= 1
-            imageState.value = list[currentItemIndex.intValue]
         }
+        controlsState.value = true
     }
-    Box(
-        modifier = modifier.background(Color.Black, RectangleShape),
-        contentAlignment = Alignment.BottomCenter,
-    ) {
-        ImageBackground(
-            modifier = modifier,
-            imageState = imageState,
-        )
+
+    val itemState = rememberDerivedState(currentItemIndex.intValue) {
+        list[currentItemIndex.intValue]
+    }
+    val itemType = remberItemType(itemState.value)
+    val imageSrc = rememberImageFromItem(itemState.value)
+
+    BoxWithControls(
+        src = itemState.value,
+        controlsState = controlsState,
+        controls = { src, bckColor, state ->
+            Box(
+                modifier = Modifier.fillMaxSize()
+            ) {
+                ItemInfo(
+                    modifier = Modifier
+                        .align(Alignment.TopCenter)
+                        .fillMaxWidth(),
+                    src = src,
+                    visible = state.value,
+                    backgroundColor = bckColor
+                )
+            }
+        }
+    ) { _, bckColor, state ->
         ImmersiveList(
             modifier = Modifier.fillMaxSize(),
             currentItemIndex = currentItemIndex,
             listAlignment = Alignment.BottomStart,
             background = { _, _ ->
-                Box(
-                    modifier = Modifier.fillMaxSize()
-                ) {
-                    ImageAny(
-                        src = imageFromItem(currentItemIndex.intValue),
-                        contentDescription = null,
-                        contentScale = imageScaleType.value,
-                        modifier = Modifier
-                            .fillMaxSize()
-                            .focusable()
-                            .focusRequester(focusRequester)
-                            .swipeGestures(
-                                onDoubleTap = {
-                                    switchImageScale()
-                                },
-                                onTap = {
-                                    infoVisible.toggle()
-                                },
-                                onSwipeLeft = {
-                                    nextItem()
-                                },
-                                onSwipeRight = {
-                                    prevItem()
-                                },
-                                onSwipeUp = {
-                                    nextItem()
-                                },
-                                onSwipeDown = {
-                                    prevItem()
-                                },
-                            )
-                            .onKeyEvent { ev -> handleKey(ev) }
-                    )
-                    customOverlay(list[currentItemIndex.intValue])
-                    ItemInfo(
-                        src = list[currentItemIndex.intValue],
-                        infoVisible = infoVisible,
-                        createColorBrush = createColorBrush
-                    )
-                }
+                ImageAny(
+                    src = imageSrc.value,
+                    contentDescription = null,
+                    contentScale = imageScaleType.value,
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .focusable()
+                        .focusRequester(focusRequester)
+                        .swipeGestures(
+                            onTap = { state.toggle() },
+                            onDoubleTap = { switchImageScale() },
+                            onSwipeLeft = { nextItem() },
+                            onSwipeRight = { prevItem() },
+                            onSwipeUp = { nextItem() },
+                            onSwipeDown = { prevItem() },
+                        )
+                )
+                customContentViewer(imageSrc.value, itemType, list)
             }
         ) {
-            AnimatedVisibility(
-                visible = infoVisible.value,
-                enter = fadeIn() + slideInVertically(initialOffsetY = { it / 2 }),
-                exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 2 }),
+            ImmersiveInnerList(
+                modifier = Modifier.fillMaxWidth(),
+                visible = controlsState.value,
+                backgroundColor = bckColor
             ) {
-                ImageBackground(
-                    modifier = Modifier.fillMaxWidth(),
-                    imageState = imageState,
-                    transform = { color -> createColorBrush(color, Gravity.BOTTOM) }
+                TVRow(
+                    horizontalArrangement = Arrangement.spacedBy(16.dp),
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(16.dp),
+                    contentPadding = 16.dp,
+                    state = listState
                 ) {
-                    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 ->
-                            val fr = rememberFocusRequester()
-                            PhotoCard(
-                                modifier = Modifier
-                                    .immersiveListItem(index)
-                                    .onKeyEvent { ev -> handleKey(ev) },
-                                item = item,
-                                focusRequester = fr,
-                            )
-                            SideEffect {
-                                val isFocused = (currentItemIndex.intValue == index)
-                                if (isFocused) {
-                                    imageState.value = list[currentItemIndex.intValue]
-                                    try {
-                                        fr.requestFocus()
-                                    } catch (e: Exception) {
-                                        Timber.e(e)
-                                    }
+                    itemsIndexed(list) { index, item ->
+                        val fr = rememberFocusRequester()
+                        PhotoCard(
+                            modifier = Modifier.immersiveListItem(index),
+                            item = item,
+                            focusRequester = fr,
+                        )
+                        SideEffect {
+                            val isFocused = (currentItemIndex.intValue == index)
+                            if (isFocused) {
+                                try {
+                                    fr.requestFocus()
+                                } catch (e: Exception) {
+                                    Timber.e(e)
                                 }
                             }
                         }
                     }
-                    Spacer(modifier = Modifier.height(32.dp))
-                }
-                LaunchedEffect(currentItemIndex.intValue) {
-                    if (transition.currentState != transition.targetState) {
-                        if (!infoVisible.value) {
-                            try {
-                                focusRequester.requestFocus()
-                            } catch (e: Exception) {
-                                Timber.e(e)
-                            }
-                        } else {
-                            listState.scrollToItem(currentItemIndex.intValue)
-                        }
-                    }
                 }
+                Spacer(modifier = Modifier.height(32.dp))
             }
         }
-        LaunchedEffect(currentItemIndex.intValue) {
-            if (!initialized.value) {
-                listState.scrollToItem(currentItemIndex.intValue)
-                initialized.value = true
+    }
+
+    LaunchedEffect(currentItemIndex.intValue, controlsState.value) {
+        if (!initialized.value) {
+            listState.scrollToItem(currentItemIndex.intValue)
+            initialized.value = true
+        } else {
+            if (controlsState.value && (delayedHide > 0)) {
+                delay(delayedHide)
+                controlsState.value = false
             }
-            delay(5000)
-            infoVisible.value = false
         }
     }
-}
+
+}
+
+val contentScaleSaver = Saver<MutableState<ContentScale>, String>(
+    save = { value -> value.value.toString() },
+    restore = { str ->
+        mutableStateOf(
+            when (str) {
+                "Crop" -> ContentScale.Crop
+                "Fit" -> ContentScale.Fit
+                else -> ContentScale.Crop
+            }
+        )
+    }
+)

+ 77 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/ImmersiveInnerList.kt

@@ -0,0 +1,77 @@
+/*
+ *  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.EnterTransition
+import androidx.compose.animation.ExitTransition
+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.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Brush.Companion.verticalGradient
+import androidx.compose.ui.graphics.Color
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvlib.extensions.ComposeExt
+import org.mjdev.tvlib.extensions.ComposeExt.createVerticalColorBrush
+import org.mjdev.tvlib.extensions.ModifierExt.conditional
+import org.mjdev.tvlib.helpers.brush.BrushBuilder
+import org.mjdev.tvlib.ui.components.immersivelist.ImmersiveListScope
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun ImmersiveListScope.ImmersiveInnerList(
+    modifier: Modifier = Modifier,
+    visible: Boolean = ComposeExt.isEditMode(),
+    backgroundColor: Color = Color.Transparent,
+    enter: EnterTransition = fadeIn() + slideInVertically(initialOffsetY = { it / 2 }),
+    exit: ExitTransition = fadeOut() + slideOutVertically(targetOffsetY = { it / 2 }),
+    list: @Composable ImmersiveListScope.() -> Unit = {},
+) {
+    val background: State<Brush> = remember(backgroundColor) {
+        derivedStateOf {
+            if (backgroundColor == Color.Transparent) {
+                verticalGradient(
+                    listOf(
+                        Color.Transparent,
+                        Color.Transparent
+                    )
+                )
+            } else {
+                createVerticalColorBrush(
+                    backgroundColor,
+                    Gravity.BOTTOM
+                )
+            }
+        }
+    }
+    AnimatedVisibility(
+        modifier = modifier,
+        visible = visible,
+        enter = enter,
+        exit = exit
+    ) {
+        Box(
+            modifier = modifier
+                .background(background.value)
+        ) {
+            list()
+        }
+    }
+}

+ 43 - 66
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/gallery/ItemInfo.kt

@@ -12,65 +12,43 @@ import android.view.Gravity
 import androidx.compose.animation.AnimatedVisibility
 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.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.State
+import androidx.compose.runtime.derivedStateOf
 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.R
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.ColorExt.invert
+import org.mjdev.tvlib.extensions.ComposeExt.createVerticalColorBrush
+import org.mjdev.tvlib.extensions.ComposeExt.isEditMode
 import org.mjdev.tvlib.helpers.media.MetadataRetriever
 import org.mjdev.tvlib.helpers.media.MetadataRetriever.Companion.rememberMetaDataRetriever
 import org.mjdev.tvlib.interfaces.ItemWithDate
 import org.mjdev.tvlib.interfaces.ItemWithTitle
 import org.mjdev.tvlib.ui.components.text.AutoHideEmptyText
-import org.mjdev.tvlib.ui.components.image.ImageBackground
 
 @Previews
 @Composable
 fun ItemInfo(
+    modifier: Modifier = Modifier,
     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,
-                )
-            }
-        )
-    },
-    initialColor: Color = Color.DarkGray,
-    imageState: MutableState<Any?> = mutableStateOf(src),
+    visible: Boolean = isEditMode(),
+    backgroundColor: Color = Color.Transparent,
     metadataRetriever: MetadataRetriever = rememberMetaDataRetriever(),
     titleFromItem: () -> Any? = {
         (src as? ItemWithTitle<*>)?.title
@@ -82,44 +60,43 @@ fun ItemInfo(
         metadataRetriever.getInfo(src)
     },
 ) {
-    // todo color invert text does not work properly?
+    val bck = createVerticalColorBrush(backgroundColor, Gravity.TOP)
+    val textColor = remember(backgroundColor) {
+        derivedStateOf {
+            backgroundColor.invert().copy(alpha = 1.0f)
+        }
+    }
     AnimatedVisibility(
-        visible = infoVisible.value,
-        enter = fadeIn(),
-        exit = fadeOut(),
+        modifier = modifier,
+        visible = visible,
+        enter = slideInVertically(),
+        exit = slideOutVertically(),
     ) {
         Box(
-            modifier = Modifier.fillMaxHeight(),
+            modifier = modifier.background(bck),
             contentAlignment = Alignment.TopStart
         ) {
-            ImageBackground(
-                modifier = Modifier.fillMaxWidth(),
-                initialColor = initialColor,
-                imageState = imageState,
-                transform = { color -> createColorBrush(color, Gravity.TOP) }
-            ) { bckColor ->
-                Column(
-                    modifier = Modifier.padding(16.dp, 0.dp, 0.dp, 16.dp)
-                ) {
-                    Spacer(modifier = Modifier.height(16.dp))
-                    AutoHideEmptyText(
-                        text = titleFromItem(),
-                        color = bckColor.invert().copy(alpha = 1.0f),
-                        fontSize = 24.sp
-                    )
-                    AutoHideEmptyText(
-                        text = dateFromItem(),
-                        color = bckColor.invert().copy(alpha = 1.0f),
-                        fontSize = 16.sp
-                    )
-                    AutoHideEmptyText(
-                        text = detailsFromItem(),
-                        color = bckColor.invert().copy(alpha = 1.0f),
-                        fontSize = 12.sp,
-                        lineHeight = 12.sp
-                    )
-                    Spacer(modifier = Modifier.height(16.dp))
-                }
+            Column(
+                modifier = Modifier.padding(16.dp, 0.dp, 0.dp, 16.dp)
+            ) {
+                Spacer(modifier = Modifier.height(16.dp))
+                AutoHideEmptyText(
+                    text = titleFromItem(),
+                    color = textColor.value,
+                    fontSize = 24.sp
+                )
+                AutoHideEmptyText(
+                    text = dateFromItem(),
+                    color = textColor.value,
+                    fontSize = 16.sp
+                )
+                AutoHideEmptyText(
+                    text = detailsFromItem(),
+                    color = textColor.value,
+                    fontSize = 12.sp,
+                    lineHeight = 12.sp
+                )
+                Spacer(modifier = Modifier.height(16.dp))
             }
         }
     }

+ 17 - 13
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/icon/IconAny.kt

@@ -28,7 +28,8 @@ import androidx.compose.ui.res.painterResource
 import androidx.tv.material3.ExperimentalTvMaterial3Api
 import androidx.tv.material3.Icon
 import androidx.tv.material3.LocalContentColor
-import coil.compose.AsyncImage
+import com.skydoves.landscapist.ImageOptions
+import com.skydoves.landscapist.glide.GlideImage
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.DrawableExt.asImageBitmap
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
@@ -94,25 +95,28 @@ fun IconAny(
             tint
         )
 
-        is URL -> AsyncImage(
-            model = src,
-            contentDescription = contentDescription,
+        is URL -> GlideImage(
+            imageModel = { src },
             modifier = modifier.recomposeHighlighter(),
-            colorFilter = ColorFilter.tint(tint)
+            imageOptions = ImageOptions(
+                colorFilter = ColorFilter.tint(tint)
+            )
         )
 
-        is Uri -> AsyncImage(
-            model = src,
-            contentDescription = contentDescription,
+        is Uri -> GlideImage(
+            imageModel = { src },
             modifier = modifier.recomposeHighlighter(),
-            colorFilter = ColorFilter.tint(tint)
+            imageOptions = ImageOptions(
+                colorFilter = ColorFilter.tint(tint)
+            )
         )
 
-        is String -> AsyncImage(
-            model = src,
-            contentDescription = contentDescription,
+        is String -> GlideImage(
+            imageModel = { src },
             modifier = modifier.recomposeHighlighter(),
-            colorFilter = ColorFilter.tint(tint)
+            imageOptions = ImageOptions(
+                colorFilter = ColorFilter.tint(tint)
+            )
         )
 
         is ImageVector -> Icon(

+ 26 - 40
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/ImageAny.kt

@@ -17,6 +17,7 @@ import androidx.compose.foundation.Image
 import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -30,10 +31,9 @@ import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.unit.dp
 import com.skydoves.landscapist.ImageOptions
-import com.skydoves.landscapist.coil.CoilImage
+import com.skydoves.landscapist.glide.GlideImage
 import org.mjdev.tvlib.R
 import org.mjdev.tvlib.annotations.Previews
-import org.mjdev.tvlib.extensions.ComposeExt.rememberImageLoader
 import org.mjdev.tvlib.extensions.DrawableExt.asImageBitmap
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 import java.net.URL
@@ -56,13 +56,17 @@ fun ImageAny(
     val width = if (constraints.minWidth == 0) 1 else constraints.minWidth
     val height = if (constraints.minHeight == 0) 1 else constraints.minHeight
 
-    when (src) {
+    val imageSrc = if(src is State<*>) src.value else src
 
-        null, Unit -> {
+    when (imageSrc) {
+
+        null, Unit, "0", 0 -> {
             Image(
                 painterResource(R.drawable.broken_image),
                 contentDescription,
-                modifier.recomposeHighlighter().padding(16.dp),
+                modifier
+                    .padding(16.dp)
+                    .recomposeHighlighter(),
                 alignment,
                 contentScale,
                 alpha,
@@ -71,7 +75,7 @@ fun ImageAny(
         }
 
         Color -> Image(
-            ColorDrawable((src as Color).toArgb()).asImageBitmap(width, height),
+            ColorDrawable((imageSrc as Color).toArgb()).asImageBitmap(width, height),
             contentDescription,
             modifier.recomposeHighlighter(),
             alignment,
@@ -81,7 +85,7 @@ fun ImageAny(
         )
 
         is Bitmap -> Image(
-            src.asImageBitmap(),
+            imageSrc.asImageBitmap(),
             contentDescription,
             modifier.recomposeHighlighter(),
             alignment,
@@ -91,7 +95,7 @@ fun ImageAny(
         )
 
         is ImageBitmap -> Image(
-            src,
+            imageSrc,
             contentDescription,
             modifier.recomposeHighlighter(),
             alignment,
@@ -101,7 +105,7 @@ fun ImageAny(
         )
 
         is ColorDrawable -> Image(
-            src.asImageBitmap(1, 1),
+            imageSrc.asImageBitmap(1, 1),
             contentDescription,
             modifier.recomposeHighlighter(),
             alignment,
@@ -111,7 +115,7 @@ fun ImageAny(
         )
 
         is Drawable -> Image(
-            src.asImageBitmap(),
+            imageSrc.asImageBitmap(),
             contentDescription,
             modifier.recomposeHighlighter(),
             alignment,
@@ -121,7 +125,7 @@ fun ImageAny(
         )
 
         is Int -> Image(
-            painterResource(src),
+            painterResource(imageSrc),
             contentDescription,
             modifier.recomposeHighlighter(),
             alignment,
@@ -130,59 +134,41 @@ fun ImageAny(
             colorFilter
         )
 
-        is URL -> CoilImage(
-            imageLoader = {
-                rememberImageLoader()
-            },
-            modifier = modifier.recomposeHighlighter(),
-            imageModel = {
-                src
-            },
+        is URL -> GlideImage(
+            imageModel = { imageSrc },
             imageOptions = ImageOptions(
                 contentScale = contentScale,
                 alignment = alignment,
                 contentDescription = contentDescription,
                 alpha = alpha,
                 colorFilter = colorFilter,
-            ),
+            )
         )
 
-        is Uri -> CoilImage(
-            imageLoader = {
-                rememberImageLoader()
-            },
-            modifier = modifier.recomposeHighlighter(),
-            imageModel = {
-                src
-            },
+        is Uri -> GlideImage(
+            imageModel = { imageSrc },
             imageOptions = ImageOptions(
                 contentScale = contentScale,
                 alignment = alignment,
                 contentDescription = contentDescription,
                 alpha = alpha,
                 colorFilter = colorFilter,
-            ),
+            )
         )
 
-        is String -> CoilImage(
-            imageLoader = {
-                rememberImageLoader()
-            },
-            modifier = modifier.recomposeHighlighter(),
-            imageModel = {
-                src
-            },
+        is String -> GlideImage(
+            imageModel = { imageSrc },
             imageOptions = ImageOptions(
                 contentScale = contentScale,
                 alignment = alignment,
                 contentDescription = contentDescription,
                 alpha = alpha,
                 colorFilter = colorFilter,
-            ),
+            )
         )
 
         is ImageVector -> Image(
-            src,
+            imageSrc,
             contentDescription,
             modifier.recomposeHighlighter(),
             alignment,
@@ -191,7 +177,7 @@ fun ImageAny(
             colorFilter
         )
 
-        else -> throw (RuntimeException("Unknown image format $src."))
+        else -> throw (RuntimeException("Unknown image format $imageSrc."))
 
     }
 

+ 14 - 38
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/ImageBackground.kt

@@ -11,69 +11,45 @@ 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.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.graphics.RectangleShape
 import androidx.compose.ui.graphics.Shape
-import androidx.compose.ui.platform.LocalContext
-import androidx.core.graphics.drawable.toBitmap
-import coil.ImageLoader
-import coil.request.CachePolicy
-import coil.request.ImageRequest
 import org.mjdev.tvlib.annotations.Previews
-import org.mjdev.tvlib.extensions.BitmapExt.majorColor
-import org.mjdev.tvlib.extensions.ComposeExt.rememberImageLoader
+import org.mjdev.tvlib.extensions.ComposeExt.rememberColorFromImage
+import org.mjdev.tvlib.extensions.ComposeExt.rememberImageFromItem
 import org.mjdev.tvlib.extensions.ModifierExt.conditional
-import org.mjdev.tvlib.interfaces.ItemPhoto
-import org.mjdev.tvlib.interfaces.ItemWithBackground
-import org.mjdev.tvlib.interfaces.ItemWithImage
 
 @Previews
 @Composable
-fun ImageBackground(
+fun ImageColoredBackground(
     modifier: Modifier = Modifier,
-    initialColor: Color = Color.DarkGray,
-    imageState: MutableState<Any?> = mutableStateOf(initialColor),
-    imageFromItem: (data:Any?) -> Any? = { data ->
-            val photo = (data as? ItemPhoto)?.uri
-            val img = (data as? ItemWithImage<*>)?.image
-            val background = (data as? ItemWithBackground<*>)?.background
-            val color = data as? Color
-            val string = data.toString()
-            color ?: photo ?: img ?: background ?: string
-    },
+    image: Any? = null,
     shape: Shape = RectangleShape,
     transform: ((color: Color) -> Brush)? = null,
     contentAlignment: Alignment = Alignment.TopStart,
-    imageLoader: ImageLoader = rememberImageLoader(),
     content: @Composable (bckColor: Color) -> Unit = {}
 ) {
-    val context = LocalContext.current
-    val bckColor = remember { mutableStateOf(Color.Transparent) }
+    val imageSrc = rememberImageFromItem(image)
+    val bckColor = rememberColorFromImage(imageSrc.value)
+
     Box(
         modifier = modifier
             .conditional(transform == null) {
                 background(bckColor.value, RectangleShape)
             }
             .conditional(transform != null) {
-                background(transform!!.invoke(bckColor.value), shape)
+                background(
+                    transform!!.invoke(bckColor.value),
+                    shape
+                )
             },
         contentAlignment = contentAlignment
     ) {
         content(bckColor.value)
     }
-    LaunchedEffect(imageState.value) {
-        bckColor.value = imageLoader.execute(
-            ImageRequest.Builder(context)
-                .data(imageFromItem(imageState.value))
-                .diskCachePolicy(CachePolicy.DISABLED)
-                .build()
-        ).drawable?.toBitmap()?.majorColor(Color.Black) ?: Color.Black
-    }
-}
+}
+
+

+ 4 - 9
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/PhotoImage.kt

@@ -29,14 +29,13 @@ import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import com.skydoves.landscapist.ImageOptions
-import com.skydoves.landscapist.coil.CoilImage
-import com.skydoves.landscapist.coil.CoilImageState
+import com.skydoves.landscapist.glide.GlideImage
+import com.skydoves.landscapist.glide.GlideImageState
 import org.mjdev.tvlib.R
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.BitmapExt.majorColor
 import org.mjdev.tvlib.extensions.ColorExt.contrastAndBrightness
 import org.mjdev.tvlib.extensions.ComposeExt.isEditMode
-import org.mjdev.tvlib.extensions.ComposeExt.rememberImageLoader
 import org.mjdev.tvlib.extensions.ComposeExt.toDrawable
 import org.mjdev.tvlib.extensions.DrawableExt.asPhoto
 import org.mjdev.tvlib.extensions.ModifierExt.conditional
@@ -69,14 +68,10 @@ fun PhotoImage(
     shape: Shape = RoundedCornerShape(roundCornerSize),
     colorFilter: ColorFilter? = null,
     contentDescription: String? = null,
-
-    onImageStateChanged: (state: CoilImageState) -> Unit = {}
+    onImageStateChanged: (state: GlideImageState) -> Unit = {}
 ) {
     val isEdit = isEditMode()
-    CoilImage(
-        imageLoader = {
-            rememberImageLoader()
-        },
+    GlideImage(
         imageModel = {
             src
         },

+ 3 - 2
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/immersivelist/ImmersiveList.kt

@@ -34,8 +34,9 @@ import org.mjdev.tvlib.extensions.ModifierExt.bringIntoViewIfChildrenAreFocused
 @Composable
 fun ImmersiveList(
     background: @Composable ImmersiveListBackgroundScope.(
-        index: Int, listHasFocus: Boolean
-    ) -> Unit = { idx, focus -> },
+        index: Int,
+        listHasFocus: Boolean
+    ) -> Unit = { _, _ -> },
      modifier: Modifier = Modifier,
     listAlignment: Alignment = Alignment.BottomEnd,
     currentItemIndex: MutableIntState = remember { mutableIntStateOf(0) },

+ 23 - 20
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/media/MediaPlayerContainer.kt

@@ -28,31 +28,34 @@ import org.mjdev.tvlib.ui.components.media.MediaPlayerState.Companion.rememberMe
 @Composable
 fun MediaPlayerContainer(
     modifier: Modifier = Modifier,
+    visible: Boolean = true,
     state: MediaPlayerState = rememberMediaPlayerState(),
 ) {
     val nextItem: () -> Unit = { state.player.seekToNext() }
     val prevItem: () -> Unit = { state.player.seekToPrevious() }
-    Box(
-        modifier = modifier
-            .fillMaxSize()
-            .swipeGestures(
-                onSwipeLeft = { nextItem() },
-                onSwipeRight = { prevItem() },
-                onSwipeUp = { nextItem() },
-                onSwipeDown = { prevItem() },
+    if (visible) {
+        Box(
+            modifier = modifier
+                .fillMaxSize()
+                .swipeGestures(
+                    onSwipeLeft = { nextItem() },
+                    onSwipeRight = { prevItem() },
+                    onSwipeUp = { nextItem() },
+                    onSwipeDown = { prevItem() },
+                )
+                .background(Color.Black)
+                .recomposeHighlighter()
+        ) {
+            state.player.GetPlayerView()
+            PlayerControlView(
+                state = state
             )
-            .background(Color.Black)
-            .recomposeHighlighter()
-    ) {
-        state.player.GetPlayerView()
-        PlayerControlView(
-            state = state
-        )
-    }
-    DisposableEffect(Unit) {
-        state.startPlay()
-        onDispose {
-            state.dispose()
+        }
+        DisposableEffect(Unit) {
+            state.startPlay()
+            onDispose {
+                state.dispose()
+            }
         }
     }
 }