Ver código fonte

performance improvements

Milan Jurkulak 3 anos atrás
pai
commit
1d27d017e5

+ 36 - 2
app/objectbox-models/default.json

@@ -39,7 +39,7 @@
     },
     {
       "id": "2:7141750768191734097",
-      "lastPropertyId": "12:6138526348322531011",
+      "lastPropertyId": "13:503445141634537419",
       "name": "Movie",
       "properties": [
         {
@@ -92,6 +92,11 @@
           "name": "id",
           "type": 6,
           "flags": 1
+        },
+        {
+          "id": "13:503445141634537419",
+          "name": "subtitle",
+          "type": 9
         }
       ],
       "relations": []
@@ -288,9 +293,38 @@
         }
       ],
       "relations": []
+    },
+    {
+      "id": "5:6626640907745307806",
+      "lastPropertyId": "4:4895179631110667602",
+      "name": "Category",
+      "properties": [
+        {
+          "id": "1:3008860336305693215",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "2:1550404233910431310",
+          "name": "title",
+          "type": 9
+        },
+        {
+          "id": "3:3840804705832268977",
+          "name": "subtitle",
+          "type": 9
+        },
+        {
+          "id": "4:4895179631110667602",
+          "name": "image",
+          "type": 9
+        }
+      ],
+      "relations": []
     }
   ],
-  "lastEntityId": "4:1987880888030669889",
+  "lastEntityId": "5:6626640907745307806",
   "lastIndexId": "0:0",
   "lastRelationId": "0:0",
   "lastSequenceId": "0:0",

+ 11 - 1
app/objectbox-models/default.json.bak

@@ -212,7 +212,7 @@
     },
     {
       "id": "4:1987880888030669889",
-      "lastPropertyId": "12:7864280554037368021",
+      "lastPropertyId": "14:6587709986428549544",
       "name": "User",
       "properties": [
         {
@@ -275,6 +275,16 @@
           "id": "12:7864280554037368021",
           "name": "age",
           "type": 5
+        },
+        {
+          "id": "13:3813998053361806387",
+          "name": "email",
+          "type": 9
+        },
+        {
+          "id": "14:6587709986428549544",
+          "name": "emailVerified",
+          "type": 1
         }
       ],
       "relations": []

+ 49 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/local/Category.kt

@@ -0,0 +1,49 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.local
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import io.objectbox.annotation.Entity
+import io.objectbox.annotation.Id
+import org.mjdev.tvlib.interfaces.ItemWithId
+import org.mjdev.tvlib.interfaces.ItemWithImage
+import org.mjdev.tvlib.interfaces.ItemWithSubtitle
+import org.mjdev.tvlib.interfaces.ItemWithTitle
+import java.io.Serializable
+
+@Suppress("unused")
+@Entity
+@JsonClass(generateAdapter = true)
+class Category() :
+    Serializable,
+    ItemWithId,
+    ItemWithTitle<String>,
+    ItemWithSubtitle<String>,
+    ItemWithImage<String> {
+
+    constructor(title: String) : this() {
+        this.title = title
+    }
+
+    @Id
+    override var id: Long = 0
+
+    @Json(name = "title")
+    override var title: String? = null
+
+    @Json(name = "subtitle")
+    override val subtitle: String? = null
+
+    @Json(name = "image")
+    override val image: String? = null
+
+    override fun toString(): String = "Category[$title]"
+
+}

+ 13 - 15
app/src/main/kotlin/org/mjdev/tvapp/data/local/Movie.kt

@@ -39,12 +39,6 @@ class Movie :
     @Json(name = "id")
     override var id: Long = 0
 
-    @Json(name = "category")
-    var category: String? = ""
-        get() = (field ?: country)?.replaceFirstChar { ch ->
-            ch.titlecase(Locale.ROOT)
-        }
-
     @Json(name = "studio")
     var studio: String? = null
 
@@ -60,21 +54,25 @@ class Movie :
     @Json(name = "description")
     override var description: String? = null
 
+    @Json(name = "videoUri")
+    override var uri: String? = null
+
+    override var subtitle: String? = null
+        get() = field ?: studio
+
+    @Json(name = "category")
+    var category: String? = ""
+        get() = (field ?: country)?.replaceFirstChar { ch ->
+            ch.titlecase(Locale.ROOT)
+        }
+
     @Json(name = "backgroundImageUrl")
     override var background: String? = null
         get() = field ?: image
 
     @Json(name = "imageUrl")
     override var image: String? = null
-
-    @Json(name = "videoUri")
-    override var uri: String? = null
-
-    override var subtitle: String?
-        get() = studio
-        set(value) {
-            studio = value
-        }
+        get() = field ?: uri
 
     override fun equals(other: Any?): Boolean {
         if (other == null || other !is Movie) return false

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

@@ -12,6 +12,8 @@ import com.squareup.moshi.Json
 import com.squareup.moshi.JsonClass
 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.ItemWithDescription
 import org.mjdev.tvlib.interfaces.ItemWithId
 import org.mjdev.tvlib.interfaces.ItemWithImage
@@ -20,12 +22,10 @@ import org.mjdev.tvlib.interfaces.ItemWithTitle
 import org.mjdev.tvlib.interfaces.ItemWithUri
 import java.io.Serializable
 
-// todo
-
 @Suppress("unused")
 @Entity
 @JsonClass(generateAdapter = true)
-class TVChannel :
+class TVChannel() :
     Serializable,
     ItemWithId,
     ItemWithTitle<String>,
@@ -34,6 +34,32 @@ class TVChannel :
     ItemWithUri<String>,
     ItemWithDescription<String> {
 
+    constructor(stream: Stream, channel: Channel?) : this() {
+
+        this.streamUrl = stream.url
+        this.httpReferrer = stream.httpReferrer ?: ""
+        this.userAgent = stream.userAgent ?: ""
+
+        this.channelId = channel?.id ?: ""
+        this.name = channel?.name ?: ""
+        this.altNames = channel?.altNames ?: emptyList()
+        this.broadcastArea = channel?.broadcastArea ?: emptyList()
+        this.city = channel?.city ?: ""
+        this.categories = channel?.categories ?: emptyList()
+        this.closed = channel?.closed ?: ""
+        this.country = channel?.country ?: ""
+        this.isNsfw = channel?.isNsfw ?: false
+        this.languages = channel?.languages ?: emptyList()
+        this.launched = channel?.launched ?: ""
+        this.logo = channel?.logo ?: ""
+        this.network = channel?.network ?: ""
+        this.owners = channel?.owners ?: emptyList()
+        this.replacedBy = channel?.replacedBy ?: ""
+        this.subdivision = channel?.subdivision ?: ""
+        this.website = channel?.website ?: ""
+
+    }
+
     @Id
     override var id: Long = 0
 
@@ -107,4 +133,22 @@ class TVChannel :
 
     override val uri: String? get() = streamUrl
 
+    override fun toString(): String = "Channel[$title : $uri]"
+
+    companion object {
+
+        fun TVChannel.asMovie(category: String): Movie = Movie().also { m ->
+            m.category = category
+            m.country = country
+            m.isNsfw = isNsfw ?: false
+            m.studio = network
+            m.uri = uri
+            m.description = description
+            m.image = image
+            m.subtitle = subtitle
+            m.title = name
+        }
+
+    }
+
 }

+ 10 - 0
app/src/main/kotlin/org/mjdev/tvapp/database/DAO.kt

@@ -9,9 +9,11 @@ import io.objectbox.BoxStore
 import io.objectbox.kotlin.awaitCallInTx
 import io.objectbox.kotlin.boxFor
 import org.mjdev.tvapp.BuildConfig
+import org.mjdev.tvapp.data.local.Category
 import org.mjdev.tvapp.data.local.Message
 import org.mjdev.tvapp.data.local.Movie
 import org.mjdev.tvapp.data.local.MyObjectBox
+import org.mjdev.tvapp.data.local.TVChannel
 
 class DAO(
     @ApplicationContext
@@ -42,6 +44,14 @@ class DAO(
         store.boxFor()
     }
 
+    val categoryDao: Box<Category> by lazy {
+        store.boxFor()
+    }
+
+    val channelsDao: Box<TVChannel> by lazy {
+        store.boxFor()
+    }
+
     val messagesDao: Box<Message> by lazy {
         store.boxFor()
     }

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

@@ -10,9 +10,9 @@
 
 package org.mjdev.tvapp.module
 
-import android.content.ComponentName
 import android.content.Context
 import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
+import com.skydoves.sandwich.adapters.ApiResponseCallAdapterFactory
 import com.squareup.moshi.Moshi
 import dagger.Module
 import dagger.Provides
@@ -22,9 +22,7 @@ import dagger.hilt.components.SingletonComponent
 import okhttp3.OkHttpClient
 import okhttp3.logging.HttpLoggingInterceptor
 import org.mjdev.tvapp.BuildConfig
-import org.mjdev.tvapp.activity.MainActivity
 import org.mjdev.tvapp.repository.ApiService
-import org.mjdev.tvlib.helpers.apps.AppsManager
 import org.mjdev.tvlib.helpers.cursor.AudioCursor
 import org.mjdev.tvlib.helpers.cursor.PhotoCursor
 import org.mjdev.tvlib.helpers.cursor.VideoCursor
@@ -44,20 +42,7 @@ import javax.inject.Singleton
 class ProvideModule {
 
     private val isDebug = BuildConfig.DEBUG
-    private val BASE_URL = BuildConfig.IPTV_API_URL
-
-
-    @Singleton
-    @Provides
-    fun providesAppsManager(
-        @ApplicationContext
-        context: Context
-    ): AppsManager = AppsManager(
-        context, ComponentName(
-            BuildConfig.APPLICATION_ID,
-            MainActivity::class.java.name
-        )
-    )
+    private val baseUrl = BuildConfig.IPTV_API_URL
 
     @Singleton
     @Provides
@@ -131,9 +116,10 @@ class ProvideModule {
     fun providesRetrofit(
         okHttpClient: OkHttpClient
     ): Retrofit = Retrofit.Builder()
-        .baseUrl(BASE_URL)
+        .baseUrl(baseUrl)
         .client(okHttpClient)
         .addConverterFactory(MoshiConverterFactory.create())
+        .addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
         .addCallAdapterFactory(CoroutineCallAdapterFactory())
         .build()
 

+ 6 - 6
app/src/main/kotlin/org/mjdev/tvapp/repository/ApiService.kt

@@ -21,21 +21,21 @@ import retrofit2.http.GET
 interface ApiService {
 
     @GET("channels.json")
-    fun channels(): ApiResponse<List<Channel>>
+    suspend fun channels(): ApiResponse<List<Channel>>
 
     @GET("streams.json")
-    fun streams(): ApiResponse<List<Stream>>
+    suspend fun streams(): ApiResponse<List<Stream>>
 
     @GET("guides.json")
-    fun epg(): ApiResponse<List<Epg>>
+    suspend fun epg(): ApiResponse<List<Epg>>
 
     @GET("categories.json")
-    fun categories(): ApiResponse<List<Category>>
+    suspend fun categories(): ApiResponse<List<Category>>
 
     @GET("languages.json")
-    fun languages(): ApiResponse<List<Language>>
+    suspend fun languages(): ApiResponse<List<Language>>
 
     @GET("countries.json")
-    fun countries(): ApiResponse<List<Country>>
+    suspend fun countries(): ApiResponse<List<Country>>
 
 }

+ 0 - 21
app/src/main/kotlin/org/mjdev/tvapp/state/DetailsLoadingState.kt

@@ -1,21 +0,0 @@
-/*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
- */
-
-package org.mjdev.tvapp.state
-
-sealed class DetailsLoadingState {
-
-    object Loading : DetailsLoadingState()
-
-    object NotFound : DetailsLoadingState()
-
-    class Ready(val data: Any?) : DetailsLoadingState()
-
-    class Error(val error: Exception) : DetailsLoadingState()
-
-}

+ 3 - 2
app/src/main/kotlin/org/mjdev/tvapp/ui/pages/MainPage.kt

@@ -94,6 +94,7 @@ class MainPage : Page() {
 
         val user = remember { mutableStateOf<User?>(null) }
         val authManager = rememberAuthManager { u -> user.value = u }
+        val apps = remember { viewModel.apps }.collectAsState(emptyList())
 
         Box(
             modifier = Modifier.fillMaxSize(),
@@ -122,8 +123,8 @@ class MainPage : Page() {
                     }
                 },
                 customRows = mutableListOf<@Composable () -> Unit>().apply {
-                    if (viewModel.appsList.size > 0) add {
-                        AppsRow(apps = viewModel.appsList)
+                    add {
+                        AppsRow(apps = apps)
                     }
                     if (viewModel.localAudioCursor.count > 0) add {
                         LocalAudioRow(

+ 18 - 7
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt

@@ -8,17 +8,20 @@
 
 package org.mjdev.tvapp.viewmodel
 
+import android.content.ComponentName
 import android.content.Context
 import androidx.lifecycle.viewModelScope
 import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.channelFlow
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.runBlocking
+import org.mjdev.tvapp.BuildConfig
+import org.mjdev.tvapp.activity.MainActivity
 import org.mjdev.tvlib.extensions.ListExt.asMap
-import org.mjdev.tvlib.helpers.apps.AppsManager
 import org.mjdev.tvlib.helpers.cursor.AudioCursor
 import org.mjdev.tvlib.helpers.cursor.PhotoCursor
 import org.mjdev.tvlib.helpers.cursor.VideoCursor
@@ -29,11 +32,15 @@ import org.mjdev.tvapp.data.local.Movie
 import org.mjdev.tvapp.database.DAO
 import org.mjdev.tvapp.repository.IMovieRepository
 import org.mjdev.tvapp.repository.MovieRepository
+import org.mjdev.tvlib.helpers.apps.appsManager
 import org.mjdev.tvlib.network.NetworkConnectivityServiceImpl
 import javax.inject.Inject
 
 @HiltViewModel
-class MainViewModel @Inject constructor() : BaseViewModel() {
+class MainViewModel @Inject constructor(
+    @ApplicationContext
+    context: Context
+) : BaseViewModel() {
 
     @Inject
     lateinit var movieRepository: IMovieRepository
@@ -41,9 +48,6 @@ class MainViewModel @Inject constructor() : BaseViewModel() {
     @Inject
     lateinit var networkInfo: NetworkConnectivityService
 
-    @Inject
-    lateinit var appsList: AppsManager
-
     @Inject
     lateinit var localAudioCursor: AudioCursor
 
@@ -53,6 +57,14 @@ class MainViewModel @Inject constructor() : BaseViewModel() {
     @Inject
     lateinit var localPhotoCursor: PhotoCursor
 
+    val apps = appsManager(
+        context,
+        ComponentName(
+            BuildConfig.APPLICATION_ID,
+            MainActivity::class.java.name
+        )
+    ).stateInViewModel()
+
     val messages: StateFlow<List<Message>> = channelFlow {
         send(movieRepository.getMessages().getOrThrow())
     }.stateIn(
@@ -119,10 +131,9 @@ class MainViewModel @Inject constructor() : BaseViewModel() {
         @Suppress("unused")
         fun mockMainViewModel(
             context: Context
-        ): MainViewModel = MainViewModel().apply {
+        ): MainViewModel = MainViewModel(context).apply {
             movieRepository = MovieRepository(DAO(context))
             networkInfo = NetworkConnectivityServiceImpl(context)
-            appsList = AppsManager(context)
             localAudioCursor = AudioCursor(context)
             localVideoCursor = VideoCursor(context)
             localPhotoCursor = PhotoCursor(context)

+ 20 - 9
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/GlobalExt.kt

@@ -14,6 +14,7 @@ import com.skydoves.sandwich.ApiResponse
 import com.skydoves.sandwich.getOrNull
 import com.skydoves.sandwich.message
 import com.skydoves.sandwich.onError
+import com.skydoves.sandwich.onException
 import com.skydoves.sandwich.toFlow
 import kotlinx.coroutines.flow.Flow
 import timber.log.Timber
@@ -21,24 +22,34 @@ import timber.log.Timber
 @Suppress("unused")
 object GlobalExt {
 
+    @Suppress("MemberVisibilityCanBePrivate")
+    class CodeException(
+        val code:Int = -1,
+        message:String = String.format("$code : Unhandled Exception.")
+    ) : Exception(message)
+
     fun MutableState<Boolean>.toggle() {
         value = !value
     }
 
     fun <T> ApiResponse<T>.safeFlow(
-        error: (code: Int, message: String?) -> Unit = { code, message ->
-            Timber.e("Response error : ($code, ${message})")
+        error: (exception: Exception) -> Unit = { exception ->
+            Timber.e(exception)
         }
-    ): Flow<T> = onError {
-        error(statusCode.code, message())
+    ): Flow<T> = onException {
+        error(exception)
+    }.onError {
+        error(CodeException(statusCode.code, message()))
     }.toFlow()
 
     inline fun <reified T> ApiResponse<List<T>>.safeGet(
-        crossinline error: (code: Int, message: String?) -> Unit = { code, message ->
-            Timber.e("Response error : ($code, ${message})")
-        },
-    ): List<T> = onError {
-        error(statusCode.code, message())
+        crossinline error: (exception: Exception) -> Unit = { exception ->
+            Timber.e(exception)
+        }
+    ): List<T> = onException {
+        error(exception)
+    }.onError {
+        error(CodeException(statusCode.code, message()))
     }.getOrNull() ?: emptyList()
 
     suspend fun <E> runSafe(block: suspend () -> E): Result<E> = try {

+ 47 - 33
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/apps/AppsManager.kt

@@ -16,8 +16,8 @@ import android.graphics.drawable.Drawable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
 import androidx.compose.ui.platform.LocalContext
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.channelFlow
 import org.mjdev.tvlib.interfaces.ItemWithImage
 import org.mjdev.tvlib.interfaces.ItemWithIntent
 import org.mjdev.tvlib.interfaces.ItemWithTitle
@@ -26,45 +26,59 @@ import java.util.Collections
 @Composable
 fun rememberAppsManager(
     vararg excluded: ComponentName
-): List<App> {
+): Flow<List<App>> {
     val context: Context = LocalContext.current
     return remember {
-        AppsManager(context, *excluded)
+        appsManager(context, *excluded)
     }
 }
 
-class AppsManager(
+fun appsManager(
     context: Context,
     vararg excluded: ComponentName
-) : ArrayList<App>() {
-    init {
-        runBlocking(Dispatchers.IO) {
-            val packageManager = context.packageManager
-            val mainIntent = Intent(Intent.ACTION_MAIN, null).apply {
-                addCategory(Intent.CATEGORY_LAUNCHER)
-            }
-            val resolveInfo = packageManager.queryIntentActivities(mainIntent, 0)
-            Collections.sort(resolveInfo, ResolveInfo.DisplayNameComparator(packageManager))
-            resolveInfo.forEach { ri ->
-                val isExcluded = excluded.any { cn ->
-                    cn.packageName == ri.activityInfo.packageName &&
-                            cn.shortClassName == ri.activityInfo.name
-                }
-                if (!isExcluded) {
-                    val title = ri.activityInfo.loadLabel(packageManager).toString()
-                    val image = ri.activityInfo
-                        .loadBanner(packageManager) ?: ri.activityInfo
-                        .loadIcon(packageManager)
-                    val intent = Intent().apply {
-                        setClassName(
-                            ri.activityInfo.packageName,
-                            ri.activityInfo.name
-                        )
-                    }
-                    add(App(title, image, intent))
-                }
-            }
+) = channelFlow {
+    val packageManager = context.packageManager
+    val mainIntent = Intent(Intent.ACTION_MAIN, null).apply {
+        addCategory(Intent.CATEGORY_LAUNCHER)
+    }
+    val isNotExcluded: (ri: ResolveInfo) -> Boolean = { ri ->
+        !excluded.any { cn ->
+            cn.packageName == ri.activityInfo.packageName &&
+                    cn.shortClassName == ri.activityInfo.name
+        }
+    }
+    val title: (ri: ResolveInfo) -> String = { ri ->
+        ri.activityInfo.loadLabel(packageManager).toString()
+    }
+    val image: (ri: ResolveInfo) -> Drawable = { ri ->
+        ri.activityInfo
+            .loadBanner(packageManager) ?: ri.activityInfo
+            .loadIcon(packageManager)
+    }
+    val intent: (ri: ResolveInfo) -> Intent = { ri ->
+        Intent().apply {
+            setClassName(
+                ri.activityInfo.packageName,
+                ri.activityInfo.name
+            )
+        }
+    }
+    packageManager.queryIntentActivities(
+        mainIntent,
+        0
+    ).apply {
+        Collections.sort(
+            this,
+            ResolveInfo.DisplayNameComparator(packageManager)
+        )
+    }.mapNotNull { ri ->
+        if (isNotExcluded(ri)) {
+            App(title(ri), image(ri), intent(ri))
+        } else {
+            null
         }
+    }.also { list ->
+        send(list)
     }
 }
 

+ 0 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/image/PhotoImage.kt

@@ -31,7 +31,6 @@ import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.core.graphics.drawable.toDrawable
-import coil.request.ImageRequest
 import com.skydoves.landscapist.ImageOptions
 import com.skydoves.landscapist.coil.CoilImage
 import com.skydoves.landscapist.coil.CoilImageState

+ 5 - 3
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/AppsRow.kt

@@ -11,6 +11,8 @@ package org.mjdev.tvlib.ui.components.tv
 import android.content.Context
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.layout.ContentScale
@@ -40,14 +42,14 @@ fun AppsRow(
     startApp: Context.(app: App?) -> Unit = { app ->
         startActivity(app?.intent)
     },
-    apps: List<App> = rememberAppsManager()
+    apps: State<List<App>> = rememberAppsManager().collectAsState(emptyList())
 ) {
     val context: Context = LocalContext.current
     val cardWidth = computeCardWidth(3f)
-    if (apps.isNotEmpty()) {
+    if (apps.value.isNotEmpty()) {
         CategoryRow(
             title = title,
-            items = apps,
+            items = apps.value,
             rowState = rowState,
             padding = padding,
             cardWidth = cardWidth,

+ 30 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/viewmodel/BaseViewModel.kt

@@ -9,10 +9,16 @@
 package org.mjdev.tvlib.viewmodel
 
 import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
 import timber.log.Timber
 
+@Suppress("unused")
 open class BaseViewModel : ViewModel() {
 
     val error: MutableStateFlow<Throwable?> = MutableStateFlow(null)
@@ -44,4 +50,28 @@ open class BaseViewModel : ViewModel() {
         errorHandler = block
     }
 
+    fun <K, V> Flow<Map<K, V>>.stateInViewModel(
+        initial: Map<K, V> = mapOf()
+    ): StateFlow<Map<K, V>> = stateIn(
+        viewModelScope,
+        SharingStarted.WhileSubscribed(5000L),
+        initial
+    )
+
+    fun <T> Flow<List<T>>.stateInViewModel(
+        initial: List<T> = listOf()
+    ): StateFlow<List<T>> = stateIn(
+        viewModelScope,
+        SharingStarted.WhileSubscribed(5000L),
+        initial
+    )
+
+    fun <T> Flow<T>.stateInViewModel(
+        initial: T
+    ): StateFlow<T> = stateIn(
+        viewModelScope,
+        SharingStarted.WhileSubscribed(5000L),
+        initial
+    )
+
 }