Selaa lähdekoodia

glide album art

Milan Jurkulak 2 vuotta sitten
vanhempi
sitoutus
b6cdeb8648
24 muutettua tiedostoa jossa 552 lisäystä ja 449 poistoa
  1. 24 0
      .run/tvapp [ktlint].run.xml
  2. 18 12
      app/build.gradle.kts
  3. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/module/ProvideModule.kt
  4. 53 51
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncAdapter.kt
  5. 2 1
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt
  6. 11 4
      build.gradle.kts
  7. 1 1
      settings.gradle.kts
  8. 4 3
      tvlib/build.gradle.kts
  9. 0 2
      tvlib/src/main/kotlin/org/mjdev/tvlib/application/TvApplication.kt
  10. 16 15
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/GlobalExt.kt
  11. 27 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ListExt.kt
  12. 11 4
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/ApplicationGlideApp.kt
  13. 277 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/albumart/AlbumArtLoaderFactory.kt
  14. 0 275
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/coil/AlbumArtDecoder.kt
  15. 0 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/CachingCursor.kt
  16. 1 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/http/UserAgentInterceptor.kt
  17. 1 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/interfaces/ItemWithImage.kt
  18. 1 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/audiopreview/dailymotion/DailyMotionVideoView.kt
  19. 11 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/card/ItemCard.kt
  20. 2 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/card/PhotoCard.kt
  21. 10 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/carousel/CarouselCard.kt
  22. 67 61
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/complex/FocusableCard.kt
  23. 13 13
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/ImageAny.kt
  24. 1 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/updater/AppUpdater.kt

+ 24 - 0
.run/tvapp [ktlint].run.xml

@@ -0,0 +1,24 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="tvapp [ktlint]" type="GradleRunConfiguration" factoryName="Gradle" nameIsGenerated="true">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="ktlintFormat" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <ForceTestExec>false</ForceTestExec>
+    <method v="2" />
+  </configuration>
+</component>

+ 18 - 12
app/build.gradle.kts

@@ -72,7 +72,6 @@ android {
             put("auth0Domain", "@string/com_auth0_domain")
             put("auth0Scheme", "demo")
         }
-
     }
 
     buildTypes {
@@ -88,12 +87,12 @@ android {
             buildConfigField(
                 "String",
                 "SYNC_AUTH",
-                "\"${defaultConfig.applicationId}${applicationIdSuffix}.sync\""
+                "\"${defaultConfig.applicationId}$applicationIdSuffix.sync\""
             )
             resValue(
                 "string",
                 "sync_auth",
-                "${defaultConfig.applicationId}${applicationIdSuffix}.sync"
+                "${defaultConfig.applicationId}$applicationIdSuffix.sync"
             )
         }
 
@@ -108,12 +107,12 @@ android {
             buildConfigField(
                 "String",
                 "SYNC_AUTH",
-                "\"${defaultConfig.applicationId}${applicationIdSuffix}.sync\""
+                "\"${defaultConfig.applicationId}$applicationIdSuffix.sync\""
             )
             resValue(
                 "string",
                 "sync_auth",
-                "${defaultConfig.applicationId}${applicationIdSuffix}.sync"
+                "${defaultConfig.applicationId}$applicationIdSuffix.sync"
             )
         }
 
@@ -128,17 +127,16 @@ android {
             buildConfigField(
                 "String",
                 "SYNC_AUTH",
-                "\"${defaultConfig.applicationId}${applicationIdSuffix}.sync\""
+                "\"${defaultConfig.applicationId}$applicationIdSuffix.sync\""
             )
             resValue(
                 "string",
                 "sync_auth",
-                "${defaultConfig.applicationId}${applicationIdSuffix}.sync"
+                "${defaultConfig.applicationId}$applicationIdSuffix.sync"
             )
             multiDexEnabled = true
             matchingFallbacks += listOf("debug")
         }
-
     }
 
     buildFeatures {
@@ -182,7 +180,6 @@ android {
 //    tasks.dokkaGfm {
 //        outputDirectory.set(File(projectDir, "../wiki/documentation"))
 //    }
-
 }
 
 dependencies {
@@ -234,7 +231,8 @@ dependencies {
     implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
     implementation("com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2")
     // sandwich
-    implementation("com.github.skydoves:sandwich:1.3.9")
+    implementation("com.github.skydoves:sandwich:2.0.4")
+    implementation("com.github.skydoves:sandwich-retrofit:2.0.4")
     // encrypt data
     implementation("com.scottyab:aescrypt:0.0.1")
     // permission
@@ -252,6 +250,14 @@ dependencies {
     implementation("androidx.glance:glance-material3:1.0.0")
     // fix duplicates
     implementation("androidx.work:work-runtime-ktx:2.9.0")
+    // image loading
+    implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0")
+    implementation("com.github.bumptech.glide:compose:1.0.0-beta01")
+    implementation("com.github.skydoves:landscapist-glide:2.2.12")
+    implementation("com.github.skydoves:landscapist-transformation:2.2.12")
+    implementation("com.github.skydoves:landscapist-palette:2.2.12")
+    implementation("com.github.skydoves:landscapist-placeholder:2.2.12")
+    ksp("com.github.bumptech.glide:ksp:4.15.1")
     // exoplayer
     implementation("androidx.media3:media3-exoplayer:1.2.0")
     implementation("androidx.media3:media3-exoplayer-dash:1.2.0")
@@ -270,7 +276,6 @@ dependencies {
     implementation("androidx.media3:media3-decoder:1.2.0")
     implementation("androidx.media3:media3-datasource:1.2.0")
     implementation("androidx.media3:media3-common:1.2.0")
-//    implementation("net.engawapg.lib:zoomable:1.5.2")
     // libs mismatch
     constraints {
         implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.0").apply {
@@ -281,6 +286,7 @@ dependencies {
         }
     }
     // fix duplicates
+    @Suppress("UnstableApiUsage")
     configurations {
         all {
             resolutionStrategy {
@@ -288,4 +294,4 @@ dependencies {
             }
         }
     }
-}
+}

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/module/ProvideModule.kt

@@ -12,7 +12,7 @@ package org.mjdev.tvapp.module
 
 import android.content.Context
 import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
-import com.skydoves.sandwich.adapters.ApiResponseCallAdapterFactory
+import com.skydoves.sandwich.retrofit.adapters.ApiResponseCallAdapterFactory
 import com.squareup.moshi.Moshi
 import dagger.Module
 import dagger.Provides

+ 53 - 51
app/src/main/kotlin/org/mjdev/tvapp/sync/SyncAdapter.kt

@@ -38,66 +38,67 @@ class SyncAdapter(
         authority: String?,
         provider: ContentProviderClient?,
         syncResult: SyncResult?
-    ) = launch(IO) {
-        try {
-
-            val streams = apiService.streams().safeGet()
-            val channels = apiService.channels().safeGet()
-
-            val oldMovies = movieDao.all
-            val newMovies = mutableListOf<Movie>()
-
-            streams.forEach { stream ->
-                channels.firstOrNull { channel ->
-                    channel.id == stream.channelId
-                }.also { channel ->
-                    (channel?.categories ?: listOf(null)).forEach { ctg ->
-                        newMovies.add(Movie().apply {
-                            title = channel?.name ?: stream.channelId
-                            uri = stream.url
-                            category = ctg
-                            image = channel?.logo
-                            country = channel?.country
-                            isNsfw = channel?.isNsfw ?: false
-                        })
+    ) {
+        launch(IO) {
+            try {
+
+                val streams = apiService.streams().safeGet()
+                val channels = apiService.channels().safeGet()
+
+                val oldMovies = movieDao.all
+                val newMovies = mutableListOf<Movie>()
+
+                streams.forEach { stream ->
+                    channels.firstOrNull { channel ->
+                        channel.id == stream.channelId
+                    }.also { channel ->
+                        (channel?.categories ?: listOf(null)).forEach { ctg ->
+                            newMovies.add(Movie().apply {
+                                title = channel?.name ?: stream.channelId
+                                uri = stream.url
+                                category = ctg
+                                image = channel?.logo
+                                country = channel?.country
+                                isNsfw = channel?.isNsfw ?: false
+                            })
+                        }
                     }
                 }
-            }
 
-            with(SyncItems(oldMovies, newMovies) ) {
-                toAdd.forEach { movie ->
-                    try {
-                        dao.movieDao.tx {
-                            put(movie)
+                with(SyncItems(oldMovies, newMovies)) {
+                    toAdd.forEach { movie ->
+                        try {
+                            dao.movieDao.tx {
+                                put(movie)
+                            }
+                            Timber.d("Movie: $movie stored.")
+                        } catch (e: Exception) {
+                            Timber.e(e)
                         }
-                        Timber.d("Movie: $movie stored.")
-                    } catch (e: Exception) {
-                        Timber.e(e)
                     }
-                }
-                toUpdate.forEach { movie ->
-                    try {
-                        dao.movieDao.tx {
-                            put(movie)
+                    toUpdate.forEach { movie ->
+                        try {
+                            dao.movieDao.tx {
+                                put(movie)
+                            }
+                            Timber.d("Movie: $movie updated.")
+                        } catch (e: Exception) {
+                            Timber.e(e)
                         }
-                        Timber.d("Movie: $movie updated.")
-                    } catch (e: Exception) {
-                        Timber.e(e)
                     }
-                }
-                toRemove.forEach { movie ->
-                    try {
-                        dao.movieDao.tx {
-                            remove(movie)
+                    toRemove.forEach { movie ->
+                        try {
+                            dao.movieDao.tx {
+                                remove(movie)
+                            }
+                            Timber.d("Movie: $movie removed.")
+                        } catch (e: Exception) {
+                            Timber.e(e)
                         }
-                        Timber.d("Movie: $movie removed.")
-                    } catch (e: Exception) {
-                        Timber.e(e)
                     }
                 }
-            }
 
-            // todo non ui thread
+                // todo non ui thread
 //            launch(UI) {
 //                WebScrapper(context, urls) { page, videoURL ->
 //                    try {
@@ -131,8 +132,9 @@ class SyncAdapter(
 //                }
 //            ).start()
 
-        } catch (e: Exception) {
-            Timber.e(e)
+            } catch (e: Exception) {
+                Timber.e(e)
+            }
         }
     }
 

+ 2 - 1
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt

@@ -28,6 +28,7 @@ import org.mjdev.tvapp.data.local.Movie
 import org.mjdev.tvapp.database.DAO
 import org.mjdev.tvapp.database.DAO.Companion.findById
 import org.mjdev.tvlib.extensions.ListExt.asMap
+import org.mjdev.tvlib.extensions.ListExt.takeIf
 import org.mjdev.tvlib.helpers.apps.appsManager
 import org.mjdev.tvlib.interfaces.ItemWithImage.Companion.hasImage
 import javax.inject.Inject
@@ -73,7 +74,7 @@ class MainViewModel @Inject constructor(
             addAll(localAudioCursor.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
             addAll(localVideoCursor.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
             addAll(localPhotoCursor.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
-            addAll(dao.movieDao.all.take(ITEMS_FROM_CATEGORY))
+            addAll(dao.movieDao.all.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
         }.also { result ->
             emit(result)
         }

+ 11 - 4
build.gradle.kts

@@ -12,6 +12,13 @@ plugins {
     kotlin("android") version "1.7.20" apply false
     id("com.google.dagger.hilt.android") version "2.47" apply false
     id("com.google.devtools.ksp") version "1.9.0-1.0.11" apply false
+    id("org.jlleitschuh.gradle.ktlint") version "11.3.2" apply true
+    // todo ktorfit https://foso.github.io/Ktorfit/
+//    id("de.jensklingenberg.ktorfit") version "1.10.2" apply true
+}
+
+subprojects {
+    apply(plugin = "org.jlleitschuh.gradle.ktlint")
 }
 
 buildscript {
@@ -23,16 +30,16 @@ buildscript {
     }
     dependencies {
         classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21")
-        classpath("com.android.tools:r8:8.1.56")
+        classpath("com.android.tools:r8:8.1.72")
         classpath("io.objectbox:objectbox-gradle-plugin:3.6.0")
         classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0")
         classpath("com.android.tools.build:gradle:8.2.0")
-        classpath("com.android.tools.build:gradle-api:8.1.3")
+        classpath("com.android.tools.build:gradle-api:8.1.4")
 //        classpath("com.newrelic.agent.android:agent-gradle-plugin:5.9.0")
-        classpath ("app.cash.paparazzi:paparazzi-gradle-plugin:1.3.1")
+//        classpath ("app.cash.paparazzi:paparazzi-gradle-plugin:1.3.1")
     }
 }
 
 tasks.register<Delete>("clean") {
     delete(rootProject.layout.buildDirectory)
-}
+}

+ 1 - 1
settings.gradle.kts

@@ -52,4 +52,4 @@ gradleEnterprise {
 
 rootProject.name = "TvApp"
 include(":app")
-include(":tvlib")
+include(":tvlib")

+ 4 - 3
tvlib/build.gradle.kts

@@ -94,7 +94,6 @@ android {
 //    tasks.dokkaGfm {
 //        outputDirectory.set(File(projectDir, "../wiki/documentation"))
 //    }
-
 }
 
 dependencies {
@@ -135,7 +134,8 @@ dependencies {
     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")
+    implementation("com.github.skydoves:sandwich:2.0.4")
+    implementation("com.github.skydoves:sandwich-retrofit:2.0.4")
     // image loading
     implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0")
     implementation("com.github.bumptech.glide:compose:1.0.0-beta01")
@@ -143,6 +143,7 @@ dependencies {
     implementation("com.github.skydoves:landscapist-transformation:2.2.12")
     implementation("com.github.skydoves:landscapist-palette:2.2.12")
     implementation("com.github.skydoves:landscapist-placeholder:2.2.12")
+    ksp("com.github.bumptech.glide:ksp:4.15.1")
     // zxing
     implementation("com.google.zxing:core:3.5.2")
     // svg
@@ -225,4 +226,4 @@ dependencies {
     testImplementation("junit:junit:4.13.2")
     androidTestImplementation("androidx.test.ext:junit:1.1.5")
     androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
-}
+}

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

@@ -18,6 +18,4 @@ open class TvApplication : Application() {
         super.onCreate()
         DynamicColors.applyToActivitiesIfAvailable(this)
     }
-
-//    open fun createImageLoader(): ImageLoader? = null
 }

+ 16 - 15
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/GlobalExt.kt

@@ -20,6 +20,7 @@ import com.skydoves.sandwich.toFlow
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.launch
 import timber.log.Timber
@@ -37,13 +38,11 @@ object GlobalExt {
         context: CoroutineContext = IO,
         start: CoroutineStart = CoroutineStart.DEFAULT,
         block: suspend CoroutineScope.() -> Unit
-    ) {
-        CoroutineScope(context).launch(
-            context = context,
-            start = start,
-            block = block
-        )
-    }
+    ): Job = CoroutineScope(context).launch(
+        context = context,
+        start = start,
+        block = block
+    )
 
     fun <T> T.postDelayed(
         delay: Long,
@@ -52,11 +51,10 @@ object GlobalExt {
         block.invoke(this)
     }, delay)
 
-    @Suppress("MemberVisibilityCanBePrivate")
     class CodeException(
-        val code: Int = -1,
-        message: String = String.format("$code : Unhandled Exception.")
-    ) : Exception(message)
+        message: String = "Unhandled Exception.",
+        code: Int = -1,
+    ) : Exception(String.format("$code : $message."))
 
     fun MutableState<Boolean>.toggle() {
         value = !value
@@ -65,11 +63,14 @@ object GlobalExt {
     fun <T> ApiResponse<T>.safeFlow(
         error: (exception: Exception) -> Unit = { exception ->
             Timber.e(exception)
+        },
+        onError: (error: ApiResponse.Failure.Error) -> Unit = { e ->
+            error(CodeException(e.message()))
         }
     ): Flow<T> = onException {
-        error(exception)
+        error(this)
     }.onError {
-        error(CodeException(statusCode.code, message()))
+        onError(this)
     }.toFlow()
 
     inline fun <reified T> ApiResponse<List<T>>.safeGet(
@@ -77,9 +78,9 @@ object GlobalExt {
             Timber.e(exception)
         }
     ): List<T> = onException {
-        error(exception)
+        error(this)
     }.onError {
-        error(CodeException(statusCode.code, message()))
+        error(CodeException(message()))
     }.getOrNull() ?: emptyList()
 
     suspend fun <E> runSafe(block: suspend () -> E): Result<E> = try {

+ 27 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ListExt.kt

@@ -13,6 +13,33 @@ import androidx.core.os.bundleOf
 @Suppress("unused")
 object ListExt {
 
+    fun <T> Iterable<T>.takeIf(
+        n: Int,
+        condition: T.() -> Boolean
+    ): List<T> {
+        require(n >= 0) { "Requested element count $n is less than zero." }
+        if (n == 0) return emptyList()
+        if (this is Collection<T>) {
+            if (n >= size) return toList()
+            if (n == 1) return listOf(first())
+        }
+        val list = ArrayList<T>(n)
+        for (item in this) {
+            if(item.condition()) {
+                list.add(item)
+            }
+            if (list.size == n)
+                break
+        }
+        return list.optimizeReadOnlyList()
+    }
+
+    private fun <T> List<T>.optimizeReadOnlyList() = when (size) {
+        0 -> emptyList()
+        1 -> listOf(this[0])
+        else -> this
+    }
+
     fun <T> MutableList<T>.addUnique(element: T, replace: Boolean = true) {
         val duplicate = firstOrNull { p ->
             p.toString().contentEquals(element.toString()) || p?.equals(element) == true

+ 11 - 4
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/ApplicationGlideApp.kt

@@ -28,6 +28,8 @@ 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.R
+import org.mjdev.tvlib.glide.sources.albumart.AlbumArtLoaderFactory
 import org.mjdev.tvlib.glide.sources.layout.LayoutLoaderFactory
 import org.mjdev.tvlib.glide.sources.layout.LayoutResId
 import org.mjdev.tvlib.glide.sources.svg.SvgDecoder
@@ -44,6 +46,9 @@ class ApplicationGlideApp : AppGlideModule() {
      */
     private val cacheSize256MegaBytes: Long = 256L * 1024L * 1024L
 
+    private fun calculator(context:Context): MemorySizeCalculator =
+        MemorySizeCalculator.Builder(context).build()
+
     /**
      * Apply custom image loader options
      * @param context Context
@@ -51,7 +56,7 @@ class ApplicationGlideApp : AppGlideModule() {
      */
     override fun applyOptions(context: Context, builder: GlideBuilder) {
         builder.apply {
-            val calculator = MemorySizeCalculator.Builder(context).build()
+            val calculator = calculator(context)
 
             val customMemoryCacheSize = calculator.memoryCacheSize
             val customBitmapPoolSize = calculator.bitmapPoolSize
@@ -75,9 +80,9 @@ class ApplicationGlideApp : AppGlideModule() {
                     .downsample(DownsampleStrategy.DEFAULT)
                     .encodeFormat(Bitmap.CompressFormat.PNG)
                     .encodeQuality(100)
-//                    .placeholder(EMPTY_DRAWABLE)
-//                    .error(EMPTY_DRAWABLE)
-//                    .fallback(EMPTY_DRAWABLE)
+                    .placeholder(R.drawable.broken_image)
+                    .error(R.drawable.broken_image)
+                    .fallback(R.drawable.broken_image)
             )
         }
     }
@@ -100,6 +105,8 @@ class ApplicationGlideApp : AppGlideModule() {
             )
             // LayoutRes support (create images from layout res Id)
             prepend(LayoutResId::class.java, Drawable::class.java, LayoutLoaderFactory(context))
+            // Album Art audio files
+            append(String::class.java, InputStream::class.java, AlbumArtLoaderFactory(context))
             // SVG support
             register(SVG::class.java, PictureDrawable::class.java, SvgDrawableTranscoder())
             append(InputStream::class.java, SVG::class.java, SvgDecoder())

+ 277 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/glide/sources/albumart/AlbumArtLoaderFactory.kt

@@ -0,0 +1,277 @@
+/*
+ *  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.albumart
+
+import android.content.Context
+import android.media.MediaMetadataRetriever
+import android.util.Xml
+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.ModelLoader.LoadData
+import com.bumptech.glide.load.model.ModelLoaderFactory
+import com.bumptech.glide.load.model.MultiModelLoaderFactory
+import com.bumptech.glide.signature.ObjectKey
+import kotlinx.coroutines.Job
+import okhttp3.Cache
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.logging.HttpLoggingInterceptor
+import org.mjdev.tvlib.extensions.GlobalExt.launch
+import org.mjdev.tvlib.helpers.http.UserAgentInterceptor
+import org.mjdev.tvlib.network.CacheInterceptor
+import org.mjdev.tvlib.webclient.cache.MimeTypeMapUtils
+import org.xmlpull.v1.XmlPullParser
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.InputStream
+
+// todo better performance && precache && dagger
+@Suppress("UNCHECKED_CAST", "PrivatePropertyName")
+class AlbumArtLoaderFactory(
+    private val context: Context
+) : ModelLoader<String?, InputStream?>, ModelLoaderFactory<String?, InputStream?> {
+
+    private val USER_AGENT =
+        "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0"
+    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()
+
+    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) {
+               // no op
+            }
+        }
+        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 {
+                    null
+                }
+            } catch (e: Exception) {
+                null
+            }
+            return ins?.readBytes()
+        } else {
+            return null
+        }
+    }
+
+    private fun retrieveXMLFromURL(url: String): InputStream? = try {
+        val response = call(url)
+        if (response.code == 200) {
+            response.body.byteStream()
+        } else {
+            null
+        }
+    } catch (e: Exception) {
+        null
+    }
+
+    @Throws(Exception::class)
+    private 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
+            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.")
+        }
+        while (parser.next() != XmlPullParser.END_TAG) {
+            if (parser.eventType != XmlPullParser.START_TAG) {
+                continue
+            }
+            val name = parser.name
+            if (name == "release-group") {
+                releaseGroupID = parser.getAttributeValue("", "id")
+                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++
+            }
+        }
+    }
+
+    override fun buildLoadData(
+        source: String,
+        width: Int,
+        height: Int,
+        options: Options
+    ): LoadData<InputStream?> = LoadData<InputStream?>(ObjectKey(source), CoverFetcher(source))
+
+    override fun handles(
+        model: String
+    ): Boolean = model.isAudioFile()
+
+    override fun build(
+        multiFactory: MultiModelLoaderFactory
+    ): ModelLoader<String?, InputStream?> = AlbumArtLoaderFactory(context)
+
+    override fun teardown() {
+    }
+
+    inner class CoverFetcher(
+        private val source: String?
+    ) : DataFetcher<InputStream?> {
+
+        private var job: Job? = null
+
+        override fun loadData(
+            priority: Priority,
+            callback: DataFetcher.DataCallback<in InputStream?>
+        ) {
+            job?.cancel()
+            job = launch {
+                try {
+                    val data = getCoverData(source)
+                    if (data != null) {
+                        callback.onDataReady(ByteArrayInputStream(data))
+                    } else {
+                        callback.onLoadFailed(Exception("No cover"))
+                    }
+                } catch (e:Exception) {
+                    callback.onLoadFailed(e)
+                }
+            }
+        }
+
+        private fun getCoverData(source: String?): ByteArray? {
+            var rawData: ByteArray? = null
+            MediaMetadataRetriever().use { retriever ->
+                if (source?.isMP3() == true) {
+                    retriever.setDataSource(source)
+                    rawData = try {
+                        retriever.embeddedPicture
+                    } catch (e: Exception) {
+                        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 = File(source ?: "").name.substringBeforeLast(".")
+                    }
+                    rawData = downloadAlbumArt(albumName, artistName)
+                }
+            }
+            return rawData
+        }
+
+        override fun cleanup() {
+        }
+
+        override fun cancel() {
+            job?.cancel()
+        }
+
+        override fun getDataClass(): Class<InputStream?> {
+            return InputStream::class.java as Class<InputStream?>
+        }
+
+        override fun getDataSource(): DataSource {
+            return DataSource.LOCAL
+        }
+
+    }
+
+}
+
+private fun String.isAudioFile(): Boolean =
+    MimeTypeMapUtils.getMimeTypeFromUrl(this)?.contains("audio") ?: false
+
+private fun String.isMP3(): Boolean =
+    contains(".mp3")

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

@@ -1,275 +0,0 @@
-///*
-// *  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()
-//
-//    }
-//
-//}

+ 0 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/cursor/CachingCursor.kt

@@ -29,7 +29,6 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.platform.LocalContext
 import org.mjdev.tvlib.extensions.CursorExt.isNotEmpty
-import org.mjdev.tvlib.extensions.GlobalExt.IO
 import org.mjdev.tvlib.extensions.GlobalExt.launch
 import timber.log.Timber
 

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

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

+ 1 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/interfaces/ItemWithImage.kt

@@ -13,6 +13,6 @@ interface ItemWithImage<T> {
     val image: T?
 
     companion object {
-        val <T> ItemWithImage<T>.hasImage get() = image?.toString()?.isNotEmpty() == true
+        val <T> ItemWithImage<T>.hasImage get() = !(image?.toString()).isNullOrEmpty()
     }
 }

+ 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.UserAgentInterceptor
+import org.mjdev.tvlib.helpers.http.UserAgentInterceptor
 import org.mjdev.tvlib.network.CacheInterceptor
 import org.mjdev.tvlib.ui.components.audiopreview.IPreviewEngine
 import timber.log.Timber

+ 11 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/card/ItemCard.kt

@@ -9,6 +9,7 @@
 package org.mjdev.tvlib.ui.components.card
 
 import android.annotation.SuppressLint
+import androidx.compose.foundation.Image
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
@@ -18,6 +19,7 @@ import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.FocusState
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.tv.material3.CardBorder
@@ -27,6 +29,7 @@ import androidx.tv.material3.CardGlow
 import androidx.tv.material3.CardScale
 import androidx.tv.material3.CardShape
 import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvlib.R
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.ComposeExt.computeCardWidth
 import org.mjdev.tvlib.extensions.ComposeExt.isEditMode
@@ -53,12 +56,20 @@ fun ItemCard(
     border: CardBorder = CardDefaults.colorFocusBorder(Color.Green),
     glow: CardGlow = CardDefaults.colorFocusGlow(Color.Green),
     aspectRatio: Float = 16f / 9f,
+    placeholder : @Composable () -> Unit = {
+        Image(
+            painter = painterResource(R.drawable.broken_image),
+            "",
+            modifier.recomposeHighlighter()
+        )
+    },
     imageRenderer: @Composable () -> Unit = {
         ImageAny(
             modifier = modifier.fillMaxSize(),
             src = (item as? ItemWithImage<*>)?.image,
             contentDescription = (item as? ItemWithDescription<*>)?.description?.toString(),
             contentScale = contentScale,
+            placeholder = placeholder
         )
     },
     cardWidth: Dp = computeCardWidth(),

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

@@ -12,6 +12,7 @@ import android.annotation.SuppressLint
 import androidx.annotation.FloatRange
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
 import androidx.compose.ui.Modifier
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.MutableState
@@ -58,7 +59,7 @@ fun PhotoCard(
     brightness: Float = -255f,
     placeholder: @Composable () -> Unit = {
         ImageAny(
-            modifier = modifier,
+            modifier = Modifier.fillMaxSize().padding(64.dp),
             src = R.drawable.broken_image
         )
     },

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

@@ -9,6 +9,7 @@
 package org.mjdev.tvlib.ui.components.carousel
 
 import android.annotation.SuppressLint
+import androidx.compose.foundation.Image
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.defaultMinSize
 import androidx.compose.foundation.layout.fillMaxSize
@@ -17,9 +18,11 @@ import androidx.compose.runtime.MutableState
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.FocusState
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.unit.dp
 import androidx.tv.material3.CardScale
 import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvlib.R
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.ComposeExt.isEditMode
 import org.mjdev.tvlib.extensions.ComposeExt.rememberFocusState
@@ -46,6 +49,12 @@ fun CarouselCard(
         item,
         FocusHelper(focused)
     ),
+    placeholder : @Composable () -> Unit = {
+        Image(
+            painter = painterResource(R.drawable.broken_image),
+            contentDescription="",
+        )
+    },
     imageRenderer: @Composable () -> Unit = {
         val image = (item as? ItemWithImage<*>)?.image
         val background = (item as? ItemWithBackground<*>)?.background
@@ -54,6 +63,7 @@ fun CarouselCard(
             src = background ?: image,
             contentDescription = (item as? ItemWithDescription<*>)?.description?.toString(),
             contentScale = contentScale,
+            placeholder = placeholder
         )
     },
     titlePadding: PaddingValues = PaddingValues(8.dp, 12.dp, 8.dp, 12.dp),

+ 67 - 61
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/complex/FocusableCard.kt

@@ -9,6 +9,7 @@
 package org.mjdev.tvlib.ui.components.complex
 
 import android.annotation.SuppressLint
+import androidx.compose.foundation.Image
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.PaddingValues
@@ -26,6 +27,7 @@ import androidx.compose.ui.focus.onFocusChanged
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.tv.material3.CardBorder
@@ -37,6 +39,7 @@ import androidx.tv.material3.CardShape
 import androidx.tv.material3.CompactCard
 import androidx.tv.material3.ExperimentalTvMaterial3Api
 import androidx.tv.material3.MaterialTheme
+import org.mjdev.tvlib.R
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.ComposeExt.computeCardWidth
 import org.mjdev.tvlib.extensions.ComposeExt.isEditMode
@@ -75,12 +78,19 @@ fun FocusableCard(
     border: CardBorder = CardDefaults.colorFocusBorder(Color.Green),
     glow: CardGlow = CardDefaults.colorFocusGlow(Color.Green),
     showTitle: Boolean = true,
+    placeholder: @Composable () -> Unit = {
+        Image(
+            painter = painterResource(R.drawable.broken_image),
+            contentDescription = ""
+        )
+    },
     imageRenderer: @Composable () -> Unit = {
         ImageAny(
             modifier = Modifier.fillMaxSize(),
             src = (item as? ItemWithImage<*>)?.image,
             contentDescription = (item as? ItemWithDescription<*>)?.description?.toString(),
             contentScale = contentScale,
+            placeholder = placeholder
         )
     },
     focused: Boolean = isEditMode(),
@@ -100,7 +110,12 @@ fun FocusableCard(
     aspectRatio: Float = 16f / 9f,
     isEdit: Boolean = isEditMode(),
     onClick: (item: Any?) -> Unit = {},
-) = Box(
+) = CompactCard(
+    scale = scale,
+    shape = shape,
+    colors = colors,
+    border = border,
+    glow = glow,
     modifier = modifier
         .recomposeHighlighter()
         .size(
@@ -118,67 +133,58 @@ fun FocusableCard(
         }
         .conditional(isEdit) {
             defaultMinSize(80.dp)
-        }
-) {
-    CompactCard(
-        scale = scale,
-        shape = shape,
-        colors = colors,
-        border = border,
-        glow = glow,
-        modifier = Modifier.fillMaxSize(),
-        image = { imageRenderer() },
-        title = {
-            if (showTitle) {
-                Box(
-                    modifier = Modifier
-                        .fillMaxWidth()
-                        .background(
-                            if (focusState.isFocused)
-                                textBackgroundSelected
-                            else
-                                textBackgroundUnselected,
-                            RectangleShape
-                        )
-                ) {
-                    TextAny(
-                        modifier = Modifier.padding(titlePadding),
-                        maxLines = 1,
-                        color = textColor,
-                        style = MaterialTheme.typography.bodyMedium,
-                        text = if (isEdit) "title" else (item as? ItemWithTitle<*>)?.title
+        },
+    image = { imageRenderer() },
+    title = {
+        if (showTitle) {
+            Box(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .background(
+                        if (focusState.isFocused)
+                            textBackgroundSelected
+                        else
+                            textBackgroundUnselected,
+                        RectangleShape
                     )
-                }
+            ) {
+                TextAny(
+                    modifier = Modifier.padding(titlePadding),
+                    maxLines = 1,
+                    color = textColor,
+                    style = MaterialTheme.typography.bodyMedium,
+                    text = if (isEdit) "title" else (item as? ItemWithTitle<*>)?.title
+                )
             }
-        },
-        subtitle = {
-            if (showTitle) {
-                Box(
-                    modifier = Modifier
-                        .fillMaxWidth()
-                        .background(
-                            if (focusState.isFocused)
-                                textBackgroundSelected
-                            else
-                                textBackgroundUnselected,
-                            RectangleShape
-                        )
-                ) {
-                    AutoHideEmptyText(
-                        modifier = Modifier.padding(titlePadding),
-                        maxLines = 1,
-                        color = textColor,
-                        style = MaterialTheme.typography.bodySmall,
-                        text = if (isEdit) "subtitle" else (item as? ItemWithSubtitle<*>)?.subtitle
+        }
+    },
+    subtitle = {
+        if (showTitle) {
+            Box(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .background(
+                        if (focusState.isFocused)
+                            textBackgroundSelected
+                        else
+                            textBackgroundUnselected,
+                        RectangleShape
                     )
-                }
+            ) {
+                AutoHideEmptyText(
+                    modifier = Modifier.padding(titlePadding),
+                    maxLines = 1,
+                    color = textColor,
+                    style = MaterialTheme.typography.bodySmall,
+                    text = if (isEdit) "subtitle" else (item as? ItemWithSubtitle<*>)?.subtitle
+                )
             }
-        },
-        description = {
-            // todo
-        },
-        onClick = {
-            onClick(item)
-        },
-    )
-}
+        }
+    },
+    description = {
+        // todo
+    },
+    onClick = {
+        onClick(item)
+    },
+)

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

@@ -15,7 +15,6 @@ import android.graphics.drawable.Drawable
 import android.net.Uri
 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
@@ -29,7 +28,6 @@ import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.graphics.vector.ImageVector
 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.glide.GlideImage
 import org.mjdev.tvlib.R
@@ -38,6 +36,7 @@ import org.mjdev.tvlib.extensions.DrawableExt.asImageBitmap
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 import java.net.URL
 
+@Suppress("MoveVariableDeclarationIntoWhen")
 @SuppressLint("ModifierParameter")
 @Previews
 @Composable
@@ -49,6 +48,17 @@ fun ImageAny(
     contentScale: ContentScale = ContentScale.Fit,
     alpha: Float = DefaultAlpha,
     colorFilter: ColorFilter? = null,
+    placeholder : @Composable () -> Unit = {
+        Image(
+            painterResource(R.drawable.broken_image),
+            contentDescription,
+            modifier.recomposeHighlighter(),
+            alignment,
+            contentScale,
+            alpha,
+            colorFilter
+        )
+    }
 ) = BoxWithConstraints(
     modifier = modifier,
     contentAlignment = Alignment.Center
@@ -61,17 +71,7 @@ fun ImageAny(
     when (imageSrc) {
 
         null, Unit, "0", 0 -> {
-            Image(
-                painterResource(R.drawable.broken_image),
-                contentDescription,
-                modifier
-                    .padding(16.dp)
-                    .recomposeHighlighter(),
-                alignment,
-                contentScale,
-                alpha,
-                colorFilter
-            )
+            placeholder()
         }
 
         Color -> Image(

+ 1 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/updater/AppUpdater.kt

@@ -17,8 +17,8 @@ import androidx.compose.runtime.remember
 import androidx.compose.ui.platform.LocalContext
 import androidx.core.net.toUri
 import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
-import com.skydoves.sandwich.adapters.ApiResponseCallAdapterFactory
 import com.skydoves.sandwich.getOrNull
+import com.skydoves.sandwich.retrofit.adapters.ApiResponseCallAdapterFactory
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow