From 013cfc31d91dace481eb94f25f2dda71bb6e526d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 18:17:12 +0200 Subject: [PATCH 01/10] =?UTF-8?q?perf(scanner):=20=C3=A9liminer=20le=20dou?= =?UTF-8?q?ble=20scan=20MediaStore=20au=20d=C3=A9marrage=20et=20optimiser?= =?UTF-8?q?=20la=20requ=C3=AAte=20sous-titres=20(fixes=20#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/data/repository/VideoRepository.kt | 1 + .../app/data/scanner/MediaScanner.kt | 3 +- .../app/data/scanner/MediaStoreScanner.kt | 12 ++++--- .../com/localstream/app/di/NoOpTestDoubles.kt | 1 + .../app/data/MediaStoreScannerTest.kt | 29 ++++++++++++++++- .../app/data/VideoRepositoryTest.kt | 31 +++++++++++++++++-- .../app/ui/details/DetailsViewModelTest.kt | 1 + .../app/ui/history/HistoryViewModelTest.kt | 1 + .../app/ui/library/LibraryViewModelTest.kt | 1 + .../app/ui/player/PlayerViewModelTest.kt | 1 + .../ui/playlists/PlaylistsViewModelTest.kt | 1 + 11 files changed, 73 insertions(+), 9 deletions(-) diff --git a/native/app/src/main/java/com/localstream/app/data/repository/VideoRepository.kt b/native/app/src/main/java/com/localstream/app/data/repository/VideoRepository.kt index 3991abf..eb3b930 100644 --- a/native/app/src/main/java/com/localstream/app/data/repository/VideoRepository.kt +++ b/native/app/src/main/java/com/localstream/app/data/repository/VideoRepository.kt @@ -33,6 +33,7 @@ class VideoRepository( whitelistedVideos = whitelistedVideos, movieCollections = movieCollections, releaseDates = releaseDates, + rawVideos = rawVideos, ) _videosFlow.value = groupedVideos return groupedVideos diff --git a/native/app/src/main/java/com/localstream/app/data/scanner/MediaScanner.kt b/native/app/src/main/java/com/localstream/app/data/scanner/MediaScanner.kt index 30c02be..0ce3284 100644 --- a/native/app/src/main/java/com/localstream/app/data/scanner/MediaScanner.kt +++ b/native/app/src/main/java/com/localstream/app/data/scanner/MediaScanner.kt @@ -13,6 +13,7 @@ interface MediaScanner { fun scanAndGroup( whitelistedVideos: Set = emptySet(), movieCollections: Map = emptyMap(), - releaseDates: Map = emptyMap() + releaseDates: Map = emptyMap(), + rawVideos: List? = null, ): List } diff --git a/native/app/src/main/java/com/localstream/app/data/scanner/MediaStoreScanner.kt b/native/app/src/main/java/com/localstream/app/data/scanner/MediaStoreScanner.kt index f740446..4eb61cb 100644 --- a/native/app/src/main/java/com/localstream/app/data/scanner/MediaStoreScanner.kt +++ b/native/app/src/main/java/com/localstream/app/data/scanner/MediaStoreScanner.kt @@ -172,7 +172,10 @@ class MediaStoreScanner( MediaStore.Files.FileColumns.DISPLAY_NAME ) - val selection = "${MediaStore.Files.FileColumns.DATA} LIKE '%.srt' OR ${MediaStore.Files.FileColumns.DATA} LIKE '%.vtt'" + val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE '%.srt' OR " + + "${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE '%.vtt' OR " + + "${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE '%.ass' OR " + + "${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE '%.ssa'" val cursor = resolver.query( MediaStore.Files.getContentUri("external"), @@ -205,13 +208,14 @@ class MediaStoreScanner( override fun scanAndGroup( whitelistedVideos: Set, movieCollections: Map, - releaseDates: Map + releaseDates: Map, + rawVideos: List?, ): List { - val rawVideos = scanVideoFiles() + val videos = rawVideos ?: scanVideoFiles() val subtitles = scanSubtitleFiles() val subIndex = VideoNameParser.buildSubtitleIndex(subtitles) - val videosWithSubtitles = rawVideos.map { video -> + val videosWithSubtitles = videos.map { video -> val folder = VideoNameParser.parentFolder(video.path) val matchedSub = VideoNameParser.matchSubtitle(subIndex, video.name, folder) if (matchedSub != null) { diff --git a/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt b/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt index d737494..611b83c 100644 --- a/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt +++ b/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt @@ -37,6 +37,7 @@ class NoOpScanner : MediaScanner { whitelistedVideos: Set, movieCollections: Map, releaseDates: Map, + rawVideos: List?, ): List = emptyList() } diff --git a/native/app/src/test/java/com/localstream/app/data/MediaStoreScannerTest.kt b/native/app/src/test/java/com/localstream/app/data/MediaStoreScannerTest.kt index 6d8377d..5dd61ee 100644 --- a/native/app/src/test/java/com/localstream/app/data/MediaStoreScannerTest.kt +++ b/native/app/src/test/java/com/localstream/app/data/MediaStoreScannerTest.kt @@ -87,4 +87,31 @@ class MediaStoreScannerTest { } assertEquals(2, result.size) } -} + + @Test + fun scanSubtitleFiles_findsAssAndSsa() { + File(tempDir, "Anime.ass").createNewFile() + File(tempDir, "Anime.ssa").createNewFile() + + val scanner = MediaStoreScanner(context = null, customDirectories = listOf(tempDir)) + val subs = scanner.scanSubtitleFiles() + + assertEquals(2, subs.size) + assertTrue(subs.any { it.name == "Anime.ass" }) + assertTrue(subs.any { it.name == "Anime.ssa" }) + } + + @Test + fun scanAndGroup_withRawVideos_reusesPassedVideos() { + val preScanned = listOf( + VideoItem(url = "file://custom/Item1.mkv", name = "Item1.mkv"), + VideoItem(url = "file://custom/Item2.mkv", name = "Item2.mkv"), + ) + val scanner = MediaStoreScanner(context = null, customDirectories = listOf(tempDir)) + val result = scanner.scanAndGroup(rawVideos = preScanned) + + assertEquals(2, result.size) + assertTrue(result.any { it.name == "Item1.mkv" }) + assertTrue(result.any { it.name == "Item2.mkv" }) + } +} diff --git a/native/app/src/test/java/com/localstream/app/data/VideoRepositoryTest.kt b/native/app/src/test/java/com/localstream/app/data/VideoRepositoryTest.kt index 029fba9..b44fe6e 100644 --- a/native/app/src/test/java/com/localstream/app/data/VideoRepositoryTest.kt +++ b/native/app/src/test/java/com/localstream/app/data/VideoRepositoryTest.kt @@ -53,6 +53,30 @@ class VideoRepositoryTest { val emptyRepo = VideoRepository(FakeMediaScanner(emptyList())) assertEquals(0, emptyRepo.scanAndLoad().size) } + + @Test + fun scanAndLoad_callsScanVideoFilesOnlyOnce() { + var scanCount = 0 + val countingScanner = object : MediaScanner { + override fun scanVideoFiles(): List { + scanCount++ + return listOf(VideoItem(url = "file://test.mp4", name = "Test.mp4")) + } + override fun scanSubtitleFiles() = emptyList() + override fun scanAndGroup( + whitelistedVideos: Set, + movieCollections: Map, + releaseDates: Map, + rawVideos: List?, + ): List { + val list = rawVideos ?: scanVideoFiles() + return list + } + } + val repo = VideoRepository(countingScanner) + repo.scanAndLoad() + assertEquals(1, scanCount) + } } private class FakeMediaScanner(private val videos: List) : MediaScanner { @@ -62,8 +86,9 @@ private class FakeMediaScanner(private val videos: List) : MediaScann whitelistedVideos: Set, movieCollections: Map, releaseDates: Map, + rawVideos: List?, ): List { - // Regroupement minimal pour les tests (utilise le VideoGrouper r\u00e9el) - return com.localstream.app.domain.VideoGrouper.groupVideos(videos, movieCollections, releaseDates, whitelistedVideos) + // Regroupement minimal pour les tests (utilise le VideoGrouper réel) + return com.localstream.app.domain.VideoGrouper.groupVideos(rawVideos ?: videos, movieCollections, releaseDates, whitelistedVideos) } -} +} diff --git a/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt index 4832991..e42a49c 100644 --- a/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt @@ -179,6 +179,7 @@ class DetailsViewModelTest { whitelistedVideos: Set, movieCollections: Map, releaseDates: Map, + rawVideos: List?, ): List = groupedVideos } diff --git a/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt index 404f4bb..866b2ca 100644 --- a/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt @@ -92,6 +92,7 @@ class HistoryViewModelTest { whitelistedVideos: Set, movieCollections: Map, releaseDates: Map, + rawVideos: List?, ): List = emptyList() } 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..b462717 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 @@ -192,6 +192,7 @@ class LibraryViewModelTest { whitelistedVideos: Set, movieCollections: Map, releaseDates: Map, + rawVideos: List?, ): List = groupedVideos } diff --git a/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt index 73f5a10..11a77c3 100644 --- a/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt @@ -434,6 +434,7 @@ class PlayerViewModelTest { whitelistedVideos: Set, movieCollections: Map, releaseDates: Map, + rawVideos: List?, ): List = grouped } diff --git a/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt index e5e6225..be02389 100644 --- a/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt @@ -98,6 +98,7 @@ class PlaylistsViewModelTest { whitelistedVideos: Set, movieCollections: Map, releaseDates: Map, + rawVideos: List?, ): List = emptyList() } From 040f43f85091c3e1f3fe24a09bd2ba8b8dc9d35e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 18:21:19 +0200 Subject: [PATCH 02/10] =?UTF-8?q?perf(domain):=20pr=C3=A9compiler=20et=20m?= =?UTF-8?q?=C3=A9mo=C3=AFser=20les=20Regex=20dans=20TitleCleaner,=20Format?= =?UTF-8?q?ters=20et=20VideoNameParser=20(fixes=20#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/localstream/app/domain/Formatters.kt | 30 +++++++++++-------- .../app/domain/VideoFilterSorter.kt | 4 ++- .../localstream/app/domain/VideoGrouper.kt | 7 +++-- .../localstream/app/domain/VideoNameParser.kt | 14 +++++---- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/native/app/src/main/java/com/localstream/app/domain/Formatters.kt b/native/app/src/main/java/com/localstream/app/domain/Formatters.kt index a3b2c29..26ed8d4 100644 --- a/native/app/src/main/java/com/localstream/app/domain/Formatters.kt +++ b/native/app/src/main/java/com/localstream/app/domain/Formatters.kt @@ -8,10 +8,17 @@ import kotlin.math.pow object TitleCleaner { private val YEAR_IN_PAREN_REGEX = Regex("[\\(\\[]((?:19|20)\\d{2})[\\)\\]]") private val YEAR_REGEX = Regex("(?:^|[\\s\\._\\-\\(\\[])((?:19|20)\\d{2})(?=[\\s\\._\\-\\)\\]]|$)") + private val EXTENSION_REGEX = Regex("\\.[^/.]+$") + private val SEASON_EPISODE_CLEAN_REGEX = Regex("[sS]\\d+(\\s*)?([eE]\\d+)?|(\\d+)(\\s*)?x(\\d+).*", RegexOption.IGNORE_CASE) + private val YEAR_SUFFIX_REGEX = Regex("(?<=\\w|\\s|\\.|\\-|_|\\()\\s*[\\(\\.\\[\\-_]?(19|20)\\d{2}.*") + private val SEPARATORS_REGEX = Regex("[\\.\\-_]") + private val TAGS_REGEX = Regex("1080p|720p|2160p|4k|bluray|webrip|hdtv|x264|x265|hevc|vostfr|french|truefrench", RegexOption.IGNORE_CASE) + private val OPEN_BRACKET_END_REGEX = Regex("[\\(\\[\\{]\\s*$") + private val TRAILING_SYMBOLS_REGEX = Regex("[\\s\\-\\.\\(\\)\\[\\]\\{\\}]+$") @Suppress("ReturnCount") fun extractYear(filename: String): Int? { - val nameWithoutExt = filename.replace(Regex("\\.[^/.]+$"), "") + val nameWithoutExt = filename.replace(EXTENSION_REGEX, "") val parenMatch = YEAR_IN_PAREN_REGEX.find(nameWithoutExt) if (parenMatch != null) { return parenMatch.groupValues[1].toIntOrNull() @@ -21,22 +28,21 @@ object TitleCleaner { } fun getCleanTitle(filename: String): String { - var title = filename.replace(Regex("\\.[^/.]+$"), "") - title = title.replace(Regex("[sS]\\d+(\\s*)?([eE]\\d+)?|(\\d+)(\\s*)?x(\\d+).*", RegexOption.IGNORE_CASE), "") - title = title.replace(Regex("(?<=\\w|\\s|\\.|\\-|_|\\()\\s*[\\(\\.\\[\\-_]?(19|20)\\d{2}.*"), "") - title = title.replace(Regex("[\\.\\-_]"), " ") - title = title.replace( - Regex("1080p|720p|2160p|4k|bluray|webrip|hdtv|x264|x265|hevc|vostfr|french|truefrench", RegexOption.IGNORE_CASE), - "" - ) + var title = filename.replace(EXTENSION_REGEX, "") + title = title.replace(SEASON_EPISODE_CLEAN_REGEX, "") + title = title.replace(YEAR_SUFFIX_REGEX, "") + title = title.replace(SEPARATORS_REGEX, " ") + title = title.replace(TAGS_REGEX, "") return title.trim() - .replace(Regex("[\\(\\[\\{]\\s*$"), "") - .replace(Regex("[\\s\\-\\.\\(\\)\\[\\]\\{\\}]+$"), "") + .replace(OPEN_BRACKET_END_REGEX, "") + .replace(TRAILING_SYMBOLS_REGEX, "") .trim() } } object Formatters { + private val TRAILING_ZEROS_REGEX = Regex("\\.?0+$") + private val suspectPaths = listOf( "/dcim/", "/camera/", "/whatsapp/", "/snapchat/", "/instagram/", "/telegram/", "/signal/", "/viber/", "/messenger/", "/tiktok/", @@ -65,7 +71,7 @@ object Formatters { val sizes = arrayOf("B", "KB", "MB", "GB", "TB") val i = floor(ln(bytes.toDouble()) / ln(k)).toInt() val value = bytes / k.pow(i.toDouble()) - val formatted = String.format(Locale.US, "%.2f", value).replace(Regex("\\.?0+$"), "") + val formatted = String.format(Locale.US, "%.2f", value).replace(TRAILING_ZEROS_REGEX, "") return "$formatted ${sizes[i]}" } diff --git a/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt b/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt index 2138603..66b41fd 100644 --- a/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt +++ b/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt @@ -23,6 +23,8 @@ object VideoFilterSorter { } } + private val HD_OR_4K_REGEX = Regex("1080p|720p|2160p|4k", RegexOption.IGNORE_CASE) + private fun filterByResolution(videos: List, opts: FilterSortOptions): List { if (opts.filterResolution == ResolutionFilter.ALL) return videos return videos.filter { v -> @@ -33,7 +35,7 @@ object VideoFilterSorter { ResolutionFilter.TWO_K -> n.contains("1440p") ResolutionFilter.ONE_THOUSAND_EIGHTY_P -> n.contains("1080p") ResolutionFilter.SEVEN_HUNDRED_TWENTY_P -> n.contains("720p") - ResolutionFilter.SD -> !Regex("1080p|720p|2160p|4k", RegexOption.IGNORE_CASE).containsMatchIn(n) + ResolutionFilter.SD -> !HD_OR_4K_REGEX.containsMatchIn(n) ResolutionFilter.ALL -> true } } diff --git a/native/app/src/main/java/com/localstream/app/domain/VideoGrouper.kt b/native/app/src/main/java/com/localstream/app/domain/VideoGrouper.kt index c157e2b..f4c35a5 100644 --- a/native/app/src/main/java/com/localstream/app/domain/VideoGrouper.kt +++ b/native/app/src/main/java/com/localstream/app/domain/VideoGrouper.kt @@ -53,12 +53,15 @@ object VideoGrouper { return video.copy(season = sStr.toIntOrNull(), episode = eStr.toIntOrNull()) } + private val SERIES_SEPARATORS_REGEX = Regex("[\\.\\-_/\\\\\\[\\]\\(\\)]") + private val SERIES_TRAILING_DASH_SPACE_REGEX = Regex("[\\s\\-]+$") + private fun resolveSeriesName(video: VideoItem, match: MatchResult?): String { val sName = video.seriesName ?: if (match != null) { video.name.substring(0, match.range.first) - .replace(Regex("[\\.\\-_/\\\\\\[\\]\\(\\)]"), " ") + .replace(SERIES_SEPARATORS_REGEX, " ") .trim() - .replace(Regex("[\\s\\-]+$"), "") + .replace(SERIES_TRAILING_DASH_SPACE_REGEX, "") } else { TitleCleaner.getCleanTitle(video.name) } diff --git a/native/app/src/main/java/com/localstream/app/domain/VideoNameParser.kt b/native/app/src/main/java/com/localstream/app/domain/VideoNameParser.kt index 9b7188c..3c4ef67 100644 --- a/native/app/src/main/java/com/localstream/app/domain/VideoNameParser.kt +++ b/native/app/src/main/java/com/localstream/app/domain/VideoNameParser.kt @@ -14,12 +14,16 @@ object VideoNameParser { val SEASON_EPISODE_REGEX = Regex("[sS](\\d+)(\\s*)[eE](\\d+)|(\\d+)(\\s*)x(\\d+)") private val SEASON_FOLDER_REGEX = Regex("Saison\\s*(\\d+)|Season\\s*(\\d+)|S(\\d+)", RegexOption.IGNORE_CASE) + private val EXTENSION_DOT_WORD_REGEX = Regex("\\.\\w+$") + private val SERIES_SEPARATORS_REGEX = Regex("[\\.\\-_/\\\\\\[\\]\\(\\)]") + private val SERIES_TRAILING_DASH_SPACE_REGEX = Regex("[\\s\\-]+$") + private val LEADING_DIGITS_REGEX = Regex("^(\\d+)") fun videoBaseName(fileName: String): String = - fileName.replace(Regex("\\.\\w+$"), "").lowercase() + fileName.replace(EXTENSION_DOT_WORD_REGEX, "").lowercase() fun subtitleBaseName(fileName: String): String = - fileName.replace(Regex("\\.\\w+$"), "").replace(LANG_SUFFIX_REGEX, "").lowercase() + fileName.replace(EXTENSION_DOT_WORD_REGEX, "").replace(LANG_SUFFIX_REGEX, "").lowercase() fun parentFolder(relativePath: String): String { val normalized = relativePath.replace('\\', '/').trimEnd('/') @@ -34,9 +38,9 @@ object VideoNameParser { val sStr = if (groupValues[1].isNotEmpty()) groupValues[1] else groupValues[4] val eStr = if (groupValues[3].isNotEmpty()) groupValues[3] else groupValues[6] var seriesName = fileName.substring(0, match.range.first) - .replace(Regex("[\\.\\-_/\\\\\\[\\]\\(\\)]"), " ") + .replace(SERIES_SEPARATORS_REGEX, " ") .trim() - .replace(Regex("[\\s\\-]+$"), "") + .replace(SERIES_TRAILING_DASH_SPACE_REGEX, "") if (seriesName.isEmpty()) seriesName = "Série Inconnue" return SeriesInfo(seriesName = seriesName, season = sStr.toIntOrNull(), episode = eStr.toIntOrNull()) } @@ -53,7 +57,7 @@ object VideoNameParser { sMatch.groupValues[2].ifEmpty { sMatch.groupValues[3] } } val seriesName = if (pathParts.size >= 3) pathParts[pathParts.size - 3] else "Série Inconnue" - val epMatch = Regex("^(\\d+)").find(fileName) + val epMatch = LEADING_DIGITS_REGEX.find(fileName) return SeriesInfo( seriesName = seriesName, season = sVal.toIntOrNull(), From 50e609fb93aa155c8ba8a58f415fcf653d14b863 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 18:25:38 +0200 Subject: [PATCH 03/10] =?UTF-8?q?perf(compose):=20virtualiser=20les=20rang?= =?UTF-8?q?=C3=A9es=20de=20l'=C3=A9cran=20d'accueil=20dans=20LazyColumn=20?= =?UTF-8?q?pour=20=C3=A9liminer=20les=20janks=20(fixes=20#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../localstream/app/ui/screens/HomeScreen.kt | 105 +++++++++++++----- 1 file changed, 80 insertions(+), 25 deletions(-) diff --git a/native/app/src/main/java/com/localstream/app/ui/screens/HomeScreen.kt b/native/app/src/main/java/com/localstream/app/ui/screens/HomeScreen.kt index 512505e..041bfbf 100644 --- a/native/app/src/main/java/com/localstream/app/ui/screens/HomeScreen.kt +++ b/native/app/src/main/java/com/localstream/app/ui/screens/HomeScreen.kt @@ -88,33 +88,86 @@ fun HomeScreen( onOpenDetails = onOpenDetails, ) } - item(key = "rows", contentType = "rows") { - // Chevauchement du bas du hero (-mt-12 du web). - Column(modifier = Modifier.offset(y = (-24).dp)) { - if (uiState.showTmdbBanner) { - TmdbBanner( - onConfigure = onConfigureTmdb, - onDismiss = onDismissTmdbBanner, - ) - } - if (uiState.continueWatching.isNotEmpty()) { - HomeRow( - title = "Continuer la lecture", - items = uiState.continueWatching, - uiState = uiState, - showResetProgress = true, - onOpenDetails = onOpenDetails, - onResetProgress = onResetProgress, - ) - } - HomeRow("Nouveautés", uiState.recentAdditions, uiState, false, onOpenDetails, onResetProgress) - HomeRow("Recommandations", uiState.recommendations, uiState, false, onOpenDetails, onResetProgress) - HomeRow("Séries", uiState.series, uiState, false, onOpenDetails, onResetProgress) - HomeRow("Films", uiState.movies, uiState, false, onOpenDetails, onResetProgress) - HomeRow("De A à Z", uiState.alphabetical, uiState, false, onOpenDetails, onResetProgress) - Spacer(modifier = Modifier.height(24.dp)) + if (uiState.showTmdbBanner) { + item(key = "tmdb_banner", contentType = "banner") { + TmdbBanner( + onConfigure = onConfigureTmdb, + onDismiss = onDismissTmdbBanner, + modifier = Modifier.offset(y = (-24).dp), + ) } } + if (uiState.continueWatching.isNotEmpty()) { + item(key = "row_continue_watching", contentType = "row") { + HomeRow( + title = "Continuer la lecture", + items = uiState.continueWatching, + uiState = uiState, + showResetProgress = true, + onOpenDetails = onOpenDetails, + onResetProgress = onResetProgress, + modifier = Modifier.offset(y = (-24).dp), + ) + } + } + item(key = "row_recent", contentType = "row") { + HomeRow( + title = "Nouveautés", + items = uiState.recentAdditions, + uiState = uiState, + showResetProgress = false, + onOpenDetails = onOpenDetails, + onResetProgress = onResetProgress, + modifier = Modifier.offset(y = (-24).dp), + ) + } + item(key = "row_recommendations", contentType = "row") { + HomeRow( + title = "Recommandations", + items = uiState.recommendations, + uiState = uiState, + showResetProgress = false, + onOpenDetails = onOpenDetails, + onResetProgress = onResetProgress, + modifier = Modifier.offset(y = (-24).dp), + ) + } + item(key = "row_series", contentType = "row") { + HomeRow( + title = "Séries", + items = uiState.series, + uiState = uiState, + showResetProgress = false, + onOpenDetails = onOpenDetails, + onResetProgress = onResetProgress, + modifier = Modifier.offset(y = (-24).dp), + ) + } + item(key = "row_movies", contentType = "row") { + HomeRow( + title = "Films", + items = uiState.movies, + uiState = uiState, + showResetProgress = false, + onOpenDetails = onOpenDetails, + onResetProgress = onResetProgress, + modifier = Modifier.offset(y = (-24).dp), + ) + } + item(key = "row_alphabetical", contentType = "row") { + HomeRow( + title = "De A à Z", + items = uiState.alphabetical, + uiState = uiState, + showResetProgress = false, + onOpenDetails = onOpenDetails, + onResetProgress = onResetProgress, + modifier = Modifier.offset(y = (-24).dp), + ) + } + item(key = "bottom_spacer", contentType = "spacer") { + Spacer(modifier = Modifier.height(24.dp)) + } } } @@ -139,6 +192,7 @@ private fun HomeRow( showResetProgress: Boolean, onOpenDetails: (VideoItem) -> Unit, onResetProgress: (String) -> Unit, + modifier: Modifier = Modifier, ) { VideoRow( title = title, @@ -149,6 +203,7 @@ private fun HomeRow( showResetProgress = showResetProgress, onOpenDetails = onOpenDetails, onResetProgress = onResetProgress, + modifier = modifier, ) } From d0dc0b259e6ac2c2b433130e288d051af05830f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 18:34:15 +0200 Subject: [PATCH 04/10] =?UTF-8?q?perf(player):=20d=C3=A9coupler=20la=20syn?= =?UTF-8?q?chronisation=20de=20position=20pour=20=C3=A9viter=20la=20recomp?= =?UTF-8?q?osition=20globale=20du=20PlayerScreen=20(fixes=20#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/ui/player/BottomPlayerBar.kt | 6 ------ .../app/ui/player/PlayerControlsOverlay.kt | 16 +++++++++++++-- .../localstream/app/ui/player/PlayerScreen.kt | 2 +- .../app/ui/player/PlayerViewModel.kt | 20 +++++++++++++------ .../app/ui/player/PlayerViewModelTest.kt | 13 ++++++++++++ 5 files changed, 42 insertions(+), 15 deletions(-) diff --git a/native/app/src/main/java/com/localstream/app/ui/player/BottomPlayerBar.kt b/native/app/src/main/java/com/localstream/app/ui/player/BottomPlayerBar.kt index 5f2066f..841990b 100644 --- a/native/app/src/main/java/com/localstream/app/ui/player/BottomPlayerBar.kt +++ b/native/app/src/main/java/com/localstream/app/ui/player/BottomPlayerBar.kt @@ -100,12 +100,6 @@ fun BottomPlayerBar( } } - val sliderValue = if (durationMs > 0L) { - displayPos.coerceIn(0L, durationMs).toFloat() - } else { - 0f - } - Slider( value = sliderValue, onValueChange = { diff --git a/native/app/src/main/java/com/localstream/app/ui/player/PlayerControlsOverlay.kt b/native/app/src/main/java/com/localstream/app/ui/player/PlayerControlsOverlay.kt index f1b2fd0..792ba76 100644 --- a/native/app/src/main/java/com/localstream/app/ui/player/PlayerControlsOverlay.kt +++ b/native/app/src/main/java/com/localstream/app/ui/player/PlayerControlsOverlay.kt @@ -11,6 +11,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.StateFlow + @Composable fun PlayerControlsOverlay( isVisible: Boolean, @@ -20,7 +24,6 @@ fun PlayerControlsOverlay( playbackSpeed: Float, isPlaying: Boolean, hasNextVideo: Boolean, - positionMs: Long, durationMs: Long, onBack: () -> Unit, onOpenTracks: () -> Unit, @@ -34,7 +37,16 @@ fun PlayerControlsOverlay( onToggleLock: () -> Unit, onEnterPip: () -> Unit, modifier: Modifier = Modifier, + positionMs: Long = 0L, + positionMsFlow: StateFlow? = null, ) { + val currentPosition = if (positionMsFlow != null) { + val pos by positionMsFlow.collectAsStateWithLifecycle() + pos + } else { + positionMs + } + AnimatedVisibility( visible = isVisible && !isLocked, enter = fadeIn(), @@ -68,7 +80,7 @@ fun PlayerControlsOverlay( ) BottomPlayerBar( - positionMs = positionMs, + positionMs = currentPosition, durationMs = durationMs, isLocked = isLocked, onSeek = onSeek, diff --git a/native/app/src/main/java/com/localstream/app/ui/player/PlayerScreen.kt b/native/app/src/main/java/com/localstream/app/ui/player/PlayerScreen.kt index e0f6629..a66ceec 100644 --- a/native/app/src/main/java/com/localstream/app/ui/player/PlayerScreen.kt +++ b/native/app/src/main/java/com/localstream/app/ui/player/PlayerScreen.kt @@ -605,7 +605,7 @@ fun PlayerScreen( playbackSpeed = uiState.playbackSpeed, isPlaying = uiState.isPlaying, hasNextVideo = uiState.nextVideo != null, - positionMs = uiState.positionMs, + positionMsFlow = viewModel.positionMs, durationMs = uiState.durationMs, onBack = onBack, onOpenTracks = { showTracksSheet = true }, diff --git a/native/app/src/main/java/com/localstream/app/ui/player/PlayerViewModel.kt b/native/app/src/main/java/com/localstream/app/ui/player/PlayerViewModel.kt index 16bfc35..85852eb 100644 --- a/native/app/src/main/java/com/localstream/app/ui/player/PlayerViewModel.kt +++ b/native/app/src/main/java/com/localstream/app/ui/player/PlayerViewModel.kt @@ -90,6 +90,9 @@ class PlayerViewModel( private val _uiState = MutableStateFlow(PlayerUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _positionMs = MutableStateFlow(0L) + val positionMs: StateFlow = _positionMs.asStateFlow() + private data class SavePositionRequest( val videoName: String, val positionMs: Long, @@ -144,6 +147,7 @@ class PlayerViewModel( val pct = state?.progressPct ?: 0.0 val pos = if (isFinished(isWatched, pct, rawPos, targetVideo.duration * 1000L)) 0L else rawPos + _positionMs.value = pos _uiState.update { it.copy( initialPositionMs = pos, @@ -174,6 +178,7 @@ class PlayerViewModel( 0L } + _positionMs.value = pos _uiState.update { it.copy( initialPositionMs = pos, @@ -258,9 +263,9 @@ class PlayerViewModel( } fun onPositionChanged(positionMs: Long, durationMs: Long) { - _uiState.update { state -> - val newDur = if (durationMs > 0L) durationMs else state.durationMs - state.copy(positionMs = positionMs, durationMs = newDur) + _positionMs.value = positionMs + if (durationMs > 0L && durationMs != _uiState.value.durationMs) { + _uiState.update { it.copy(durationMs = durationMs) } } val video = _uiState.value.currentVideo ?: return @@ -288,7 +293,7 @@ class PlayerViewModel( private fun saveCurrentPosition() { val video = _uiState.value.currentVideo ?: return - val pos = _uiState.value.positionMs + val pos = _positionMs.value val dur = _uiState.value.durationMs if (pos > 0L) { viewModelScope.launch { @@ -414,8 +419,10 @@ class PlayerViewModel( } fun seekBy(deltaMs: Long) { - val state = _uiState.value - val newPos = (state.positionMs + deltaMs).coerceIn(0L, state.durationMs.coerceAtLeast(1L)) + val curPos = _positionMs.value + val dur = _uiState.value.durationMs + val newPos = (curPos + deltaMs).coerceIn(0L, dur.coerceAtLeast(1L)) + _positionMs.value = newPos val type = if (deltaMs >= 0) FeedbackType.SEEK_FORWARD else FeedbackType.SEEK_REWIND val text = if (deltaMs >= 0) "+10s" else "-10s" _uiState.update { @@ -523,6 +530,7 @@ class PlayerViewModel( 0L } + _positionMs.value = pos _uiState.update { it.copy( initialPositionMs = pos, diff --git a/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt index 73f5a10..6350923 100644 --- a/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt @@ -422,6 +422,19 @@ class PlayerViewModelTest { assertEquals(ep2.name, state.nextVideo?.name) } + @Test + fun `onPositionChanged updates positionMs StateFlow`() = runTest { + val viewModel = PlayerViewModel(video1.name, container) + backgroundScope.launch { viewModel.uiState.collect {} } + backgroundScope.launch { viewModel.positionMs.collect {} } + advanceUntilIdle() + + viewModel.onPositionChanged(positionMs = 42000L, durationMs = 100000L) + advanceUntilIdle() + + assertEquals(42000L, viewModel.positionMs.value) + } + // -------- Fakes -------- private class FakeScanner( From c5d56369cf0f9cc31e5c041707b33ab1b7c4504a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 18:40:23 +0200 Subject: [PATCH 05/10] perf(db): resoudre probleme N+1 playlists, batch writes et indexer playback_state --- .../3.json | 224 ++++++++++++++++++ .../localstream/app/data/db/AppDatabase.kt | 11 +- .../app/data/db/dao/PlaylistDao.kt | 10 + .../app/data/db/dao/TmdbMetadataDao.kt | 3 + .../app/data/db/dao/WatchedItemDao.kt | 3 + .../app/data/db/entity/PlaybackStateEntity.kt | 20 +- .../app/data/db/entity/PlaylistEntity.kt | 12 + .../app/data/repository/PlaylistRepository.kt | 39 ++- .../app/data/repository/TmdbRepository.kt | 15 +- .../data/repository/WatchStateRepository.kt | 5 +- .../com/localstream/app/di/NoOpTestDoubles.kt | 4 + .../app/data/AppDatabaseMigrationTest.kt | 28 +++ .../app/data/LegacyDataImporterTest.kt | 6 + .../app/data/WatchedItemDaoTest.kt | 19 +- .../app/data/repository/TmdbRepositoryTest.kt | 4 + .../app/ui/details/DetailsViewModelTest.kt | 11 + .../app/ui/history/HistoryViewModelTest.kt | 3 + .../app/ui/library/LibraryViewModelTest.kt | 6 + .../app/ui/player/PlayerViewModelTest.kt | 5 + .../ui/playlists/PlaylistsViewModelTest.kt | 7 + 20 files changed, 395 insertions(+), 40 deletions(-) create mode 100644 native/app/schemas/com.localstream.app.data.db.AppDatabase/3.json diff --git a/native/app/schemas/com.localstream.app.data.db.AppDatabase/3.json b/native/app/schemas/com.localstream.app.data.db.AppDatabase/3.json new file mode 100644 index 0000000..2633236 --- /dev/null +++ b/native/app/schemas/com.localstream.app.data.db.AppDatabase/3.json @@ -0,0 +1,224 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "d26195f2f513711c07e8e858973d8cb1", + "entities": [ + { + "tableName": "watched_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `watched` INTEGER NOT NULL, `watched_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "watchedAt", + "columnName": "watched_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "playback_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `progress_pct` REAL NOT NULL, `position_ms` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progressPct", + "columnName": "progress_pct", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "position_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [ + { + "name": "index_playback_state_last_played_at", + "unique": false, + "columnNames": [ + "last_played_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_state_last_played_at` ON `${TABLE_NAME}` (`last_played_at`)" + }, + { + "name": "index_playback_state_progress_pct", + "unique": false, + "columnNames": [ + "progress_pct" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_state_progress_pct` ON `${TABLE_NAME}` (`progress_pct`)" + } + ] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playlist_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `video_name` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `video_name`), FOREIGN KEY(`playlist_id`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoName", + "columnName": "video_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "video_name" + ] + }, + "indices": [ + { + "name": "index_playlist_item_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_item_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tmdb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query_key` TEXT NOT NULL, `json` TEXT NOT NULL, `fetched_at` INTEGER NOT NULL, PRIMARY KEY(`query_key`))", + "fields": [ + { + "fieldPath": "queryKey", + "columnName": "query_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query_key" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd26195f2f513711c07e8e858973d8cb1')" + ] + } +} \ No newline at end of file diff --git a/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt b/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt index ea34564..746be40 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt @@ -30,7 +30,7 @@ import com.localstream.app.data.db.entity.WatchedItemEntity PlaylistItemEntity::class, TmdbMetadataEntity::class, ], - version = 2, + version = 3, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { @@ -58,6 +58,13 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE INDEX IF NOT EXISTS `index_playback_state_last_played_at` ON `playback_state` (`last_played_at`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_playback_state_progress_pct` ON `playback_state` (`progress_pct`)") + } + } + @Volatile private var INSTANCE: AppDatabase? = null @@ -68,7 +75,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, DB_NAME, ) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() .also { INSTANCE = it } } diff --git a/native/app/src/main/java/com/localstream/app/data/db/dao/PlaylistDao.kt b/native/app/src/main/java/com/localstream/app/data/db/dao/PlaylistDao.kt index 6e058ca..1ff9fec 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/dao/PlaylistDao.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/dao/PlaylistDao.kt @@ -9,6 +9,8 @@ import com.localstream.app.data.db.entity.PlaylistEntity import com.localstream.app.data.db.entity.PlaylistItemEntity import kotlinx.coroutines.flow.Flow +import com.localstream.app.data.db.entity.PlaylistWithItems + @Dao @Suppress("TooManyFunctions") interface PlaylistDao { @@ -18,9 +20,17 @@ interface PlaylistDao { @Query("SELECT * FROM playlist ORDER BY created_at ASC") fun observePlaylists(): Flow> + @Transaction + @Query("SELECT * FROM playlist ORDER BY created_at ASC") + fun observePlaylistsWithItems(): Flow> + @Query("SELECT * FROM playlist ORDER BY created_at ASC") suspend fun getAllPlaylists(): List + @Transaction + @Query("SELECT * FROM playlist ORDER BY created_at ASC") + suspend fun getPlaylistsWithItems(): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertPlaylist(playlist: PlaylistEntity) diff --git a/native/app/src/main/java/com/localstream/app/data/db/dao/TmdbMetadataDao.kt b/native/app/src/main/java/com/localstream/app/data/db/dao/TmdbMetadataDao.kt index b90a279..3303bce 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/dao/TmdbMetadataDao.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/dao/TmdbMetadataDao.kt @@ -15,6 +15,9 @@ interface TmdbMetadataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertMetadata(entity: TmdbMetadataEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMetadataList(entities: List) + @Query("DELETE FROM tmdb_metadata WHERE query_key = :queryKey") suspend fun deleteMetadata(queryKey: String) diff --git a/native/app/src/main/java/com/localstream/app/data/db/dao/WatchedItemDao.kt b/native/app/src/main/java/com/localstream/app/data/db/dao/WatchedItemDao.kt index 8ccba74..830e8fd 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/dao/WatchedItemDao.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/dao/WatchedItemDao.kt @@ -27,6 +27,9 @@ interface WatchedItemDao { @Query("DELETE FROM watched_items WHERE name = :name") suspend fun deleteByName(name: String) + @Query("DELETE FROM watched_items WHERE name IN (:names)") + suspend fun deleteByNames(names: List) + @Query("DELETE FROM watched_items") suspend fun deleteAll() diff --git a/native/app/src/main/java/com/localstream/app/data/db/entity/PlaybackStateEntity.kt b/native/app/src/main/java/com/localstream/app/data/db/entity/PlaybackStateEntity.kt index a65250b..b30ef81 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/entity/PlaybackStateEntity.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/entity/PlaybackStateEntity.kt @@ -4,16 +4,24 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import androidx.room.Index + /** * Table Room : progression de lecture. - * Fusionne les trois cl\u00e9s localStorage : watchProgress, watchPositions et recentlyWatched. + * Fusionne les trois clés localStorage : watchProgress, watchPositions et recentlyWatched. * - * - [progressPct] : pourcentage (0.0 – 100.0), \u00e9quivalent "watchProgress" - * - [positionMs] : position en millisecondes, \u00e9quivalent "watchPositions" - * - [lastPlayedAt] : timestamp ms, permet de r\u00e9construire "recentlyWatched" par tri DESC - * - [mediaStoreId] : nullable, fiabilise la cl\u00e9 \u00e0 terme (renommage de fichier) + * - [progressPct] : pourcentage (0.0 – 100.0), équivalent "watchProgress" + * - [positionMs] : position en millisecondes, équivalent "watchPositions" + * - [lastPlayedAt] : timestamp ms, permet de reconstruire "recentlyWatched" par tri DESC + * - [mediaStoreId] : nullable, fiabilise la clé à terme (renommage de fichier) */ -@Entity(tableName = "playback_state") +@Entity( + tableName = "playback_state", + indices = [ + Index(value = ["last_played_at"]), + Index(value = ["progress_pct"]), + ], +) data class PlaybackStateEntity( @PrimaryKey @ColumnInfo(name = "name") val name: String, diff --git a/native/app/src/main/java/com/localstream/app/data/db/entity/PlaylistEntity.kt b/native/app/src/main/java/com/localstream/app/data/db/entity/PlaylistEntity.kt index fff9dd0..3bd4b0b 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/entity/PlaylistEntity.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/entity/PlaylistEntity.kt @@ -39,4 +39,16 @@ data class PlaylistItemEntity( @ColumnInfo(name = "video_name") val videoName: String, /** Position 0-based dans la playlist. */ @ColumnInfo(name = "position") val position: Int = 0, +) + +/** + * Relation 1:N entre une Playlist et ses items pour chargement optimisé sans N+1. + */ +data class PlaylistWithItems( + @androidx.room.Embedded val playlist: PlaylistEntity, + @androidx.room.Relation( + parentColumn = "id", + entityColumn = "playlist_id", + ) + val items: List = emptyList(), ) diff --git a/native/app/src/main/java/com/localstream/app/data/repository/PlaylistRepository.kt b/native/app/src/main/java/com/localstream/app/data/repository/PlaylistRepository.kt index 701dc83..5aaf88b 100644 --- a/native/app/src/main/java/com/localstream/app/data/repository/PlaylistRepository.kt +++ b/native/app/src/main/java/com/localstream/app/data/repository/PlaylistRepository.kt @@ -3,43 +3,29 @@ package com.localstream.app.data.repository import com.localstream.app.data.db.dao.PlaylistDao import com.localstream.app.data.db.entity.PlaylistEntity import com.localstream.app.data.db.entity.PlaylistItemEntity +import com.localstream.app.data.db.entity.PlaylistWithItems import com.localstream.app.domain.model.PlaylistInfo import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import java.util.UUID /** * Repository de gestion des playlists utilisateur. * * Toutes les mutations sont suspendues. - * [observePlaylists] expose un Flow de [PlaylistInfo] reconstitu\u00e9 depuis les deux tables Room. + * [observePlaylists] expose un Flow de [PlaylistInfo] reconstitué depuis les deux tables Room. */ class PlaylistRepository(private val playlistDao: PlaylistDao) { - /** Flow reactif : liste compl\u00e8te des playlists avec leurs vid\u00e9os ordonn\u00e9es. */ + /** Flow réactif : liste complète des playlists avec leurs vidéos ordonnées en une seule requête. */ val observePlaylists: Flow> = - combine( - playlistDao.observePlaylists(), - // On observe les items globaux via getAllItems dans un flow d\u00e9di\u00e9 (simple polling n'est - // pas id\u00e9al ; remplacer par une requ\u00eate GROUP BY en Phase 8 si besoin de perf). - playlistDao.observePlaylists(), // second slot — force re-collect quand les playlists changent - ) { playlists, _ -> - buildPlaylistInfoList(playlists) - } - - private suspend fun buildPlaylistInfoList(playlists: List): List = - playlists.map { entity -> - val items = playlistDao.getItems(entity.id) - PlaylistInfo( - id = entity.id, - name = entity.name, - videoNames = items.sortedBy { it.position }.map { it.videoName }, - ) + playlistDao.observePlaylistsWithItems().map { list -> + list.map { it.toPlaylistInfo() } } suspend fun getAllPlaylists(): List { - val entities = playlistDao.getAllPlaylists() - return buildPlaylistInfoList(entities) + val list = playlistDao.getPlaylistsWithItems() + return list.map { it.toPlaylistInfo() } } suspend fun createPlaylist(name: String): PlaylistInfo { @@ -82,4 +68,11 @@ class PlaylistRepository(private val playlistDao: PlaylistDao) { val existing = playlistDao.getAllPlaylists().firstOrNull { it.id == playlistId } ?: return playlistDao.upsertPlaylist(existing.copy(name = newName)) } -} +} + +private fun PlaylistWithItems.toPlaylistInfo(): PlaylistInfo = + PlaylistInfo( + id = playlist.id, + name = playlist.name, + videoNames = items.sortedBy { it.position }.map { it.videoName }, + ) diff --git a/native/app/src/main/java/com/localstream/app/data/repository/TmdbRepository.kt b/native/app/src/main/java/com/localstream/app/data/repository/TmdbRepository.kt index 97ba059..f6769b8 100644 --- a/native/app/src/main/java/com/localstream/app/data/repository/TmdbRepository.kt +++ b/native/app/src/main/java/com/localstream/app/data/repository/TmdbRepository.kt @@ -277,7 +277,7 @@ open class TmdbRepository( tmdbApi.getSeason(tvId, seasonNum, apiKey) } val now = System.currentTimeMillis() - for (epDto in seasonDetails.episodes) { + val entities = seasonDetails.episodes.map { epDto -> val epKey = "${lookupName}_s${epDto.seasonNumber}_e${epDto.episodeNumber}" val episode = TmdbEpisode( epKey = epKey, @@ -287,14 +287,15 @@ open class TmdbRepository( seasonNumber = epDto.seasonNumber, episodeNumber = epDto.episodeNumber, ) - tmdbMetadataDao.insertMetadata( - TmdbMetadataEntity( - queryKey = epKey, - json = json.encodeToString(episode), - fetchedAt = now, - ) + TmdbMetadataEntity( + queryKey = epKey, + json = json.encodeToString(episode), + fetchedAt = now, ) } + if (entities.isNotEmpty()) { + tmdbMetadataDao.insertMetadataList(entities) + } } catch (_: Exception) { } } diff --git a/native/app/src/main/java/com/localstream/app/data/repository/WatchStateRepository.kt b/native/app/src/main/java/com/localstream/app/data/repository/WatchStateRepository.kt index f927ba2..db46856 100644 --- a/native/app/src/main/java/com/localstream/app/data/repository/WatchStateRepository.kt +++ b/native/app/src/main/java/com/localstream/app/data/repository/WatchStateRepository.kt @@ -68,7 +68,10 @@ class WatchStateRepository( } ) } else { - episodes.forEach { ep -> watchedItemDao.deleteByName(ep.name) } + val names = episodes.map { it.name } + if (names.isNotEmpty()) { + watchedItemDao.deleteByNames(names) + } } } diff --git a/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt b/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt index d737494..0fbcdd1 100644 --- a/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt +++ b/native/app/src/main/java/com/localstream/app/di/NoOpTestDoubles.kt @@ -47,6 +47,7 @@ class NoOpWatchedItemDao : WatchedItemDao { override suspend fun upsert(item: WatchedItemEntity) {} override suspend fun upsertAll(items: List) {} override suspend fun deleteByName(name: String) {} + override suspend fun deleteByNames(names: List) {} override suspend fun deleteAll() {} override suspend fun findByName(name: String): WatchedItemEntity? = null } @@ -67,6 +68,7 @@ class NoOpPlaybackStateDao : PlaybackStateDao { class NoOpTmdbMetadataDao : TmdbMetadataDao { override suspend fun getMetadata(queryKey: String): TmdbMetadataEntity? = null override suspend fun insertMetadata(entity: TmdbMetadataEntity) {} + override suspend fun insertMetadataList(entities: List) {} override suspend fun deleteMetadata(queryKey: String) {} override suspend fun clearAll() {} override suspend fun getAll(): List = emptyList() @@ -75,7 +77,9 @@ class NoOpTmdbMetadataDao : TmdbMetadataDao { @Suppress("EmptyFunctionBlock") class NoOpPlaylistDao : PlaylistDao { override fun observePlaylists(): Flow> = MutableStateFlow(emptyList()) + override fun observePlaylistsWithItems(): Flow> = MutableStateFlow(emptyList()) override suspend fun getAllPlaylists(): List = emptyList() + override suspend fun getPlaylistsWithItems(): List = emptyList() override suspend fun upsertPlaylist(playlist: PlaylistEntity) {} override suspend fun upsertPlaylists(playlists: List) {} override suspend fun deletePlaylist(id: String) {} diff --git a/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt b/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt index 3248fe0..bb1a397 100644 --- a/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt +++ b/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt @@ -39,4 +39,32 @@ class AppDatabaseMigrationTest { assertTrue(sql.contains("`fetched_at` INTEGER NOT NULL")) assertTrue(sql.contains("PRIMARY KEY(`query_key`)")) } + + @Test + fun migration2To3_hasCorrectVersions() { + assertEquals(2, AppDatabase.MIGRATION_2_3.startVersion) + assertEquals(3, AppDatabase.MIGRATION_2_3.endVersion) + } + + @Test + fun migration2To3_executesCreateIndicesOnPlaybackState() { + val executedQueries = mutableListOf() + + val dbProxy = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + if (method.name == "execSQL" && args != null && args.isNotEmpty()) { + executedQueries.add(args[0].toString()) + } + null + } as SupportSQLiteDatabase + + AppDatabase.MIGRATION_2_3.migrate(dbProxy) + + assertEquals(2, executedQueries.size) + assertTrue(executedQueries.any { it.contains("CREATE INDEX IF NOT EXISTS `index_playback_state_last_played_at`") }) + assertTrue(executedQueries.any { it.contains("CREATE INDEX IF NOT EXISTS `index_playback_state_progress_pct`") }) + } } + diff --git a/native/app/src/test/java/com/localstream/app/data/LegacyDataImporterTest.kt b/native/app/src/test/java/com/localstream/app/data/LegacyDataImporterTest.kt index 92d24e6..eeade9c 100644 --- a/native/app/src/test/java/com/localstream/app/data/LegacyDataImporterTest.kt +++ b/native/app/src/test/java/com/localstream/app/data/LegacyDataImporterTest.kt @@ -167,6 +167,7 @@ private class FakeWatchedItemDao : WatchedItemDao { override suspend fun upsert(item: WatchedItemEntity) { store[item.name] = item } override suspend fun upsertAll(items: List) { items.forEach { store[it.name] = it } } override suspend fun deleteByName(name: String) { store.remove(name) } + override suspend fun deleteByNames(names: List) { names.forEach { store.remove(it) } } override suspend fun deleteAll() { store.clear() } override suspend fun findByName(name: String) = store[name] } @@ -187,7 +188,12 @@ private class FakePlaylistDao : PlaylistDao { val playlists = mutableMapOf() val items = mutableListOf() override fun observePlaylists(): Flow> = flow { emit(playlists.values.toList()) } + override fun observePlaylistsWithItems(): Flow> = flow { + emit(playlists.values.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) }) + } override suspend fun getAllPlaylists() = playlists.values.toList() + override suspend fun getPlaylistsWithItems(): List = + playlists.values.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) } override suspend fun upsertPlaylist(playlist: PlaylistEntity) { playlists[playlist.id] = playlist } override suspend fun upsertPlaylists(playlists: List) { playlists.forEach { this.playlists[it.id] = it } } override suspend fun deletePlaylist(id: String) { playlists.remove(id); items.removeAll { it.playlistId == id } } diff --git a/native/app/src/test/java/com/localstream/app/data/WatchedItemDaoTest.kt b/native/app/src/test/java/com/localstream/app/data/WatchedItemDaoTest.kt index 702a426..095d9c5 100644 --- a/native/app/src/test/java/com/localstream/app/data/WatchedItemDaoTest.kt +++ b/native/app/src/test/java/com/localstream/app/data/WatchedItemDaoTest.kt @@ -73,9 +73,22 @@ class WatchedItemDaoTest { private fun runBlocking(block: suspend () -> Unit) { kotlinx.coroutines.runBlocking { block() } } + + @Test + fun deleteByNames_removesMultipleItems() = runBlocking { + dao.upsert(WatchedItemEntity("Film1.mp4", watched = true)) + dao.upsert(WatchedItemEntity("Film2.mp4", watched = true)) + dao.upsert(WatchedItemEntity("Film3.mp4", watched = true)) + + dao.deleteByNames(listOf("Film1.mp4", "Film2.mp4")) + + val remaining = dao.getAllWatchedItems() + assertEquals(1, remaining.size) + assertEquals("Film3.mp4", remaining.first().name) + } } -// -------- Impl\u00e9mentation en m\u00e9moire -------- +// -------- Implémentation en mémoire -------- private class InMemoryWatchedItemDao : WatchedItemDao { private val store = mutableMapOf() @@ -94,6 +107,10 @@ private class InMemoryWatchedItemDao : WatchedItemDao { override suspend fun deleteByName(name: String) { store.remove(name) } + override suspend fun deleteByNames(names: List) { + names.forEach { store.remove(it) } + } + override suspend fun deleteAll() { store.clear() } override suspend fun findByName(name: String) = store[name] diff --git a/native/app/src/test/java/com/localstream/app/data/repository/TmdbRepositoryTest.kt b/native/app/src/test/java/com/localstream/app/data/repository/TmdbRepositoryTest.kt index 1c1c300..bf87133 100644 --- a/native/app/src/test/java/com/localstream/app/data/repository/TmdbRepositoryTest.kt +++ b/native/app/src/test/java/com/localstream/app/data/repository/TmdbRepositoryTest.kt @@ -474,6 +474,10 @@ class FakeTmdbMetadataDao : TmdbMetadataDao { map[entity.queryKey] = entity } + override suspend fun insertMetadataList(entities: List) { + entities.forEach { map[it.queryKey] = it } + } + override suspend fun deleteMetadata(queryKey: String) { map.remove(queryKey) } diff --git a/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt index 4832991..a015019 100644 --- a/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/details/DetailsViewModelTest.kt @@ -23,6 +23,7 @@ import com.localstream.app.data.remote.tmdb.dto.TmdbSearchResponse import com.localstream.app.data.remote.tmdb.dto.TmdbSeasonDetailsDto import com.localstream.app.data.repository.OpenSubtitlesRepository import com.localstream.app.data.repository.PlaylistRepository +import kotlinx.coroutines.flow.map import com.localstream.app.data.repository.SettingsRepository import com.localstream.app.data.repository.TmdbRepository import com.localstream.app.data.repository.VideoRepository @@ -193,6 +194,9 @@ class DetailsViewModelTest { override suspend fun deleteByName(name: String) { items.value = items.value.filterNot { it.name == name } } + override suspend fun deleteByNames(names: List) { + items.value = items.value.filterNot { it.name in names } + } override suspend fun deleteAll() { items.value = emptyList() } override suspend fun findByName(name: String): WatchedItemEntity? = items.value.find { it.name == name } } @@ -218,7 +222,13 @@ class DetailsViewModelTest { private val items = mutableListOf() override fun observePlaylists(): Flow> = playlists + override fun observePlaylistsWithItems(): Flow> = + playlists.map { list -> + list.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) } + } override suspend fun getAllPlaylists(): List = playlists.value + override suspend fun getPlaylistsWithItems(): List = + playlists.value.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) } override suspend fun upsertPlaylist(playlist: PlaylistEntity) { playlists.value = playlists.value.filterNot { it.id == playlist.id } + playlist } @@ -258,6 +268,7 @@ class DetailsViewModelTest { private val items = mutableMapOf() override suspend fun getMetadata(queryKey: String): TmdbMetadataEntity? = items[queryKey] override suspend fun insertMetadata(entity: TmdbMetadataEntity) { items[entity.queryKey] = entity } + override suspend fun insertMetadataList(entities: List) { entities.forEach { items[it.queryKey] = it } } override suspend fun deleteMetadata(queryKey: String) { items.remove(queryKey) } override suspend fun clearAll() = items.clear() override suspend fun getAll(): List = items.values.toList() diff --git a/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt index 404f4bb..1b6ae77 100644 --- a/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/history/HistoryViewModelTest.kt @@ -106,6 +106,9 @@ class HistoryViewModelTest { override suspend fun deleteByName(name: String) { items.value = items.value.filterNot { it.name == name } } + override suspend fun deleteByNames(names: List) { + items.value = items.value.filterNot { it.name in names } + } override suspend fun deleteAll() { items.value = emptyList() } override suspend fun findByName(name: String): WatchedItemEntity? = items.value.find { it.name == name } } 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..f313ccb 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 @@ -206,6 +206,9 @@ class LibraryViewModelTest { override suspend fun deleteByName(name: String) { items.value = items.value.filterNot { it.name == name } } + override suspend fun deleteByNames(names: List) { + items.value = items.value.filterNot { it.name in names } + } override suspend fun deleteAll() { items.value = emptyList() } @@ -238,6 +241,9 @@ class LibraryViewModelTest { override suspend fun insertMetadata(entity: TmdbMetadataEntity) { items[entity.queryKey] = entity } + override suspend fun insertMetadataList(entities: List) { + entities.forEach { items[it.queryKey] = it } + } override suspend fun deleteMetadata(queryKey: String) { items.remove(queryKey) } diff --git a/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt index 73f5a10..94a7402 100644 --- a/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/player/PlayerViewModelTest.kt @@ -460,6 +460,11 @@ class PlayerViewModelTest { flow.value = map.values.toList() } + override suspend fun deleteByNames(names: List) { + names.forEach { map.remove(it) } + flow.value = map.values.toList() + } + override suspend fun deleteAll() { map.clear() flow.value = emptyList() diff --git a/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt b/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt index e5e6225..329f21d 100644 --- a/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt +++ b/native/app/src/test/java/com/localstream/app/ui/playlists/PlaylistsViewModelTest.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -106,7 +107,13 @@ class PlaylistsViewModelTest { private val items = mutableListOf() override fun observePlaylists(): Flow> = playlists + override fun observePlaylistsWithItems(): Flow> = + playlists.map { list -> + list.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) } + } override suspend fun getAllPlaylists(): List = playlists.value + override suspend fun getPlaylistsWithItems(): List = + playlists.value.map { p -> com.localstream.app.data.db.entity.PlaylistWithItems(p, items.filter { it.playlistId == p.id }) } override suspend fun upsertPlaylist(playlist: PlaylistEntity) { playlists.value = playlists.value.filterNot { it.id == playlist.id } + playlist } From 1ca27920036e061fd329daa176417fd27e04b654 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 18:44:51 +0200 Subject: [PATCH 06/10] perf(library): eviter re-filtrage synchrone et deporter calculs TMDB/Home sur Default --- .../localstream/app/ui/home/HomeViewModel.kt | 5 ++ .../app/ui/library/LibraryViewModel.kt | 55 +++++++++++++++++-- .../app/ui/library/LibraryViewModelTest.kt | 18 ++++++ 3 files changed, 73 insertions(+), 5 deletions(-) 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..22d27a7 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 @@ -81,6 +81,7 @@ class LibraryViewModel( 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( From c76ceb9dcfeea8f410f85e23282cc51f0ffcc73d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 18:47:55 +0200 Subject: [PATCH 07/10] perf(sorting): optimiser complexite comparateur tri et lookups O(1) --- .../app/domain/VideoFilterSorter.kt | 103 +++++++++++------- .../app/ui/history/HistoryViewModel.kt | 5 +- .../app/ui/playlists/PlaylistsViewModel.kt | 3 +- 3 files changed, 66 insertions(+), 45 deletions(-) diff --git a/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt b/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt index 2138603..9dd61d1 100644 --- a/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt +++ b/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt @@ -40,13 +40,67 @@ object VideoFilterSorter { } private fun sortVideos(videos: List, opts: FilterSortOptions): List { - return videos.sortedWith { a, b -> - val aWatched = isItemWatched(a, opts.watchedVideos) - val bWatched = isItemWatched(b, opts.watchedVideos) - if (aWatched != bWatched) { - if (aWatched) 1 else -1 - } else { - compareItemsByCriteria(a, b, opts) + val watchedCache = videos.associateWith { isItemWatched(it, opts.watchedVideos) } + + return when (opts.sortBy) { + SortBy.ALPHA -> { + val alphaCache = videos.associateWith { if (it.isSeriesGroup) it.seriesName.orEmpty() else it.name } + videos.sortedWith { a, b -> + val aWatched = watchedCache[a] ?: false + val bWatched = watchedCache[b] ?: false + if (aWatched != bWatched) { + if (aWatched) 1 else -1 + } else { + (alphaCache[a].orEmpty()).compareTo(alphaCache[b].orEmpty()) + } + } + } + SortBy.DATE -> { + val dateCache = videos.associateWith { + val lookup = if (it.isSeriesGroup) it.seriesName.orEmpty() else it.name + opts.releaseDates[lookup] ?: it.lastModified.toString() + } + videos.sortedWith { a, b -> + val aWatched = watchedCache[a] ?: false + val bWatched = watchedCache[b] ?: false + if (aWatched != bWatched) { + if (aWatched) 1 else -1 + } else { + (dateCache[b].orEmpty()).compareTo(dateCache[a].orEmpty()) + } + } + } + SortBy.SIZE -> { + val sizeCache = videos.associateWith { + if (it.isSeriesGroup) it.episodes.orEmpty().sumOf { ep -> ep.size } else it.size + } + videos.sortedWith { a, b -> + val aWatched = watchedCache[a] ?: false + val bWatched = watchedCache[b] ?: false + if (aWatched != bWatched) { + if (aWatched) 1 else -1 + } else { + (sizeCache[b] ?: 0L).compareTo(sizeCache[a] ?: 0L) + } + } + } + SortBy.DURATION -> { + val durationCache = videos.associateWith { + if (it.isSeriesGroup) { + it.episodes.orEmpty().sumOf { ep -> opts.videoDurations[ep.name] ?: 0L } + } else { + opts.videoDurations[it.name] ?: 0L + } + } + videos.sortedWith { a, b -> + val aWatched = watchedCache[a] ?: false + val bWatched = watchedCache[b] ?: false + if (aWatched != bWatched) { + if (aWatched) 1 else -1 + } else { + (durationCache[b] ?: 0L).compareTo(durationCache[a] ?: 0L) + } + } } } } @@ -58,40 +112,5 @@ object VideoFilterSorter { watchedVideos[v.name] == true } } - - private fun compareItemsByCriteria(a: VideoItem, b: VideoItem, opts: FilterSortOptions): Int { - return when (opts.sortBy) { - SortBy.ALPHA -> compareAlpha(a, b) - SortBy.DATE -> compareDate(a, b, opts.releaseDates) - SortBy.SIZE -> compareSize(a, b) - SortBy.DURATION -> compareDuration(a, b, opts.videoDurations) - } - } - - private fun compareAlpha(a: VideoItem, b: VideoItem): Int { - val nameA = if (a.isSeriesGroup) a.seriesName ?: "" else a.name - val nameB = if (b.isSeriesGroup) b.seriesName ?: "" else b.name - return nameA.compareTo(nameB) - } - - private fun compareDate(a: VideoItem, b: VideoItem, releaseDates: Map): Int { - val lookupA = if (a.isSeriesGroup) a.seriesName ?: "" else a.name - val lookupB = if (b.isSeriesGroup) b.seriesName ?: "" else b.name - val dateA = releaseDates[lookupA] ?: a.lastModified.toString() - val dateB = releaseDates[lookupB] ?: b.lastModified.toString() - return dateB.compareTo(dateA) - } - - private fun compareSize(a: VideoItem, b: VideoItem): Int { - val sizeA = if (a.isSeriesGroup) a.episodes.orEmpty().sumOf { it.size } else a.size - val sizeB = if (b.isSeriesGroup) b.episodes.orEmpty().sumOf { it.size } else b.size - return sizeB.compareTo(sizeA) - } - - private fun compareDuration(a: VideoItem, b: VideoItem, videoDurations: Map): Int { - val durA = if (a.isSeriesGroup) a.episodes.orEmpty().sumOf { videoDurations[it.name] ?: 0L } else videoDurations[a.name] ?: 0L - val durB = if (b.isSeriesGroup) b.episodes.orEmpty().sumOf { videoDurations[it.name] ?: 0L } else videoDurations[b.name] ?: 0L - return durB.compareTo(durA) - } } diff --git a/native/app/src/main/java/com/localstream/app/ui/history/HistoryViewModel.kt b/native/app/src/main/java/com/localstream/app/ui/history/HistoryViewModel.kt index a85b186..afc8132 100644 --- a/native/app/src/main/java/com/localstream/app/ui/history/HistoryViewModel.kt +++ b/native/app/src/main/java/com/localstream/app/ui/history/HistoryViewModel.kt @@ -42,7 +42,8 @@ class HistoryViewModel( container.settingsRepository.observeForceAvailable, ) { watchedSet: Set, playbackMap: Map, diskVideos: List, forceSet: Set -> val allNames = (watchedSet + playbackMap.keys).distinct() - val diskVideoNames = diskVideos.map { it.name }.toSet() + val diskVideoMap = diskVideos.associateBy { it.name } + val diskVideoNames = diskVideoMap.keys val historyItems = allNames.map { name -> val pb = playbackMap[name] @@ -51,7 +52,7 @@ class HistoryViewModel( val isForceAvailable = forceSet.contains(name) val effectiveAvailable = isDiskAvailable || isForceAvailable - val diskVideo = diskVideos.find { it.name == name } + val diskVideo = diskVideoMap[name] val durationMs = (diskVideo?.duration ?: 0L) * 1000L val positionMs = pb?.positionMs ?: 0L val progressPercent = if (durationMs > 0L) { diff --git a/native/app/src/main/java/com/localstream/app/ui/playlists/PlaylistsViewModel.kt b/native/app/src/main/java/com/localstream/app/ui/playlists/PlaylistsViewModel.kt index 641b74f..79c025d 100644 --- a/native/app/src/main/java/com/localstream/app/ui/playlists/PlaylistsViewModel.kt +++ b/native/app/src/main/java/com/localstream/app/ui/playlists/PlaylistsViewModel.kt @@ -33,8 +33,9 @@ class PlaylistsViewModel( container.videoRepository.observeVideos, ) { playlists, selectedId, videos -> val selected = playlists.find { it.id == selectedId } + val videoMap = videos.associateBy { it.name } val playlistVideos = selected?.videoNames?.mapNotNull { name -> - videos.find { it.name == name } + videoMap[name] } ?: emptyList() PlaylistsUiState( From b9a911bce1149b3d7ecc2b3369f4a25add42d8e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 18:50:34 +0200 Subject: [PATCH 08/10] perf(ui): memoiser allocations graphiques et formats dans VideoCard --- .../app/ui/components/VideoCard.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/native/app/src/main/java/com/localstream/app/ui/components/VideoCard.kt b/native/app/src/main/java/com/localstream/app/ui/components/VideoCard.kt index 2811ca4..c9a8b32 100644 --- a/native/app/src/main/java/com/localstream/app/ui/components/VideoCard.kt +++ b/native/app/src/main/java/com/localstream/app/ui/components/VideoCard.kt @@ -1,4 +1,4 @@ -package com.localstream.app.ui.components +package com.localstream.app.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -19,6 +19,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -42,6 +43,8 @@ import com.localstream.app.ui.theme.Zinc800 private val WatchedGreen = Color(0xFF16A34A) // green-600 Tailwind private val ProgressTrack = Color(0xFF52525B) // zinc-600 Tailwind +private val DesaturatedColorFilter: ColorFilter = + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.25f) }) /** * Carte d'affiche vidéo (équivalent Compose de `VideoCard.tsx`) : @@ -63,8 +66,12 @@ fun VideoCard( modifier: Modifier = Modifier, episodeLabel: String? = null, ) { - val title = VideoUiSelectors.displayTitle(video) - val resolution = Formatters.getResolution(video.name) + val title = remember(video.name, video.seriesName, video.isSeriesGroup) { + VideoUiSelectors.displayTitle(video) + } + val resolution = remember(video.name) { + Formatters.getResolution(video.name) + } Column(modifier = modifier) { Box( @@ -201,11 +208,7 @@ private fun PosterImage( model = posterUrl, contentDescription = title, contentScale = ContentScale.Crop, - colorFilter = if (isWatched) { - ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.25f) }) - } else { - null - }, + colorFilter = if (isWatched) DesaturatedColorFilter else null, modifier = Modifier .fillMaxSize() .then(dimmed), From 4e86fcc27ba1c2404b988f662a5ec8003527b353 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 20:25:44 +0200 Subject: [PATCH 09/10] fix(detekt): supprimer l'avertissement LongParameterList sur LibraryViewModel --- .../java/com/localstream/app/ui/library/LibraryViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 22d27a7..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,7 +74,7 @@ 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, From 095bae6d2b49c593b438e1aba3abe8626fd839e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 20:28:13 +0200 Subject: [PATCH 10/10] refactor(sorting): factoriser les comparateurs pour respecter les regles Detekt --- .../app/domain/VideoFilterSorter.kt | 56 +++++++------------ .../com/localstream/app/domain/SortingTest.kt | 29 ++++++++++ 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt b/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt index 9dd61d1..9e52cdb 100644 --- a/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt +++ b/native/app/src/main/java/com/localstream/app/domain/VideoFilterSorter.kt @@ -41,48 +41,40 @@ object VideoFilterSorter { private fun sortVideos(videos: List, opts: FilterSortOptions): List { val watchedCache = videos.associateWith { isItemWatched(it, opts.watchedVideos) } + val watchedComparator = Comparator { a, b -> + val aWatched = watchedCache[a] ?: false + val bWatched = watchedCache[b] ?: false + if (aWatched != bWatched) { + if (aWatched) 1 else -1 + } else { + 0 + } + } + val criteriaComparator = getCriteriaComparator(videos, opts) + return videos.sortedWith(watchedComparator.then(criteriaComparator)) + } + private fun getCriteriaComparator( + videos: List, + opts: FilterSortOptions, + ): Comparator { return when (opts.sortBy) { SortBy.ALPHA -> { val alphaCache = videos.associateWith { if (it.isSeriesGroup) it.seriesName.orEmpty() else it.name } - videos.sortedWith { a, b -> - val aWatched = watchedCache[a] ?: false - val bWatched = watchedCache[b] ?: false - if (aWatched != bWatched) { - if (aWatched) 1 else -1 - } else { - (alphaCache[a].orEmpty()).compareTo(alphaCache[b].orEmpty()) - } - } + compareBy { alphaCache[it].orEmpty() } } SortBy.DATE -> { val dateCache = videos.associateWith { val lookup = if (it.isSeriesGroup) it.seriesName.orEmpty() else it.name opts.releaseDates[lookup] ?: it.lastModified.toString() } - videos.sortedWith { a, b -> - val aWatched = watchedCache[a] ?: false - val bWatched = watchedCache[b] ?: false - if (aWatched != bWatched) { - if (aWatched) 1 else -1 - } else { - (dateCache[b].orEmpty()).compareTo(dateCache[a].orEmpty()) - } - } + compareByDescending { dateCache[it].orEmpty() } } SortBy.SIZE -> { val sizeCache = videos.associateWith { if (it.isSeriesGroup) it.episodes.orEmpty().sumOf { ep -> ep.size } else it.size } - videos.sortedWith { a, b -> - val aWatched = watchedCache[a] ?: false - val bWatched = watchedCache[b] ?: false - if (aWatched != bWatched) { - if (aWatched) 1 else -1 - } else { - (sizeCache[b] ?: 0L).compareTo(sizeCache[a] ?: 0L) - } - } + compareByDescending { sizeCache[it] ?: 0L } } SortBy.DURATION -> { val durationCache = videos.associateWith { @@ -92,15 +84,7 @@ object VideoFilterSorter { opts.videoDurations[it.name] ?: 0L } } - videos.sortedWith { a, b -> - val aWatched = watchedCache[a] ?: false - val bWatched = watchedCache[b] ?: false - if (aWatched != bWatched) { - if (aWatched) 1 else -1 - } else { - (durationCache[b] ?: 0L).compareTo(durationCache[a] ?: 0L) - } - } + compareByDescending { durationCache[it] ?: 0L } } } } diff --git a/native/app/src/test/java/com/localstream/app/domain/SortingTest.kt b/native/app/src/test/java/com/localstream/app/domain/SortingTest.kt index 0cf0736..da5a678 100644 --- a/native/app/src/test/java/com/localstream/app/domain/SortingTest.kt +++ b/native/app/src/test/java/com/localstream/app/domain/SortingTest.kt @@ -46,5 +46,34 @@ class SortingTest { assertEquals(1, res.size) assertEquals("Film.1080p.mkv", res[0].name) } + + @Test + fun filterAndSortVideos_sortsByDateDescending() { + val opts = baseOpts.copy( + sortBy = SortBy.DATE, + releaseDates = mapOf("Old.mkv" to "2010-01-01", "New.mkv" to "2024-05-01"), + ) + val res = VideoFilterSorter.filterAndSortVideos(listOf(v("Old.mkv"), v("New.mkv")), opts) + assertEquals(listOf("New.mkv", "Old.mkv"), res.map { it.name }) + } + + @Test + fun filterAndSortVideos_sortsBySizeDescending() { + val opts = baseOpts.copy(sortBy = SortBy.SIZE) + val small = v("Small.mkv").copy(size = 100L) + val large = v("Large.mkv").copy(size = 5000L) + val res = VideoFilterSorter.filterAndSortVideos(listOf(small, large), opts) + assertEquals(listOf("Large.mkv", "Small.mkv"), res.map { it.name }) + } + + @Test + fun filterAndSortVideos_sortsByDurationDescending() { + val opts = baseOpts.copy( + sortBy = SortBy.DURATION, + videoDurations = mapOf("Short.mkv" to 60L, "Long.mkv" to 3600L), + ) + val res = VideoFilterSorter.filterAndSortVideos(listOf(v("Short.mkv"), v("Long.mkv")), opts) + assertEquals(listOf("Long.mkv", "Short.mkv"), res.map { it.name }) + } }