diff --git a/native/app/src/main/java/com/localstream/app/ui/home/HomeViewModel.kt b/native/app/src/main/java/com/localstream/app/ui/home/HomeViewModel.kt index c56a77a..e5e71dc 100644 --- a/native/app/src/main/java/com/localstream/app/ui/home/HomeViewModel.kt +++ b/native/app/src/main/java/com/localstream/app/ui/home/HomeViewModel.kt @@ -10,8 +10,11 @@ import com.localstream.app.domain.model.TmdbMetadata import com.localstream.app.domain.model.VideoItem import com.localstream.app.ui.library.LibraryUiState import com.localstream.app.ui.library.LibraryViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -43,10 +46,12 @@ data class HomeUiState( */ class HomeViewModel( libraryUiState: StateFlow, + computationDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ViewModel() { val uiState: StateFlow = libraryUiState .map { deriveHomeUiState(it) } + .flowOn(computationDispatcher) .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), diff --git a/native/app/src/main/java/com/localstream/app/ui/library/LibraryViewModel.kt b/native/app/src/main/java/com/localstream/app/ui/library/LibraryViewModel.kt index 6e8fc47..31520b0 100644 --- a/native/app/src/main/java/com/localstream/app/ui/library/LibraryViewModel.kt +++ b/native/app/src/main/java/com/localstream/app/ui/library/LibraryViewModel.kt @@ -74,13 +74,14 @@ data class LibraryUiState( * (Phase 3), l'enrichissement TMDB (Phase 5) et observe l'état de visionnage * (Phase 4). Partagé entre les écrans via le scope de l'activité. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") class LibraryViewModel( private val videoRepository: VideoRepository, private val watchStateRepository: WatchStateRepository, private val tmdbRepository: TmdbRepository, private val settingsRepository: SettingsRepository, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val computationDispatcher: CoroutineDispatcher = Dispatchers.Default, private val metadataChunkSize: Int = DEFAULT_METADATA_CHUNK_SIZE, ) : ViewModel() { @@ -119,7 +120,9 @@ class LibraryViewModel( // Cache Room d'abord : les affiches s'affichent immédiatement. val cached = loadCachedMetadata(grouped) if (cached.isNotEmpty()) { - _uiState.update { it.copy(metadata = it.metadata + cached).withDerived() } + withContext(computationDispatcher) { + _uiState.update { it.withMetadataUpdate(cached) } + } } if (hasKey) { @@ -134,10 +137,12 @@ class LibraryViewModel( } } - private fun publishVideos(grouped: List) { + private suspend fun publishVideos(grouped: List) { val durations = videoRepository.getRawVideos().associate { it.name to it.duration } - _uiState.update { - it.copy(videos = grouped, videoDurations = durations).withDerived() + withContext(computationDispatcher) { + _uiState.update { + it.copy(videos = grouped, videoDurations = durations).withDerived() + } } } @@ -198,7 +203,9 @@ class LibraryViewModel( }.filterNotNull() if (results.isNotEmpty()) { val additions = results.associate { it.queryKey to it } - _uiState.update { it.copy(metadata = it.metadata + additions).withDerived() } + withContext(computationDispatcher) { + _uiState.update { it.withMetadataUpdate(additions) } + } } } } @@ -308,6 +315,44 @@ class LibraryViewModel( ) } + /** + * Met à jour les métadonnées sans ré-exécuter filterAndSort si le tri et les filtres + * actifs ne dépendent pas des métadonnées (évite 20 tris complets lors du streaming TMDB). + */ + private fun LibraryUiState.withMetadataUpdate(additions: Map): LibraryUiState { + val newMetadata = metadata + additions + val needsFilterSort = filterGenre != null || sortBy == SortBy.DATE + val newFilteredSorted = if (needsFilterSort) { + val newReleaseDates = newMetadata.mapNotNull { (key, meta) -> + meta.releaseDate?.takeIf { it.isNotBlank() }?.let { key to it } + }.toMap() + val newVideoGenres = newMetadata.mapValues { it.value.genreIds } + VideoFilterSorter.filterAndSortVideos( + videos, + FilterSortOptions( + sortBy = sortBy, + filterGenre = filterGenre, + filterResolution = filterResolution, + releaseDates = newReleaseDates, + videoGenres = newVideoGenres, + videoDurations = videoDurations, + watchedVideos = watched, + ), + ) + } else { + filteredSorted + } + return copy( + metadata = newMetadata, + filteredSorted = newFilteredSorted, + searchResults = if (needsFilterSort) { + VideoUiSelectors.filterByQuery(newFilteredSorted, _searchQuery.value) + } else { + searchResults + }, + ) + } + companion object { const val DEFAULT_METADATA_CHUNK_SIZE = 8 const val WIFI_METADATA_CHUNK_SIZE = 16 diff --git a/native/app/src/test/java/com/localstream/app/ui/library/LibraryViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/library/LibraryViewModelTest.kt index 9df3c91..9c58c0c 100644 --- a/native/app/src/test/java/com/localstream/app/ui/library/LibraryViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/library/LibraryViewModelTest.kt @@ -27,6 +27,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -83,6 +85,7 @@ class LibraryViewModelTest { tmdbRepository = tmdbRepository, settingsRepository = SettingsRepository(), ioDispatcher = testDispatcher, + computationDispatcher = testDispatcher, ) } @@ -180,6 +183,21 @@ class LibraryViewModelTest { assertEquals(listOf("Avatar.2009.2160p.mkv"), playbackDao.deletedNames) } + @Test + fun `HomeViewModel derive les rows sur le computationDispatcher sans jank`() = runTest(testDispatcher) { + val homeVm = com.localstream.app.ui.home.HomeViewModel( + libraryUiState = viewModel.uiState, + computationDispatcher = testDispatcher, + ) + backgroundScope.launch { homeVm.uiState.collect() } + viewModel.refreshLibrary() + advanceUntilIdle() + + val homeState = homeVm.uiState.value + assertTrue(homeState.hasContent) + assertEquals(3, homeState.alphabetical.size) + } + // -------- Fakes -------- private class FakeScanner(