From 6a12c948179311e60127648ab2cda69fe6e1cc29 Mon Sep 17 00:00:00 2001 From: Maxime BARDREAU Date: Fri, 6 Nov 2026 16:55:10 +0100 Subject: [PATCH 01/11] feat: add capability-gated music AI and queue DJ beta --- README.md | 19 ++ .../downtify/feature/home/HomeScreen.kt | 151 ++++++--- .../downtify/feature/library/LibraryScreen.kt | 4 +- .../downtify/feature/musicai/MusicAiScreen.kt | 175 ++++++++++ .../feature/musicai/MusicAiViewModel.kt | 159 +++++++++ .../downtify/feature/musicai/MusicDjPanel.kt | 181 +++++++++++ .../feature/musicai/MusicDjViewModel.kt | 168 ++++++++++ .../downtify/feature/musicai/MusicForYou.kt | 307 ++++++++++++++++++ .../downtify/feature/player/MiniPlayer.kt | 6 +- .../feature/player/NowPlayingScreen.kt | 37 ++- .../downtify/feature/player/PlayerOptions.kt | 67 ++++ .../feature/player/PlayerViewModel.kt | 3 + .../downtify/feature/player/PlayingCover.kt | 1 + .../feature/player/QueueDjViewModel.kt | 119 +++++++ .../downtify/feature/player/QueueScreen.kt | 50 ++- .../downtify/feature/search/RemoteItems.kt | 21 +- .../feature/settings/SettingsScreen.kt | 11 + .../downtify/feature/similar/SimilarScreen.kt | 30 ++ .../feature/similar/SimilarViewModel.kt | 64 +++- .../downtify/ui/DowntifyApp.kt | 62 +++- .../downtify/ui/common/LibraryItems.kt | 19 +- .../ui/common/MusicAiCapabilitiesLocal.kt | 9 + .../downtify/ui/common/SongMix.kt | 88 +++++ .../downtify/ui/navigation/Routes.kt | 12 +- app/src/main/res/values-bg/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-hu/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../downtify/CarServiceFixture.kt | 6 + .../downtify/SongMixViewModelTest.kt | 80 +++++ .../feature/library/LibraryScreenTest.kt | 43 ++- .../feature/musicai/MusicForYouScreenTest.kt | 79 +++++ .../core/data/library/LibrarySnapshot.kt | 2 +- .../core/data/musicai/MusicAiRepository.kt | 166 ++++++++++ .../core/data/similar/SimilarRepository.kt | 42 ++- .../data/similar/SimilarAvailabilityTest.kt | 118 +++++++ .../downtify/core/model/DjIds.kt | 19 ++ .../downtify/core/model/MediaIds.kt | 14 + .../downtify/core/network/ApiFactory.kt | 2 + .../downtify/core/network/MusicAiApi.kt | 64 ++++ .../downtify/core/network/MusicDjApi.kt | 30 ++ .../downtify/core/network/ServerUrls.kt | 22 ++ .../downtify/core/network/dto/MusicAiDtos.kt | 66 ++++ .../downtify/core/network/dto/MusicDjDtos.kt | 58 ++++ .../core/network/MusicAiContractTest.kt | 96 ++++++ core/player/build.gradle.kts | 1 + .../downtify/core/player/ActivityTracker.kt | 5 +- .../downtify/core/player/DjControlTracker.kt | 119 +++++++ .../downtify/core/player/DjDataSource.kt | 40 +++ .../downtify/core/player/DjMediaItems.kt | 57 ++++ .../downtify/core/player/DjTimeline.kt | 56 ++++ .../downtify/core/player/ListenTracker.kt | 7 +- .../downtify/core/player/MediaItems.kt | 15 +- .../core/player/MusicAiListenTracker.kt | 157 +++++++++ .../downtify/core/player/PlaybackService.kt | 97 +++++- .../core/player/PlaybackSleepTimer.kt | 38 +++ .../downtify/core/player/PlayerController.kt | 133 +++++++- .../downtify/core/player/PlayerModule.kt | 28 +- .../downtify/core/player/PlayerState.kt | 25 +- .../downtify/core/player/StreamResolver.kt | 10 + .../downtify/core/player/TranscodeWarmer.kt | 3 +- .../core/player/DjControlTrackerTest.kt | 84 +++++ .../core/player/DjLivePlaybackTest.kt | 51 +++ .../downtify/core/player/DjPlaybackTest.kt | 74 +++++ .../downtify/core/player/DjTimelineTest.kt | 47 +++ .../core/player/MusicAiDjListeningTest.kt | 85 +++++ .../core/player/PlaybackSleepTimerTest.kt | 47 +++ docs/music-ai.md | 54 +++ gradle/libs.versions.toml | 1 + 73 files changed, 3776 insertions(+), 136 deletions(-) create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiScreen.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiViewModel.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYou.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerOptions.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/MusicAiCapabilitiesLocal.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt create mode 100644 app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt create mode 100644 app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYouScreenTest.kt create mode 100644 core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt create mode 100644 core/data/src/test/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarAvailabilityTest.kt create mode 100644 core/model/src/main/kotlin/com/henriquesebastiao/downtify/core/model/DjIds.kt create mode 100644 core/model/src/main/kotlin/com/henriquesebastiao/downtify/core/model/MediaIds.kt create mode 100644 core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiApi.kt create mode 100644 core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicDjApi.kt create mode 100644 core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicAiDtos.kt create mode 100644 core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicDjDtos.kt create mode 100644 core/network/src/test/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiContractTest.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjDataSource.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjMediaItems.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/MusicAiListenTracker.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackSleepTimer.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTrackerTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjLivePlaybackTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjTimelineTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/MusicAiDjListeningTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackSleepTimerTest.kt create mode 100644 docs/music-ai.md diff --git a/README.md b/README.md index ac126f4..4afca72 100644 --- a/README.md +++ b/README.md @@ -118,3 +118,22 @@ These are designs, not code: Downtify implements them in its own components (`:c GPL-3.0 — see [LICENSE](LICENSE). Android Auto stability: see [the diagnostic and receiver acceptance checklist](docs/android-auto.md#stability-follow-up-full-phone-freeze-report). Launcher visibility and unit tests alone do not validate a full-phone freeze fix. + +## Experimental local music AI and DJ beta + ++The optional matching-server integration adds For you playlists, mixes based on ++a song, thematic continuous DJ and a DJ button on the current queue. The normal ++player retains pause, next, volume and a 5–60 minute sleep timer, while showing ++the current/next DJ title and transition progress. Playlist menus use three recent ++destinations followed by a bounded selector. ++ ++**All AI modules default off on new installations and accounts.** The server does ++the model inference and mixing; Android downloads no AI model. Existing explicit ++preferences are preserved. The latest server engine still requires its one-hour ++real playback qualification before ordinary interactive sessions are available. ++ ++Debug assembly, 454 unit tests (two skipped), Spotless, Detekt and Lint passed for ++the delivered beta. Physical-device background acceptance and phone battery/CPU/ ++memory/data measurements remain pending. See [beta behavior, resource use and ++limits](docs/music-ai.md). ++ \ No newline at end of file diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt index 34990fe..0eb626d 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt @@ -15,12 +15,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme @@ -31,6 +33,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -45,7 +48,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import com.henriquesebastiao.downtify.R import com.henriquesebastiao.downtify.core.designsystem.component.Artwork import com.henriquesebastiao.downtify.core.designsystem.component.ArtworkTone @@ -64,11 +70,19 @@ import com.henriquesebastiao.downtify.core.model.PlaybackContextType import com.henriquesebastiao.downtify.core.model.Playlist import com.henriquesebastiao.downtify.core.model.RecentContext import com.henriquesebastiao.downtify.core.network.ServerUrls +import com.henriquesebastiao.downtify.feature.musicai.MusicAiViewModel +import com.henriquesebastiao.downtify.feature.musicai.MusicForYouActions +import com.henriquesebastiao.downtify.feature.musicai.musicDjThemeItems +import com.henriquesebastiao.downtify.feature.musicai.musicForYouItems import com.henriquesebastiao.downtify.ui.common.CoverCard import com.henriquesebastiao.downtify.ui.common.LocalCoverUrls +import com.henriquesebastiao.downtify.ui.common.LocalMusicAiCapabilities import com.henriquesebastiao.downtify.ui.common.PreviewData import com.henriquesebastiao.downtify.ui.common.albumSubtitle import com.henriquesebastiao.downtify.ui.common.plusBottomBars +import com.henriquesebastiao.downtify.ui.common.similarityAvailable +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive @Composable fun HomeRoute( @@ -82,8 +96,24 @@ fun HomeRoute( onOpenSimilar: () -> Unit, modifier: Modifier = Modifier, viewModel: HomeViewModel = hiltViewModel(), + onOpenMusicAi: () -> Unit = {}, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val musicAi: MusicAiViewModel = hiltViewModel() + val musicDj: com.henriquesebastiao.downtify.feature.musicai.MusicDjViewModel = hiltViewModel() + val djState by musicDj.state.collectAsStateWithLifecycle() + val aiState by musicAi.state.collectAsStateWithLifecycle() + val aiCaps by musicAi.capabilities.collectAsStateWithLifecycle() + LaunchedEffect(aiCaps.features) { musicAi.refreshPlaylists() } + val lifecycle = LocalLifecycleOwner.current + LaunchedEffect(lifecycle, musicAi) { + lifecycle.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive) { + musicAi.refreshPlaylists() + delay(60000) + } + } + } HomeScreen( state, onOpenSettings, @@ -96,6 +126,25 @@ fun HomeRoute( onOpenPodcasts, onOpenSimilar, onOpenMenu = onOpenMenu, + forYouContent = { + musicDjThemeItems(aiCaps, djState, musicDj::continuous) + musicForYouItems( + aiState, + aiCaps, + MusicForYouActions( + musicAi::refreshPlaylists, + musicAi::forYou, + musicAi::selectCadence, + musicAi::mood, + musicAi::play, + musicAi::save, + onOpenMusicAi, + musicAi::clearSelection, + ), + ) + }, + forYouRefreshing = aiState.busy, + onRefreshForYou = musicAi::refreshPlaylists, ) } @@ -113,7 +162,14 @@ fun HomeScreen( onOpenPodcasts: () -> Unit = {}, onOpenSimilar: () -> Unit = {}, onOpenMenu: () -> Unit = {}, + forYouContent: (LazyListScope.() -> Unit)? = null, + forYouRefreshing: Boolean = false, + onRefreshForYou: () -> Unit = {}, ) { + val caps = LocalMusicAiCapabilities.current + val forYouAvailable = forYouContent != null && listOf("personalization", "daily", "weekly").any(caps::enabled) + var homeTab by rememberSaveable { mutableStateOf("for-you") } + val showForYou = forYouAvailable && homeTab == "for-you" Scaffold( modifier = modifier, contentWindowInsets = WindowInsets(0), @@ -152,8 +208,8 @@ fun HomeScreen( }, ) { padding -> PullToRefreshBox( - isRefreshing = state.refreshing, - onRefresh = onRefresh, + isRefreshing = if (showForYou) forYouRefreshing else state.refreshing, + onRefresh = if (showForYou) onRefreshForYou else onRefresh, modifier = Modifier.fillMaxSize().padding(padding), ) { LazyColumn( @@ -163,41 +219,60 @@ fun HomeScreen( item(key = "status") { ServerStatusLine(state, Modifier.padding(horizontal = Spacing.screen, vertical = Spacing.sm)) } - when { - state.loading -> item(key = "loading") { - Box(Modifier.fillMaxWidth().padding(Spacing.xxl), contentAlignment = Alignment.Center) { - LoadingIndicator() + if (forYouAvailable) { + item(key = "home-tabs") { + Row( + Modifier.padding(horizontal = Spacing.screen), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip(selected = showForYou, onClick = { + homeTab = "for-you" + }, label = { Text("Pour vous") }) + FilterChip(selected = !showForYou, onClick = { + homeTab = "library" + }, label = { Text("Ma musique") }) } } + } + if (showForYou) { + forYouContent?.invoke(this) + } else { + when { + state.loading -> item(key = "loading") { + Box(Modifier.fillMaxWidth().padding(Spacing.xxl), contentAlignment = Alignment.Center) { + LoadingIndicator() + } + } - state.libraryEmpty -> item(key = "empty") { - if (state.favoritePlaylists.isNotEmpty()) HomeAccess(state, onOpenRecent) - EmptyState( - icon = DowntifyIcons.Library, - title = stringResource(R.string.home_empty_title), - body = stringResource(R.string.home_empty_body), - hero = true, - ) - } - - else -> { - if (state.jumpBackIn.isNotEmpty() || state.favoritePlaylists.isNotEmpty()) { - item(key = "access") { HomeAccess(state, onOpenRecent) } + state.libraryEmpty -> item(key = "empty") { + if (state.favoritePlaylists.isNotEmpty()) HomeAccess(state, onOpenRecent) + EmptyState( + icon = DowntifyIcons.Library, + title = stringResource(R.string.home_empty_title), + body = stringResource(R.string.home_empty_body), + hero = true, + ) } - if (state.recentlyAdded.isNotEmpty()) { - item(key = "recent") { RecentlyAdded(state.recentlyAdded, onOpenAlbum, onSeeAll) } + + else -> { + if (state.jumpBackIn.isNotEmpty() || state.favoritePlaylists.isNotEmpty()) { + item(key = "access") { HomeAccess(state, onOpenRecent) } + } + if (state.recentlyAdded.isNotEmpty()) { + item(key = "recent") { RecentlyAdded(state.recentlyAdded, onOpenAlbum, onSeeAll) } + } } } - } - if (!state.loading) { - item(key = "shortcuts") { - Shortcuts( - showDiscover = state.showDiscover && !state.libraryEmpty, - showPodcasts = state.showPodcasts, - onOpenDiscover = onOpenDiscover, - onOpenPodcasts = onOpenPodcasts, - onOpenSimilar = onOpenSimilar, - ) + if (!state.loading) { + item(key = "shortcuts") { + Shortcuts( + showDiscover = state.showDiscover && !state.libraryEmpty, + showPodcasts = state.showPodcasts, + onOpenDiscover = onOpenDiscover, + onOpenPodcasts = onOpenPodcasts, + onOpenSimilar = onOpenSimilar, + ) + } } } } @@ -252,12 +327,14 @@ private fun Shortcuts( onClick = onOpenDiscover, ) } - Shortcut( - icon = DowntifyIcons.Graphic, - title = stringResource(R.string.home_shortcut_similar), - body = stringResource(R.string.home_shortcut_similar_body), - onClick = onOpenSimilar, - ) + if (LocalMusicAiCapabilities.current.similarityAvailable()) { + Shortcut( + icon = DowntifyIcons.Graphic, + title = stringResource(R.string.home_shortcut_similar), + body = stringResource(R.string.home_shortcut_similar_body), + onClick = onOpenSimilar, + ) + } if (showPodcasts) { Shortcut( icon = DowntifyIcons.Podcasts, diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/library/LibraryScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/library/LibraryScreen.kt index 5470e36..6f8d291 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/library/LibraryScreen.kt @@ -108,7 +108,7 @@ data class LibraryNavigation( val onArtist: (String) -> Unit = {}, val onPlaylist: (String) -> Unit = {}, val onLiked: () -> Unit = {}, - val onSimilar: (artist: String, title: String) -> Unit = { _, _ -> }, + val onSimilar: (artist: String, title: String, seed: String) -> Unit = { _, _, _ -> }, ) @Composable @@ -133,7 +133,7 @@ fun LibraryRoute( onAddToQueue = viewModel::addToQueue, onGoToAlbum = { navigation.onAlbum(it.albumId) }, onGoToArtist = { navigation.onArtist(it.artistId) }, - onFindSimilar = { artist, title -> navigation.onSimilar(artist, title) }, + onFindSimilar = { artist, title, seed -> navigation.onSimilar(artist, title, seed) }, onMoveToTrash = LocalMoveToTrash.current, ), modifier = modifier, diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiScreen.kt new file mode 100644 index 0000000..9454ee3 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiScreen.kt @@ -0,0 +1,175 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.henriquesebastiao.downtify.core.designsystem.component.Tag +import com.henriquesebastiao.downtify.core.designsystem.component.TagTone + +private val features = linkedMapOf( + "personalization" to "Personnalisation et nouvelles écoutes", + "similarity" to "Musiques basées sur un titre", + "daily" to "Mixes du jour", + "weekly" to "Mixes de la semaine", + "external_beta" to "Découverte externe — bêta", + "dj_prepared" to "Sets DJ préparés", + "dj_interactive" to "DJ en temps réel", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MusicAiRoute(onBack: () -> Unit, viewModel: MusicAiViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val caps by viewModel.capabilities.collectAsStateWithLifecycle() + var mode by rememberSaveable { mutableStateOf("playlists") } + var serverExpanded by rememberSaveable { mutableStateOf(false) } + val shown = features.filterKeys { it.startsWith("dj_") == (mode == "dj") } + LaunchedEffect(Unit) { viewModel.refresh() } + Scaffold(topBar = { + TopAppBar(title = { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Text("IA") + Tag("Expérimental", tone = TagTone.Secondary) + } + }, navigationIcon = { TextButton(onClick = onBack) { Text("Retour") } }) + }) { padding -> + LazyColumn( + Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Text( + "Vos préférences sont partagées entre le web et Android. " + + "Chaque fonction peut être désactivée séparément.", + ) + if (state.message.isNotBlank()) Text(state.message) + if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + } + if (caps.version != 1) { + item { Text("Ces fonctions ne sont pas disponibles sur ce serveur.") } + } else { + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = mode == "playlists", onClick = { + mode = "playlists" + }, label = { Text("Playlists personnalisées") }) + FilterChip(selected = mode == "dj", onClick = { mode = "dj" }, label = { Text("DJ") }) + } + } + item { Text("Vos préférences", style = MaterialTheme.typography.titleLarge) } + items(shown.keys.toList(), key = { "pref-$it" }) { feature -> + MusicAiSwitch( + shown.getValue(feature), + state.preferences[feature] == true, + !state.busy && state.preferences.isNotEmpty(), + ) { viewModel.toggle(feature, it) } + } + if (caps.administrator) { + item { + TextButton(onClick = { serverExpanded = !serverExpanded }) { Text("Activation sur le serveur") } + } + if (serverExpanded) { + item { + Text( + "Ces choix s’appliquent à tous les utilisateurs, " + + "en plus de leurs préférences personnelles.", + ) + } + items(shown.keys.toList(), key = { "global-$it" }) { feature -> + MusicAiSwitch( + "Serveur : ${shown.getValue(feature)}", + state.global[feature] == true, + !state.busy && state.global.isNotEmpty(), + ) { + viewModel.toggle(feature, it, global = true) + } + } + } + } + if (mode == "dj") { + item { + Text( + "Préparez un set à l’avance ou mixez en temps réel sur le serveur. " + + "Le prochain titre et le thème changent à la prochaine transition disponible.", + ) + } + if (caps.enabled("dj_prepared") || caps.enabled("dj_interactive")) { + item { MusicDjPanel() } + } else { + item { + Text( + "Activez un mode DJ pour ouvrir le studio. " + + "L’administrateur doit aussi autoriser ce mode.", + ) + } + } + } else { + item { + Text( + "Retrouvez vos playlists dans Accueil → Pour vous. " + + "Mixes du jour à 04 h ; de la semaine, le lundi à 04 h 15, heure de Paris.", + ) + Text( + "Les favoris et les copies de playlists enregistrées sont conservés " + + "quand une fonction est désactivée.", + ) + } + item { + Text("Confidentialité", style = MaterialTheme.typography.titleLarge) + Text( + "Effacer le profil retire les écoutes utilisées pour personnaliser les propositions, " + + "sans toucher aux favoris ni aux playlists enregistrées.", + ) + TextButton(onClick = viewModel::forget, enabled = !state.busy) { + Text("Effacer mon profil d’écoute") + } + } + } + } + } + } +} + +@Composable +private fun MusicAiSwitch(label: String, checked: Boolean, enabled: Boolean, change: (Boolean) -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f)) + Switch( + checked = checked, + enabled = enabled, + onCheckedChange = change, + modifier = Modifier.semantics { + contentDescription = + label + }, + ) + } +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiViewModel.kt new file mode 100644 index 0000000..d1d7427 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiViewModel.kt @@ -0,0 +1,159 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.henriquesebastiao.downtify.core.data.library.LibraryRepository +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.data.sync.SyncCoordinator +import com.henriquesebastiao.downtify.core.model.PlaybackContext +import com.henriquesebastiao.downtify.core.model.PlaybackContextType +import com.henriquesebastiao.downtify.core.network.dto.MusicAiPlaylistDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiTrackDto +import com.henriquesebastiao.downtify.core.player.PlayerController +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class MusicAiUiState( + val preferences: Map = emptyMap(), + val global: Map = emptyMap(), + val tracks: List = emptyList(), + val playlists: List = emptyList(), + val cadence: String = "daily", + val selectedMood: String = "", + val source: String = "personalization", + val busy: Boolean = false, + val message: String = "", +) + +@HiltViewModel +class MusicAiViewModel @Inject constructor( + private val music: MusicAiRepository, + private val library: LibraryRepository, + private val player: PlayerController, + private val sync: SyncCoordinator, +) : ViewModel() { + val capabilities = music.capabilities + private val mutable = MutableStateFlow(MusicAiUiState()) + val state = mutable.asStateFlow() + private var job: Job? = null + + init { + viewModelScope.launch { + music.session.drop(1).collect { + job?.cancel() + mutable.value = MusicAiUiState() + } + } + } + + private fun run(work: suspend () -> Unit) { + if (mutable.value.busy) return + job = viewModelScope.launch { + mutable.update { it.copy(busy = true, message = "") } + try { + work() + } catch (error: MusicAiFailure) { + mutable.update { + it.copy( + message = when (error.status) { + 409 -> "Cette fonction est désactivée." + 503 -> "L’analyse sonore n’est pas encore prête." + 403 -> "Cette action nécessite un administrateur." + else -> "Le serveur est indisponible. Réessayez." + }, + ) + } + } finally { + mutable.update { it.copy(busy = false) } + } + } + } + + fun refresh() = run { + music.refresh() + if (capabilities.value.version == 1) { + val preferences = music.preferences() + val global = if (capabilities.value.administrator) music.globalFeatures() else emptyMap() + mutable.update { it.copy(preferences = preferences, global = global) } + } + } + fun toggle(feature: String, enabled: Boolean, global: Boolean = false) = run { + if (global) music.setGlobal(feature, enabled) else music.set(feature, enabled) + val preferences = music.preferences() + val flags = if (capabilities.value.administrator) music.globalFeatures() else emptyMap() + mutable.update { it.copy(preferences = preferences, global = flags, tracks = emptyList()) } + } + fun refreshPlaylists() = run { + music.refresh() + val caps = capabilities.value + val playlists = if (caps.enabled("daily") || caps.enabled("weekly")) music.playlists() else emptyList() + mutable.update { + it.copy( + playlists = playlists, + cadence = if (caps.enabled(it.cadence)) { + it.cadence + } else if (caps.enabled("daily")) { + "daily" + } else { + "weekly" + }, + tracks = if (caps.enabled(it.source)) it.tracks else emptyList(), + ) + } + } + fun selectCadence(value: String) { + if (value in listOf("daily", "weekly") && capabilities.value.enabled(value)) { + mutable.update { it.copy(cadence = value) } + } + } + fun clearSelection() { + if (!mutable.value.busy) { + mutable.update { it.copy(tracks = emptyList(), selectedMood = "", message = "") } + } + } + fun forYou() = run { + val tracks = music.recommendations() + mutable.update { it.copy(tracks = tracks, source = "personalization", selectedMood = "") } + } + fun mood(cadence: String, mood: String) = run { + val tracks = music.playlist(cadence, mood) + mutable.update { it.copy(tracks = tracks, source = cadence, cadence = cadence, selectedMood = mood) } + } + fun save(name: String) = run { + val selection = mutable.value + val saved = music.save(name.trim(), selection.tracks.map { it.id }, selection.source) + sync.requestSync() + mutable.update { it.copy(message = "Playlist enregistrée : ${saved.name}") } + } + fun forget() = run { + music.forget() + mutable.update { it.copy(tracks = emptyList(), message = "Profil d’écoute effacé.") } + } + fun play(row: MusicAiTrackDto, queue: Boolean = false) { + val snapshot = library.library.value ?: return + val track = snapshot.byId[row.id] + if (track == null) { + sync.requestSync() + mutable.update { it.copy(message = "La bibliothèque est en cours d’actualisation.") } + return + } + if (queue) { + player.addToQueue(track) + } else { + val tracks = snapshot.tracks(mutable.value.tracks.map { it.id }) + player.play( + tracks, + tracks.indexOf(track).coerceAtLeast(0), + PlaybackContext(PlaybackContextType.Songs, "", ""), + ) + } + } +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt new file mode 100644 index 0000000..73669d7 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt @@ -0,0 +1,181 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@Composable +fun MusicDjPanel(viewModel: MusicDjViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val caps by viewModel.capabilities.collectAsStateWithLifecycle() + val lifecycle = LocalLifecycleOwner.current + var theme by rememberSaveable { mutableStateOf("fete") } + var count by rememberSaveable { mutableStateOf("12") } + var mode by rememberSaveable { mutableStateOf("prepared") } + val ready = if (mode == "prepared") caps.dj.preparedReady else caps.dj.interactiveReady + LaunchedEffect(caps.features) { + if (!caps.enabled("dj_" + mode)) mode = if (caps.enabled("dj_prepared")) "prepared" else "interactive" + } + LaunchedEffect(lifecycle, viewModel) { + lifecycle.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive) { + viewModel.refresh() + delay(5000) + } + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Votre set DJ", style = MaterialTheme.typography.titleLarge) + if (caps.enabled("dj_prepared")) { + FilterChip(selected = mode == "prepared", onClick = { + mode = "prepared" + }, label = { Text("Set préparé") }) + } + if (caps.enabled("dj_interactive")) { + FilterChip(selected = mode == "interactive", onClick = { + mode = + "interactive" + }, label = { Text("Direct interactif") }) + } + Text("Un mix de vos fichiers locaux, avec transitions calculées sur le serveur. Les originaux sont conservés.") + if (!ready) { + Text( + if (mode == "interactive") { + "Le direct nécessite une validation continue d’une heure sur ce serveur." + } else { + "Le moteur DJ se prépare ou est indisponible." + }, + ) + } + listOf( + "calme" to "Calme", + "concentration" to "Concentration", + "energie" to "Énergie", + "fete" to "Fête", + "melancolie" to "Mélancolie", + "detente" to "Détente", + ).forEach { (id, title) -> + FilterChip(selected = theme == id, onClick = { theme = id }, label = { Text(title) }) + } + OutlinedTextField( + value = count, + onValueChange = { count = it.filter(Char::isDigit).take(2) }, + label = { Text("Nombre de titres, de 2 à 40") }, + singleLine = true, + ) + Button( + onClick = { viewModel.create(mode, theme, count.toIntOrNull() ?: 12) }, + enabled = ready && !state.busy && count.toIntOrNull() in 2..40, + ) { Text(if (mode == "prepared") "Préparer le set" else "Démarrer le direct") } + Text( + "Un seul rendu à la fois. La lecture utilise le lecteur habituel et conserve votre file.", + ) + if (state.message.isNotEmpty()) Text(state.message) + state.sessions.forEach { session -> + Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Text("${session.theme} · ${session.tracks.size} titres", style = MaterialTheme.typography.titleMedium) + Text( + when (session.state) { + "queued" -> "En attente" + "preparing" -> "Analyse des morceaux" + "rendering" -> "Préparation du mix" + "ready" -> if (session.mode == "interactive") "Terminé" else "Prêt à écouter" + "playing" -> "Direct en cours" + "paused" -> "Direct en pause" + "failed" -> "Le rendu a échoué. Essayez une autre sélection." + else -> "Rendu arrêté" + }, + ) + if (session.state in setOf("queued", "preparing", "rendering", "playing", "paused")) { + if (session.state in setOf("queued", "preparing", "rendering")) { + LinearProgressIndicator(progress = { session.progress.toFloat().coerceIn(0f, 1f) }) + } + TextButton(onClick = { + viewModel.stop(session.id) + }, enabled = !state.busy) { Text("Arrêter le rendu") } + } + if (session.mode == "interactive" && session.state in setOf("playing", "paused")) { + TextButton(onClick = { + viewModel.command( + session, + if (session.state == + "paused" + ) { + "resume" + } else { + "pause" + }, + ) + }) { + Text(if (session.state == "paused") "Reprendre le direct" else "Mettre le direct en pause") + } + TextButton(onClick = { viewModel.command(session, "next") }) { Text("Prochain titre") } + Text( + "Prochaine transition modifiable dans environ ${kotlin.math.ceil( + (session.nextMutableAt - session.position).coerceAtLeast(0.0), + ).toInt()} s.", + ) + TextButton(onClick = { + viewModel.command(session, "theme", mapOf("theme" to theme)) + }) { Text("Appliquer le thème choisi") } + session.tracks.forEach { track -> + TextButton(onClick = { + viewModel.command(session, "replace", mapOf("id" to track.id)) + }) { Text("Suivant : ${track.title}") } + } + session.commands.firstOrNull()?.takeIf { !it.accepted }?.let { Text(it.reason) } + } + val canPlay = when (session.mode) { + "prepared" -> session.state == "ready" + "interactive" -> session.state in setOf("playing", "paused") + else -> false + } + if (canPlay) { + Button(onClick = { viewModel.play(session) }) { Text("Écouter le set") } + if (session.mode == + "prepared" + ) { + Text("Repères : lancez le set, puis touchez un titre pour vous y déplacer.") + } + session.timeline.filter { session.mode == "prepared" }.forEach { marker -> + val track = session.tracks.find { it.id == marker.id } + TextButton(onClick = { viewModel.seek(session, marker.start) }) { + Text( + "${(marker.start / 60).toInt()}:${(marker.start % 60).toInt().toString().padStart( + 2, + '0', + )} · ${track?.title.orEmpty()}", + ) + } + marker.transition?.reason?.takeIf(String::isNotBlank)?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt new file mode 100644 index 0000000..02d4c9e --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt @@ -0,0 +1,168 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.model.DjIds +import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto +import com.henriquesebastiao.downtify.core.player.PlayerController +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class MusicDjUiState( + val sessions: List = emptyList(), + val busy: Boolean = false, + val message: String = "", +) + +@HiltViewModel +class MusicDjViewModel @Inject constructor( + private val music: MusicAiRepository, + private val player: PlayerController, +) : ViewModel() { + private val mutable = MutableStateFlow(MusicDjUiState()) + val state = mutable.asStateFlow() + val capabilities = music.capabilities + private val mutex = Mutex() + private var pending: Pair, String>? = null + + init { + viewModelScope.launch { + music.session.drop(1).collectLatest { + mutable.value = MusicDjUiState() + pending = null + } + } + viewModelScope.launch { + music.capabilities.collectLatest { + mutable.update { state -> + state.copy(sessions = state.sessions.filter { row -> it.enabled("dj_" + row.mode) }) + } + } + } + } + + suspend fun refresh() = mutex.withLock { + if (!anyEnabled()) return@withLock + try { + val found = music.djSessions().filter { + music.capabilities.value.enabled("dj_" + it.mode) && + DjIds.valid(it.id) + } + if (anyEnabled()) mutable.update { it.copy(sessions = found) } + } catch (_: MusicAiFailure) { + mutable.update { it.copy(message = "Les sets sont temporairement indisponibles.") } + } + } + + private fun anyEnabled() = + music.capabilities.value.enabled("dj_prepared") || music.capabilities.value.enabled("dj_interactive") + + fun create(mode: String, theme: String, count: Int) = action { + val ready = if (mode == + "prepared" + ) { + music.capabilities.value.dj.preparedReady + } else { + music.capabilities.value.dj.interactiveReady + } + if (!music.capabilities.value.enabled("dj_" + mode) || !ready) { + return@action + } + val intent = Triple(mode, theme, count.coerceIn(2, 40)) + val request = pending?.takeIf { it.first == intent }?.second ?: UUID.randomUUID().toString() + pending = intent to request + music.djCreate(intent.second, intent.third, request, intent.first) + pending = null + } + + fun stop(id: String) = action { music.djStop(id) } + fun continuous(theme: String, style: String) = action { + val owner = music.session.value + val caps = music.capabilities.value + if (!caps.enabled("dj_interactive") || !caps.dj.interactiveReady || !caps.dj.continuousSupported) return@action + val created = music.djContinuous(theme, style, UUID.randomUUID().toString()) + mutable.update { it.copy(message = "Préparation du DJ continu ; votre écoute reste disponible.") } + repeat(450) { + delay(2000) + if (owner != music.session.value) return@action + if (!music.capabilities.value.enabled("dj_interactive")) return@action + val session = music.djSession(created.id) + if (session.state in setOf("failed", "stopped", "cancelled")) throw MusicAiFailure() + if (session.state == "playing") { + player.playDj(session.id, "DJ continu · $theme", 0, "interactive") + mutable.update { it.copy(message = "DJ continu lancé dans le lecteur.") } + return@action + } + } + music.djStop(created.id) + mutable.update { it.copy(message = "Préparation trop longue. Essayez une sélection plus petite.") } + } + + fun command(session: MusicDjSessionDto, operation: String, payload: Map = emptyMap()) = action { + val result = music.djCommand(session.id, operation, payload) + if (player.state.value.dj?.id == session.id) { + if (operation == "pause") player.pause() + if (operation == "resume") player.resume() + } + mutable.update { + it.copy( + message = if (result.accepted) { + "Commande reçue. Délai estimé : ${kotlin.math.ceil(result.effectiveAfterSeconds).toInt()} s." + } else { + result.reason + }, + ) + } + } + + private fun action(work: suspend () -> Unit) { + if (mutable.value.busy) return + mutable.update { it.copy(busy = true, message = "") } + viewModelScope.launch { + try { + mutex.withLock { work() } + refresh() + } catch (error: MusicAiFailure) { + mutable.update { + it.copy( + message = when (error.status) { + 409 -> "Le module DJ est désactivé." + 429 -> "Deux sets sont déjà en attente." + 503 -> "Le moteur DJ se prépare encore." + else -> "La demande n’a pas abouti. Vous pouvez réessayer." + }, + ) + } + } finally { + mutable.update { it.copy(busy = false) } + } + } + } + + fun play(session: MusicDjSessionDto) { + if (session.state !in setOf("ready", "playing", "paused") || + !music.capabilities.value.enabled("dj_" + session.mode) + ) { + return + } + player.playDj(session.id, "Set DJ · ${session.theme}", (session.duration * 1000).toLong(), session.mode) + } + + fun seek(session: MusicDjSessionDto, seconds: Double) { + if (session.mode == "prepared" && player.state.value.dj?.id == session.id && seconds.isFinite()) { + player.seekTo((seconds.coerceIn(0.0, session.duration) * 1000).toLong()) + } + } +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYou.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYou.kt new file mode 100644 index 0000000..970dd61 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYou.kt @@ -0,0 +1,307 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiPlaylistDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiTrackDto + +private val moodNames = mapOf( + "calme" to "Calme", + "concentration" to "Concentration", + "energie" to "Énergie", + "fete" to "Fête", + "melancolie" to "Mélancolie", + "detente" to "Détente", +) +private val moodColors = mapOf( + "calme" to Color(0xFF4F46E5), + "concentration" to Color(0xFF334155), + "energie" to Color(0xFFC2410C), + "fete" to Color(0xFFA21CAF), + "melancolie" to Color(0xFF1D4ED8), + "detente" to Color(0xFF0F766E), +) +private fun mixTitle(cadence: String, mood: String) = + "${if (cadence == "daily") "Mix du jour" else "Mix de la semaine"} · ${moodNames[mood].orEmpty()}" +private fun selectionTitle(state: MusicAiUiState) = + if (state.selectedMood.isBlank()) "À écouter maintenant" else mixTitle(state.source, state.selectedMood) + +data class MusicForYouActions( + val refresh: () -> Unit = {}, + val forYou: () -> Unit = {}, + val cadence: (String) -> Unit = {}, + val mood: (String, String) -> Unit = { _, _ -> }, + val play: (MusicAiTrackDto, Boolean) -> Unit = { _, _ -> }, + val save: (String) -> Unit = {}, + val settings: () -> Unit = {}, + val clearSelection: () -> Unit = {}, +) + +fun LazyListScope.musicDjThemeItems( + caps: MusicAiCapabilitiesDto, + state: MusicDjUiState, + onStart: (String, String) -> Unit, +) { + if (!caps.enabled("dj_interactive") || !caps.dj.continuousSupported) return + item("dj-themes") { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Vos mixes DJ", style = MaterialTheme.typography.titleLarge) + Text( + "Une sélection locale qui se prolonge avec des transitions adaptées.", + style = MaterialTheme.typography.bodySmall, + ) + val recipes = listOf( + Triple("Fête", "fete", "library"), Triple("Rap", "energie", "rap"), Triple("Rock", "energie", "rock"), + Triple( + "Électro", + "fete", + "electro", + ), + Triple("Calme", "calme", "library"), Triple("Concentration", "concentration", "library"), + Triple( + "Énergie", + "energie", + "library", + ), + Triple("Détente", "detente", "library"), Triple("Mélancolie", "melancolie", "library"), + ) + recipes.chunked(3).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { (label, theme, style) -> + Card(Modifier.weight(1f)) { + TextButton( + onClick = { onStart(theme, style) }, + enabled = + !state.busy && caps.dj.interactiveReady, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Text(label, maxLines = 1) + Text("DJ continu", style = MaterialTheme.typography.labelSmall) + } + } + } + } + } + } + if (!caps.dj.interactiveReady) { + Text( + "Disponible après validation du moteur sur le serveur.", + style = MaterialTheme.typography.bodySmall, + ) + } + if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (state.message.isNotBlank()) Text(state.message, style = MaterialTheme.typography.bodySmall) + } + } +} + +fun LazyListScope.musicForYouItems(state: MusicAiUiState, caps: MusicAiCapabilitiesDto, actions: MusicForYouActions) { + item("ai-title") { MusicForYouHeader(state, caps, actions) } + if (state.tracks.isNotEmpty() && caps.enabled(state.source)) { + item("ai-back") { + TextButton( + onClick = actions.clearSelection, + enabled = !state.busy, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text("← Tous mes mixes") + } + } + musicSelectionItems(state, actions) + } else { + musicCatalogItems(state, caps, actions) + } +} + +private fun LazyListScope.musicCatalogItems( + state: MusicAiUiState, + caps: MusicAiCapabilitiesDto, + actions: MusicForYouActions, +) { + item("ai-cadence") { + Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for ((key, label) in listOf("daily" to "Chaque jour", "weekly" to "Chaque semaine")) { + if (caps.enabled(key)) { + FilterChip(selected = state.cadence == key, onClick = { + actions.cadence(key) + }, label = { Text(label) }) + } + } + } + } + val cards = state.playlists.filter { + it.cadence == state.cadence && caps.enabled(it.cadence) && it.mood in moodNames + } + if (cards.isEmpty() && !state.busy) { + item("ai-empty") { + Text( + "Vos mixes apparaîtront ici après leur génération. Ils se renouvellent automatiquement.", + Modifier.padding(16.dp), + ) + } + } + items(cards.chunked(2), key = { "ai-card-${it.first().cadence}-${it.first().mood}-${it.first().period}" }) { pair -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + for (card in pair) { + MusicMixCard(card, !state.busy && caps.model.ready, { + actions.mood(card.cadence, card.mood) + }, Modifier.weight(1f)) + } + if (pair.size == 1) Spacer(Modifier.weight(1f)) + } + } + if (caps.enabled("personalization")) { + item("ai-recommend") { + TextButton( + onClick = actions.forYou, + enabled = !state.busy && caps.model.ready, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text("À écouter maintenant") + } + } + } +} + +@Composable +private fun MusicForYouHeader(state: MusicAiUiState, caps: MusicAiCapabilitiesDto, actions: MusicForYouActions) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Pour vous", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold) + Text("Vos mixes du jour et de la semaine, à partir de votre musique et de vos goûts.") + Row { + TextButton(onClick = actions.settings) { Text("Préférences IA") } + TextButton(onClick = actions.refresh, enabled = !state.busy) { Text("Actualiser") } + } + if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (state.message.isNotBlank()) Text(state.message) + if (!caps.model.ready) Text("L’analyse locale se prépare. Votre bibliothèque reste disponible.") + } +} + +@Composable +private fun MusicMixCard( + card: MusicAiPlaylistDto, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val color = moodColors[card.mood] ?: MaterialTheme.colorScheme.primary + Card(onClick = onClick, enabled = enabled, modifier = modifier) { + Box( + Modifier.fillMaxWidth().height(165.dp).background( + Brush.linearGradient( + listOf( + color, + Color( + color.red * .65f, + color.green * .65f, + color.blue * .65f, + ), + ), + ), + ), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + if (card.cadence == + "daily" + ) { + "MIX DU JOUR" + } else { + "MIX DE LA SEMAINE" + }, + color = Color.White, + style = MaterialTheme.typography.labelSmall, + ) + Spacer(Modifier.weight(1f)) + Text( + moodNames[card.mood].orEmpty(), + color = Color.White, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + "${card.count} titres · ${if (card.cadence == "daily") "Chaque jour" else "Chaque semaine"}", + Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + ) + } +} + +private fun LazyListScope.musicSelectionItems(state: MusicAiUiState, actions: MusicForYouActions) { + item("ai-selection") { + Column(Modifier.padding(16.dp)) { + Text(selectionTitle(state), style = MaterialTheme.typography.headlineSmall) + Text("${state.tracks.size} titres · bibliothèque locale") + Button(onClick = { + actions.play(state.tracks.first(), false) + }, enabled = !state.busy) { Text("Tout écouter") } + } + } + items(state.tracks, key = { "ai-track-${it.id}" }) { track -> + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(track.title, style = MaterialTheme.typography.titleMedium) + Text(track.artist, style = MaterialTheme.typography.bodyMedium) + Text(track.reason, style = MaterialTheme.typography.bodySmall) + Row { + TextButton(onClick = { actions.play(track, false) }) { Text("Écouter") } + TextButton(onClick = { actions.play(track, true) }) { Text("Ajouter à la file") } + } + } + } + item("ai-save") { SaveMusicMix(state, actions.save) } +} + +@Composable +private fun SaveMusicMix(state: MusicAiUiState, save: (String) -> Unit) { + var name by rememberSaveable(state.source, state.selectedMood) { mutableStateOf(selectionTitle(state)) } + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = name, onValueChange = { + name = it.take(160) + }, label = { Text("Nom de la playlist") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + Button(onClick = { + save(name) + }, enabled = !state.busy && name.isNotBlank()) { Text("Enregistrer une copie stable") } + Text( + "La copie enregistrée ne sera pas remplacée au prochain renouvellement.", + style = MaterialTheme.typography.bodySmall, + ) + } +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayer.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayer.kt index f4ac9d5..b23067f 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayer.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayer.kt @@ -44,8 +44,10 @@ fun MiniPlayer( val coverUrl = playingCoverUrl(state) CoverTheme(coverUrl) { MiniPlayerCard( - title = track?.displayTitle ?: stream?.title ?: episode?.title.orEmpty(), - subtitle = track?.displayArtist ?: stream?.artist ?: episode?.showName.orEmpty(), + title = track?.displayTitle ?: stream?.title ?: state.dj?.title ?: episode?.title.orEmpty(), + subtitle = + track?.displayArtist ?: stream?.artist ?: state.dj?.artist + ?: episode?.showName.orEmpty(), coverUrl = coverUrl, onOpen = onOpen, openLabel = stringResource(R.string.player_open), diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/NowPlayingScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/NowPlayingScreen.kt index 54b95a9..92aa3d7 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/NowPlayingScreen.kt @@ -99,6 +99,7 @@ data class NowPlayingActions( /** Moves the synced lyrics this many ms later (earlier when negative). */ val onShiftLyrics: (Long) -> Unit = {}, val onResetLyrics: () -> Unit = {}, + val onSleepTimer: (Int?) -> Unit = {}, ) /** @@ -117,6 +118,7 @@ data class NowPlayingActions( @Composable fun NowPlayingScreen( state: PlayerState, + sleepActive: Boolean = false, isLiked: Boolean, serverName: String, actions: NowPlayingActions, @@ -129,7 +131,7 @@ fun NowPlayingScreen( val track = state.track val episode = state.episode val stream = state.stream - if (track == null && episode == null && stream == null) return + if (!state.hasMedia) return val downloaded = hasLibraryCopy || downloadJob?.status == ServerJobStatus.Done // Where the artist tap goes: the track's artist, or that of the library copy behind a stream. val artistId = (track ?: libraryTrack.takeIf { stream != null })?.artistId.orEmpty() @@ -150,10 +152,12 @@ fun NowPlayingScreen( verticalArrangement = Arrangement.spacedBy(if (compact) Spacing.sm else Spacing.md), ) { Header( + track = track, + sleepActive = sleepActive, context = state.context, episode = episode, actions = actions, - lyrics = episode == null && (stream == null || hasLibraryCopy), + lyrics = state.dj == null && episode == null && (stream == null || hasLibraryCopy), queue = episode == null, ) // The cover takes whatever the rest leaves, square. @@ -171,14 +175,32 @@ fun NowPlayingScreen( ) } TitleBlock( - title = track?.displayTitle ?: stream?.title ?: episode?.title.orEmpty(), - artist = track?.displayArtist ?: stream?.artist ?: episode?.showName.orEmpty(), + title = track?.displayTitle ?: stream?.title ?: state.dj?.title ?: episode?.title.orEmpty(), + artist = + track?.displayArtist ?: stream?.artist ?: state.dj?.artist + ?: episode?.showName.orEmpty(), onArtist = when { episode != null -> ({ actions.onGoToShow(episode.showId) }) artistId.isNotEmpty() -> ({ actions.onGoToArtist(artistId) }) else -> null }, ) + state.dj?.let { dj -> + if (dj.nextTitle.isNotBlank()) { + Text( + "À suivre · ${dj.nextTitle} · ${dj.nextArtist}", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + dj.transition?.let { progress -> + Text("Transition en cours", style = MaterialTheme.typography.labelSmall) + androidx.compose.material3.LinearProgressIndicator(progress = { + progress + }, modifier = Modifier.fillMaxWidth()) + } + } SeekBar(state, actions.onSeek, formatOf(track, state)) if (episode != null) { EpisodeTransport(state, actions, compact) @@ -187,7 +209,7 @@ fun NowPlayingScreen( state = state, actions = actions, compact = compact, - isLiked = isLiked.takeIf { stream == null || hasLibraryCopy }, + isLiked = isLiked.takeIf { state.dj == null && (stream == null || hasLibraryCopy) }, trailing = if (stream != null && !downloaded) { { StreamDownloadButton( @@ -216,6 +238,8 @@ fun NowPlayingScreen( */ @Composable private fun Header( + track: Track?, + sleepActive: Boolean, context: PlaybackContext?, episode: PlayingEpisode?, actions: NowPlayingActions, @@ -268,6 +292,7 @@ private fun Header( ) } Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PlayerOptions(track, sleepActive, actions.onSleepTimer) if (lyrics) { HeaderButton( DowntifyIcons.Lyrics, @@ -368,6 +393,7 @@ internal fun TrackTransport( labels = transportLabels(R.string.player_previous, R.string.player_next), compact = compact, ) + if (state.dj != null) return val toggles = buildList { add( PlayerToggle( @@ -508,6 +534,7 @@ private fun SourceLines(state: PlayerState, track: Track?, serverName: String) { when { track != null -> sourceLine(serverName, state.quality, state.fromPhone) state.stream != null -> stringResource(R.string.player_source_stream, serverName) + state.dj != null -> "Mix DJ local · $serverName" else -> stringResource(R.string.player_source_unknown, serverName) }, style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerOptions.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerOptions.kt new file mode 100644 index 0000000..e5d40b4 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerOptions.kt @@ -0,0 +1,67 @@ +package com.henriquesebastiao.downtify.feature.player + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.henriquesebastiao.downtify.core.designsystem.component.CircleButton +import com.henriquesebastiao.downtify.core.designsystem.component.DowntifyIcons +import com.henriquesebastiao.downtify.core.model.Track +import com.henriquesebastiao.downtify.core.player.PlaybackSleepTimer +import com.henriquesebastiao.downtify.ui.common.LocalMusicAiCapabilities +import com.henriquesebastiao.downtify.ui.common.LocalSongMix + +@Composable +fun PlayerOptions(track: Track?, sleepActive: Boolean, setSleepTimer: (Int?) -> Unit) { + val startMix = LocalSongMix.current + var open by remember { mutableStateOf(false) } + Box { + CircleButton(DowntifyIcons.MoreVert, "Options de lecture", { open = true }) + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + if (track != null && LocalMusicAiCapabilities.current.enabled("similarity")) { + DropdownMenuItem( + text = { Text("Lancer un mix à partir de ce titre") }, + onClick = { + open = false + startMix(track) + }, + ) + HorizontalDivider() + } + PlaybackSleepTimer.PRESETS.forEach { minutes -> + DropdownMenuItem( + text = { + Text( + if (minutes == + 60 + ) { + "Mettre en pause dans 1 h" + } else { + "Mettre en pause dans $minutes min" + }, + ) + }, + onClick = { + open = false + setSleepTimer(minutes) + }, + ) + } + if (sleepActive) { + DropdownMenuItem( + text = { Text("Annuler la pause programmée") }, + onClick = { + open = false + setSleepTimer(null) + }, + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerViewModel.kt index 4f0815d..d959f6d 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerViewModel.kt @@ -49,7 +49,10 @@ class PlayerViewModel @Inject constructor( server: ServerRepository, private val catalog: CatalogRepository, queue: ServerQueueRepository, + private val sleepTimer: com.henriquesebastiao.downtify.core.player.PlaybackSleepTimer, ) : ViewModel() { + val sleepDeadline = sleepTimer.deadline + val setSleepTimer: (Int?) -> Unit = sleepTimer::set val state: StateFlow = player.state val hasMedia: StateFlow = player.hasMedia diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayingCover.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayingCover.kt index ed7e413..bca845e 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayingCover.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayingCover.kt @@ -15,3 +15,4 @@ fun playingCoverUrl(state: PlayerState, size: Int = ServerUrls.COVER_SMALL): Str state.episode?.artworkUrl?.ifBlank { null } ?: state.stream?.coverUrl?.ifBlank { null } ?: state.track?.let { LocalCoverUrls.current.track(it.id.takeIf { _ -> it.hasCover }, size) } + ?: state.dj?.trackId?.let { LocalCoverUrls.current.track(it, size) } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt new file mode 100644 index 0000000..e2cb612 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt @@ -0,0 +1,119 @@ +package com.henriquesebastiao.downtify.feature.player + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.henriquesebastiao.downtify.core.data.library.LibraryRepository +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.player.PlayerController +import com.henriquesebastiao.downtify.core.player.mediaKey +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class QueueDjViewModel @Inject constructor( + private val music: MusicAiRepository, + private val player: PlayerController, + private val library: LibraryRepository, +) : ViewModel() { + val capabilities = music.capabilities + private val messageState = MutableStateFlow("") + val message = messageState.asStateFlow() + private val busyState = MutableStateFlow(false) + val busy = busyState.asStateFlow() + private val armedState = MutableStateFlow(false) + val armed = armedState.asStateFlow() + private var pendingId: String? = null + set(value) { + field = value + armedState.value = value != null + } + + fun toggle() { + val active = player.state.value.dj?.id ?: pendingId + if (active != null) { + viewModelScope.launch { + try { + music.djStop(active) + player.exitDj(active) + } catch (_: MusicAiFailure) { + messageState.value = + "Impossible d’arrêter le DJ pour le moment." + } + pendingId = null + busyState.value = false + } + return + } + if (busyState.value) return + val snapshot = player.state.value + val defer = snapshot.isPlaying + val ids = (if (defer) emptyList() else listOfNotNull(snapshot.track?.id)) + snapshot.upNext.map { it.trackId } + if (ids.size !in 2..40 || ids.any { library.library.value?.byId?.containsKey(it) != true }) { + messageState.value = "Choisissez de 2 à 40 titres locaux à suivre." + return + } + prepare(ids, defer) + } + + private fun prepare(ids: List, defer: Boolean) { + val owner = music.session.value + val key = player.state.value.mediaKey + busyState.value = true + messageState.value = "Préparation des transitions ; votre écoute continue." + viewModelScope.launch { + try { + val caps = music.capabilities.value + if (!caps.enabled("dj_interactive") || !caps.dj.interactiveReady || + !caps.dj.queueSupported + ) { + return@launch + } + val created = music.djQueue(ids, UUID.randomUUID().toString()) + pendingId = created.id + repeat(450) { + delay(2000) + if (pendingId != created.id) return@launch + if (music.session.value != owner || player.state.value.mediaKey != key) { + if (music.session.value == owner) music.djStop(created.id) + pendingId = null + messageState.value = "Le titre a changé avant le relais. La lecture classique continue." + return@launch + } + val session = music.djSession(created.id) + if (session.state in setOf("failed", "stopped", "cancelled")) throw MusicAiFailure() + if (session.state == "playing") music.djCommand(session.id, "pause") + if (session.state == "paused") { + if (defer) { + player.queueDj(session.id, "Mode DJ · votre file", (session.duration * 1000).toLong()) + messageState.value = "Mode DJ prêt · relais au prochain titre." + } else { + music.djCommand(session.id, "resume") + player.playDj( + session.id, + "Mode DJ · votre file", + (session.duration * 1000).toLong(), + "interactive", + ) + messageState.value = "Mode DJ actif." + } + return@launch + } + } + music.djStop(created.id) + pendingId = null + messageState.value = "La préparation a pris trop de temps. Votre file reste disponible." + } catch (_: MusicAiFailure) { + messageState.value = "Le mode DJ est indisponible. Votre file classique reste disponible." + pendingId = null + } finally { + busyState.value = false + } + } + } +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreen.kt index 237b1b2..6b84ddf 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreen.kt @@ -86,6 +86,14 @@ data class QueueActions( val onClearQueued: () -> Unit = {}, /** Play the item at `from` right after the item at `after` (player indices); `queued` = "Next in queue". */ val onMove: (from: Int, after: Int, queued: Boolean) -> Unit = { _, _, _ -> }, + val onToggleDj: () -> Unit = {}, +) + +data class QueueDjState( + val available: Boolean = false, + val ready: Boolean = false, + val active: Boolean = false, + val message: String = "", ) /** @@ -100,6 +108,7 @@ fun QueueScreen( actions: QueueActions, controls: NowPlayingActions, modifier: Modifier = Modifier, + dj: QueueDjState = QueueDjState(), ) { val now = nowPlayingRow(state) ?: return val listState = rememberLazyListState() @@ -119,6 +128,26 @@ fun QueueScreen( ) { Column(Modifier.fillMaxSize().safeDrawingPadding()) { TopBar(state.context, actions.onClose) + if (dj.available || state.dj != null) { + TextButton( + onClick = actions.onToggleDj, + enabled = dj.ready || dj.active || state.dj != null, + modifier = Modifier.padding(horizontal = Spacing.screen), + ) { + Text(if (dj.active || state.dj != null) "Désactiver le mode DJ" else "Mode DJ") + } + if (dj.message.isNotBlank() || + !dj.ready + ) { + Text( + dj.message.ifBlank { + "Disponible après validation du moteur sur le serveur." + }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = Spacing.screen), + ) + } + } LazyColumn( state = listState, contentPadding = PaddingValues(horizontal = Spacing.screen, vertical = Spacing.sm), @@ -137,7 +166,23 @@ fun QueueScreen( trailing = { PlayingBars(animate = state.isPlaying) }, ) } - if (queued.isNotEmpty()) { + if (state.dj != null && rest.isNotEmpty()) { + item(key = "dj-next") { + Column { + QueueHeader("À suivre") + QueueRow( + title = rest.first().title, + artist = rest.first().artist, + artworkUrl = null, + current = false, + onClick = {}, + containerColor = rowColor, + trailing = {}, + ) + } + } + } + if (state.dj == null && queued.isNotEmpty()) { item(key = "queued-header") { QueueHeader( stringResource(R.string.player_queue_next_in_queue), @@ -147,7 +192,7 @@ fun QueueScreen( } entries(queued, actions, reorder, rowColor) } - if (rest.isNotEmpty()) { + if (state.dj == null && rest.isNotEmpty()) { item(key = "rest-header") { QueueHeader(nextFromLabel(state.context)) } entries(rest, actions, reorder, rowColor) } @@ -406,6 +451,7 @@ private fun nowPlayingRow(state: PlayerState): NowRow? { return NowRow(track.displayTitle, track.displayArtist, cover) } return state.stream?.let { NowRow(it.title, it.artist, it.coverUrl.ifBlank { null }) } + ?: state.dj?.let { NowRow(it.title, it.artist, null) } } /** diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/search/RemoteItems.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/search/RemoteItems.kt index 9152d40..a22c1a7 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/search/RemoteItems.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/search/RemoteItems.kt @@ -59,6 +59,7 @@ import com.henriquesebastiao.downtify.core.model.ServerJob import com.henriquesebastiao.downtify.core.model.ServerJobStatus import com.henriquesebastiao.downtify.core.model.Track import com.henriquesebastiao.downtify.core.model.formatDuration +import com.henriquesebastiao.downtify.ui.common.LocalMusicAiCapabilities /** * A song the server can download, as a card of a separated list: tapping anywhere plays it in @@ -151,14 +152,18 @@ private fun RemoteSongMenu( Box { CircleButton(DowntifyIcons.MoreVert, stringResource(R.string.track_more, song.title), { open = true }) DropdownMenu(expanded = open, onDismissRequest = { open = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.search_similar, song.title)) }, - leadingIcon = { Icon(painterResource(DowntifyIcons.Explore), contentDescription = null) }, - onClick = { - open = false - onSimilar() - }, - ) + val caps = LocalMusicAiCapabilities.current + val localAvailable = libraryTrack != null && caps.enabled("similarity") + if (caps.version != 1 || caps.enabled("external_beta") || localAvailable) { + DropdownMenuItem( + text = { Text(stringResource(R.string.search_similar, song.title)) }, + leadingIcon = { Icon(painterResource(DowntifyIcons.Explore), contentDescription = null) }, + onClick = { + open = false + onSimilar() + }, + ) + } if (job == null && libraryTrack == null) { DropdownMenuItem( text = { Text(stringResource(R.string.search_download_to_server, song.title)) }, diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/settings/SettingsScreen.kt index 1510cc1..986a4b3 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/settings/SettingsScreen.kt @@ -92,6 +92,7 @@ fun SettingsRoute( onOpenAbout: () -> Unit, modifier: Modifier = Modifier, viewModel: SettingsViewModel = hiltViewModel(), + onOpenAi: () -> Unit = {}, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current @@ -109,6 +110,7 @@ fun SettingsRoute( onFont = viewModel::setFont, onFloatingNavBar = viewModel::setFloatingNavBar, onOpenAbout = onOpenAbout, + onOpenAi = onOpenAi, onOpenLanguage = { openAppLanguageSettings(context) }, onDownloadWifiOnly = viewModel::setDownloadWifiOnly, onOfflineLimit = viewModel::setOfflineLimit, @@ -136,6 +138,7 @@ data class SettingsActions( val onFont: (AppFont) -> Unit = {}, val onFloatingNavBar: (Boolean) -> Unit = {}, val onOpenAbout: () -> Unit = {}, + val onOpenAi: () -> Unit = {}, val onOpenLanguage: () -> Unit = {}, val onDownloadWifiOnly: (Boolean) -> Unit = {}, val onOfflineLimit: (Long) -> Unit = {}, @@ -180,6 +183,14 @@ fun SettingsScreen(state: SettingsUiState, actions: SettingsActions, modifier: M ) { ServerCard(state, onChangeServer = { dialog = SettingsDialog.Unpair }, onOpenWeb = actions.onOpenWeb) + SectionCard( + icon = DowntifyIcons.Graphic, + title = "IA", + onClick = actions.onOpenAi, + trailing = { Tag("Expérimental", tone = TagTone.Secondary) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.screen, vertical = Spacing.sm), + ) { Text("Playlists personnalisées et DJ en temps réel") } + GroupTitle(stringResource(R.string.settings_playback)) GroupedList(playbackRows(state, actions) { dialog = it }) Spacer(Modifier.height(Spacing.lg)) diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/similar/SimilarScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/similar/SimilarScreen.kt index ad1f767..66366cb 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/similar/SimilarScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/similar/SimilarScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -58,6 +59,17 @@ import com.henriquesebastiao.downtify.ui.common.plusBottomBars @Composable fun SimilarRoute(onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: SimilarViewModel = hiltViewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val caps by viewModel.capabilities.collectAsStateWithLifecycle() + val localEnabled = caps.version != 1 || caps.enabled("similarity") + val externalAvailable = caps.version == 1 && caps.enabled("external_beta") + if (!localEnabled && !externalAvailable) { + EmptyState( + icon = DowntifyIcons.Explore, + title = "Les musiques similaires sont désactivées dans vos préférences ou sur ce serveur.", + action = { TextButton(onClick = onBack) { Text(stringResource(R.string.action_back)) } }, + ) + return + } val snackbar = remember { SnackbarHostState() } val resources = LocalContext.current.resources LaunchedEffect(viewModel) { @@ -89,6 +101,10 @@ fun SimilarRoute(onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: S onShowLyrics = viewModel::showLyrics, snackbarHostState = snackbar, modifier = modifier, + localEnabled = localEnabled, + externalAvailable = externalAvailable, + external = viewModel.external, + onExternalChange = viewModel::onExternalChange, ) val lyrics by viewModel.lyricsFor.collectAsStateWithLifecycle() lyrics?.let { @@ -115,6 +131,10 @@ fun SimilarScreen( onToggleLike: (Track) -> Unit = {}, onShowLyrics: (Track) -> Unit = {}, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + localEnabled: Boolean = true, + externalAvailable: Boolean = false, + external: Boolean = false, + onExternalChange: (Boolean) -> Unit = {}, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( @@ -131,6 +151,16 @@ fun SimilarScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { + if (externalAvailable) { + Row(Modifier.padding(horizontal = Spacing.screen)) { + TextButton(onClick = { onExternalChange(false) }, enabled = localEnabled && external) { + Text(if (external) "Bibliothèque" else "Bibliothèque · sélectionnée") + } + TextButton(onClick = { onExternalChange(true) }, enabled = !external) { + Text(if (external) "Externe — bêta · sélectionné" else "Externe — bêta") + } + } + } Column( Modifier.fillMaxWidth().padding(bottom = Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.sm), diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/similar/SimilarViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/similar/SimilarViewModel.kt index 956a34d..39646eb 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/similar/SimilarViewModel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/similar/SimilarViewModel.kt @@ -88,6 +88,10 @@ class SimilarViewModel @Inject constructor( private set var track by mutableStateOf(savedState[KEY_TRACK] ?: "") private set + private var seed: String = savedState[KEY_SEED] ?: "" + var external by mutableStateOf(false) + private set + val capabilities = similar.capabilities private val songs = MutableStateFlow>(emptyList()) private val loading = MutableStateFlow(false) @@ -151,6 +155,27 @@ class SimilarViewModel @Inject constructor( init { if (artist.isNotBlank() || track.isNotBlank()) search() viewModelScope.launch { followRouteArgs() } + viewModelScope.launch { + capabilities.collect { caps -> + if (caps.version == 1) { + val before = external + if (external && !caps.enabled("external_beta")) external = false + if (!caps.enabled("similarity") && caps.enabled("external_beta")) external = true + val feature = if (external) "external_beta" else "similarity" + if (!caps.enabled(feature)) { + searching?.cancel() + songs.value = emptyList() + loading.value = false + loadingMore.value = false + hasMore.value = false + } + if (before != external) { + songs.value = emptyList() + search() + } + } + } + } viewModelScope.launch { player.state .map { state -> state.stream?.videoId to state.upNext.count { !it.queued } } @@ -164,14 +189,18 @@ class SimilarViewModel @Inject constructor( * already on top: follow the route's arguments so the mix follows. */ private suspend fun followRouteArgs() { - savedState.getStateFlow(KEY_ARTIST, artist) - .combine(savedState.getStateFlow(KEY_TRACK, track)) { a, t -> a to t } + combine( + savedState.getStateFlow(KEY_ARTIST, artist), + savedState.getStateFlow(KEY_TRACK, track), + savedState.getStateFlow(KEY_SEED, seed), + ) { a, t, id -> Triple(a, t, id) } .distinctUntilChanged() - .collect { (a, t) -> - if (a.isBlank() && t.isBlank()) return@collect - if (a == artist && t == track) return@collect + .collect { (a, t, id) -> + if (a.isBlank() && t.isBlank() && id.isBlank()) return@collect + if (a == artist && t == track && id == seed) return@collect artist = a track = t + seed = id search() } } @@ -227,24 +256,41 @@ class SimilarViewModel @Inject constructor( fun onArtistChange(value: String) { artist = value + seed = "" } fun onTrackChange(value: String) { track = value + seed = "" + } + + fun onExternalChange(value: Boolean) { + if (value && !capabilities.value.enabled("external_beta")) return + if (!value && !capabilities.value.enabled("similarity")) return + external = value + songs.value = emptyList() + search() } fun search() { searching?.cancel() - if (artist.isBlank() && track.isBlank()) return + val caps = capabilities.value + if (caps.version == 1 && !caps.enabled(if (external) "external_beta" else "similarity")) { + songs.value = emptyList() + loading.value = false + return + } + if (artist.isBlank() && track.isBlank() && seed.isBlank()) return searching = viewModelScope.launch { loading.value = true error.value = null // A new seed restarts the endless list. hasMore.value = true loadingMore.value = false - when (val result = similar.similar(artist, track)) { + when (val result = similar.similar(artist, track, seed, external)) { is ServerResult.Ok -> { songs.value = result.value + hasMore.value = !similar.local || external searched.value = true } @@ -280,7 +326,7 @@ class SimilarViewModel @Inject constructor( } loadingMore.value = true try { - val fresh = when (val result = similar.similar(seedArtist, seed.title)) { + val fresh = when (val result = similar.similar(seedArtist, seed.title, external = external)) { is ServerResult.Ok -> result.value.filter { song -> songs.value.none { sameSong(it, song) } } else -> emptyList() } @@ -297,6 +343,7 @@ class SimilarViewModel @Inject constructor( fun pivot(song: RemoteSong) { artist = song.artist track = song.title + seed = library.library.value?.findSong(song)?.id.orEmpty() search() } @@ -344,6 +391,7 @@ class SimilarViewModel @Inject constructor( private companion object { const val KEY_ARTIST = "artist" const val KEY_TRACK = "track" + const val KEY_SEED = "seed" const val STOP_TIMEOUT_MS = 5_000L } } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt index b51dba4..ac91b7c 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt @@ -175,6 +175,7 @@ private fun MainShell() { } // Now Playing, open or closed or anywhere between: it grows out of the mini player and shrinks back into it. val expansion = rememberPlayerExpansion() + val mixAction = com.henriquesebastiao.downtify.ui.common.songMixAction() val scope = rememberCoroutineScope() LaunchedEffect(hasMedia) { if (!hasMedia) expansion.snapTo(open = false) } // The side menu, opened from Home's menu button. @@ -213,6 +214,8 @@ private fun MainShell() { // The same instance MoveToTrashHost uses: whether this pairing gets the music trash. val trash: MoveToTrashViewModel = hiltViewModel() val trashOn by trash.canManage.collectAsStateWithLifecycle() + val musicAi: com.henriquesebastiao.downtify.feature.musicai.MusicAiViewModel = hiltViewModel() + val aiCapabilities by musicAi.capabilities.collectAsStateWithLifecycle() AppDrawer( drawerState = drawerState, selected = DrawerDestination.entries.firstOrNull { destination?.hasRoute(it.route::class) == true }, @@ -220,7 +223,9 @@ private fun MainShell() { scope.launch { drawerState.close() } navController.navigate(item.route) { launchSingleTop = true } }, - destinations = DrawerDestination.entries.filter { trashOn || !it.trashOnly }, + destinations = DrawerDestination.entries.filter { + (trashOn || !it.trashOnly) && (!it.musicAiOnly || aiCapabilities.version == 1) + }, ) { Box(Modifier.fillMaxSize()) { NavigationSuiteScaffold( @@ -258,7 +263,11 @@ private fun MainShell() { } var stackHeight by remember { mutableStateOf(0.dp) } val covered = if (miniPlayer || floatingBar) stackHeight else 0.dp - CompositionLocalProvider(LocalBottomBarsInset provides covered) { + CompositionLocalProvider( + LocalBottomBarsInset provides covered, + com.henriquesebastiao.downtify.ui.common.LocalMusicAiCapabilities provides aiCapabilities, + com.henriquesebastiao.downtify.ui.common.LocalSongMix provides mixAction, + ) { Box(Modifier.fillMaxSize().then(content)) { MoveToTrashHost(onMessage = { text -> scope.launch { snackbar.showSnackbar(text) } }) { AppNavHost(navController, onOpenMenu = { scope.launch { drawerState.open() } }) @@ -289,17 +298,22 @@ private fun MainShell() { } } if (hasMedia) { - NowPlayingOverlay( - expansion = expansion, - player = player, - attached = floatingBar, - onOpen = { scope.launch { expansion.expand() } }, - onClose = { scope.launch { expansion.collapse() } }, - onNavigate = { route -> - scope.launch { expansion.collapse() } - navController.navigate(route) { launchSingleTop = true } - }, - ) + CompositionLocalProvider( + com.henriquesebastiao.downtify.ui.common.LocalMusicAiCapabilities provides aiCapabilities, + com.henriquesebastiao.downtify.ui.common.LocalSongMix provides mixAction, + ) { + NowPlayingOverlay( + expansion = expansion, + player = player, + attached = floatingBar, + onOpen = { scope.launch { expansion.expand() } }, + onClose = { scope.launch { expansion.collapse() } }, + onNavigate = { route -> + scope.launch { expansion.collapse() } + navController.navigate(route) { launchSingleTop = true } + }, + ) + } } } } @@ -321,6 +335,7 @@ private fun AppNavHost(navController: NavHostController, onOpenMenu: () -> Unit) onOpenDiscover = { navController.navigate(DiscoverDestination) }, onOpenPodcasts = { navController.navigate(PodcastsDestination) }, onOpenSimilar = { navController.navigate(SimilarRoute()) }, + onOpenMusicAi = { navController.navigate(com.henriquesebastiao.downtify.ui.navigation.MusicAiRoute) }, ) } composable { @@ -349,6 +364,9 @@ private fun AppNavHost(navController: NavHostController, onOpenMenu: () -> Unit) composable { SimilarScreenRoute(onBack = navController::popBackStack) } + composable { + com.henriquesebastiao.downtify.feature.musicai.MusicAiRoute(onBack = navController::popBackStack) + } composable { DiscoverRoute( onBack = navController::popBackStack, @@ -370,7 +388,7 @@ private fun AppNavHost(navController: NavHostController, onOpenMenu: () -> Unit) onArtist = toArtist, onPlaylist = toPlaylist, onLiked = { navController.navigate(LikedRoute) }, - onSimilar = { artist, title -> navController.navigate(SimilarRoute(artist, title)) }, + onSimilar = { artist, title, seed -> navController.navigate(SimilarRoute(artist, title, seed)) }, ), ) } @@ -391,6 +409,7 @@ private fun AppNavHost(navController: NavHostController, onOpenMenu: () -> Unit) SettingsRoute( onBack = navController::popBackStack, onOpenAbout = { navController.navigate(AboutDestination) }, + onOpenAi = { navController.navigate(com.henriquesebastiao.downtify.ui.navigation.MusicAiRoute) }, ) } composable { @@ -457,6 +476,12 @@ private fun NowPlayingOverlay( val serverName by player.serverName.collectAsStateWithLifecycle() val lyrics by player.lyrics.collectAsStateWithLifecycle() val lyricsOffset by player.lyricsOffset.collectAsStateWithLifecycle() + val sleepDeadline by player.sleepDeadline.collectAsStateWithLifecycle() + val queueDj: com.henriquesebastiao.downtify.feature.player.QueueDjViewModel = hiltViewModel() + val djCaps by queueDj.capabilities.collectAsStateWithLifecycle() + val djMessage by queueDj.message.collectAsStateWithLifecycle() + val djArmed by queueDj.armed.collectAsStateWithLifecycle() + val djBusy by queueDj.busy.collectAsStateWithLifecycle() var lyricsOpen by rememberSaveable { mutableStateOf(false) } var queueOpen by rememberSaveable { mutableStateOf(false) } val open = expansion.expanded @@ -483,6 +508,7 @@ private fun NowPlayingOverlay( val controls = remember(player, onClose, onNavigate) { NowPlayingActions( onCollapse = onClose, + onSleepTimer = player.setSleepTimer, onTogglePlay = player::togglePlayPause, onNext = player::next, onPrevious = player::previous, @@ -529,6 +555,7 @@ private fun NowPlayingOverlay( full = { coverModifier -> NowPlayingScreen( state = state, + sleepActive = sleepDeadline != null, isLiked = isLiked, serverName = serverName, downloadJob = streamJob, @@ -554,8 +581,15 @@ private fun NowPlayingOverlay( onRemove = player::removeFromQueue, onClearQueued = player::clearQueued, onMove = player::moveInQueue, + onToggleDj = queueDj::toggle, ), controls = controls, + dj = com.henriquesebastiao.downtify.feature.player.QueueDjState( + available = djCaps.enabled("dj_interactive") && djCaps.dj.queueSupported, + ready = djCaps.dj.interactiveReady && djCaps.dj.queueSupported, + active = djArmed || djBusy, + message = djMessage, + ), ) } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LibraryItems.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LibraryItems.kt index 84bb5e2..de9ae79 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LibraryItems.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LibraryItems.kt @@ -279,7 +279,7 @@ data class TrackActions( val onAddToQueue: ((Track) -> Unit)? = null, val onGoToAlbum: ((Track) -> Unit)? = null, val onGoToArtist: ((Track) -> Unit)? = null, - val onFindSimilar: ((artist: String, title: String) -> Unit)? = null, + val onFindSimilar: ((artist: String, title: String, seed: String) -> Unit)? = null, /** Only for an admin's pairing while the server has the music trash on (see `LocalMoveToTrash`). */ val onMoveToTrash: ((Track) -> Unit)? = null, ) @@ -291,6 +291,7 @@ private data class TrackMenuColors(val container: Color, val content: Color) @Composable private fun TrackMenu(track: Track, isLiked: Boolean, actions: TrackActions, colors: TrackMenuColors) { if (actions == TrackActions()) return + val startMix = LocalSongMix.current var open by remember { mutableStateOf(false) } Box { CircleButton( @@ -301,6 +302,16 @@ private fun TrackMenu(track: Track, isLiked: Boolean, actions: TrackActions, col contentColor = colors.content, ) DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + if (LocalMusicAiCapabilities.current.enabled("similarity")) { + DropdownMenuItem( + text = { Text("Lancer un mix à partir de ce titre") }, + leadingIcon = { Icon(painterResource(DowntifyIcons.Explore), contentDescription = null) }, + onClick = { + open = false + startMix(track) + }, + ) + } actions.onAddToQueue?.let { add -> DropdownMenuItem( text = { Text(stringResource(R.string.track_add_to_queue)) }, @@ -350,14 +361,16 @@ private fun TrackMenu(track: Track, isLiked: Boolean, actions: TrackActions, col ) } } - if (track.displayArtist.isNotBlank() && track.displayTitle.isNotBlank()) { + if (track.displayArtist.isNotBlank() && track.displayTitle.isNotBlank() && + LocalMusicAiCapabilities.current.similarityAvailable() + ) { actions.onFindSimilar?.let { find -> DropdownMenuItem( text = { Text(stringResource(R.string.similar_find)) }, leadingIcon = { Icon(painterResource(DowntifyIcons.Explore), contentDescription = null) }, onClick = { open = false - find(track.displayArtist, track.displayTitle) + find(track.displayArtist, track.displayTitle, track.id) }, ) } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/MusicAiCapabilitiesLocal.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/MusicAiCapabilitiesLocal.kt new file mode 100644 index 0000000..d0cf3e2 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/MusicAiCapabilitiesLocal.kt @@ -0,0 +1,9 @@ +package com.henriquesebastiao.downtify.ui.common + +import androidx.compose.runtime.staticCompositionLocalOf +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto + +/** Legacy servers keep their native Similar action; v1 servers govern each module. */ +val LocalMusicAiCapabilities = staticCompositionLocalOf { MusicAiCapabilitiesDto() } + +fun MusicAiCapabilitiesDto.similarityAvailable() = version != 1 || enabled("similarity") || enabled("external_beta") diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt new file mode 100644 index 0000000..df608fa --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt @@ -0,0 +1,88 @@ +package com.henriquesebastiao.downtify.ui.common + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.henriquesebastiao.downtify.core.data.ServerResult +import com.henriquesebastiao.downtify.core.data.library.LibraryRepository +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.model.Track +import com.henriquesebastiao.downtify.core.network.CatalogJson +import com.henriquesebastiao.downtify.core.network.session.SessionStore +import com.henriquesebastiao.downtify.core.player.PlayerController +import com.henriquesebastiao.downtify.core.player.mediaKey +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.launch + +val LocalSongMix = staticCompositionLocalOf<(Track) -> Unit> { {} } + +@HiltViewModel +class SongMixViewModel @Inject constructor( + private val musicAi: MusicAiRepository, + private val library: LibraryRepository, + private val player: PlayerController, + private val sessions: SessionStore, +) : ViewModel() { + var busy by mutableStateOf(false) + private set + var message by mutableStateOf(null) + private set + + fun clearMessage() { + message = null + } + + fun start(seed: Track) { + if (busy) return + val owner = sessions.current ?: return + val current = player.state.value.mediaKey + busy = true + message = "Préparation du mix…" + viewModelScope.launch { + try { + val refreshed = musicAi.refresh() + if (refreshed !is ServerResult.Ok || !musicAi.capabilities.value.enabled("similarity")) { + message = "La similarité locale est désactivée ou indisponible." + return@launch + } + val result = CatalogJson.similarTracks(musicAi.similar("", "", seed = seed.id)) + if (sessions.current != owner || player.state.value.mediaKey != current) return@launch + val snapshot = library.library.value ?: return@launch + val tracks = result + .mapNotNull { snapshot.byId[it.id] }.filter { it.id != seed.id }.distinctBy { it.id } + if (!musicAi.capabilities.value.enabled("similarity") || tracks.isEmpty()) { + message = "Mix indisponible : vérifie la similarité IA et l’analyse de ce titre." + } else { + player.startMix(seed, tracks) + message = "Mix lancé · ${tracks.size + 1} titres" + } + } catch (_: MusicAiFailure) { + if (sessions.current == owner) message = "Mix indisponible : ce titre doit être analysé." + } finally { + busy = false + } + } + } +} + +@Composable +fun songMixAction(viewModel: SongMixViewModel = hiltViewModel()): (Track) -> Unit { + val context = LocalContext.current + LaunchedEffect(viewModel.message) { + viewModel.message?.let { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + viewModel.clearMessage() + } + } + return viewModel::start +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/navigation/Routes.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/navigation/Routes.kt index 47d6607..95368c2 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/navigation/Routes.kt @@ -14,6 +14,8 @@ import kotlinx.serialization.Serializable @Serializable data object DownloadsRoute +@Serializable data object MusicAiRoute + /** The server's music trash (an admin's pairing, while the server has it on). */ @Serializable data object TrashRoute @@ -41,7 +43,7 @@ import kotlinx.serialization.Serializable @Serializable data class SearchQueryRoute(val query: String) /** Tracks like one track, from YouTube Music's radio mix; blank until something is named. */ -@Serializable data class SimilarRoute(val artist: String = "", val track: String = "") +@Serializable data class SimilarRoute(val artist: String = "", val track: String = "", val seed: String = "") /** The three top-level destinations of the navigation bar / rail. */ enum class TopLevelDestination( @@ -66,7 +68,15 @@ enum class DrawerDestination( @DrawableRes val selectedIcon: Int, /** Shown only to an admin's pairing on a server that has the music trash on. */ val trashOnly: Boolean = false, + val musicAiOnly: Boolean = false, ) { Downloads(DownloadsRoute, R.string.nav_downloads, DowntifyIcons.Downloads, DowntifyIcons.DownloadsFilled), Trash(TrashRoute, R.string.nav_trash, DowntifyIcons.Delete, DowntifyIcons.Delete, trashOnly = true), + MusicAi( + MusicAiRoute, + R.string.nav_music_ai, + DowntifyIcons.Library, + DowntifyIcons.LibraryFilled, + musicAiOnly = true, + ), } diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 6c501b7..3edb22c 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -551,4 +551,5 @@ По-силно Максимум Увеличава звука до безопасната граница за всяка песен. Аудиофайловете не се променят. + Вашата музика diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index b9bad1f..475ca3b 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -551,4 +551,5 @@ Πιο δυνατά Μέγιστο Αυξάνει την ένταση εντός του ασφαλούς ορίου κάθε τραγουδιού. Τα αρχεία ήχου δεν αλλάζουν. + Η μουσική σας diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index bfc3e0e..6544294 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -565,4 +565,5 @@ Más alto Máximo Aumenta el volumen dentro del límite seguro de cada canción. Los archivos de audio no cambian. + Tu música diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5c9bcae..ca66774 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -565,4 +565,5 @@ Plus fort Maximum Le volume augmente dans la limite propre à chaque titre. Vos fichiers restent inchangés. + Votre musique diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 2511c7a..cfc7bda 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -551,4 +551,5 @@ Hangosabb Maximum A hangerőt az egyes számok biztonságos határáig növeli. A hangfájlok változatlanok maradnak. + Saját zenék diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 80108a0..4020ec1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -565,4 +565,5 @@ Mais alto Máximo Aumenta o volume dentro do limite seguro de cada faixa. Os arquivos de áudio permanecem intactos. + Sua música diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 7a1683d..f05b1ac 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -551,4 +551,5 @@ Daha yüksek Maksimum Sesi her parçanın güvenli sınırına kadar artırır. Ses dosyaları değişmez. + Müziğiniz diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8af7328..ea52ea2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ + Your music Downtify diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/CarServiceFixture.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/CarServiceFixture.kt index c6bdc1e..3a29679 100644 --- a/app/src/test/kotlin/com/henriquesebastiao/downtify/CarServiceFixture.kt +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/CarServiceFixture.kt @@ -10,6 +10,7 @@ import com.henriquesebastiao.downtify.core.model.PlaybackSnapshot import com.henriquesebastiao.downtify.core.model.Playlist import com.henriquesebastiao.downtify.core.model.SavedPlaybackItem import com.henriquesebastiao.downtify.core.model.Track +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto import com.henriquesebastiao.downtify.core.network.session.Session import com.henriquesebastiao.downtify.core.player.PlaybackService import io.mockk.coEvery @@ -46,10 +47,15 @@ internal class CarServiceFixture { every { likedIds } returns MutableStateFlow(listOf("a")) every { successfulSync } returns MutableStateFlow(0) } + service.sleepTimer = com.henriquesebastiao.downtify.core.player.PlaybackSleepTimer() service.sessions = mockk(relaxed = true) { every { current } answers { association.value } every { session } returns association } + service.musicAi = mockk(relaxed = true) { + every { session } returns association + every { capabilities } returns MutableStateFlow(MusicAiCapabilitiesDto()) + } service.playback = mockk(relaxed = true) { coEvery { load(any()) } coAnswers { restorationGate?.await() diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt new file mode 100644 index 0000000..0e62079 --- /dev/null +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt @@ -0,0 +1,80 @@ +package com.henriquesebastiao.downtify + +import com.henriquesebastiao.downtify.core.data.ServerResult +import com.henriquesebastiao.downtify.core.data.library.LibraryRepository +import com.henriquesebastiao.downtify.core.data.library.LibrarySnapshot +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.model.Track +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.session.SessionStore +import com.henriquesebastiao.downtify.core.player.PlayerController +import com.henriquesebastiao.downtify.core.player.PlayerState +import com.henriquesebastiao.downtify.core.player.PlayingStream +import com.henriquesebastiao.downtify.ui.common.SongMixViewModel +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SongMixViewModelTest { + @Test fun `disabled similarity makes no suggestion request`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val fixture = Fixture(enabled = false) + fixture.viewModel.start(fixture.seed) + runCurrent() + coVerify(exactly = 0) { fixture.ai.similar(any(), any(), any(), any()) } + verify(exactly = 0) { fixture.player.startMix(any(), any()) } + } finally { + Dispatchers.resetMain() + } + } + + @Test fun `a stream changed during analysis cannot be replaced by the old mix response`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val fixture = Fixture(enabled = true) + coEvery { fixture.ai.similar(any(), any(), any(), any()) } coAnswers { + fixture.state.value = PlayerState(stream = PlayingStream("two", "", "", "", "")) + Json.parseToJsonElement( + """{"tracks":[{"song_id":"next","name":"Next","source":"library"}]}""", + ).jsonObject + } + fixture.viewModel.start(fixture.seed) + runCurrent() + coVerify(exactly = 1) { fixture.ai.similar("", "", false, "seed") } + verify(exactly = 0) { fixture.player.startMix(any(), any()) } + } finally { + Dispatchers.resetMain() + } + } + + private class Fixture(enabled: Boolean) { + val seed = mockk(relaxed = true) { every { id } returns "seed" } + val state = MutableStateFlow(PlayerState(stream = PlayingStream("one", "", "", "", ""))) + val player = mockk(relaxed = true) { every { state } returns this@Fixture.state } + val ai = mockk { + every { capabilities } returns + MutableStateFlow(MusicAiCapabilitiesDto(1, features = mapOf("similarity" to enabled))) + coEvery { refresh() } returns ServerResult.Ok(Unit) + } + private val library = mockk { + every { library } returns MutableStateFlow(LibrarySnapshot.Empty) + } + private val sessions = mockk { every { current } returns mockk(relaxed = true) } + val viewModel = SongMixViewModel(ai, library, player, sessions) + } +} diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/library/LibraryScreenTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/library/LibraryScreenTest.kt index 7dca0d9..5f145d5 100644 --- a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/library/LibraryScreenTest.kt +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/library/LibraryScreenTest.kt @@ -1,5 +1,6 @@ package com.henriquesebastiao.downtify.feature.library +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -15,6 +16,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.henriquesebastiao.downtify.core.data.settings.LibrarySort import com.henriquesebastiao.downtify.core.designsystem.theme.DowntifyTheme import com.henriquesebastiao.downtify.core.model.formatDuration +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.ui.common.LocalMusicAiCapabilities import com.henriquesebastiao.downtify.ui.common.PreviewData import com.henriquesebastiao.downtify.ui.common.TrackActions import org.junit.Assert.assertEquals @@ -176,7 +179,7 @@ class LibraryScreenTest { @Test fun trackMenuFindsSimilarTracks() { - var similar: Pair? = null + var similar: Triple? = null compose.setContent { DowntifyTheme { LibraryScreen( @@ -188,13 +191,47 @@ class LibraryScreenTest { onRefresh = {}, onPlaySong = {}, trackActions = TrackActions( - onFindSimilar = { artist, title -> similar = artist to title }, + onFindSimilar = { artist, title, seed -> similar = Triple(artist, title, seed) }, ), ) } } compose.onNodeWithContentDescription("More options for Low Tide").performClick() compose.onNodeWithText("Find similar").performClick() - assertEquals("Kenji Aoki" to "Low Tide", similar) + assertEquals( + Triple( + "Kenji Aoki", + "Low Tide", + PreviewData.tracks.first { + it.displayTitle == "Low Tide" + }.id, + ), + similar, + ) + } + + @Test + fun anOpenMenuHidesSimilarWhenItsModuleIsDisabled() { + var caps by mutableStateOf(MusicAiCapabilitiesDto(version = 1, features = mapOf("similarity" to true))) + compose.setContent { + DowntifyTheme { + CompositionLocalProvider(LocalMusicAiCapabilities provides caps) { + LibraryScreen( + state = songsState(), + navigation = LibraryNavigation(), + onSelectTab = {}, + onSort = {}, + onToggleLayout = {}, + onRefresh = {}, + onPlaySong = {}, + trackActions = TrackActions(onFindSimilar = { _, _, _ -> }), + ) + } + } + } + compose.onNodeWithContentDescription("More options for Low Tide").performClick() + compose.onNodeWithText("Find similar").assertIsEnabled() + compose.runOnIdle { caps = caps.copy(features = mapOf("similarity" to false)) } + compose.onNodeWithText("Find similar").assertDoesNotExist() } } diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYouScreenTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYouScreenTest.kt new file mode 100644 index 0000000..8174097 --- /dev/null +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYouScreenTest.kt @@ -0,0 +1,79 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.henriquesebastiao.downtify.core.designsystem.theme.DowntifyTheme +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiPlaylistDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiStatusDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiTrackDto +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@Config(qualifiers = "w411dp-h914dp") +@RunWith(AndroidJUnit4::class) +class MusicForYouScreenTest { + @get:Rule val compose = createComposeRule() + private val mix = MusicAiPlaylistDto("daily", "calme", "2026-11-06", 40) + private val track = MusicAiTrackDto("synthetic-one", "Synthetic title", "Synthetic artist") + private val caps = + MusicAiCapabilitiesDto( + version = 1, + features = mapOf("daily" to true, "personalization" to true), + model = MusicAiStatusDto(ready = true), + ) + + @Test fun openingAMixShowsItsTracksAndReturningKeepsTheCards() { + var state by mutableStateOf(MusicAiUiState(playlists = listOf(mix))) + var played = "" + compose.setContent { + DowntifyTheme { + LazyColumn { + musicForYouItems( + state, + caps, + MusicForYouActions( + mood = { cadence, mood -> + state = + state.copy(tracks = listOf(track), source = cadence, selectedMood = mood) + }, + clearSelection = { state = state.copy(tracks = emptyList()) }, + play = { value, _ -> played = value.id }, + ), + ) + } + } + } + compose.onNodeWithText("Calme").performClick() + compose.onAllNodesWithText("Mix du jour · Calme").assertCountEquals(2) + compose.onNodeWithText("Calme").assertDoesNotExist() + compose.onNodeWithText("Tout écouter").performClick() + assertEquals(track.id, played) + compose.onNodeWithText("← Tous mes mixes").performClick() + compose.onNodeWithText("Calme").assertExists() + } + + @Test fun disablingTheModuleHidesExistingCardsAndPlaybackCommands() { + val state = + MusicAiUiState(playlists = listOf(mix), tracks = listOf(track), source = "daily", selectedMood = "calme") + compose.setContent { + DowntifyTheme { + LazyColumn { musicForYouItems(state, caps.copy(features = emptyMap()), MusicForYouActions()) } + } + } + compose.onNodeWithText("Calme").assertDoesNotExist() + compose.onNodeWithText("Tout écouter").assertDoesNotExist() + compose.onNodeWithText("À écouter maintenant").assertDoesNotExist() + } +} diff --git a/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/library/LibrarySnapshot.kt b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/library/LibrarySnapshot.kt index 03e19ad..377bee3 100644 --- a/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/library/LibrarySnapshot.kt +++ b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/library/LibrarySnapshot.kt @@ -21,7 +21,7 @@ class LibrarySnapshot(val tracks: List) { * and artist. Files carry no id to compare against, so this is * best-effort: null when the sync hasn't caught up yet. */ - fun findSong(song: RemoteSong): Track? = findTrack(song.title, song.artist) + fun findSong(song: RemoteSong): Track? = byId[song.id] ?: findTrack(song.title, song.artist) /** Same match by plain title and artist (a playing stream, a queue row). */ fun findTrack(title: String, artist: String): Track? { diff --git a/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt new file mode 100644 index 0000000..1638aaf --- /dev/null +++ b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt @@ -0,0 +1,166 @@ +package com.henriquesebastiao.downtify.core.data.musicai + +import com.henriquesebastiao.downtify.core.data.ServerResult +import com.henriquesebastiao.downtify.core.data.di.ApplicationScope +import com.henriquesebastiao.downtify.core.network.ApiFactory +import com.henriquesebastiao.downtify.core.network.MusicAiApi +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiListenDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiSelectionDto +import com.henriquesebastiao.downtify.core.network.session.SessionStore +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerializationException +import retrofit2.HttpException + +class MusicAiFailure(val status: Int? = null, cause: Exception? = null) : Exception("Music service unavailable", cause) + +/** No local profile: the server owns preferences and derives recommendations for its account. */ +@Singleton +class MusicAiRepository @Inject constructor( + private val sessions: SessionStore, + private val apis: ApiFactory, + @ApplicationScope scope: CoroutineScope, +) { + private val mutableCapabilities = MutableStateFlow(MusicAiCapabilitiesDto()) + val capabilities = mutableCapabilities.asStateFlow() + val session get() = sessions.session + private val refreshMutex = Mutex() + + init { + scope.launch(Dispatchers.IO) { + sessions.session.collectLatest { paired -> + mutableCapabilities.value = MusicAiCapabilitiesDto() + if (paired != null) { + while (isActive) { + refresh() + delay(30_000) + } + } + } + } + } + + suspend fun refresh(): ServerResult = refreshMutex.withLock { + val paired = sessions.current ?: return@withLock ServerResult.Unreachable + val found = try { + call { it.capabilities() } + } catch (error: MusicAiFailure) { + if (sessions.current == paired && error.status in setOf(404, 405)) { + mutableCapabilities.value = MusicAiCapabilitiesDto() + } + return@withLock if (error.cause is IOException) { + ServerResult.Unreachable + } else { + ServerResult.Failed( + error.status, + ) + } + } + if (sessions.current == paired) mutableCapabilities.value = found + ServerResult.Ok(Unit) + } + + suspend fun preferences() = call { it.preferences().preferences } + suspend fun djSessions() = call { it.djSessions().sessions } + suspend fun djCreate(theme: String, count: Int, requestId: String, mode: String = "prepared") = call { + it.djCreate(com.henriquesebastiao.downtify.core.network.dto.MusicDjSelectionDto(requestId, theme, count, mode)) + } + suspend fun djSession(id: String) = call { it.djSession(id) } + suspend fun djQueue(ids: List, requestId: String) = call { + it.djCreate( + com.henriquesebastiao.downtify.core.network.dto.MusicDjSelectionDto( + requestId, + "fete", + ids.size, + "interactive", + ids = ids, + queueOrder = true, + ), + ) + } + suspend fun djContinuous(theme: String, style: String, requestId: String) = call { + it.djCreate( + com.henriquesebastiao.downtify.core.network.dto.MusicDjSelectionDto( + requestId, + theme, + 24, + "interactive", + continuous = true, + style = style, + ), + ) + } + suspend fun djCommand(id: String, action: String, payload: Map = emptyMap()) = call { + it.djCommand( + id, + com.henriquesebastiao.downtify.core.network.dto.MusicDjCommandDto( + java.util.UUID.randomUUID().toString(), + action, + payload, + ), + ) + } + suspend fun djStop(id: String) = call { it.djStop(id) } + suspend fun set(feature: String, enabled: Boolean) { + call { it.preferences(mapOf(feature to enabled)) } + refresh() + } + suspend fun globalFeatures() = call { it.globalFeatures() } + suspend fun setGlobal(feature: String, enabled: Boolean) { + call { it.globalFeatures(mapOf(feature to enabled)) } + refresh() + } + suspend fun recommendations(seed: String = "") = call { it.recommendations(seed).tracks } + suspend fun playlist(cadence: String, mood: String) = call { it.playlist(cadence, mood).tracks } + suspend fun playlists() = call { it.playlists().playlists } + suspend fun save(name: String, ids: List, source: String) = + call { it.save(MusicAiSelectionDto(name, ids, source)) } + suspend fun forget() { + call { it.forget() } + refresh() + } + suspend fun similar(artist: String, title: String, external: Boolean = false, seed: String = "") = call { + if (external) { + it.externalSimilar(artist, title) + } else { + it.similar( + if (seed.isBlank()) artist else "", + if (seed.isBlank()) title else "", + seed, + ) + } + } + suspend fun listen(event: MusicAiListenDto) { + if (!capabilities.value.enabled("personalization")) return + call { it.listen(event) } + } + + private suspend fun call(block: suspend (MusicAiApi) -> T): T = withContext(Dispatchers.IO) { + val paired = sessions.current ?: throw MusicAiFailure() + try { + val result = block(apis.createMusicAi(paired.baseUrl)) + if (sessions.current != paired) throw CancellationException("Account changed") + result + } catch (error: HttpException) { + throw MusicAiFailure(error.code(), error) + } catch (error: IOException) { + throw MusicAiFailure(cause = error) + } catch (error: SerializationException) { + throw MusicAiFailure(cause = error) + } + } +} diff --git a/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarRepository.kt b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarRepository.kt index 8d7069f..1753233 100644 --- a/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarRepository.kt +++ b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarRepository.kt @@ -2,6 +2,8 @@ package com.henriquesebastiao.downtify.core.data.similar import com.henriquesebastiao.downtify.core.data.ServerResult import com.henriquesebastiao.downtify.core.data.callServer +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository import com.henriquesebastiao.downtify.core.model.RemoteSong import com.henriquesebastiao.downtify.core.network.ApiFactory import com.henriquesebastiao.downtify.core.network.CatalogJson @@ -15,9 +17,43 @@ import javax.inject.Singleton * asks nothing and answers nothing (one field is enough for a seed). */ @Singleton -class SimilarRepository @Inject constructor(private val sessions: SessionStore, private val apis: ApiFactory) { - suspend fun similar(artist: String, track: String): ServerResult> { - if (artist.isBlank() && track.isBlank()) return ServerResult.Ok(emptyList()) +class SimilarRepository @Inject constructor( + private val sessions: SessionStore, + private val apis: ApiFactory, + private val musicAi: MusicAiRepository, +) { + val local: Boolean get() = musicAi.capabilities.value.version == 1 + val capabilities = musicAi.capabilities + + suspend fun similar( + artist: String, + track: String, + seed: String = "", + external: Boolean = false, + ): ServerResult> { + if (artist.isBlank() && track.isBlank() && seed.isBlank()) return ServerResult.Ok(emptyList()) + val refreshed = musicAi.refresh() + if (refreshed !is ServerResult.Ok) { + val legacy = refreshed is ServerResult.Failed && refreshed.status in setOf(404, 405) + if (!legacy) { + return when (refreshed) { + is ServerResult.Failed -> refreshed + else -> ServerResult.Unreachable + } + } + } else if (!local) { + return ServerResult.Failed(503) + } + if (local) { + if (!capabilities.value.enabled(if (external) "external_beta" else "similarity")) { + return ServerResult.Failed(409) + } + return try { + ServerResult.Ok(CatalogJson.similarTracks(musicAi.similar(artist.trim(), track.trim(), external, seed))) + } catch (error: MusicAiFailure) { + ServerResult.Failed(error.status) + } + } return callServer(sessions, apis, patient = true) { api -> CatalogJson.similarTracks(api.similarTracks(artist.trim(), track.trim(), SIMILAR_LIMIT)) } diff --git a/core/data/src/test/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarAvailabilityTest.kt b/core/data/src/test/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarAvailabilityTest.kt new file mode 100644 index 0000000..b8d490a --- /dev/null +++ b/core/data/src/test/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarAvailabilityTest.kt @@ -0,0 +1,118 @@ +package com.henriquesebastiao.downtify.core.data.similar + +import com.henriquesebastiao.downtify.core.data.ServerResult +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.network.ApiFactory +import com.henriquesebastiao.downtify.core.network.session.Session +import com.henriquesebastiao.downtify.core.network.session.SessionStore +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SimilarAvailabilityTest { + private val server = MockWebServer() + private val scope = CoroutineScope(SupervisorJob().apply { cancel() } + Dispatchers.IO) + private lateinit var ai: MusicAiRepository + private lateinit var similar: SimilarRepository + + @Before + fun setUp() { + server.start() + val paired = + MutableStateFlow(Session(server.url("/").toString(), "server", "Test", "device", "fixture")) + val sessions = mockk(relaxed = true) + every { sessions.current } answers { paired.value } + every { sessions.session } returns paired + val apis = ApiFactory(OkHttpClient()) + ai = MusicAiRepository(sessions, apis, scope) + similar = SimilarRepository(sessions, apis, ai) + } + + @After + fun tearDown() { + scope.cancel() + server.close() + } + + private fun answer(code: Int, body: String = "{}") { + server.enqueue(MockResponse.Builder().code(code).body(body).build()) + } + + @Test + fun `a temporary capability failure never invokes the legacy radio`() = runTest { + answer(503) + answer(200, """{"tracks":[]}""") + assertEquals(ServerResult.Failed(503), similar.similar("Synthetic artist", "Synthetic title")) + assertEquals(1, server.requestCount) + } + + @Test + fun `authentication failure is not an old server`() = runTest { + answer(401) + answer(200, """{"tracks":[]}""") + assertEquals(ServerResult.Failed(401), similar.similar("Synthetic artist", "Synthetic title")) + assertEquals(1, server.requestCount) + } + + @Test + fun `unreadable capabilities never invoke an external fallback`() = runTest { + answer(200, "not-json") + answer(200, """{"tracks":[]}""") + assertEquals(ServerResult.Failed(null), similar.similar("Synthetic artist", "Synthetic title")) + assertEquals(1, server.requestCount) + } + + @Test + fun `a confirmed older server keeps its original radio endpoint`() = runTest { + answer(404) + answer(200, """{"tracks":[]}""") + assertTrue(similar.similar("Synthetic artist", "Synthetic title") is ServerResult.Ok) + assertEquals(2, server.requestCount) + assertEquals("/api/v1/music-ai/capabilities", server.takeRequest().url.encodedPath) + assertEquals("/api/similar/tracks", server.takeRequest().url.encodedPath) + } + + @Test + fun `disabled modules are refused without a recommendation request`() = runTest { + answer(200, """{"version":1,"features":{"similarity":false,"external_beta":false}}""") + answer(200, """{"tracks":[]}""") + assertEquals(ServerResult.Failed(409), similar.similar("Synthetic artist", "Synthetic title")) + assertEquals(1, server.requestCount) + } + + @Test + fun `an unsupported future protocol is not treated as legacy`() = runTest { + answer(200, """{"version":2}""") + answer(200, """{"tracks":[]}""") + assertEquals(ServerResult.Failed(503), similar.similar("Synthetic artist", "Synthetic title")) + assertEquals(1, server.requestCount) + } + + @Test + fun `known capabilities survive a transient refresh failure`() = runTest { + answer(200, """{"version":1,"features":{"similarity":true}}""") + assertTrue(ai.refresh() is ServerResult.Ok) + answer(503) + assertEquals(ServerResult.Failed(503), ai.refresh()) + assertEquals(1, ai.capabilities.value.version) + assertTrue(ai.capabilities.value.enabled("similarity")) + } +} diff --git a/core/model/src/main/kotlin/com/henriquesebastiao/downtify/core/model/DjIds.kt b/core/model/src/main/kotlin/com/henriquesebastiao/downtify/core/model/DjIds.kt new file mode 100644 index 0000000..b2f99fb --- /dev/null +++ b/core/model/src/main/kotlin/com/henriquesebastiao/downtify/core/model/DjIds.kt @@ -0,0 +1,19 @@ +package com.henriquesebastiao.downtify.core.model + +/** A server-owned render, never a library track or an arbitrary media URL. */ +data class DjReference(val id: String, val mode: String) + +object DjIds { + private val identity = Regex("[a-f0-9]{32}") + fun valid(id: String) = identity.matches(id) + fun mediaId(id: String, mode: String = "prepared"): String { + require(valid(id) && mode in setOf("prepared", "interactive")) + return "dj:$mode:$id" + } + fun referenceOf(mediaId: String?): DjReference? { + val match = Regex("dj:(prepared|interactive):([a-f0-9]{32})").matchEntire(mediaId.orEmpty()) ?: return null + return DjReference(match.groupValues[2], match.groupValues[1]) + } + fun idOf(mediaId: String?): String? = referenceOf(mediaId)?.id + fun isDj(mediaId: String?) = mediaId?.startsWith("dj:") == true +} diff --git a/core/model/src/main/kotlin/com/henriquesebastiao/downtify/core/model/MediaIds.kt b/core/model/src/main/kotlin/com/henriquesebastiao/downtify/core/model/MediaIds.kt new file mode 100644 index 0000000..b080ecd --- /dev/null +++ b/core/model/src/main/kotlin/com/henriquesebastiao/downtify/core/model/MediaIds.kt @@ -0,0 +1,14 @@ +package com.henriquesebastiao.downtify.core.model + +/** Only native library ids may reach track statistics, favorites and transcoding. */ +object MediaIds { + fun isLibrary(id: String?): Boolean { + if (id.isNullOrBlank()) return false + return when { + EpisodeIds.isEpisode(id) -> false + StreamIds.isStream(id) -> false + DjIds.isDj(id) -> false + else -> true + } + } +} diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt index 3128dae..515bf2b 100644 --- a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt @@ -36,6 +36,8 @@ class ApiFactory @Inject constructor(@DefaultClient private val client: OkHttpCl /** The music trash endpoints ([TrashApi]). */ fun createTrash(baseUrl: String): TrashApi = retrofit(baseUrl).create(TrashApi::class.java) + fun createMusicAi(baseUrl: String): MusicAiApi = retrofit(baseUrl).create(MusicAiApi::class.java) + private fun retrofit(baseUrl: String, patient: Boolean = false): Retrofit { val slot = if (patient) cachedPatient else cached slot?.let { (url, retrofit) -> if (url == baseUrl) return retrofit } diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiApi.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiApi.kt new file mode 100644 index 0000000..17143d7 --- /dev/null +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiApi.kt @@ -0,0 +1,64 @@ +package com.henriquesebastiao.downtify.core.network + +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiEffectiveDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiListenDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiPlaylistsDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiPreferencesDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiSavedDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiSelectionDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiTracksDto +import kotlinx.serialization.json.JsonObject +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +/** Optional versioned endpoints; pairing and authentication use the normal client. */ +interface MusicAiApi : MusicDjApi { + @GET("api/v1/music-ai/capabilities") + suspend fun capabilities(): MusicAiCapabilitiesDto + + @GET("api/v1/music-ai/preferences") + suspend fun preferences(): MusicAiPreferencesDto + + @PUT("api/v1/music-ai/preferences") + suspend fun preferences(@Body changes: Map): MusicAiEffectiveDto + + @GET("api/v1/music-ai/admin/features") + suspend fun globalFeatures(): Map + + @PUT("api/v1/music-ai/admin/features") + suspend fun globalFeatures(@Body changes: Map): Map + + @GET("api/v1/music-ai/recommendations") + suspend fun recommendations(@Query("seed") seed: String = ""): MusicAiTracksDto + + @GET("api/v1/music-ai/similar") + suspend fun similar( + @Query("artist") artist: String, + @Query("track") track: String, + @Query("seed") seed: String = "", + ): JsonObject + + @GET("api/v1/music-ai/external-similar") + suspend fun externalSimilar(@Query("artist") artist: String, @Query("track") track: String): JsonObject + + @GET("api/v1/music-ai/playlists/{cadence}/{mood}") + suspend fun playlist(@Path("cadence") cadence: String, @Path("mood") mood: String): MusicAiTracksDto + + @GET("api/v1/music-ai/playlists") + suspend fun playlists(): MusicAiPlaylistsDto + + @POST("api/v1/music-ai/playlists/save") + suspend fun save(@Body selection: MusicAiSelectionDto): MusicAiSavedDto + + @POST("api/v1/music-ai/listens") + suspend fun listen(@Body event: MusicAiListenDto): JsonObject + + @DELETE("api/v1/music-ai/profile") + suspend fun forget(): JsonObject +} diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicDjApi.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicDjApi.kt new file mode 100644 index 0000000..e154735 --- /dev/null +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicDjApi.kt @@ -0,0 +1,30 @@ +package com.henriquesebastiao.downtify.core.network + +import kotlinx.serialization.json.JsonObject +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +interface MusicDjApi { + @GET("api/v1/music-ai/dj/sessions") + suspend fun djSessions(): com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionsDto + + @GET("api/v1/music-ai/dj/sessions/{id}") + suspend fun djSession(@Path("id") id: String): com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto + + @POST("api/v1/music-ai/dj/sessions/{id}/commands") + suspend fun djCommand( + @Path("id") id: String, + @Body command: com.henriquesebastiao.downtify.core.network.dto.MusicDjCommandDto, + ): com.henriquesebastiao.downtify.core.network.dto.MusicDjCommandResultDto + + @POST("api/v1/music-ai/dj/sessions") + suspend fun djCreate( + @Body selection: com.henriquesebastiao.downtify.core.network.dto.MusicDjSelectionDto, + ): com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto + + @DELETE("api/v1/music-ai/dj/sessions/{id}") + suspend fun djStop(@Path("id") id: String): JsonObject +} diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ServerUrls.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ServerUrls.kt index 12422cb..060f677 100644 --- a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ServerUrls.kt +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ServerUrls.kt @@ -6,6 +6,28 @@ import java.net.URLEncoder /** Absolute URLs on a server for things loaded outside Retrofit (images, audio). */ object ServerUrls { + fun djFile(baseUrl: String, id: String, mode: String = "prepared"): String { + require(com.henriquesebastiao.downtify.core.model.DjIds.valid(id)) + require(mode in setOf("prepared", "interactive")) + val resource = if (mode == "prepared") "media" else "hls/index.m3u8" + return baseUrl.trimEnd('/') + "/api/v1/music-ai/dj/sessions/$id/$resource" + } + + fun isDjHlsResource(baseUrl: String, url: String): Boolean = try { + val base = java.net.URI(baseUrl) + val target = java.net.URI(url) + val path = base.rawPath.orEmpty().trimEnd('/') + "/api/v1/music-ai/dj/sessions/" + val resource = target.rawPath.orEmpty().removePrefix(path) + if (base.scheme != target.scheme || base.authority != target.authority) { + false + } else { + val plain = target.rawQuery == null && target.rawFragment == null + plain && target.rawPath.orEmpty().startsWith(path) && + Regex("[a-f0-9]{32}/hls/(index\\.m3u8|segment-[0-9]{8}\\.ts)").matches(resource) + } + } catch (_: java.net.URISyntaxException) { + false + } const val COVER_SMALL = 150 const val COVER_MEDIUM = 300 const val COVER_LARGE = 600 diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicAiDtos.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicAiDtos.kt new file mode 100644 index 0000000..23cb1bb --- /dev/null +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicAiDtos.kt @@ -0,0 +1,66 @@ +package com.henriquesebastiao.downtify.core.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable data class MusicAiStatusDto( + val ready: Boolean = false, + val state: String = "", + @SerialName("prepared_ready") val preparedReady: Boolean = false, + @SerialName("interactive_ready") val interactiveReady: Boolean = false, + @SerialName("queue_supported") val queueSupported: Boolean = false, + @SerialName("continuous_supported") val continuousSupported: Boolean = false, +) + +@Serializable data class MusicAiCapabilitiesDto( + val version: Int = 0, + val administrator: Boolean = false, + @SerialName("profile_revision") val profileRevision: Int = 0, + val features: Map = emptyMap(), + val model: MusicAiStatusDto = MusicAiStatusDto(), + val dj: MusicAiStatusDto = MusicAiStatusDto(), + val moods: List = emptyList(), +) { + fun enabled(feature: String) = version == 1 && features[feature] == true +} + +@Serializable data class MusicAiPreferencesDto( + val preferences: Map = emptyMap(), + val effective: Map = emptyMap(), +) + +@Serializable data class MusicAiEffectiveDto(val effective: Map = emptyMap()) + +@Serializable data class MusicAiTrackDto( + val id: String, + val title: String = "", + val artist: String = "", + val album: String = "", + val reason: String = "", + val file: String = "", +) + +@Serializable data class MusicAiTracksDto(val tracks: List = emptyList(), val source: String = "local") + +@Serializable data class MusicAiPlaylistDto( + val cadence: String, + val mood: String, + val period: String, + val count: Int, + val created: Double = 0.0, +) + +@Serializable data class MusicAiPlaylistsDto(val playlists: List = emptyList()) + +@Serializable data class MusicAiSelectionDto(val name: String, val ids: List, val source: String) + +@Serializable data class MusicAiSavedDto(val name: String, val count: Int = 0) + +@Serializable data class MusicAiListenDto( + @SerialName("event_id") val eventId: String, + @SerialName("track_id") val trackId: String, + val seconds: Double, + val outcome: String, + val sequence: Int = 0, + val generation: Int = 0, +) diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicDjDtos.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicDjDtos.kt new file mode 100644 index 0000000..0065bdc --- /dev/null +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicDjDtos.kt @@ -0,0 +1,58 @@ +package com.henriquesebastiao.downtify.core.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable data class MusicDjSelectionDto( + @SerialName("request_id") val requestId: String, + val theme: String, + val count: Int, + val mode: String = "prepared", + val ids: List = emptyList(), + @SerialName("queue_order") val queueOrder: Boolean = false, + val continuous: Boolean = false, + val style: String = "library", +) + +@Serializable data class MusicDjTransitionDto(val reason: String = "") + +@Serializable data class MusicDjMarkerDto( + val id: String, + val start: Double = 0.0, + val end: Double = 0.0, + val transition: MusicDjTransitionDto? = null, +) + +@Serializable data class MusicDjSessionDto( + val id: String, + val mode: String = "prepared", + val state: String, + val theme: String, + val progress: Double = 0.0, + val phase: String = "", + val error: String = "", + val duration: Double = 0.0, + val position: Double = 0.0, + val rendered: Double = 0.0, + @SerialName("next_mutable_at") val nextMutableAt: Double = 0.0, + val commands: List = emptyList(), + val tracks: List = emptyList(), + val timeline: List = emptyList(), + @SerialName("media_origin") val mediaOrigin: Double? = null, + val continuous: Boolean = false, +) + +@Serializable data class MusicDjSessionsDto(val sessions: List = emptyList()) + +@Serializable data class MusicDjCommandDto( + @SerialName("request_id") val requestId: String, + val action: String, + val payload: Map = emptyMap(), +) + +@Serializable data class MusicDjCommandResultDto( + val accepted: Boolean, + @SerialName("effective_after_seconds") val effectiveAfterSeconds: Double = 0.0, + @SerialName("effective_at") val effectiveAt: Double = 0.0, + val reason: String = "", +) diff --git a/core/network/src/test/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiContractTest.kt b/core/network/src/test/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiContractTest.kt new file mode 100644 index 0000000..4c5ad47 --- /dev/null +++ b/core/network/src/test/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiContractTest.kt @@ -0,0 +1,96 @@ +package com.henriquesebastiao.downtify.core.network + +import kotlinx.coroutines.test.runTest +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test + +class MusicAiContractTest { + private val server = MockWebServer() + private val api get() = ApiFactory(OkHttpClient()).createMusicAi(server.url("/").toString()) + + @Before + fun setUp() = server.start() + + @After + fun tearDown() = server.close() + + @Test + fun `two versions of a song are addressed by the chosen native id`() = runTest { + server.enqueue(MockResponse.Builder().body("""{"source":"local","tracks":[]}""").build()) + api.similar("", "", "t0123456789abcdef") + val url = server.takeRequest().url + assertEquals("/api/v1/music-ai/similar", url.encodedPath) + assertEquals("t0123456789abcdef", url.queryParameter("seed")) + assertEquals("", url.queryParameter("artist")) + assertEquals("", url.queryParameter("track")) + } + + @Test + fun `external beta uses its own route instead of a local recommendation`() = runTest { + server.enqueue(MockResponse.Builder().body("""{"tracks":[]}""").build()) + api.externalSimilar("Synthetic artist", "Synthetic title") + val url = server.takeRequest().url + assertEquals("/api/v1/music-ai/external-similar", url.encodedPath) + assertEquals("Synthetic title", url.queryParameter("track")) + assertFalse(url.queryParameterNames.contains("seed")) + } + + @Test + fun `preferences rely on authenticated ownership and default missing features to off`() = runTest { + server.enqueue(MockResponse.Builder().body("""{"effective":{"similarity":false}}""").build()) + api.preferences(mapOf("similarity" to false)) + val request = server.takeRequest() + assertEquals("PUT", request.method) + assertEquals("""{"similarity":false}""", request.body?.utf8()) + server.enqueue(MockResponse.Builder().body("""{"version":1,"features":{"similarity":false}}""").build()) + val capabilities = api.capabilities() + assertFalse(capabilities.enabled("similarity")) + assertFalse(capabilities.enabled("external_beta")) + assertFalse(capabilities.enabled("dj_interactive")) + } + + @Test fun `home playlist cards use one owner scoped metadata request`() = runTest { + server.enqueue( + MockResponse.Builder().body( + """{"playlists":[{"cadence":"daily","mood":"calme","period":"2026-11-06","count":40,"created":1}]}""", + ).build(), + ) + val result = api.playlists() + val request = server.takeRequest() + assertEquals("GET", request.method) + assertEquals("/api/v1/music-ai/playlists", request.url.encodedPath) + assertEquals(0, request.url.querySize) + assertEquals(1, server.requestCount) + assertEquals(40, result.playlists.single().count) + assertEquals("calme", result.playlists.single().mood) + } + + @Test fun `DJ contract uses an idempotent request and ignores arbitrary server media URLs`() = runTest { + val id = "a".repeat(32) + server.enqueue( + MockResponse.Builder().body( + """{"id":"$id","state":"queued","theme":"fete","media_url":"https://untrusted.test/file"}""", + ).build(), + ) + val found = api.djCreate( + com.henriquesebastiao.downtify.core.network.dto.MusicDjSelectionDto("intent", "fete", 2), + ) + val request = server.takeRequest() + assertEquals("/api/v1/music-ai/dj/sessions", request.url.encodedPath) + assertEquals("POST", request.method) + val body = request.body?.utf8().orEmpty() + org.junit.Assert.assertTrue(body.contains("\"request_id\":\"intent\"")) + assertFalse(body.contains("owner")) + assertEquals(id, found.id) + assertEquals( + "https://fixture.test/api/v1/music-ai/dj/sessions/$id/media", + ServerUrls.djFile("https://fixture.test", found.id), + ) + } +} diff --git a/core/player/build.gradle.kts b/core/player/build.gradle.kts index 76e1d57..1a04aec 100644 --- a/core/player/build.gradle.kts +++ b/core/player/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { api(projects.core.data) api(libs.media3.session) implementation(libs.media3.exoplayer) + implementation(libs.media3.exoplayer.hls) implementation(libs.media3.datasource.okhttp) implementation(libs.media3.database) implementation(libs.kotlinx.coroutines.guava) diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/ActivityTracker.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/ActivityTracker.kt index e398eb0..d8cdffa 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/ActivityTracker.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/ActivityTracker.kt @@ -5,6 +5,7 @@ import androidx.media3.common.Player import com.henriquesebastiao.downtify.core.data.activity.ActivityTrack import com.henriquesebastiao.downtify.core.data.activity.PlaybackActivityReporter import com.henriquesebastiao.downtify.core.data.activity.PlaybackActivityState +import com.henriquesebastiao.downtify.core.model.DjIds import com.henriquesebastiao.downtify.core.model.StreamIds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -62,7 +63,9 @@ internal class ActivityTracker( // Nothing was reported yet and nothing plays: no need to say "stopped". if (last == null && state == PlaybackActivityState.Stopped) return // Streams have no library row to report: keep quiet, like the web player does. - if (StreamIds.isStream(player.currentMediaItem?.mediaId)) { + if (StreamIds.isStream(player.currentMediaItem?.mediaId) || + DjIds.isDj(player.currentMediaItem?.mediaId) + ) { last = key ticker?.cancel() return diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt new file mode 100644 index 0000000..4e4d793 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt @@ -0,0 +1,119 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.model.DjIds +import com.henriquesebastiao.downtify.core.model.DjReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** Share one server render with web clients while retaining the native foreground media service. */ +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +internal class DjControlTracker( + private val player: Player, + private val music: MusicAiRepository, + private val scope: CoroutineScope, +) : Player.Listener { + private var current: DjReference? = null + private var polling: Job? = null + private var pausedByServer = false + + fun start() { + player.addListener(this) + changed() + } + fun stop() { + player.removeListener(this) + polling?.cancel() + } + + private fun command(reference: DjReference, action: String) { + if (reference.mode != "interactive") return + val owner = music.session.value + scope.launch { + if (music.session.value != owner || !music.capabilities.value.enabled("dj_interactive")) return@launch + try { + music.djCommand(reference.id, action) + } catch ( + _: MusicAiFailure, + ) { /* Next server poll reconciles. */ } + } + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + if (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) { + current?.let { command(it, if (playWhenReady) "resume" else "pause") } + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = changed() + + private fun changed() { + val next = DjIds.referenceOf(player.currentMediaItem?.mediaId) + if (next == current) return + current?.let { command(it, "pause") } + polling?.cancel() + pausedByServer = false + current = next + (player as? androidx.media3.exoplayer.ExoPlayer)?.pauseAtEndOfMediaItems = next != null + if (next == null) return + val handover = + next.mode == "interactive" && + player.currentMediaItem?.mediaMetadata?.extras?.getBoolean("djStartOnLoad") == true && + player.playWhenReady + if (handover) command(next, "resume") + val owner = music.session.value + polling = scope.launch { + if (handover) delay(1000) + while (isActive && current == next && owner == music.session.value) { + if (music.capabilities.value.enabled("dj_" + next.mode)) reconcile(next) + delay(2000) + } + } + } + + private suspend fun reconcile(reference: DjReference) { + try { + val owner = music.session.value + val session = music.djSession(reference.id) + if (owner != music.session.value || current != reference || + !music.capabilities.value.enabled("dj_" + reference.mode) + ) { + return + } + if (owner != null) updateMetadata(session, owner.baseUrl) + if (reference.mode != "interactive") return + when (session.state) { + "paused" -> { + if (player.playWhenReady) pausedByServer = true + player.pause() + } + + "playing" -> if (pausedByServer) { + pausedByServer = false + player.play() + } + + "failed", "stopped", "cancelled", "deleted", "deleting" -> player.pause() + } + } catch (error: MusicAiFailure) { + if (error.status in setOf(401, 403, 404, 409)) player.pause() + } + } + + private fun updateMetadata( + session: com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto, + baseUrl: String, + ) { + val item = player.currentMediaItem ?: return + if (session.timeline.isEmpty()) return + val position = DjTimeline.position(player, session.mediaOrigin?.times(1000)?.toLong() ?: 0) + val updated = DjTimeline.metadata(item, session, position, baseUrl) + if (updated.mediaMetadata != item.mediaMetadata) player.replaceMediaItem(player.currentMediaItemIndex, updated) + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjDataSource.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjDataSource.kt new file mode 100644 index 0000000..a0eaa84 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjDataSource.kt @@ -0,0 +1,40 @@ +package com.henriquesebastiao.downtify.core.player + +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener + +/** DJ bytes must always reach the authenticated server, including range requests after a seek. */ +@UnstableApi +internal class DjDataSource( + private val regular: DataSource.Factory, + private val direct: DataSource.Factory, + private val liveResource: (Uri) -> Boolean = { false }, +) : DataSource { + private var active: DataSource? = null + private val listeners = mutableListOf() + override fun addTransferListener(transferListener: TransferListener) { + listeners += transferListener + active?.addTransferListener(transferListener) + } + override fun open(dataSpec: DataSpec): Long { + check(active == null) + val factory = if (DjMediaItems.idOf(dataSpec.uri) != null || liveResource(dataSpec.uri)) direct else regular + val source = factory.createDataSource() + listeners.forEach(source::addTransferListener) + active = source + return source.open(dataSpec) + } + override fun read(buffer: ByteArray, offset: Int, length: Int) = checkNotNull(active).read(buffer, offset, length) + override fun getUri(): Uri? = active?.uri + override fun getResponseHeaders(): Map> = active?.responseHeaders ?: emptyMap() + override fun close() { + try { + active?.close() + } finally { + active = null + } + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjMediaItems.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjMediaItems.kt new file mode 100644 index 0000000..c3a64df --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjMediaItems.kt @@ -0,0 +1,57 @@ +package com.henriquesebastiao.downtify.core.player + +import android.net.Uri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.MimeTypes +import com.henriquesebastiao.downtify.core.model.DjIds +import com.henriquesebastiao.downtify.core.model.DjReference + +/** Logical render addresses survive MediaController IPC and private queue persistence. */ +object DjMediaItems { + fun uri(id: String, mode: String = "prepared"): Uri { + DjIds.mediaId(id, mode) + return Uri.Builder().scheme(MediaItems.SCHEME).authority("dj").appendPath(mode).appendPath(id).build() + } + fun addressOf(uri: Uri): DjReference? { + if (uri.scheme != MediaItems.SCHEME || uri.authority != "dj") return null + val parts = uri.pathSegments + return when (parts.size) { + 1 -> parts[0].takeIf(DjIds::valid)?.let { DjReference(it, "prepared") } + 2 -> DjIds.referenceOf("dj:${parts[0]}:${parts[1]}") + else -> null + } + } + fun idOf(uri: Uri) = addressOf(uri)?.id + fun mimeType(mode: String) = if (mode == "interactive") MimeTypes.APPLICATION_M3U8 else MimeTypes.AUDIO_MP4 + fun from(id: String, title: String, durationMs: Long, mode: String = "prepared"): MediaItem = MediaItem.Builder() + .setMediaId(DjIds.mediaId(id, mode)).setUri(uri(id, mode)).setMimeType(mimeType(mode)) + .apply { + if (mode == "interactive") { + setLiveConfiguration( + MediaItem.LiveConfiguration.Builder() + .setTargetOffsetMs(24000).setMaxPlaybackSpeed(1f).build(), + ) + } + } + .setMediaMetadata( + MediaMetadata.Builder().setTitle(title).setArtist("Mix DJ local") + .setDurationMs(durationMs).setIsPlayable(true).setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC).build(), + ).build() + fun of(item: MediaItem): PlayingDj? = DjIds.referenceOf(item.mediaId)?.let { + val extras = item.mediaMetadata.extras + PlayingDj( + it.id, item.mediaMetadata.title?.toString().orEmpty(), it.mode, + artist = item.mediaMetadata.artist?.toString() ?: "Mix DJ local", + trackId = extras?.getString("djTrackId"), + nextTitle = extras?.getString("djNextTitle").orEmpty(), + nextArtist = extras?.getString("djNextArtist").orEmpty(), + nextTrackId = extras?.getString("djNextId"), + start = extras?.getLong("djStart") ?: 0, + end = extras?.getLong("djEnd") ?: 0, + nextStart = extras?.getLong("djNextStart", Long.MAX_VALUE) ?: Long.MAX_VALUE, + origin = extras?.getLong("djOrigin") ?: 0, + ) + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt new file mode 100644 index 0000000..36c2189 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt @@ -0,0 +1,56 @@ +package com.henriquesebastiao.downtify.core.player + +import android.net.Uri +import android.os.Bundle +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import com.henriquesebastiao.downtify.core.network.ServerUrls +import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +internal object DjTimeline { + fun position(player: Player, origin: Long): Long { + if (origin <= 0 || player.currentTimeline.isEmpty) return player.currentPosition.coerceAtLeast(0) + val window = player.currentTimeline.getWindow(player.currentMediaItemIndex, Timeline.Window()) + return if (window.windowStartTimeMs != C.TIME_UNSET) { + (window.windowStartTimeMs - origin + player.currentPosition).coerceAtLeast(0) + } else { + player.currentPosition.coerceAtLeast(0) + } + } + + fun metadata(item: MediaItem, session: MusicDjSessionDto, positionMs: Long, baseUrl: String): MediaItem { + val seconds = positionMs / 1000.0 + val index = session.timeline.indexOfFirst { it.start <= seconds && seconds < it.end } + val row = session.timeline.getOrNull(index) ?: return item + val track = session.tracks.firstOrNull { it.id == row.id } ?: return item + val nextRow = session.timeline.getOrNull(index + 1) + val next = session.tracks.firstOrNull { it.id == nextRow?.id } + val extras = Bundle().apply { + putString("djTrackId", track.id) + putString("djNextTitle", next?.title) + putString("djNextArtist", next?.artist) + putString("djNextId", next?.id) + putLong("djStart", (row.start * 1000).toLong()) + putLong("djEnd", (row.end * 1000).toLong()) + putLong("djNextStart", nextRow?.start?.times(1000)?.toLong() ?: Long.MAX_VALUE) + putLong("djOrigin", session.mediaOrigin?.times(1000)?.toLong() ?: 0) + } + return item.buildUpon().setMediaMetadata( + item.mediaMetadata.buildUpon() + .setTitle(track.title).setArtist(track.artist) + .setArtworkUri(Uri.parse(ServerUrls.cover(baseUrl, track.id, ServerUrls.COVER_LARGE))) + .setExtras(extras).build(), + ).build() + } + + fun shown(dj: PlayingDj, absolute: Long): PlayingDj = dj.copy( + transition = if (dj.end > dj.nextStart && absolute in dj.nextStart until dj.end) { + ((absolute - dj.nextStart).toFloat() / (dj.end - dj.nextStart)).coerceIn(0f, 1f) + } else { + null + }, + ) +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/ListenTracker.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/ListenTracker.kt index 275384d..3937f44 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/ListenTracker.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/ListenTracker.kt @@ -4,6 +4,7 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import com.henriquesebastiao.downtify.core.data.listens.ListenReporter +import com.henriquesebastiao.downtify.core.model.DjIds import com.henriquesebastiao.downtify.core.model.EpisodeIds import com.henriquesebastiao.downtify.core.model.ListenCounter import com.henriquesebastiao.downtify.core.model.StreamIds @@ -58,7 +59,11 @@ internal class ListenTracker( val id = player.currentMediaItem?.mediaId ?: return if (id != trackId) newPlay(id) // Podcast episodes and streams don't count as listens (they're not tracks the server knows). - if (EpisodeIds.isEpisode(id) || StreamIds.isStream(id)) return + if (EpisodeIds.isEpisode(id) || StreamIds.isStream(id) || + DjIds.isDj(id) + ) { + return + } val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: player.mediaMetadata.durationMs ?: return diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/MediaItems.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/MediaItems.kt index ba632ea..c0f71c8 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/MediaItems.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/MediaItems.kt @@ -4,7 +4,9 @@ import android.net.Uri import android.os.Bundle import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import com.henriquesebastiao.downtify.core.model.DjIds import com.henriquesebastiao.downtify.core.model.EpisodeIds +import com.henriquesebastiao.downtify.core.model.MediaIds import com.henriquesebastiao.downtify.core.model.PlaybackContext import com.henriquesebastiao.downtify.core.model.PlaybackContextType import com.henriquesebastiao.downtify.core.model.PodcastEpisode @@ -128,8 +130,14 @@ object MediaItems { fun restored(saved: SavedPlaybackItem, baseUrl: String): MediaItem { val videoId = saved.videoId val uri = when { + DjIds.referenceOf(saved.mediaId) != null -> requireNotNull(DjIds.referenceOf(saved.mediaId)).let { + DjMediaItems.uri(it.id, it.mode) + } + saved.episodeId != null -> Uri.parse(EpisodeIds.fileUrl(baseUrl, saved.episodeFile.orEmpty())) + videoId != null -> streamUri(videoId) + else -> uriFor(saved.mediaId) } val artwork = when { @@ -149,6 +157,7 @@ object MediaItems { return MediaItem.Builder() .setMediaId(saved.mediaId) .setUri(uri) + .apply { DjIds.referenceOf(saved.mediaId)?.let { setMimeType(DjMediaItems.mimeType(it.mode)) } } .setRequestMetadata(MediaItem.RequestMetadata.Builder().setMediaUri(uri).build()) .setMediaMetadata( MediaMetadata.Builder() @@ -171,11 +180,7 @@ object MediaItems { /** Keeps the restored mini player visible before Room's library snapshot arrives. */ fun trackFromMetadata(item: MediaItem): Track? { - if (EpisodeIds.isEpisode(item.mediaId) || StreamIds.isStream(item.mediaId) || - item.mediaId.isBlank() - ) { - return null - } + if (!MediaIds.isLibrary(item.mediaId)) return null val metadata = item.mediaMetadata return Track( id = item.mediaId, file = item.mediaId, title = metadata.title?.toString().orEmpty(), diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/MusicAiListenTracker.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/MusicAiListenTracker.kt new file mode 100644 index 0000000..373b816 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/MusicAiListenTracker.kt @@ -0,0 +1,157 @@ +package com.henriquesebastiao.downtify.core.player + +import android.os.SystemClock +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.model.DjIds +import com.henriquesebastiao.downtify.core.model.EpisodeIds +import com.henriquesebastiao.downtify.core.model.StreamIds +import com.henriquesebastiao.downtify.core.network.dto.MusicAiListenDto +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** Optional per-track profile events, independent of the native artist listen counter. */ +internal class MusicAiListenTracker( + private val player: Player, + private val music: MusicAiRepository, + private val scope: CoroutineScope, +) : Player.Listener { + private var track: String? = null + private var terminalTrack: String? = null + private var owner = music.session.value + private var eventId = "" + private var seconds = 0.0 + private var sequence = 0 + private var generation = 0 + private var lastPosition = 0L + private var lastClock = SystemClock.elapsedRealtime() + private var sentAt = lastClock + private var timer: Job? = null + private var occurrence: Long? = null + + private fun identity(item: MediaItem?): String? = if (DjIds.isDj(item?.mediaId)) { + item?.mediaMetadata?.extras?.getString("djTrackId") + } else { + item?.mediaId + } + + private fun currentOccurrence(): Long? = player.currentMediaItem?.takeIf { DjIds.isDj(it.mediaId) } + ?.mediaMetadata?.extras?.getLong("djStart") + + fun start() { + player.addListener(this) + timer = scope.launch { + while (isActive) { + delay(1000) + sample() + } + } + } + fun stop() { + publish("pause") + player.removeListener(this) + timer?.cancel() + } + + private fun reset(id: String?) { + occurrence = currentOccurrence() + track = + id?.takeUnless { + EpisodeIds.isEpisode(it) || StreamIds.isStream(it) || + DjIds.isDj(it) + } + owner = music.session.value + eventId = UUID.randomUUID().toString() + seconds = 0.0 + sequence = 0 + generation = music.capabilities.value.profileRevision + lastPosition = player.currentPosition + lastClock = SystemClock.elapsedRealtime() + sentAt = lastClock + } + private fun enabled() = music.capabilities.value.enabled("personalization") + private fun changed(id: String) = + track != id || occurrence != currentOccurrence() || generation != music.capabilities.value.profileRevision + private fun sample() { + if (!enabled() || owner != music.session.value) { + reset(null) + return + } + val id = identity(player.currentMediaItem) ?: return + if (id == terminalTrack) return + if (changed(id)) { + publish("pause") + reset(id) + } + if (track == null) return + val clock = SystemClock.elapsedRealtime() + val position = player.currentPosition + val elapsed = clock - lastClock + val movement = position - lastPosition + val continuousMovement = elapsed in 1..3000 && movement > 0 && movement <= elapsed * 1.25 + 200 + if (player.isPlaying && continuousMovement) { + seconds += minOf(movement, elapsed) / 1000.0 + } + lastPosition = position + lastClock = clock + if (clock - sentAt >= 20000) publish("pause") + } + private fun publish(outcome: String) { + val id = track ?: return + if (!enabled() || owner != music.session.value || generation != music.capabilities.value.profileRevision) return + val paired = owner + val event = MusicAiListenDto(eventId, id, seconds, outcome, ++sequence, generation) + sentAt = SystemClock.elapsedRealtime() + scope.launch { + if (music.session.value != paired) return@launch + try { + music.listen(event) + } catch ( + _: MusicAiFailure, + ) { /* Next checkpoint retries the cumulative duration. */ } + } + } + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (DjIds.isDj(mediaItem?.mediaId) && identity(mediaItem) == track && occurrence == currentOccurrence()) return + terminalTrack = null + publish( + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || + reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT + ) { + "complete" + } else { + "skip" + }, + ) + reset(identity(mediaItem)) + } + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + if (DjIds.isDj(player.currentMediaItem?.mediaId)) sample() + } + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) terminalTrack = null + if (!isPlaying) publish("pause") + lastPosition = player.currentPosition + lastClock = SystemClock.elapsedRealtime() + } + override fun onPlayerError(error: PlaybackException) { + terminalTrack = identity(player.currentMediaItem) + publish("error") + reset(null) + } + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + terminalTrack = identity(player.currentMediaItem) + publish("complete") + reset(null) + } + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt index a43d08c..c8f1067 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt @@ -42,7 +42,9 @@ import com.henriquesebastiao.downtify.core.data.playback.PlaybackRepository import com.henriquesebastiao.downtify.core.data.playback.playbackOwner import com.henriquesebastiao.downtify.core.data.podcasts.PodcastsRepository import com.henriquesebastiao.downtify.core.data.sync.SyncCoordinator +import com.henriquesebastiao.downtify.core.model.DjIds import com.henriquesebastiao.downtify.core.model.EpisodeIds +import com.henriquesebastiao.downtify.core.model.MediaIds import com.henriquesebastiao.downtify.core.model.PlayQueue import com.henriquesebastiao.downtify.core.model.StreamIds import com.henriquesebastiao.downtify.core.network.di.StreamingClient @@ -85,8 +87,12 @@ class PlaybackService : MediaLibraryService() { @Inject lateinit var library: LibraryRepository + @Inject lateinit var sleepTimer: PlaybackSleepTimer + @Inject lateinit var reporter: ListenReporter + @Inject lateinit var musicAi: com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository + @Inject lateinit var activityReporter: PlaybackActivityReporter @Inject lateinit var podcasts: PodcastsRepository @@ -120,6 +126,8 @@ class PlaybackService : MediaLibraryService() { private var session: MediaLibrarySession? = null private var artworkTracker: SessionArtworkTracker? = null private var listens: ListenTracker? = null + private var aiListens: MusicAiListenTracker? = null + private var djControls: DjControlTracker? = null private var activity: ActivityTracker? = null private var episodes: EpisodeProgressTracker? = null private var prefetch: StreamPrefetchTracker? = null @@ -128,6 +136,7 @@ class PlaybackService : MediaLibraryService() { private val likeCommand = SessionCommand(ACTION_TOGGLE_LIKE, Bundle.EMPTY) private val addToQueueCommand = SessionCommand(ACTION_ADD_TO_QUEUE, Bundle.EMPTY) private val moveInQueueCommand = SessionCommand(ACTION_MOVE_IN_QUEUE, Bundle.EMPTY) + private val djNextCommand = SessionCommand(ACTION_DJ_NEXT, Bundle.EMPTY) override fun onCreate() { super.onCreate() @@ -174,6 +183,7 @@ class PlaybackService : MediaLibraryService() { .build() callback.observe(checkNotNull(session)) + scope.launch { sleepTimer.run { player.pause() } } syncCoordinator.setMediaServiceActive(true) scope.launch { withContext(Dispatchers.IO) { sessions.ensureLoaded() } @@ -185,6 +195,8 @@ class PlaybackService : MediaLibraryService() { ) listens = ListenTracker(player, reporter, scope).also { it.start() } + aiListens = MusicAiListenTracker(player, musicAi, scope).also { it.start() } + djControls = DjControlTracker(player, musicAi, scope).also { it.start() } activity = ActivityTracker(player, activityReporter, scope).also { it.start() } episodes = EpisodeProgressTracker(player, podcasts, scope).also { it.start() } prefetch = StreamPrefetchTracker(player, catalog, scope, enabled = { !readAhead.enabled }).also { it.start() } @@ -194,6 +206,7 @@ class PlaybackService : MediaLibraryService() { }).also { it.start() } readAhead.start(player, scope) keepLikeButtonCurrent(player) + stopDisabledDj(player) stopWhenSignedOut(player) removeDeletedLibraryTracks(player) persistence = PlaybackPersistence(player, playback, sessions, scope).also { it.start() } @@ -220,6 +233,24 @@ class PlaybackService : MediaLibraryService() { }.launchIn(scope) } + private fun stopDisabledDj(player: Player) { + fun enforce() { + val id = player.currentMediaItem?.mediaId + if (!DjIds.isDj(id)) return + val reference = DjIds.referenceOf(id) + if (reference == null || !musicAi.capabilities.value.enabled("dj_" + reference.mode)) player.pause() + } + musicAi.capabilities.onEach { enforce() }.launchIn(scope) + player.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) enforce() + } + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + enforce() + } + }) + } + /** Unpaired (here or from the web): the queue belongs to a server the app can't reach anymore. */ private fun stopWhenSignedOut(player: Player) { scope.launch { @@ -227,6 +258,7 @@ class PlaybackService : MediaLibraryService() { val expected = sessions.current?.playbackOwner sessions.session.collect { paired -> if (paired == null || paired.playbackOwner != expected) { + sleepTimer.set(null) player.stop() player.clearMediaItems() stopSelf() @@ -244,12 +276,15 @@ class PlaybackService : MediaLibraryService() { } override fun onDestroy() { + sleepTimer.set(null) CarDiagnostics.service(false) syncCoordinator.setMediaServiceActive(false) artworkTracker?.stop() readAhead.stop() persistence?.stop() listens?.stop() + aiListens?.stop() + djControls?.stop() activity?.stop() episodes?.stop() prefetch?.stop() @@ -286,7 +321,9 @@ class PlaybackService : MediaLibraryService() { // Songs get a heart; an episode or a stream has nothing to like. combine(currentId, library.likedIds) { id, liked -> when { - EpisodeIds.isEpisode(id) || StreamIds.isStream(id) -> null + EpisodeIds.isEpisode(id) || StreamIds.isStream(id) || + DjIds.isDj(id) -> null + else -> id != null && id in liked } } @@ -394,7 +431,7 @@ class PlaybackService : MediaLibraryService() { } val commands = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() if (own(controller)) { - commands.add(likeCommand).add(addToQueueCommand).add(moveInQueueCommand) + commands.add(likeCommand).add(addToQueueCommand).add(moveInQueueCommand).add(djNextCommand) } else { commands.add(likeCommand) } @@ -413,20 +450,18 @@ class PlaybackService : MediaLibraryService() { } if (customCommand.customAction == ACTION_TOGGLE_LIKE) { val id = session.player.currentMediaItem?.mediaId - if (id != null && !EpisodeIds.isEpisode(id) && !StreamIds.isStream(id)) { + if (id != null && MediaIds.isLibrary(id)) { val liked = id in library.likedIds.value scope.launch { library.setLiked(id, !liked) } } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } + if (customCommand.customAction == ACTION_DJ_NEXT) { + val reference = DjIds.referenceOf(session.player.currentMediaItem?.mediaId) + return requestDjNext(reference) + } if (customCommand.customAction == ACTION_ADD_TO_QUEUE) { - val track = args.getString(EXTRA_TRACK_ID)?.let { library.library.value?.byId?.get(it) } - ?: return Futures.immediateFuture(SessionResult(SessionError.ERROR_BAD_VALUE)) - addToQueue( - session.player as ExoPlayer, - MediaItems.from(track, sessions.current?.baseUrl, queued = true), - ) - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + return requestQueueAdd(session, args) } if (customCommand.customAction == ACTION_MOVE_IN_QUEUE) { moveInQueue( @@ -440,6 +475,31 @@ class PlaybackService : MediaLibraryService() { return super.onCustomCommand(session, controller, customCommand, args) } + private fun requestDjNext( + reference: com.henriquesebastiao.downtify.core.model.DjReference?, + ): ListenableFuture { + if (reference?.mode != + "interactive" + ) { + return Futures.immediateFuture(SessionResult(SessionError.ERROR_BAD_VALUE)) + } + return scope.future { + try { + musicAi.djCommand(reference.id, "next") + SessionResult(SessionResult.RESULT_SUCCESS) + } catch (_: com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure) { + SessionResult(SessionError.ERROR_IO) + } + } + } + + private fun requestQueueAdd(session: MediaSession, args: Bundle): ListenableFuture { + val track = args.getString(EXTRA_TRACK_ID)?.let { library.library.value?.byId?.get(it) } + ?: return Futures.immediateFuture(SessionResult(SessionError.ERROR_BAD_VALUE)) + addToQueue(session.player as ExoPlayer, MediaItems.from(track, sessions.current?.baseUrl, queued = true)) + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + /** * Controllers send items without a URI (it's stripped between processes): * rebuild each from the library by its id. @@ -453,6 +513,22 @@ class PlaybackService : MediaLibraryService() { val snapshot = library.library.value val baseUrl = sessions.current?.baseUrl val resolved = mediaItems.mapNotNull { item -> + if (DjIds.isDj(item.mediaId)) { + val reference = DjIds.referenceOf(item.mediaId) + return@mapNotNull if (reference != null && + musicAi.capabilities.value.enabled("dj_" + reference.mode) + ) { + DjMediaItems.from( + reference.id, + item.mediaMetadata.title?.toString().orEmpty(), + item.mediaMetadata.durationMs ?: 0, + reference.mode, + ).buildUpon() + .setMediaMetadata(item.mediaMetadata).build() + } else { + null + } + } // An episode carries its file's address in the request metadata. if (EpisodeIds.isEpisode(item.mediaId)) { return@mapNotNull item.requestMetadata.mediaUri?.let { item.buildUpon().setUri(it).build() } @@ -475,6 +551,7 @@ class PlaybackService : MediaLibraryService() { const val ACTION_TOGGLE_LIKE = "com.henriquesebastiao.downtify.TOGGLE_LIKE" const val ACTION_ADD_TO_QUEUE = "com.henriquesebastiao.downtify.ADD_TO_QUEUE" const val ACTION_MOVE_IN_QUEUE = "com.henriquesebastiao.downtify.MOVE_IN_QUEUE" + const val ACTION_DJ_NEXT = "com.henriquesebastiao.downtify.DJ_NEXT" const val EXTRA_TRACK_ID = "track_id" const val EXTRA_FROM = "from" const val EXTRA_AFTER = "after" diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackSleepTimer.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackSleepTimer.kt new file mode 100644 index 0000000..600b19c --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackSleepTimer.kt @@ -0,0 +1,38 @@ +package com.henriquesebastiao.downtify.core.player + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest + +/** Owned by the foreground media service, independent of activity/controller releases. */ +@Singleton +class PlaybackSleepTimer internal constructor(private val now: () -> Long) { + @Inject constructor() : this(System::currentTimeMillis) + private val mutableDeadline = MutableStateFlow(null) + val deadline = mutableDeadline.asStateFlow() + + fun set(minutes: Int?) { + require(minutes == null || minutes in PRESETS) + mutableDeadline.value = minutes?.let { now() + it * MILLIS_PER_MINUTE } + } + + suspend fun run(pause: () -> Unit) { + deadline.collectLatest { at -> + if (at != null) { + delay((at - now()).coerceAtLeast(0)) + if (mutableDeadline.value == at) { + mutableDeadline.value = null + pause() + } + } + } + } + + companion object { + val PRESETS = listOf(5, 10, 20, 30, 45, 60) + private const val MILLIS_PER_MINUTE = 60_000L + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt index 0af2662..3a7b47e 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt @@ -156,6 +156,33 @@ class PlayerController @Inject constructor( } /** Plays [tracks] from [startIndex] as the new queue, remembering where they came from. */ + fun startMix(seed: Track, tracks: List) { + val owner = sessions.current?.playbackOwner ?: return + val expected = state.value.mediaKey + main.launch { + val player = connect() + if (sessions.current?.playbackOwner != owner || state.value.mediaKey != expected) return@launch + val from = + PlaybackContext( + com.henriquesebastiao.downtify.core.model.PlaybackContextType.Songs, + "", + "Mix · ${seed.displayTitle}", + ) + if (state.value.track?.id != seed.id) { + play(listOf(seed) + tracks, 0, from) + return@launch + } + // Rebuild only what follows: Media3 retains current position and decoder. + val index = player.currentMediaItemIndex + if (index + 1 < player.mediaItemCount) player.removeMediaItems(index + 1, player.mediaItemCount) + if (index > 0) player.removeMediaItems(0, index) + player.addMediaItems(tracks.map { MediaItems.from(it, sessions.current?.baseUrl) }) + player.shuffleModeEnabled = false + player.repeatMode = Player.REPEAT_MODE_OFF + player.playlistMetadata = MediaItems.contextMetadata(from) + } + } + fun play(tracks: List, startIndex: Int, from: PlaybackContext, shuffle: Boolean = false) { if (tracks.isEmpty()) return main.launch { @@ -247,17 +274,64 @@ class PlayerController @Inject constructor( fun pause() = command { it.pause() } + /** Insert a render without discarding the classic queue. All media uses the same session. */ + fun playDj(id: String, title: String, durationMs: Long, mode: String = "prepared") { + val owner = sessions.current?.playbackOwner ?: return + val item = DjMediaItems.from(id, title, durationMs, mode) + main.launch { + val player = connect() + if (sessions.current?.playbackOwner != owner) return@launch + val existing = (0 until player.mediaItemCount).firstOrNull { + player.getMediaItemAt(it).mediaId == + item.mediaId + } + val index = existing ?: (player.currentMediaItemIndex + 1).coerceIn(0, player.mediaItemCount) + if (existing == null) player.addMediaItem(index, item) + player.seekTo(index, 0) + player.setPlaybackSpeed(1f) + player.prepare() + player.play() + } + } + + fun queueDj(id: String, title: String, durationMs: Long) = command { + val item = DjMediaItems.from(id, title, durationMs, "interactive") + val extras = Bundle().apply { putBoolean("djStartOnLoad", true) } + it.addMediaItem( + (it.currentMediaItemIndex + 1).coerceIn(0, it.mediaItemCount), + item.buildUpon().setMediaMetadata(item.mediaMetadata.buildUpon().setExtras(extras).build()).build(), + ) + } + + fun exitDj(id: String) = command { + for (index in (0 until it.mediaItemCount).reversed()) { + if (com.henriquesebastiao.downtify.core.model.DjIds.referenceOf(it.getMediaItemAt(index).mediaId)?.id == + id + ) { + if (index == it.currentMediaItemIndex) it.pause() + it.removeMediaItem(index) + } + } + } + fun resume() = command { if (it.playbackState == Player.STATE_IDLE) it.prepare() it.play() } - fun next() = command { it.seekToNext() } + fun next() = command { + if (state.value.dj?.mode == "interactive") { + it.sendCustomCommand(SessionCommand(PlaybackService.ACTION_DJ_NEXT, Bundle.EMPTY), Bundle.EMPTY) + } else { + it.seekToNext() + } + } - fun previous() = command { it.seekToPrevious() } + fun previous() = command { if (state.value.dj == null) it.seekToPrevious() } fun seekTo(positionMs: Long) = command { - it.seekTo(positionMs) + if (state.value.dj?.mode == "interactive") return@command + it.seekTo(positionMs + (state.value.dj?.start ?: 0)) mutableState.update { s -> s.copy(positionMs = positionMs) } } @@ -313,27 +387,39 @@ class PlayerController @Inject constructor( main.launch { block(connect()) } } - private data class CurrentContent(val track: Track?, val episode: PlayingEpisode?, val stream: PlayingStream?) + private data class CurrentContent( + val track: Track?, + val episode: PlayingEpisode?, + val stream: PlayingStream?, + val dj: PlayingDj?, + ) private fun contentOf(item: MediaItem?): CurrentContent { val episode = item?.let(MediaItems::episodeOf) + val dj = item?.let(DjMediaItems::of) val stream = if (episode == null) item?.let(MediaItems::streamOf) else null - val track = if (episode == null && stream == null) { + val track = if (episode == null && stream == null && dj == null) { item?.let { library.library.value?.byId?.get(it.mediaId) ?: MediaItems.trackFromMetadata(it) } } else { null } - return CurrentContent(track, episode, stream) + return CurrentContent(track, episode, stream, dj) } private fun isNewContent(old: PlayerState, content: CurrentContent): Boolean = old.track?.id != content.track?.id || old.episode?.episodeId != content.episode?.episodeId || - old.stream?.videoId != content.stream?.videoId + old.stream?.videoId != content.stream?.videoId || old.dj?.id != content.dj?.id private fun refresh(queueChanged: Boolean = false) { val player = controller ?: return val item = player.currentMediaItem - val (track, episode, stream) = contentOf(item) + val content = contentOf(item) + val track = content.track + val episode = content.episode + val stream = content.stream + val absolute = + content.dj?.let { DjTimeline.position(player, it.origin) } ?: player.currentPosition.coerceAtLeast(0) + val dj = content.dj?.let { DjTimeline.shown(it, absolute) } // Walking the whole list is the costly part: only when the list, its order or the current item moved. val upNext = if (queueChanged) upNextOf(player) else mutableState.value.upNext mutableState.update { old -> @@ -341,31 +427,42 @@ class PlayerController @Inject constructor( track = track, episode = episode, stream = stream, + dj = dj, speed = player.playbackParameters.speed, isPlaying = player.isPlaying, isBuffering = player.playbackState == Player.STATE_BUFFERING, - positionMs = player.currentPosition.coerceAtLeast(0), - durationMs = player.duration.takeIf { it != C.TIME_UNSET } - ?: ((track?.duration ?: 0.0) * 1000).toLong().takeIf { it > 0 } - ?: item?.mediaMetadata?.durationMs - ?: 0L, + positionMs = if (dj != null) (absolute - dj.start).coerceAtLeast(0) else absolute, + durationMs = shownDuration(player, content), shuffle = player.shuffleModeEnabled, repeat = when (player.repeatMode) { Player.REPEAT_MODE_ALL -> RepeatMode.All Player.REPEAT_MODE_ONE -> RepeatMode.One else -> RepeatMode.Off }, - upNext = upNext, + upNext = if (dj != + null + ) { + dj.nextTrackId?.let { listOf(QueueEntry(-1, it, dj.nextTitle, dj.nextArtist)) }.orEmpty() + } else { + upNext + }, currentIndex = player.currentMediaItemIndex, context = MediaItems.contextOf(player.playlistMetadata), quality = item?.mediaId?.let(resolver::qualityOf), fromPhone = item?.mediaId?.let(resolver::isLocal) == true, - error = if (isNewContent(old, CurrentContent(track, episode, stream))) null else old.error, + error = if (isNewContent(old, content)) null else old.error, ) } if (player.isPlaying) startTicker() else ticker?.cancel() } + private fun shownDuration(player: Player, content: CurrentContent): Long = + content.dj?.takeIf { it.end > it.start }?.let { it.end - it.start } + ?: player.duration.takeIf { it != C.TIME_UNSET } + ?: ((content.track?.duration ?: 0.0) * 1000).toLong().takeIf { it > 0 } + ?: player.currentMediaItem?.mediaMetadata?.durationMs + ?: 0L + private fun upNextOf(player: Player): List = PlayOrder.fromCurrent(player).drop(1).map { i -> val item = player.getMediaItemAt(i) QueueEntry( @@ -383,7 +480,11 @@ class PlayerController @Inject constructor( ticker = main.launch { while (isActive) { val player = controller ?: break - mutableState.update { it.copy(positionMs = player.currentPosition.coerceAtLeast(0)) } + if (mutableState.value.dj != null) { + refresh() + } else { + mutableState.update { it.copy(positionMs = player.currentPosition.coerceAtLeast(0)) } + } delay(TICK_MS) } } diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt index 619c7f3..526f075 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt @@ -60,18 +60,22 @@ object PlayerModule { http: OkHttpDataSource.Factory, resolver: StreamResolver, store: AudioCacheStore, - ): DataSource.Factory = ResolvingDataSource.Factory( - // file:// (offline copies) is read straight from disk; http(s) goes through the streaming cache. - DefaultDataSource.Factory( - context, - CacheDataSource.Factory() - .setCache(cache) - .setUpstreamDataSourceFactory(http) - .setCacheKeyFactory { it.key ?: store.uriKey(it.uri.toString()) } - .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR), - ), - resolver, - ) + ): DataSource.Factory { + val regular = ResolvingDataSource.Factory( + // file:// (offline copies) is read straight from disk; http(s) goes through the streaming cache. + DefaultDataSource.Factory( + context, + CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(http) + .setCacheKeyFactory { it.key ?: store.uriKey(it.uri.toString()) } + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR), + ), + resolver, + ) + val direct = ResolvingDataSource.Factory(http, resolver) + return DataSource.Factory { DjDataSource(regular, direct, resolver::isDjHlsResource) } + } @Provides @Singleton diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerState.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerState.kt index dc48b01..fcef7e4 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerState.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerState.kt @@ -6,6 +6,28 @@ import com.henriquesebastiao.downtify.core.model.Track enum class RepeatMode { Off, All, One } +/** Fences delayed queue actions across local tracks, streams, podcasts and DJ. */ +val PlayerState.mediaKey: String? + get() = track?.id?.let { "track:$it" } + ?: stream?.videoId?.let { "stream:$it" } + ?: episode?.episodeId?.let { "episode:$it" } + ?: dj?.id?.let { "dj:$it" } +data class PlayingDj( + val id: String, + val title: String, + val mode: String = "prepared", + val artist: String = "Mix DJ local", + val trackId: String? = null, + val nextTitle: String = "", + val nextArtist: String = "", + val nextTrackId: String? = null, + val start: Long = 0, + val end: Long = 0, + val nextStart: Long = Long.MAX_VALUE, + val origin: Long = 0, + val transition: Float? = null, +) + /** * One queue row. [index] is its place in the player's list (what skipping to * it or removing it takes); [queued] marks a song the user added to the queue. @@ -47,6 +69,7 @@ data class PlayingStream( /** What the mini player and Now Playing show. */ data class PlayerState( + val dj: PlayingDj? = null, /** The track playing, from the library (null when nothing is loaded, or an episode or stream is). */ val track: Track? = null, /** The podcast episode playing, when it isn't a song. */ @@ -74,7 +97,7 @@ data class PlayerState( /** The last playback error, for a message; cleared by the next track. */ val error: PlaybackError? = null, ) { - val hasMedia: Boolean get() = track != null || episode != null || stream != null + val hasMedia: Boolean get() = track != null || episode != null || stream != null || dj != null val progress: Float get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f } diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/StreamResolver.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/StreamResolver.kt index ad88895..deb817d 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/StreamResolver.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/StreamResolver.kt @@ -53,6 +53,12 @@ class StreamResolver @Inject constructor( private val local = LruCache(CHOSEN_CACHE_SIZE) override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { + DjMediaItems.addressOf(dataSpec.uri)?.let { address -> + val session = sessions.current ?: throw IOException("Not paired with a server") + return dataSpec.withUri( + Uri.parse(ServerUrls.djFile(session.baseUrl, address.id, address.mode)), + ).buildUpon().setKey(null).build() + } // A song that isn't in the library: the server's stream cache has // the audio (or downloads it now), with seeking, under the token. MediaItems.streamVideoIdOf(dataSpec.uri)?.let { videoId -> @@ -81,6 +87,10 @@ class StreamResolver @Inject constructor( .setKey(cache.key(trackId, quality.encode())).build() } + fun isDjHlsResource(uri: Uri): Boolean = sessions.current?.let { + ServerUrls.isDjHlsResource(it.baseUrl, uri.toString()) + } == true + /** Resolve a future item without changing a playing track's selected representation. */ fun preloadSpec(uri: Uri, quality: StreamQuality): DataSpec? { val paired = sessions.current ?: return null diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/TranscodeWarmer.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/TranscodeWarmer.kt index 176cb17..d3d5922 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/TranscodeWarmer.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/TranscodeWarmer.kt @@ -7,6 +7,7 @@ import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import com.henriquesebastiao.downtify.core.model.EpisodeIds +import com.henriquesebastiao.downtify.core.model.MediaIds import com.henriquesebastiao.downtify.core.model.StreamIds import java.io.IOException import kotlinx.coroutines.CoroutineScope @@ -69,7 +70,7 @@ internal class TranscodeWarmer( // The track that plays next in the real order: shuffle and repeat included. val next = player.nextMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: return val trackId = player.getMediaItemAt(next).mediaId - if (trackId.isEmpty() || EpisodeIds.isEpisode(trackId) || StreamIds.isStream(trackId)) return + if (!MediaIds.isLibrary(trackId)) return scope.launch(Dispatchers.IO) { warm(trackId) } } diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTrackerTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTrackerTest.kt new file mode 100644 index 0000000..4695692 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTrackerTest.kt @@ -0,0 +1,84 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.Player +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.dto.MusicDjCommandResultDto +import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto +import com.henriquesebastiao.downtify.core.network.session.Session +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DjControlTrackerTest { + private val id = "a".repeat(32) + private val music = mockk() + private val player = mockk(relaxed = true) + private val capabilities = + MutableStateFlow(MusicAiCapabilitiesDto(version = 1, features = mapOf("dj_interactive" to true))) + private val owner = + MutableStateFlow(Session("https://fixture.test", "server", "Test", "device", "fixture")) + private var status = MusicDjSessionDto(id = id, state = "playing", theme = "fete", mode = "interactive") + + private fun setup() { + every { music.capabilities } returns capabilities + every { music.session } returns owner + every { player.currentMediaItem } returns DjMediaItems.from(id, "Live", 100000, "interactive") + coEvery { music.djSession(id) } answers { status } + coEvery { music.djCommand(any(), any(), any()) } returns MusicDjCommandResultDto(true) + } + + @Test fun `only user transport changes send global commands not buffering or audio focus`() = runTest { + setup() + val tracker = DjControlTracker(player, music, backgroundScope) + tracker.start() + runCurrent() + tracker.onIsPlayingChanged(false) + tracker.onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS) + runCurrent() + coVerify(exactly = 0) { music.djCommand(any(), any(), any()) } + tracker.onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + runCurrent() + coVerify(exactly = 1) { music.djCommand(id, "pause", any()) } + capabilities.value = MusicAiCapabilitiesDto() + tracker.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + runCurrent() + coVerify(exactly = 0) { music.djCommand(id, "resume", any()) } + tracker.stop() + } + + @Test fun `shared server pause resumes only its own loaded direct and stops polling on release`() = runTest { + setup() + var playing = true + every { player.playWhenReady } answers { playing } + every { player.pause() } answers { playing = false } + every { player.play() } answers { playing = true } + status = status.copy(state = "paused") + val tracker = DjControlTracker(player, music, backgroundScope) + tracker.start() + runCurrent() + verify(exactly = 1) { player.pause() } + status = status.copy(state = "playing") + advanceTimeBy(2001) + runCurrent() + verify(exactly = 1) { player.play() } + tracker.stop() + advanceTimeBy(4000) + runCurrent() + coVerify(exactly = 2) { music.djSession(id) } + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjLivePlaybackTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjLivePlaybackTest.kt new file mode 100644 index 0000000..067debd --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjLivePlaybackTest.kt @@ -0,0 +1,51 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import com.henriquesebastiao.downtify.core.network.ServerUrls +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DjLivePlaybackTest { + private val id = "a".repeat(32) + + @Test fun `a restored direct keeps HLS mode MIME and server relative identity`() { + val item = DjMediaItems.from(id, "Synthetic live", 60000, "interactive") + val restored = MediaItems.restored(MediaItems.saved(item), "https://fixture.test") + assertEquals("interactive", DjMediaItems.of(restored)?.mode) + assertEquals(MimeTypes.APPLICATION_M3U8, restored.localConfiguration?.mimeType) + assertEquals(DjMediaItems.uri(id, "interactive"), restored.localConfiguration?.uri) + assertNull(MediaItems.trackFromMetadata(restored)) + } + + @Test fun `HLS manifest and segment requests bypass cache only on the paired server`() { + val base = "https://fixture.test/downtify" + val resource = "$base/api/v1/music-ai/dj/sessions/$id/hls/segment-00000001.ts" + assertEquals(true, ServerUrls.isDjHlsResource(base, resource)) + assertFalse(ServerUrls.isDjHlsResource(base, resource.replace("fixture.test", "other.test"))) + assertFalse(ServerUrls.isDjHlsResource(base, "$resource?credential=caller")) + val cached = mockk() + val direct = mockk() + val upstream = mockk(relaxed = true) + every { direct.createDataSource() } returns upstream + every { upstream.open(any()) } returns 100L + val source = DjDataSource(cached, direct) { ServerUrls.isDjHlsResource(base, it.toString()) } + source.open(DataSpec.Builder().setUri(resource).build()) + source.close() + verify(exactly = 0) { cached.createDataSource() } + verify(exactly = 1) { direct.createDataSource() } + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackTest.kt new file mode 100644 index 0000000..61cd05d --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackTest.kt @@ -0,0 +1,74 @@ +package com.henriquesebastiao.downtify.core.player + +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import com.henriquesebastiao.downtify.core.model.DjIds +import com.henriquesebastiao.downtify.core.network.ServerUrls +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DjPlaybackTest { + private val id = "a".repeat(32) + + @Test fun `a persisted DJ item restores its logical identity rather than a track URL`() { + val item = DjMediaItems.from(id, "Synthetic mix", 60000) + val restored = MediaItems.restored(MediaItems.saved(item), "https://fixture.test") + assertEquals(id, DjMediaItems.of(restored)?.id) + assertEquals(DjMediaItems.uri(id), restored.localConfiguration?.uri) + assertNull(MediaItems.trackFromMetadata(restored)) + assertEquals(true, PlayerState(dj = PlayingDj(id, "Mix")).hasMedia) + } + + @Test fun `DJ routing bypasses the track cache on the initial request and after seek`() { + val cached = mockk() + val direct = mockk() + val upstream = mockk(relaxed = true) + every { direct.createDataSource() } returns upstream + every { upstream.open(any()) } returns 100L + val source = DjDataSource(cached, direct) + source.open(DataSpec.Builder().setUri(DjMediaItems.uri(id)).build()) + source.close() + source.open(DataSpec.Builder().setUri(DjMediaItems.uri(id)).setPosition(64).build()) + source.close() + verify(exactly = 0) { cached.createDataSource() } + verify(exactly = 2) { direct.createDataSource() } + verify(exactly = 2) { upstream.close() } + } + + @Test fun `normal tracks keep the existing cache route`() { + val cached = mockk() + val direct = mockk() + val upstream = mockk(relaxed = true) + every { cached.createDataSource() } returns upstream + every { upstream.open(any()) } returns 100L + val source = DjDataSource(cached, direct) + source.open(DataSpec.Builder().setUri(MediaItems.uriFor("track")).build()) + source.close() + verify(exactly = 1) { cached.createDataSource() } + verify(exactly = 0) { direct.createDataSource() } + } + + @Test fun `a render cannot supply an arbitrary URL or path`() { + assertFalse(DjIds.valid("../../private")) + assertFalse(DjIds.valid("https://other.test/file")) + assertNull(DjIds.idOf("dj:prepared:../secret")) + assertNull(DjMediaItems.idOf(Uri.parse("downtify://dj/$id/other"))) + assertEquals( + "https://fixture.test/api/v1/music-ai/dj/sessions/$id/media", + ServerUrls.djFile("https://fixture.test/", id), + ) + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjTimelineTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjTimelineTest.kt new file mode 100644 index 0000000..c0bb699 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjTimelineTest.kt @@ -0,0 +1,47 @@ +package com.henriquesebastiao.downtify.core.player + +import com.henriquesebastiao.downtify.core.network.dto.MusicAiTrackDto +import com.henriquesebastiao.downtify.core.network.dto.MusicDjMarkerDto +import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DjTimelineTest { + private val id = "a".repeat(32) + private val session = MusicDjSessionDto( + id = id, + mode = "interactive", + state = "playing", + theme = "fete", + tracks = listOf(MusicAiTrackDto("one", "First", "Artist A"), MusicAiTrackDto("two", "Next", "Artist B")), + timeline = listOf(MusicDjMarkerDto("one", 0.0, 100.0), MusicDjMarkerDto("two", 90.0, 200.0)), + ) + + @Test fun `metadata keeps the same stream and media identity`() { + val item = DjMediaItems.from(id, "Set", 0, "interactive") + val updated = DjTimeline.metadata(item, session, 95000, "https://fixture.test") + assertEquals(item.localConfiguration, updated.localConfiguration) + assertEquals(item.mediaId, updated.mediaId) + val dj = requireNotNull(DjMediaItems.of(updated)) + assertEquals("First", dj.title) + assertEquals("Artist A", dj.artist) + assertEquals("Next", dj.nextTitle) + assertEquals(0.5f, DjTimeline.shown(dj, 95000).transition) + assertNull(DjTimeline.shown(dj, 89000).transition) + } + + @Test fun `next title becomes current at the actual end of the overlap`() { + val item = DjMediaItems.from(id, "Set", 0, "interactive") + val updated = DjTimeline.metadata(item, session, 100000, "https://fixture.test") + val dj = requireNotNull(DjMediaItems.of(updated)) + assertEquals("Next", dj.title) + assertEquals(90000L, dj.start) + assertEquals("", dj.nextTitle) + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/MusicAiDjListeningTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/MusicAiDjListeningTest.kt new file mode 100644 index 0000000..cbe36fe --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/MusicAiDjListeningTest.kt @@ -0,0 +1,85 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.Player +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiListenDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiTrackDto +import com.henriquesebastiao.downtify.core.network.dto.MusicDjMarkerDto +import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto +import com.henriquesebastiao.downtify.core.network.session.Session +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.time.Duration +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowSystemClock + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MusicAiDjListeningTest { + @Test fun `only audible time is profiled and repeated DJ occurrences stay distinct`() = runTest { + val music = mockk() + val player = mockk(relaxed = true) + val paired = MutableStateFlow(Session("https://fixture.test", "test", "Test", "device", "fixture")) + val caps = MutableStateFlow(MusicAiCapabilitiesDto(version = 1, features = mapOf("personalization" to true))) + every { music.session } returns paired + every { music.capabilities } returns caps + val events = mutableListOf() + coEvery { music.listen(any()) } answers { + events.add(firstArg()) + Unit + } + val id = "a".repeat(32) + val session = + MusicDjSessionDto( + id = id, + state = "playing", + theme = "fete", + tracks = listOf(MusicAiTrackDto("one")), + timeline = listOf(MusicDjMarkerDto("one", 0.0, 100.0), MusicDjMarkerDto("one", 90.0, 190.0)), + ) + val original = DjMediaItems.from(id, "Set", 0, "interactive") + var item = DjTimeline.metadata(original, session, 0, "https://fixture.test") + var position = 0L + every { player.currentMediaItem } answers { item } + every { player.currentPosition } answers { position } + every { player.isPlaying } returns true + val tracker = MusicAiListenTracker(player, music, backgroundScope) + tracker.start() + tracker.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) + runCurrent() + repeat(5) { + position += 1000 + ShadowSystemClock.advanceBy(Duration.ofSeconds(1)) + advanceTimeBy(1000) + runCurrent() + } + position = 100000 + item = DjTimeline.metadata(original, session, position, "https://fixture.test") + tracker.onMediaMetadataChanged(item.mediaMetadata) + repeat(5) { + position += 1000 + ShadowSystemClock.advanceBy(Duration.ofSeconds(1)) + advanceTimeBy(1000) + runCurrent() + } + tracker.stop() + runCurrent() + assertEquals(listOf("one", "one"), events.map { it.trackId }) + assertEquals(listOf("pause", "pause"), events.map { it.outcome }) + assertEquals(listOf(5.0, 5.0), events.map { it.seconds }) + assertNotEquals(events[0].eventId, events[1].eventId) + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackSleepTimerTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackSleepTimerTest.kt new file mode 100644 index 0000000..8a59c7d --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackSleepTimerTest.kt @@ -0,0 +1,47 @@ +package com.henriquesebastiao.downtify.core.player + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PlaybackSleepTimerTest { + @Test fun `every requested duration pauses once and clears its deadline`() = runTest { + val timer = PlaybackSleepTimer { testScheduler.currentTime } + var pauses = 0 + backgroundScope.launch { timer.run { pauses++ } } + PlaybackSleepTimer.PRESETS.forEachIndexed { index, minutes -> + timer.set(minutes) + runCurrent() + advanceTimeBy(minutes * 60_000L - 1) + assertEquals(index, pauses) + advanceTimeBy(1) + runCurrent() + assertEquals(index + 1, pauses) + assertNull(timer.deadline.value) + } + } + + @Test fun `replacing and cancelling a timer cannot pause later playback`() = runTest { + val timer = PlaybackSleepTimer { testScheduler.currentTime } + var pauses = 0 + backgroundScope.launch { timer.run { pauses++ } } + timer.set(5) + runCurrent() + timer.set(10) + runCurrent() + advanceTimeBy(5 * 60_000L) + assertEquals(0, pauses) + timer.set(null) + runCurrent() + advanceTimeBy(60 * 60_000L) + runCurrent() + assertEquals(0, pauses) + assertNull(timer.deadline.value) + } +} diff --git a/docs/music-ai.md b/docs/music-ai.md new file mode 100644 index 0000000..8a479e1 --- /dev/null +++ b/docs/music-ai.md @@ -0,0 +1,54 @@ +# Music AI and DJ — experimental beta + +These features require a matching server with the versioned Music AI capability +API. Every AI module defaults off for new installations and accounts. The client +hides unsupported or disabled modules. Existing explicit preferences survive an +upgrade; the Android app does not silently enable server modules. + +## Included + +- Home → For you: daily and weekly mood playlists, stable saved copies and + thematic continuous DJ shortcuts. +- AI settings with independent user preferences and an experimental badge. +- Start a mix from the playing song or its overflow menu. Local similarity uses + the exact native track identity; external discovery is a separate opt-in beta. +- A DJ button on the current queue, preserving local track order and repeats. +- The normal Media3 player, foreground service and media controls show the + current and next DJ titles with transition progress. Next is a server command; + already rendered audio cannot change instantly. Prepared mixes support seeking; + interactive mixes follow their HLS window. +- A playback sleep timer: 5, 10, 20, 30, 45 or 60 minutes, or cancellation. +- Playlist menus show the three most recently used destinations, then a bounded + selector for the rest. + +## Resource use and boundaries + +Models and mixing run on the server, not on the phone. Android uses its existing +Media3 audio decoder and authenticated HTTP stack. Live DJ segments bypass the +persistent library cache; ordinary offline files retain their existing behavior. +There is no on-device model download or new model subscription. Additional phone +CPU, memory, battery and mobile-data consumption have not yet been measured. +An HLS connection and foreground playback remain necessary for interactive DJ. + +Continuous DJ currently extends a bounded selection (24 tracks for thematic +shortcuts, up to 40 for a queue), with recent-track avoidance. It is not a radio +or an unlimited catalog service. Inputs are local files. The server permits one +render session at a time and applies a separate 5 GiB cache budget by default. + +## Validation and remaining acceptance + +The delivered beta passed 454 unit tests (two skipped), debug assembly, Spotless, +Detekt and Lint. Tests cover owner changes, exact track identifiers, server +commands, HLS timeline metadata, repeated occurrences, listening events and the +service-owned timer. These checks do not establish physical-device acceptance. + +Physical Android background/network recovery and comparative listening remain +to be tested. The latest server DJ engine has not completed its one-hour real +playback qualification. Ordinary interactive creation stays gated until the +administrator completes that validation with zero interruptions and rendering +above 1.5× real time. The administrator validation stream is available for this +purpose. No synthetic test or client time claim unlocks it. + +This is a draft beta contribution; please report the device, Android version, +server capability/version and reproducible steps without credentials or private +library files. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0b43bb3..75a0817 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,6 +71,7 @@ androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androi androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidxHilt" } media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" } media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } media3-database = { module = "androidx.media3:media3-database", version.ref = "media3" } From 02e7da8d5e54f704c7507066d52d9e6f74bab13d Mon Sep 17 00:00:00 2001 From: QuietForge7294 <77000426+QuietForge7294@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:00:57 +0100 Subject: [PATCH 02/11] Unify DJ playback and present daily mixes as native playlists --- README.md | 55 ++- app/build.gradle.kts | 11 +- app/src/beta/AndroidManifest.xml | 6 + .../downtify/DowntifyApplication.kt | 4 +- .../feature/collection/CollectionScreen.kt | 57 ++- .../downtify/feature/home/HomeScreen.kt | 144 +++---- .../downtify/feature/home/HomeViewModel.kt | 8 +- .../feature/musicai/MusicAiMessages.kt | 28 ++ .../downtify/feature/musicai/MusicAiScreen.kt | 1 + .../feature/musicai/MusicAiViewModel.kt | 130 +++++- .../downtify/feature/musicai/MusicDjPanel.kt | 7 +- .../feature/musicai/MusicDjViewModel.kt | 72 ++-- .../downtify/feature/musicai/MusicForYou.kt | 16 +- .../feature/musicai/MusicLibraryPages.kt | 190 +++++++++ .../downtify/feature/musicai/MusicMoodArt.kt | 78 ++++ .../feature/musicai/TemporaryMusicPlaylist.kt | 102 +++++ .../downtify/feature/player/PlayerOptions.kt | 10 + .../feature/player/QueueDjViewModel.kt | 119 +----- .../downtify/ui/DowntifyApp.kt | 38 +- .../downtify/ui/common/LibraryItems.kt | 9 + .../downtify/ui/common/SongMix.kt | 10 + .../downtify/ui/navigation/Routes.kt | 4 + .../downtify/SongMixViewModelTest.kt | 2 +- .../feature/home/DailyMusicShelfTest.kt | 44 ++ .../musicai/TemporaryMusicPlaylistTest.kt | 92 +++++ .../core/data/musicai/MusicAiCache.kt | 42 ++ .../core/data/musicai/MusicAiRepository.kt | 244 +++++++++-- .../data/similar/SimilarAvailabilityTest.kt | 12 +- .../downtify/core/network/ApiFactory.kt | 60 ++- .../downtify/core/network/MusicAiApi.kt | 3 + .../downtify/core/network/MusicDjApi.kt | 15 +- .../downtify/core/network/di/NetworkModule.kt | 12 + .../downtify/core/network/dto/MusicAiDtos.kt | 13 + .../downtify/core/network/dto/MusicDjDtos.kt | 8 + .../downtify/core/player/DjControlTracker.kt | 114 ++++-- .../core/player/DjPlaybackCoordinator.kt | 387 ++++++++++++++++++ .../downtify/core/player/DjTimeline.kt | 78 +++- .../downtify/core/player/PlaybackService.kt | 6 + .../downtify/core/player/PlayerController.kt | 132 +++++- .../downtify/core/player/PlayerModule.kt | 7 +- .../core/player/ArtworkDataSourceTest.kt | 9 +- .../core/player/DjControlTrackerTest.kt | 73 +++- .../core/player/DjPlaybackCoordinatorTest.kt | 116 ++++++ .../downtify/core/player/DjTimelineTest.kt | 31 ++ docs/music-ai.md | 48 ++- macrobenchmark/build.gradle.kts | 32 ++ macrobenchmark/src/main/AndroidManifest.xml | 3 + .../downtify/benchmark/StartupBenchmark.kt | 49 +++ settings.gradle.kts | 1 + 49 files changed, 2340 insertions(+), 392 deletions(-) create mode 100644 app/src/beta/AndroidManifest.xml create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiMessages.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicMoodArt.kt create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylist.kt create mode 100644 app/src/test/kotlin/com/henriquesebastiao/downtify/feature/home/DailyMusicShelfTest.kt create mode 100644 app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylistTest.kt create mode 100644 core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiCache.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinatorTest.kt create mode 100644 macrobenchmark/build.gradle.kts create mode 100644 macrobenchmark/src/main/AndroidManifest.xml create mode 100644 macrobenchmark/src/main/java/com/henriquesebastiao/downtify/benchmark/StartupBenchmark.kt diff --git a/README.md b/README.md index 4afca72..77746b6 100644 --- a/README.md +++ b/README.md @@ -121,19 +121,42 @@ Android Auto stability: see [the diagnostic and receiver acceptance checklist](d ## Experimental local music AI and DJ beta -+The optional matching-server integration adds For you playlists, mixes based on -+a song, thematic continuous DJ and a DJ button on the current queue. The normal -+player retains pause, next, volume and a 5–60 minute sleep timer, while showing -+the current/next DJ title and transition progress. Playlist menus use three recent -+destinations followed by a bounded selector. -+ -+**All AI modules default off on new installations and accounts.** The server does -+the model inference and mixing; Android downloads no AI model. Existing explicit -+preferences are preserved. The latest server engine still requires its one-hour -+real playback qualification before ordinary interactive sessions are available. -+ -+Debug assembly, 454 unit tests (two skipped), Spotless, Detekt and Lint passed for -+the delivered beta. Physical-device background acceptance and phone battery/CPU/ -+memory/data measurements remain pending. See [beta behavior, resource use and -+limits](docs/music-ai.md). -+ \ No newline at end of file +**All server AI modules and the additional unqualified-DJ trial default off +for new installations/accounts.** Existing explicit preferences are preserved. + +My music keeps the normal library visible. Four daily playlist cards sit under +favorite playlists: Relaxation, Focus, Motivation and Calm. See all opens daily +and weekly selections. The daily cards use local vector artwork with mood colors. +Each selection opens the same native playlist screen as a saved playlist. +Save a named, stable copy from its header; automatic renewal never changes it. A dedicated DJ shortcut sits beside Discover, Similar +tracks and Podcasts. Song/player menus distinguish a classic mix from a DJ mix; +the current queue retains its DJ button. + +One application-scoped coordinator owns preparation and transitions into the +existing Media3 foreground service and MediaSession. Pause is local and +immediate; delayed renderer statuses cannot restart playback. Generations, +account ownership and request cancellation fence obsolete preparations. Leaving +DJ maps the dominant audible title back to its original source time. + +Known capabilities and home cards have a small private cache scoped to the +pairing. Foreground/cache invalidation replaces redundant timers. Commands, +catalog queries, images and audio use separate network dispatchers. Error +messages distinguish connection, authentication, incompatible responses, +disabled features, missing analysis and a busy engine. Home sorting runs off +the main thread. + +The optimized beta build is non-debuggable and enables R8/resource shrinking; +it uses a profileable manifest for physical-device benchmarks. Its audio +allocator target is 64 MiB. Live HLS bypasses the persistent library audio cache. +These are configured budgets, not measured phone memory, battery or temperature. +Model inference and transition rendering remain on the server; no on-device +AI model is downloaded. The local engine remains unavailable pending ONNX and +physical-device validation. + +Corrective player tests cover 25 repeated mode cycles, local pause intent, +stale status fencing and dominant-title/source-time mapping. Server/web checks +and a synthetic 50,000-entry home comparison are documented in the matching +[server PR](https://github.com/henriquesebastiao/downtify/pull/547). +Actual Pixel startup/latency, Bluetooth/background/network recovery, thermal +behavior, comparative listening and the sustained one-hour qualification are +still pending. This remains a draft beta. See [behavior and limits](docs/music-ai.md). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8fad46a..1b0f692 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.henriquesebastiao.downtify" - versionCode = 7 - versionName = "0.3.0" + versionCode = 9 + versionName = "0.3.1-beta3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -40,6 +40,13 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug") } + create("beta") { + initWith(getByName("release")) + applicationIdSuffix = ".debug" + signingConfig = signingConfigs.getByName("debug") + isDebuggable = false + matchingFallbacks += listOf("release") + } } buildFeatures { diff --git a/app/src/beta/AndroidManifest.xml b/app/src/beta/AndroidManifest.xml new file mode 100644 index 0000000..1af7801 --- /dev/null +++ b/app/src/beta/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/DowntifyApplication.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/DowntifyApplication.kt index c5bceb9..c351a3c 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/DowntifyApplication.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/DowntifyApplication.kt @@ -13,7 +13,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.crossfade import com.henriquesebastiao.downtify.core.data.offline.OfflineRepository import com.henriquesebastiao.downtify.core.data.sync.SyncCoordinator -import com.henriquesebastiao.downtify.core.network.di.DefaultClient +import com.henriquesebastiao.downtify.core.network.di.ArtworkClient import dagger.Lazy import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @@ -32,7 +32,7 @@ class DowntifyApplication : @Inject lateinit var offline: OfflineRepository @Inject - @DefaultClient + @ArtworkClient lateinit var httpClient: Lazy override val workManagerConfiguration: Configuration diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/collection/CollectionScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/collection/CollectionScreen.kt index c868ee7..7f670f2 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/collection/CollectionScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/collection/CollectionScreen.kt @@ -144,6 +144,9 @@ fun CollectionScreen( onToggleOffline: () -> Unit = {}, trackActions: TrackActions = TrackActions(), onToggleFavorite: () -> Unit = {}, + headerArtwork: (@Composable () -> Unit)? = null, + headerExtras: (@Composable () -> Unit)? = null, + showOffline: Boolean = true, ) { val covers = LocalCoverUrls.current val coverUrl = state.playlist?.let { covers.playlist(it, state.coverTrackId, ServerUrls.COVER_LARGE) } @@ -216,6 +219,9 @@ fun CollectionScreen( onPlayOrPause, onShuffle, onToggleOffline, + headerArtwork, + headerExtras, + showOffline, ) } } @@ -281,6 +287,9 @@ private fun Header( onPlayOrPause: () -> Unit, onShuffle: () -> Unit, onToggleOffline: () -> Unit, + headerArtwork: (@Composable () -> Unit)?, + headerExtras: (@Composable () -> Unit)?, + showOffline: Boolean, ) { val scheme = MaterialTheme.colorScheme Column( @@ -291,20 +300,24 @@ private fun Header( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.lg), ) { - Artwork( - url = coverUrl, - contentDescription = null, - shape = DowntifyShape.Artwork, - placeholderIcon = if (state.kind == - CollectionKind.Liked - ) { - DowntifyIcons.FavoriteFilled - } else { - DowntifyIcons.Album - }, - tone = if (state.kind == CollectionKind.Liked) ArtworkTone.Primary else ArtworkTone.Neutral, - modifier = Modifier.size(COVER_SIZE), - ) + if (headerArtwork != null) { + headerArtwork() + } else { + Artwork( + url = coverUrl, + contentDescription = null, + shape = DowntifyShape.Artwork, + placeholderIcon = if (state.kind == + CollectionKind.Liked + ) { + DowntifyIcons.FavoriteFilled + } else { + DowntifyIcons.Album + }, + tone = if (state.kind == CollectionKind.Liked) ArtworkTone.Primary else ArtworkTone.Neutral, + modifier = Modifier.size(COVER_SIZE), + ) + } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm), @@ -327,7 +340,8 @@ private fun Header( } Details(state) } - Actions(state, title, onPlayOrPause, onShuffle, onToggleOffline) + headerExtras?.invoke() + Actions(state, title, onPlayOrPause, onShuffle, onToggleOffline, showOffline) } } @@ -356,6 +370,7 @@ private fun Actions( onPlayOrPause: () -> Unit, onShuffle: () -> Unit, onToggleOffline: () -> Unit, + showOffline: Boolean, ) { val playing = state.isCurrentContext && state.isPlaying val scheme = MaterialTheme.colorScheme @@ -367,11 +382,13 @@ private fun Actions( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm), ) { - OfflineChip( - offline = state.offline, - enabled = state.tracks.isNotEmpty() || state.offline.kept, - onClick = { if (state.offline.kept) confirmRemove = true else onToggleOffline() }, - ) + if (showOffline) { + OfflineChip( + offline = state.offline, + enabled = state.tracks.isNotEmpty() || state.offline.kept, + onClick = { if (state.offline.kept) confirmRemove = true else onToggleOffline() }, + ) + } Spacer(Modifier.weight(1f)) CircleButton( DowntifyIcons.Shuffle, diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt index 0eb626d..e25f005 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme @@ -71,9 +70,6 @@ import com.henriquesebastiao.downtify.core.model.Playlist import com.henriquesebastiao.downtify.core.model.RecentContext import com.henriquesebastiao.downtify.core.network.ServerUrls import com.henriquesebastiao.downtify.feature.musicai.MusicAiViewModel -import com.henriquesebastiao.downtify.feature.musicai.MusicForYouActions -import com.henriquesebastiao.downtify.feature.musicai.musicDjThemeItems -import com.henriquesebastiao.downtify.feature.musicai.musicForYouItems import com.henriquesebastiao.downtify.ui.common.CoverCard import com.henriquesebastiao.downtify.ui.common.LocalCoverUrls import com.henriquesebastiao.downtify.ui.common.LocalMusicAiCapabilities @@ -81,8 +77,6 @@ import com.henriquesebastiao.downtify.ui.common.PreviewData import com.henriquesebastiao.downtify.ui.common.albumSubtitle import com.henriquesebastiao.downtify.ui.common.plusBottomBars import com.henriquesebastiao.downtify.ui.common.similarityAvailable -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive @Composable fun HomeRoute( @@ -96,22 +90,20 @@ fun HomeRoute( onOpenSimilar: () -> Unit, modifier: Modifier = Modifier, viewModel: HomeViewModel = hiltViewModel(), - onOpenMusicAi: () -> Unit = {}, + onOpenForYou: () -> Unit = {}, + onOpenMoodPlaylist: (String, String) -> Unit = { _, _ -> }, + onOpenDj: () -> Unit = {}, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val musicAi: MusicAiViewModel = hiltViewModel() - val musicDj: com.henriquesebastiao.downtify.feature.musicai.MusicDjViewModel = hiltViewModel() - val djState by musicDj.state.collectAsStateWithLifecycle() val aiState by musicAi.state.collectAsStateWithLifecycle() val aiCaps by musicAi.capabilities.collectAsStateWithLifecycle() - LaunchedEffect(aiCaps.features) { musicAi.refreshPlaylists() } + LaunchedEffect(aiCaps.features, aiCaps.homeCards) { musicAi.refreshHome() } val lifecycle = LocalLifecycleOwner.current LaunchedEffect(lifecycle, musicAi) { lifecycle.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - while (isActive) { - musicAi.refreshPlaylists() - delay(60000) - } + musicAi.refreshHome() + kotlinx.coroutines.awaitCancellation() } } HomeScreen( @@ -127,24 +119,20 @@ fun HomeRoute( onOpenSimilar, onOpenMenu = onOpenMenu, forYouContent = { - musicDjThemeItems(aiCaps, djState, musicDj::continuous) - musicForYouItems( - aiState, - aiCaps, - MusicForYouActions( - musicAi::refreshPlaylists, - musicAi::forYou, - musicAi::selectCadence, - musicAi::mood, - musicAi::play, - musicAi::save, - onOpenMusicAi, - musicAi::clearSelection, - ), - ) + if (listOf("daily", "weekly", "personalization").any(aiCaps::enabled)) { + item(key = "daily-mixes") { + com.henriquesebastiao.downtify.feature.musicai.DailyMusicShelf( + aiState, + aiCaps, + onOpenMoodPlaylist, + onOpenForYou, + ) + } + } }, + onOpenDj = onOpenDj, forYouRefreshing = aiState.busy, - onRefreshForYou = musicAi::refreshPlaylists, + onRefreshForYou = musicAi::refreshHome, ) } @@ -162,14 +150,11 @@ fun HomeScreen( onOpenPodcasts: () -> Unit = {}, onOpenSimilar: () -> Unit = {}, onOpenMenu: () -> Unit = {}, + onOpenDj: () -> Unit = {}, forYouContent: (LazyListScope.() -> Unit)? = null, forYouRefreshing: Boolean = false, onRefreshForYou: () -> Unit = {}, ) { - val caps = LocalMusicAiCapabilities.current - val forYouAvailable = forYouContent != null && listOf("personalization", "daily", "weekly").any(caps::enabled) - var homeTab by rememberSaveable { mutableStateOf("for-you") } - val showForYou = forYouAvailable && homeTab == "for-you" Scaffold( modifier = modifier, contentWindowInsets = WindowInsets(0), @@ -208,8 +193,11 @@ fun HomeScreen( }, ) { padding -> PullToRefreshBox( - isRefreshing = if (showForYou) forYouRefreshing else state.refreshing, - onRefresh = if (showForYou) onRefreshForYou else onRefresh, + isRefreshing = state.refreshing, + onRefresh = { + onRefresh() + onRefreshForYou() + }, modifier = Modifier.fillMaxSize().padding(padding), ) { LazyColumn( @@ -219,62 +207,46 @@ fun HomeScreen( item(key = "status") { ServerStatusLine(state, Modifier.padding(horizontal = Spacing.screen, vertical = Spacing.sm)) } - if (forYouAvailable) { - item(key = "home-tabs") { - Row( - Modifier.padding(horizontal = Spacing.screen), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - FilterChip(selected = showForYou, onClick = { - homeTab = "for-you" - }, label = { Text("Pour vous") }) - FilterChip(selected = !showForYou, onClick = { - homeTab = "library" - }, label = { Text("Ma musique") }) + + when { + state.loading -> item(key = "loading") { + Box(Modifier.fillMaxWidth().padding(Spacing.xxl), contentAlignment = Alignment.Center) { + LoadingIndicator() } } - } - if (showForYou) { - forYouContent?.invoke(this) - } else { - when { - state.loading -> item(key = "loading") { - Box(Modifier.fillMaxWidth().padding(Spacing.xxl), contentAlignment = Alignment.Center) { - LoadingIndicator() - } - } - state.libraryEmpty -> item(key = "empty") { - if (state.favoritePlaylists.isNotEmpty()) HomeAccess(state, onOpenRecent) - EmptyState( - icon = DowntifyIcons.Library, - title = stringResource(R.string.home_empty_title), - body = stringResource(R.string.home_empty_body), - hero = true, - ) - } + state.libraryEmpty -> item(key = "empty") { + if (state.favoritePlaylists.isNotEmpty()) HomeAccess(state, onOpenRecent) + EmptyState( + icon = DowntifyIcons.Library, + title = stringResource(R.string.home_empty_title), + body = stringResource(R.string.home_empty_body), + hero = true, + ) + } - else -> { - if (state.jumpBackIn.isNotEmpty() || state.favoritePlaylists.isNotEmpty()) { - item(key = "access") { HomeAccess(state, onOpenRecent) } - } - if (state.recentlyAdded.isNotEmpty()) { - item(key = "recent") { RecentlyAdded(state.recentlyAdded, onOpenAlbum, onSeeAll) } - } + else -> { + if (state.jumpBackIn.isNotEmpty() || state.favoritePlaylists.isNotEmpty()) { + item(key = "access") { HomeAccess(state, onOpenRecent) } } - } - if (!state.loading) { - item(key = "shortcuts") { - Shortcuts( - showDiscover = state.showDiscover && !state.libraryEmpty, - showPodcasts = state.showPodcasts, - onOpenDiscover = onOpenDiscover, - onOpenPodcasts = onOpenPodcasts, - onOpenSimilar = onOpenSimilar, - ) + forYouContent?.invoke(this) + if (state.recentlyAdded.isNotEmpty()) { + item(key = "recent") { RecentlyAdded(state.recentlyAdded, onOpenAlbum, onSeeAll) } } } } + if (!state.loading) { + item(key = "shortcuts") { + Shortcuts( + showDiscover = state.showDiscover && !state.libraryEmpty, + showPodcasts = state.showPodcasts, + onOpenDiscover = onOpenDiscover, + onOpenPodcasts = onOpenPodcasts, + onOpenSimilar = onOpenSimilar, + onOpenDj = onOpenDj, + ) + } + } } } } @@ -314,6 +286,7 @@ private fun Shortcuts( onOpenDiscover: () -> Unit, onOpenPodcasts: () -> Unit, onOpenSimilar: () -> Unit, + onOpenDj: () -> Unit, ) { Column( Modifier.padding(horizontal = Spacing.screen).padding(top = Spacing.xl), @@ -343,6 +316,9 @@ private fun Shortcuts( onClick = onOpenPodcasts, ) } + if (LocalMusicAiCapabilities.current.version == 1) { + Shortcut(DowntifyIcons.Graphic, "DJ · Bêta", "Créer des mixes et mixer votre file", onOpenDj) + } } } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeViewModel.kt index 44d0fad..6b40c93 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeViewModel.kt @@ -14,9 +14,11 @@ import com.henriquesebastiao.downtify.core.model.Playlist import com.henriquesebastiao.downtify.core.model.RecentContext import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn enum class ServerStatus { Streaming, Syncing, Unreachable, DifferentServer } @@ -76,10 +78,12 @@ class HomeViewModel @Inject constructor( showDiscover = info?.capabilities?.discover == true, showPodcasts = info?.capabilities?.podcasts == true, ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), HomeUiState()) + }.flowOn( + Dispatchers.Default, + ).stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), HomeUiState()) /** Pull to refresh: the server rescans its folder, then the app syncs. */ - fun refresh() = sync.requestSync(refresh = true) + fun refresh() = sync.requestSync() private companion object { const val JUMP_BACK_IN_SIZE = 6 diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiMessages.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiMessages.kt new file mode 100644 index 0000000..7dd11a1 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiMessages.kt @@ -0,0 +1,28 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailureKind + +fun musicAiFailureMessage(error: MusicAiFailure): String = when (error.kind) { + MusicAiFailureKind.Timeout -> "Le serveur met trop de temps à répondre. Votre musique locale reste disponible." + + MusicAiFailureKind.Network -> "La connexion au serveur a échoué. Les données déjà chargées restent disponibles." + + MusicAiFailureKind.Authentication -> "Votre connexion au serveur doit être renouvelée." + + MusicAiFailureKind.Forbidden -> "Ce compte n’est pas autorisé à effectuer cette action." + + MusicAiFailureKind.Disabled -> "Cette fonction est désactivée. Retrouvez ses options dans Paramètres → IA." + + MusicAiFailureKind.Busy -> "Le moteur est occupé. Votre lecture actuelle continue." + + MusicAiFailureKind.NotReady -> "Le moteur prépare encore cette fonction. Votre lecture actuelle reste disponible." + + MusicAiFailureKind.Unsupported -> "Cette version du serveur ne propose pas cette fonction." + + MusicAiFailureKind.InvalidResponse -> + "Le serveur a répondu, mais sa réponse est incompatible. " + + "Vérifiez sa version et l’adresse configurée." + + MusicAiFailureKind.Other -> "Cette demande n’a pas abouti. Votre lecture actuelle reste disponible." +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiScreen.kt index 9454ee3..14c1f17 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiScreen.kt @@ -41,6 +41,7 @@ private val features = linkedMapOf( "external_beta" to "Découverte externe — bêta", "dj_prepared" to "Sets DJ préparés", "dj_interactive" to "DJ en temps réel", + "dj_trial" to "DJ non qualifié — essai bêta volontaire", ) @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiViewModel.kt index d1d7427..4c3e3db 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiViewModel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiViewModel.kt @@ -8,16 +8,24 @@ import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository import com.henriquesebastiao.downtify.core.data.sync.SyncCoordinator import com.henriquesebastiao.downtify.core.model.PlaybackContext import com.henriquesebastiao.downtify.core.model.PlaybackContextType +import com.henriquesebastiao.downtify.core.model.Track import com.henriquesebastiao.downtify.core.network.dto.MusicAiPlaylistDto import com.henriquesebastiao.downtify.core.network.dto.MusicAiTrackDto import com.henriquesebastiao.downtify.core.player.PlayerController +import com.henriquesebastiao.downtify.feature.collection.CollectionKind +import com.henriquesebastiao.downtify.feature.collection.CollectionUiState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -31,6 +39,7 @@ data class MusicAiUiState( val source: String = "personalization", val busy: Boolean = false, val message: String = "", + val selectionId: String = "", ) @HiltViewModel @@ -43,6 +52,28 @@ class MusicAiViewModel @Inject constructor( val capabilities = music.capabilities private val mutable = MutableStateFlow(MusicAiUiState()) val state = mutable.asStateFlow() + val collectionState = combine(state, library.library, library.likedIds, player.nowPlaying) { + selection, + snapshot, + liked, + playing, + -> + val tracks = snapshot?.tracks(selection.tracks.map { it.id }).orEmpty() + CollectionUiState( + kind = CollectionKind.Playlist, + loading = snapshot == null || (selection.busy && selection.tracks.isEmpty()), + title = selectionTitle(selection), + artist = "Playlist temporaire · ${if (selection.source == "weekly") "de la semaine" else "du jour"}", + tracks = tracks, + coverTrackId = tracks.firstOrNull { it.hasCover }?.id, + currentTrackId = playing.trackId, + isPlaying = playing.isPlaying, + isCurrentContext = selection.selectionId.isNotBlank() && playing.context == selectionContext(selection), + likedIds = liked.toSet(), + ) + }.flowOn( + Dispatchers.Default, + ).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionUiState(kind = CollectionKind.Playlist)) private var job: Job? = null init { @@ -63,12 +94,7 @@ class MusicAiViewModel @Inject constructor( } catch (error: MusicAiFailure) { mutable.update { it.copy( - message = when (error.status) { - 409 -> "Cette fonction est désactivée." - 503 -> "L’analyse sonore n’est pas encore prête." - 403 -> "Cette action nécessite un administrateur." - else -> "Le serveur est indisponible. Réessayez." - }, + message = musicAiFailureMessage(error), ) } } finally { @@ -109,6 +135,43 @@ class MusicAiViewModel @Inject constructor( ) } } + fun refreshHome() = run { + music.refresh() + if (listOf("daily", "weekly", "personalization").any(capabilities.value::enabled)) { + val cards = music.home() + mutable.update { + it.copy( + playlists = cards, + message = if (music.homeStale) { + "Actualisation différée ; vos dernières playlists restent disponibles." + } else { + "" + }, + ) + } + } + } + fun openPlaylist(cadence: String, mood: String) = run { + mutable.update { it.copy(source = cadence, cadence = cadence, selectedMood = mood, tracks = emptyList()) } + music.refresh() + if (!capabilities.value.enabled(cadence)) { + mutable.update { + it.copy(message = "Cette fonction est désactivée. Retrouvez ses options dans Paramètres → IA.") + } + } else { + val tracks = music.playlist(cadence, mood) + mutable.update { + it.copy( + tracks = tracks, + source = cadence, + cadence = cadence, + selectedMood = mood, + selectionId = java.util.UUID.randomUUID().toString(), + message = if (tracks.isEmpty()) "Cette playlist est en préparation." else "", + ) + } + } + } fun selectCadence(value: String) { if (value in listOf("daily", "weekly") && capabilities.value.enabled(value)) { mutable.update { it.copy(cadence = value) } @@ -121,11 +184,26 @@ class MusicAiViewModel @Inject constructor( } fun forYou() = run { val tracks = music.recommendations() - mutable.update { it.copy(tracks = tracks, source = "personalization", selectedMood = "") } + mutable.update { + it.copy( + tracks = tracks, + source = "personalization", + selectedMood = "", + selectionId = java.util.UUID.randomUUID().toString(), + ) + } } fun mood(cadence: String, mood: String) = run { val tracks = music.playlist(cadence, mood) - mutable.update { it.copy(tracks = tracks, source = cadence, cadence = cadence, selectedMood = mood) } + mutable.update { + it.copy( + tracks = tracks, + source = cadence, + cadence = cadence, + selectedMood = mood, + selectionId = java.util.UUID.randomUUID().toString(), + ) + } } fun save(name: String) = run { val selection = mutable.value @@ -152,8 +230,42 @@ class MusicAiViewModel @Inject constructor( player.play( tracks, tracks.indexOf(track).coerceAtLeast(0), - PlaybackContext(PlaybackContextType.Songs, "", ""), + selectionContext(mutable.value), + ) + } + } + + fun playSelection(index: Int, shuffle: Boolean = false) { + val selection = mutable.value + val tracks = library.library.value?.tracks(selection.tracks.map { it.id }).orEmpty() + if (tracks.isNotEmpty()) { + player.play( + tracks, + index.coerceIn(tracks.indices), + selectionContext(selection), + shuffle, ) } } + + fun playOrPause() { + if (collectionState.value.isCurrentContext) player.togglePlayPause() else playSelection(0) + } + + fun addToQueue(track: Track) = player.addToQueue(track) + fun toggleLike(track: Track) { + viewModelScope.launch { library.setLiked(track.id, track.id !in library.likedIds.value) } + } + + private fun selectionTitle(selection: MusicAiUiState) = if (selection.selectedMood.isBlank()) { + "À écouter maintenant" + } else { + mixTitle( + selection.source, + selection.selectedMood, + ) + } + + private fun selectionContext(selection: MusicAiUiState) = + PlaybackContext(PlaybackContextType.Songs, selection.selectionId, selectionTitle(selection)) } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt index 73669d7..6c5b791 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt @@ -35,7 +35,7 @@ fun MusicDjPanel(viewModel: MusicDjViewModel = hiltViewModel()) { var theme by rememberSaveable { mutableStateOf("fete") } var count by rememberSaveable { mutableStateOf("12") } var mode by rememberSaveable { mutableStateOf("prepared") } - val ready = if (mode == "prepared") caps.dj.preparedReady else caps.dj.interactiveReady + val ready = if (mode == "prepared") caps.dj.preparedReady else caps.interactiveAvailable() LaunchedEffect(caps.features) { if (!caps.enabled("dj_" + mode)) mode = if (caps.enabled("dj_prepared")) "prepared" else "interactive" } @@ -96,7 +96,10 @@ fun MusicDjPanel(viewModel: MusicDjViewModel = hiltViewModel()) { if (state.message.isNotEmpty()) Text(state.message) state.sessions.forEach { session -> Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { - Text("${session.theme} · ${session.tracks.size} titres", style = MaterialTheme.typography.titleMedium) + Text( + "${session.theme} · ${session.trackCount ?: session.tracks.size} titres", + style = MaterialTheme.typography.titleMedium, + ) Text( when (session.state) { "queued" -> "En attente" diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt index 02d4c9e..d82a86f 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt @@ -10,7 +10,6 @@ import com.henriquesebastiao.downtify.core.player.PlayerController import dagger.hilt.android.lifecycle.HiltViewModel import java.util.UUID import javax.inject.Inject -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest @@ -30,14 +29,24 @@ data class MusicDjUiState( class MusicDjViewModel @Inject constructor( private val music: MusicAiRepository, private val player: PlayerController, + private val coordinator: com.henriquesebastiao.downtify.core.player.DjPlaybackCoordinator, ) : ViewModel() { private val mutable = MutableStateFlow(MusicDjUiState()) val state = mutable.asStateFlow() val capabilities = music.capabilities private val mutex = Mutex() + private var ownBusy = false private var pending: Pair, String>? = null init { + viewModelScope.launch { + coordinator.message.collectLatest { message -> + if (message.isNotBlank()) mutable.update { it.copy(message = message) } + } + } + viewModelScope.launch { + coordinator.busy.collectLatest { busy -> mutable.update { it.copy(busy = ownBusy || busy) } } + } viewModelScope.launch { music.session.drop(1).collectLatest { mutable.value = MusicDjUiState() @@ -75,7 +84,7 @@ class MusicDjViewModel @Inject constructor( ) { music.capabilities.value.dj.preparedReady } else { - music.capabilities.value.dj.interactiveReady + music.capabilities.value.interactiveAvailable() } if (!music.capabilities.value.enabled("dj_" + mode) || !ready) { return@action @@ -87,30 +96,21 @@ class MusicDjViewModel @Inject constructor( pending = null } - fun stop(id: String) = action { music.djStop(id) } - fun continuous(theme: String, style: String) = action { - val owner = music.session.value - val caps = music.capabilities.value - if (!caps.enabled("dj_interactive") || !caps.dj.interactiveReady || !caps.dj.continuousSupported) return@action - val created = music.djContinuous(theme, style, UUID.randomUUID().toString()) - mutable.update { it.copy(message = "Préparation du DJ continu ; votre écoute reste disponible.") } - repeat(450) { - delay(2000) - if (owner != music.session.value) return@action - if (!music.capabilities.value.enabled("dj_interactive")) return@action - val session = music.djSession(created.id) - if (session.state in setOf("failed", "stopped", "cancelled")) throw MusicAiFailure() - if (session.state == "playing") { - player.playDj(session.id, "DJ continu · $theme", 0, "interactive") - mutable.update { it.copy(message = "DJ continu lancé dans le lecteur.") } - return@action - } + fun stop(id: String) { + if (player.state.value.dj?.id == id) coordinator.stop() else action { music.djStop(id) } + } + fun continuous(theme: String, style: String) = coordinator.startTheme(theme, style) + fun seed(id: String) = coordinator.startSeed(id) + + fun command(session: MusicDjSessionDto, operation: String, payload: Map = emptyMap()) { + if (player.state.value.dj?.id == session.id && operation in setOf("pause", "resume")) { + if (operation == "pause") player.pause() else player.resume() + return } - music.djStop(created.id) - mutable.update { it.copy(message = "Préparation trop longue. Essayez une sélection plus petite.") } + action { commandRemote(session, operation, payload) } } - fun command(session: MusicDjSessionDto, operation: String, payload: Map = emptyMap()) = action { + private suspend fun commandRemote(session: MusicDjSessionDto, operation: String, payload: Map) { val result = music.djCommand(session.id, operation, payload) if (player.state.value.dj?.id == session.id) { if (operation == "pause") player.pause() @@ -129,6 +129,7 @@ class MusicDjViewModel @Inject constructor( private fun action(work: suspend () -> Unit) { if (mutable.value.busy) return + ownBusy = true mutable.update { it.copy(busy = true, message = "") } viewModelScope.launch { try { @@ -137,16 +138,12 @@ class MusicDjViewModel @Inject constructor( } catch (error: MusicAiFailure) { mutable.update { it.copy( - message = when (error.status) { - 409 -> "Le module DJ est désactivé." - 429 -> "Deux sets sont déjà en attente." - 503 -> "Le moteur DJ se prépare encore." - else -> "La demande n’a pas abouti. Vous pouvez réessayer." - }, + message = musicAiFailureMessage(error), ) } } finally { - mutable.update { it.copy(busy = false) } + ownBusy = false + mutable.update { it.copy(busy = coordinator.busy.value) } } } } @@ -157,7 +154,20 @@ class MusicDjViewModel @Inject constructor( ) { return } - player.playDj(session.id, "Set DJ · ${session.theme}", (session.duration * 1000).toLong(), session.mode) + val selection = player.selectionVersion.value + val owner = music.session.value + viewModelScope.launch { + try { + val full = if (session.tracks.isEmpty()) music.djSession(session.id) else session + if (selection == player.selectionVersion.value && + owner == music.session.value + ) { + coordinator.listen(full, "Set DJ · ${full.theme}") + } + } catch (error: MusicAiFailure) { + mutable.update { it.copy(message = musicAiFailureMessage(error)) } + } + } } fun seek(session: MusicDjSessionDto, seconds: Double) { diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYou.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYou.kt index 970dd61..64787ce 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYou.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicForYou.kt @@ -34,15 +34,15 @@ import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto import com.henriquesebastiao.downtify.core.network.dto.MusicAiPlaylistDto import com.henriquesebastiao.downtify.core.network.dto.MusicAiTrackDto -private val moodNames = mapOf( +internal val moodNames = mapOf( "calme" to "Calme", "concentration" to "Concentration", - "energie" to "Énergie", + "energie" to "Motivation", "fete" to "Fête", "melancolie" to "Mélancolie", "detente" to "Détente", ) -private val moodColors = mapOf( +internal val moodColors = mapOf( "calme" to Color(0xFF4F46E5), "concentration" to Color(0xFF334155), "energie" to Color(0xFFC2410C), @@ -50,7 +50,7 @@ private val moodColors = mapOf( "melancolie" to Color(0xFF1D4ED8), "detente" to Color(0xFF0F766E), ) -private fun mixTitle(cadence: String, mood: String) = +internal fun mixTitle(cadence: String, mood: String) = "${if (cadence == "daily") "Mix du jour" else "Mix de la semaine"} · ${moodNames[mood].orEmpty()}" private fun selectionTitle(state: MusicAiUiState) = if (state.selectedMood.isBlank()) "À écouter maintenant" else mixTitle(state.source, state.selectedMood) @@ -101,7 +101,7 @@ fun LazyListScope.musicDjThemeItems( TextButton( onClick = { onStart(theme, style) }, enabled = - !state.busy && caps.dj.interactiveReady, + !state.busy && caps.interactiveAvailable(), modifier = Modifier.fillMaxWidth(), ) { Column { @@ -113,9 +113,9 @@ fun LazyListScope.musicDjThemeItems( } } } - if (!caps.dj.interactiveReady) { + if (!caps.interactiveAvailable()) { Text( - "Disponible après validation du moteur sur le serveur.", + "Activez un moteur qualifié ou l’essai bêta volontaire dans Paramètres → IA.", style = MaterialTheme.typography.bodySmall, ) } @@ -176,7 +176,7 @@ private fun LazyListScope.musicCatalogItems( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { for (card in pair) { - MusicMixCard(card, !state.busy && caps.model.ready, { + MusicMixCard(card, !state.busy, { actions.mood(card.cadence, card.mood) }, Modifier.weight(1f)) } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt new file mode 100644 index 0000000..b7f026c --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt @@ -0,0 +1,190 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.henriquesebastiao.downtify.core.designsystem.component.Artwork +import com.henriquesebastiao.downtify.core.designsystem.component.DowntifyIcons +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.ui.common.LocalCoverUrls +import com.henriquesebastiao.downtify.ui.common.plusBottomBars + +private val dailyMoods = listOf( + "detente" to "Détente", + "concentration" to "Concentration", + "energie" to "Motivation", + "calme" to "Calme", +) + +@Composable +fun DailyMusicShelf( + state: MusicAiUiState, + caps: MusicAiCapabilitiesDto, + onOpen: (String, String) -> Unit, + onSeeAll: () -> Unit, +) { + val covers = LocalCoverUrls.current + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Pour vous", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium) + TextButton(onClick = onSeeAll) { Text("Tout voir") } + } + dailyMoods.chunked(2).forEach { pair -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + pair.forEach { (mood, label) -> + val card = state.playlists.firstOrNull { it.cadence == "daily" && it.mood == mood } + Surface( + onClick = { onOpen("daily", mood) }, + shape = MaterialTheme.shapes.large, + modifier = Modifier.weight(1f).height(112.dp), + ) { + MusicMoodArt( + mood, + "daily", + compact = true, + subtitle = if (card != null && + card.count > 0 + ) { + "${card.count} titres · du jour" + } else if (!caps.enabled("daily")) { + "À activer dans IA" + } else { + "En préparation" + }, + ) + } + } + } + } + if (state.message.isNotBlank()) Text(state.message, style = MaterialTheme.typography.bodySmall) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MusicPlaylistsRoute( + onBack: () -> Unit, + onSettings: () -> Unit, + cadence: String = "daily", + mood: String = "", + viewModel: MusicAiViewModel = hiltViewModel(), + onOpenPlaylist: (String, String) -> Unit = { c, m -> viewModel.mood(c, m) }, + onArtist: (String) -> Unit = {}, + onAlbum: (String) -> Unit = {}, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val caps by viewModel.capabilities.collectAsStateWithLifecycle() + LaunchedEffect(cadence, mood) { + if (mood.isBlank()) viewModel.refreshPlaylists() else viewModel.openPlaylist(cadence, mood) + } + if (mood.isNotBlank() || state.tracks.isNotEmpty()) { + TemporaryMusicPlaylist( + viewModel, + state, + if (mood.isBlank()) viewModel::clearSelection else onBack, + onArtist, + onAlbum, + ) + return + } + Scaffold(topBar = { + TopAppBar(title = { Text("Pour vous") }, navigationIcon = { TextButton(onClick = onBack) { Text("Retour") } }) + }) { padding -> + LazyColumn( + Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(bottom = 16.dp).plusBottomBars(), + ) { + musicForYouItems( + state, + caps, + MusicForYouActions( + viewModel::refreshPlaylists, + viewModel::forYou, + viewModel::selectCadence, + onOpenPlaylist, + viewModel::play, + viewModel::save, + onSettings, + viewModel::clearSelection, + ), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MusicStudioRoute( + onBack: () -> Unit, + onSettings: () -> Unit, + seed: String = "", + viewModel: MusicDjViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val caps by viewModel.capabilities.collectAsStateWithLifecycle() + val queue: com.henriquesebastiao.downtify.feature.player.QueueDjViewModel = hiltViewModel() + val queueMessage by queue.message.collectAsStateWithLifecycle() + LaunchedEffect(seed) { + viewModel.refresh() + if (seed.isNotBlank()) viewModel.seed(seed) + } + Scaffold(topBar = { + TopAppBar(title = { Text("DJ · Bêta") }, navigationIcon = { TextButton(onClick = onBack) { Text("Retour") } }) + }) { padding -> + LazyColumn( + Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp).plusBottomBars(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Text( + "Créez un mix préparé ou continu à partir de votre musique. Les transitions et les titres restent dans votre lecteur habituel.", + ) + TextButton(onClick = onSettings) { Text("Paramètres IA") } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.FilterChip(selected = true, onClick = {}, label = { Text("Serveur") }) + androidx.compose.material3.FilterChip(selected = false, enabled = false, onClick = { + }, label = { Text("Cet appareil · Bêta") }) + } + Text( + "Le moteur local attend la validation de l’export ONNX et des performances sur votre appareil. Aucun modèle n’est téléchargé. Le serveur reste disponible.", + style = MaterialTheme.typography.bodySmall, + ) + TextButton(onClick = queue::toggle, enabled = caps.interactiveAvailable()) { + Text("Mode DJ sur la file") + } + if (queueMessage.isNotBlank()) Text(queueMessage) + if (!caps.enabled("dj_prepared") && + !caps.enabled("dj_interactive") + ) { + Text("Activez un mode DJ dans Paramètres → IA, puis autorisez-le sur le serveur.") + } + } + musicDjThemeItems(caps, state, viewModel::continuous) + item { MusicDjPanel() } + } + } +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicMoodArt.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicMoodArt.kt new file mode 100644 index 0000000..6191619 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicMoodArt.kt @@ -0,0 +1,78 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** Native vector artwork: no network request, bitmap decoding or generated image dependency. */ +@Composable +internal fun MusicMoodArt( + mood: String, + cadence: String, + modifier: Modifier = Modifier, + subtitle: String = "", + compact: Boolean = false, +) { + val tint = moodColors[mood] ?: Color(0xFF0F766E) + val dark = Color(tint.red * .48f, tint.green * .48f, tint.blue * .48f) + Box(modifier.background(Brush.linearGradient(listOf(tint, dark)))) { + Canvas(Modifier.fillMaxSize()) { + drawCircle(Color.White.copy(alpha = .08f), size.width * .6f, Offset(size.width, size.height * .1f)) + drawCircle( + Color.White.copy(alpha = .06f), + size.width * .5f, + Offset(size.width * .9f, size.height * .15f), + style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()), + ) + } + Column( + Modifier.fillMaxSize().padding(if (compact) 12.dp else 20.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + if (cadence == + "daily" + ) { + "MIX DU JOUR" + } else { + "MIX DE LA SEMAINE" + }, + color = Color.White.copy(alpha = .8f), + style = MaterialTheme.typography.labelSmall, + ) + Spacer(Modifier.weight(1f)) + Text( + moodNames[mood] ?: "Pour vous", + color = Color.White, + fontWeight = FontWeight.Bold, + style = if (compact) MaterialTheme.typography.titleMedium else MaterialTheme.typography.headlineMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle.isNotBlank()) { + Text( + subtitle, + color = Color.White.copy(alpha = .88f), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylist.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylist.kt new file mode 100644 index 0000000..a761f5b --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylist.kt @@ -0,0 +1,102 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.henriquesebastiao.downtify.feature.collection.CollectionScreen +import com.henriquesebastiao.downtify.feature.trash.LocalMoveToTrash +import com.henriquesebastiao.downtify.ui.common.TrackActions + +/** The native playlist screen with an immutable-copy action instead of an offline subscription. */ +@Composable +internal fun TemporaryMusicPlaylist( + viewModel: MusicAiViewModel, + state: MusicAiUiState, + onBack: () -> Unit, + onArtist: (String) -> Unit, + onAlbum: (String) -> Unit, +) { + val collection by viewModel.collectionState.collectAsStateWithLifecycle() + var showSave by rememberSaveable(state.selectionId) { mutableStateOf(false) } + var name by rememberSaveable(state.selectionId) { + mutableStateOf(if (state.selectedMood.isBlank()) "Pour vous" else mixTitle(state.source, state.selectedMood)) + } + CollectionScreen( + state = collection, + onBack = onBack, + onArtist = onArtist, + onPlay = { viewModel.playSelection(it) }, + onPlayOrPause = viewModel::playOrPause, + onShuffle = { viewModel.playSelection(0, shuffle = true) }, + showOffline = false, + trackActions = TrackActions( + onToggleLike = viewModel::toggleLike, + onAddToQueue = viewModel::addToQueue, + onGoToAlbum = { onAlbum(it.albumId) }, + onGoToArtist = { onArtist(it.artistId) }, + onMoveToTrash = LocalMoveToTrash.current, + ), + headerArtwork = { + MusicMoodArt( + state.selectedMood, + state.source, + Modifier.size(232.dp).clip(RoundedCornerShape(24.dp)), + subtitle = "POUR VOUS", + ) + }, + headerExtras = { + Column { + Button(onClick = { + showSave = true + }, enabled = state.tracks.isNotEmpty() && !state.busy) { Text("Enregistrer en playlist") } + if (state.message.isNotBlank()) { + Text(state.message, style = MaterialTheme.typography.bodySmall) + } else if (state.tracks.isEmpty() && + !state.busy + ) { + Text("Cette playlist est en préparation.", style = MaterialTheme.typography.bodySmall) + } + } + }, + ) + if (showSave) { + AlertDialog( + onDismissRequest = { showSave = false }, + title = { Text("Enregistrer en playlist") }, + text = { + Column { + Text("Une copie de ces titres sera conservée. Le prochain renouvellement ne la modifiera pas.") + OutlinedTextField(value = name, onValueChange = { + name = it.take(120) + }, label = { Text("Nom de la playlist") }, singleLine = true) + } + }, + confirmButton = { + TextButton( + onClick = { + viewModel.save(name) + showSave = false + }, + enabled = + name.isNotBlank() && !state.busy, + ) { Text("Enregistrer") } + }, + dismissButton = { TextButton(onClick = { showSave = false }) { Text("Annuler") } }, + ) + } +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerOptions.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerOptions.kt index e5d40b4..65f9469 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerOptions.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerOptions.kt @@ -20,6 +20,7 @@ import com.henriquesebastiao.downtify.ui.common.LocalSongMix @Composable fun PlayerOptions(track: Track?, sleepActive: Boolean, setSleepTimer: (Int?) -> Unit) { val startMix = LocalSongMix.current + val startDj = com.henriquesebastiao.downtify.ui.common.LocalSongDjMix.current var open by remember { mutableStateOf(false) } Box { CircleButton(DowntifyIcons.MoreVert, "Options de lecture", { open = true }) @@ -34,6 +35,15 @@ fun PlayerOptions(track: Track?, sleepActive: Boolean, setSleepTimer: (Int?) -> ) HorizontalDivider() } + if (track != null && LocalMusicAiCapabilities.current.enabled("dj_interactive") && + LocalMusicAiCapabilities.current.enabled("similarity") + ) { + DropdownMenuItem(text = { Text("Lancer un mix DJ à partir de ce titre") }, onClick = { + open = false + startDj(track) + }) + HorizontalDivider() + } PlaybackSleepTimer.PRESETS.forEach { minutes -> DropdownMenuItem( text = { diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt index e2cb612..6074f98 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt @@ -1,119 +1,16 @@ package com.henriquesebastiao.downtify.feature.player import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.henriquesebastiao.downtify.core.data.library.LibraryRepository -import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure -import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository -import com.henriquesebastiao.downtify.core.player.PlayerController -import com.henriquesebastiao.downtify.core.player.mediaKey +import com.henriquesebastiao.downtify.core.player.DjPlaybackCoordinator import dagger.hilt.android.lifecycle.HiltViewModel -import java.util.UUID import javax.inject.Inject -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch +/** Navigation owns the UI; the application-scoped coordinator owns preparation. */ @HiltViewModel -class QueueDjViewModel @Inject constructor( - private val music: MusicAiRepository, - private val player: PlayerController, - private val library: LibraryRepository, -) : ViewModel() { - val capabilities = music.capabilities - private val messageState = MutableStateFlow("") - val message = messageState.asStateFlow() - private val busyState = MutableStateFlow(false) - val busy = busyState.asStateFlow() - private val armedState = MutableStateFlow(false) - val armed = armedState.asStateFlow() - private var pendingId: String? = null - set(value) { - field = value - armedState.value = value != null - } - - fun toggle() { - val active = player.state.value.dj?.id ?: pendingId - if (active != null) { - viewModelScope.launch { - try { - music.djStop(active) - player.exitDj(active) - } catch (_: MusicAiFailure) { - messageState.value = - "Impossible d’arrêter le DJ pour le moment." - } - pendingId = null - busyState.value = false - } - return - } - if (busyState.value) return - val snapshot = player.state.value - val defer = snapshot.isPlaying - val ids = (if (defer) emptyList() else listOfNotNull(snapshot.track?.id)) + snapshot.upNext.map { it.trackId } - if (ids.size !in 2..40 || ids.any { library.library.value?.byId?.containsKey(it) != true }) { - messageState.value = "Choisissez de 2 à 40 titres locaux à suivre." - return - } - prepare(ids, defer) - } - - private fun prepare(ids: List, defer: Boolean) { - val owner = music.session.value - val key = player.state.value.mediaKey - busyState.value = true - messageState.value = "Préparation des transitions ; votre écoute continue." - viewModelScope.launch { - try { - val caps = music.capabilities.value - if (!caps.enabled("dj_interactive") || !caps.dj.interactiveReady || - !caps.dj.queueSupported - ) { - return@launch - } - val created = music.djQueue(ids, UUID.randomUUID().toString()) - pendingId = created.id - repeat(450) { - delay(2000) - if (pendingId != created.id) return@launch - if (music.session.value != owner || player.state.value.mediaKey != key) { - if (music.session.value == owner) music.djStop(created.id) - pendingId = null - messageState.value = "Le titre a changé avant le relais. La lecture classique continue." - return@launch - } - val session = music.djSession(created.id) - if (session.state in setOf("failed", "stopped", "cancelled")) throw MusicAiFailure() - if (session.state == "playing") music.djCommand(session.id, "pause") - if (session.state == "paused") { - if (defer) { - player.queueDj(session.id, "Mode DJ · votre file", (session.duration * 1000).toLong()) - messageState.value = "Mode DJ prêt · relais au prochain titre." - } else { - music.djCommand(session.id, "resume") - player.playDj( - session.id, - "Mode DJ · votre file", - (session.duration * 1000).toLong(), - "interactive", - ) - messageState.value = "Mode DJ actif." - } - return@launch - } - } - music.djStop(created.id) - pendingId = null - messageState.value = "La préparation a pris trop de temps. Votre file reste disponible." - } catch (_: MusicAiFailure) { - messageState.value = "Le mode DJ est indisponible. Votre file classique reste disponible." - pendingId = null - } finally { - busyState.value = false - } - } - } +class QueueDjViewModel @Inject constructor(private val coordinator: DjPlaybackCoordinator) : ViewModel() { + val capabilities = coordinator.capabilities + val message = coordinator.message + val busy = coordinator.busy + val armed = coordinator.armed + fun toggle() = coordinator.toggleQueue() } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt index ac91b7c..c4d78e8 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt @@ -59,6 +59,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute import com.henriquesebastiao.downtify.AppState import com.henriquesebastiao.downtify.MainViewModel import com.henriquesebastiao.downtify.R @@ -176,6 +177,7 @@ private fun MainShell() { // Now Playing, open or closed or anywhere between: it grows out of the mini player and shrinks back into it. val expansion = rememberPlayerExpansion() val mixAction = com.henriquesebastiao.downtify.ui.common.songMixAction() + val djMixAction = com.henriquesebastiao.downtify.ui.common.songDjMixAction() val scope = rememberCoroutineScope() LaunchedEffect(hasMedia) { if (!hasMedia) expansion.snapTo(open = false) } // The side menu, opened from Home's menu button. @@ -267,6 +269,7 @@ private fun MainShell() { LocalBottomBarsInset provides covered, com.henriquesebastiao.downtify.ui.common.LocalMusicAiCapabilities provides aiCapabilities, com.henriquesebastiao.downtify.ui.common.LocalSongMix provides mixAction, + com.henriquesebastiao.downtify.ui.common.LocalSongDjMix provides djMixAction, ) { Box(Modifier.fillMaxSize().then(content)) { MoveToTrashHost(onMessage = { text -> scope.launch { snackbar.showSnackbar(text) } }) { @@ -301,6 +304,7 @@ private fun MainShell() { CompositionLocalProvider( com.henriquesebastiao.downtify.ui.common.LocalMusicAiCapabilities provides aiCapabilities, com.henriquesebastiao.downtify.ui.common.LocalSongMix provides mixAction, + com.henriquesebastiao.downtify.ui.common.LocalSongDjMix provides djMixAction, ) { NowPlayingOverlay( expansion = expansion, @@ -335,7 +339,11 @@ private fun AppNavHost(navController: NavHostController, onOpenMenu: () -> Unit) onOpenDiscover = { navController.navigate(DiscoverDestination) }, onOpenPodcasts = { navController.navigate(PodcastsDestination) }, onOpenSimilar = { navController.navigate(SimilarRoute()) }, - onOpenMusicAi = { navController.navigate(com.henriquesebastiao.downtify.ui.navigation.MusicAiRoute) }, + onOpenForYou = { navController.navigate(com.henriquesebastiao.downtify.ui.navigation.ForYouRoute()) }, + onOpenMoodPlaylist = { cadence, mood -> + navController.navigate(com.henriquesebastiao.downtify.ui.navigation.ForYouRoute(cadence, mood)) + }, + onOpenDj = { navController.navigate(com.henriquesebastiao.downtify.ui.navigation.MusicDjRoute()) }, ) } composable { @@ -367,6 +375,32 @@ private fun AppNavHost(navController: NavHostController, onOpenMenu: () -> Unit) composable { com.henriquesebastiao.downtify.feature.musicai.MusicAiRoute(onBack = navController::popBackStack) } + composable { entry -> + val args = entry.toRoute() + com.henriquesebastiao.downtify.feature.musicai.MusicPlaylistsRoute( + navController::popBackStack, + { + navController.navigate(com.henriquesebastiao.downtify.ui.navigation.MusicAiRoute) + }, + args.cadence, + args.mood, + onOpenPlaylist = { c, m -> + navController.navigate(com.henriquesebastiao.downtify.ui.navigation.ForYouRoute(c, m)) + }, + onArtist = toArtist, + onAlbum = toAlbum, + ) + } + composable { entry -> + val args = entry.toRoute() + com.henriquesebastiao.downtify.feature.musicai.MusicStudioRoute( + navController::popBackStack, + { + navController.navigate(com.henriquesebastiao.downtify.ui.navigation.MusicAiRoute) + }, + args.seed, + ) + } composable { DiscoverRoute( onBack = navController::popBackStack, @@ -586,7 +620,7 @@ private fun NowPlayingOverlay( controls = controls, dj = com.henriquesebastiao.downtify.feature.player.QueueDjState( available = djCaps.enabled("dj_interactive") && djCaps.dj.queueSupported, - ready = djCaps.dj.interactiveReady && djCaps.dj.queueSupported, + ready = djCaps.interactiveAvailable() && djCaps.dj.queueSupported, active = djArmed || djBusy, message = djMessage, ), diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LibraryItems.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LibraryItems.kt index de9ae79..2a19252 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LibraryItems.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LibraryItems.kt @@ -292,6 +292,7 @@ private data class TrackMenuColors(val container: Color, val content: Color) private fun TrackMenu(track: Track, isLiked: Boolean, actions: TrackActions, colors: TrackMenuColors) { if (actions == TrackActions()) return val startMix = LocalSongMix.current + val startDj = LocalSongDjMix.current var open by remember { mutableStateOf(false) } Box { CircleButton( @@ -312,6 +313,14 @@ private fun TrackMenu(track: Track, isLiked: Boolean, actions: TrackActions, col }, ) } + if (LocalMusicAiCapabilities.current.enabled("dj_interactive") && + LocalMusicAiCapabilities.current.enabled("similarity") + ) { + DropdownMenuItem(text = { Text("Lancer un mix DJ à partir de ce titre") }, onClick = { + open = false + startDj(track) + }) + } actions.onAddToQueue?.let { add -> DropdownMenuItem( text = { Text(stringResource(R.string.track_add_to_queue)) }, diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt index df608fa..3a30c9b 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt @@ -25,6 +25,7 @@ import javax.inject.Inject import kotlinx.coroutines.launch val LocalSongMix = staticCompositionLocalOf<(Track) -> Unit> { {} } +val LocalSongDjMix = staticCompositionLocalOf<(Track) -> Unit> { {} } @HiltViewModel class SongMixViewModel @Inject constructor( @@ -32,6 +33,7 @@ class SongMixViewModel @Inject constructor( private val library: LibraryRepository, private val player: PlayerController, private val sessions: SessionStore, + private val dj: com.henriquesebastiao.downtify.core.player.DjPlaybackCoordinator, ) : ViewModel() { var busy by mutableStateOf(false) private set @@ -42,6 +44,11 @@ class SongMixViewModel @Inject constructor( message = null } + fun startDj(seed: Track) { + dj.startSeed(seed.id) + message = "Préparation du mix DJ. Suivez son état dans la file du lecteur." + } + fun start(seed: Track) { if (busy) return val owner = sessions.current ?: return @@ -86,3 +93,6 @@ fun songMixAction(viewModel: SongMixViewModel = hiltViewModel()): (Track) -> Uni } return viewModel::start } + +@Composable +fun songDjMixAction(viewModel: SongMixViewModel = hiltViewModel()): (Track) -> Unit = viewModel::startDj diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/navigation/Routes.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/navigation/Routes.kt index 95368c2..ff5adc3 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/navigation/Routes.kt @@ -16,6 +16,10 @@ import kotlinx.serialization.Serializable @Serializable data object MusicAiRoute +@Serializable data class ForYouRoute(val cadence: String = "daily", val mood: String = "") + +@Serializable data class MusicDjRoute(val seed: String = "") + /** The server's music trash (an admin's pairing, while the server has it on). */ @Serializable data object TrashRoute diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt index 0e62079..cfae276 100644 --- a/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt @@ -75,6 +75,6 @@ class SongMixViewModelTest { every { library } returns MutableStateFlow(LibrarySnapshot.Empty) } private val sessions = mockk { every { current } returns mockk(relaxed = true) } - val viewModel = SongMixViewModel(ai, library, player, sessions) + val viewModel = SongMixViewModel(ai, library, player, sessions, mockk(relaxed = true)) } } diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/home/DailyMusicShelfTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/home/DailyMusicShelfTest.kt new file mode 100644 index 0000000..b45fdd4 --- /dev/null +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/home/DailyMusicShelfTest.kt @@ -0,0 +1,44 @@ +package com.henriquesebastiao.downtify.feature.home + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.henriquesebastiao.downtify.core.designsystem.theme.DowntifyTheme +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.feature.musicai.DailyMusicShelf +import com.henriquesebastiao.downtify.feature.musicai.MusicAiUiState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@Config(qualifiers = "w360dp-h800dp") +@RunWith(AndroidJUnit4::class) +class DailyMusicShelfTest { + @get:Rule val compose = createComposeRule() + + @Test fun `four pending cards stay usable and motivation preserves the energy id`() { + var selected: Pair? = null + var all = 0 + compose.setContent { + DowntifyTheme { + DailyMusicShelf( + MusicAiUiState(), + MusicAiCapabilitiesDto(version = 1, features = mapOf("daily" to true)), + onOpen = { cadence, mood -> selected = cadence to mood }, + onSeeAll = { all++ }, + ) + } + } + listOf("Détente", "Concentration", "Motivation", "Calme").forEach { compose.onNodeWithText(it).assertExists() } + compose.onAllNodesWithText("En préparation").assertCountEquals(4) + compose.onNodeWithText("Motivation").performClick() + assertEquals("daily" to "energie", selected) + compose.onNodeWithText("Tout voir").performClick() + assertEquals(1, all) + } +} diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylistTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylistTest.kt new file mode 100644 index 0000000..97ec70d --- /dev/null +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylistTest.kt @@ -0,0 +1,92 @@ +package com.henriquesebastiao.downtify.feature.musicai + +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.henriquesebastiao.downtify.core.designsystem.theme.DowntifyTheme +import com.henriquesebastiao.downtify.core.network.dto.MusicAiTrackDto +import com.henriquesebastiao.downtify.feature.collection.CollectionKind +import com.henriquesebastiao.downtify.feature.collection.CollectionUiState +import com.henriquesebastiao.downtify.ui.common.PreviewData +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@Config(sdk = [34], qualifiers = "w360dp-h1000dp") +@RunWith(AndroidJUnit4::class) +class TemporaryMusicPlaylistTest { + @get:Rule val compose = createComposeRule() + + @Test fun `temporary selection uses native playback controls and exposes saving`() { + val model = mockk(relaxed = true) + val tracks = PreviewData.albums.first().tracks + val title = mixTitle("daily", "detente") + every { model.collectionState } returns MutableStateFlow( + CollectionUiState( + loading = false, + kind = CollectionKind.Playlist, + title = title, + tracks = tracks, + ), + ) + val state = MusicAiUiState( + source = "daily", + selectedMood = "detente", + selectionId = "fixture", + tracks = tracks.map { MusicAiTrackDto(id = it.id) }, + ) + compose.setContent { DowntifyTheme { TemporaryMusicPlaylist(model, state, {}, {}, {}) } } + compose.onNodeWithContentDescription("Play $title").performClick() + verify(exactly = 1) { model.playOrPause() } + compose.onNodeWithContentDescription("Shuffle").performClick() + verify(exactly = 1) { model.playSelection(0, true) } + compose.onNodeWithText("Download").assertDoesNotExist() + compose.onNodeWithText("Enregistrer en playlist").assertHasClickAction() + verify(exactly = 0) { model.save(any()) } + } + + @Ignore( + "Robolectric dialog focus does not become idle; opening and name editing checked on physical Android 17, confirmation still needs instrumentation", + ) + @Test + fun `temporary selection uses native playlist controls and saves its named copy`() { + val model = mockk(relaxed = true) + val tracks = PreviewData.albums.first().tracks + val title = mixTitle("daily", "detente") + every { model.collectionState } returns MutableStateFlow( + CollectionUiState( + loading = false, + kind = CollectionKind.Playlist, + title = title, + tracks = tracks, + ), + ) + val state = MusicAiUiState( + source = "daily", + selectedMood = "detente", + selectionId = "fixture", + tracks = tracks.map { MusicAiTrackDto(id = it.id) }, + ) + compose.setContent { DowntifyTheme { TemporaryMusicPlaylist(model, state, {}, {}, {}) } } + compose.onNodeWithContentDescription("Play $title").assertExists() + compose.onNodeWithContentDescription("Shuffle").assertExists() + compose.onNodeWithText("Download").assertDoesNotExist() + // Bound dialog/focus animations explicitly under Robolectric. + compose.mainClock.autoAdvance = false + compose.onNodeWithText("Enregistrer en playlist").performClick() + compose.mainClock.advanceTimeBy(500) + compose.onNodeWithText("Enregistrer", useUnmergedTree = true).performClick() + compose.mainClock.advanceTimeBy(500) + verify(exactly = 1) { model.save(title) } + verify(exactly = 0) { model.playSelection(any(), any()) } + } +} diff --git a/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiCache.kt b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiCache.kt new file mode 100644 index 0000000..d9b0eca --- /dev/null +++ b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiCache.kt @@ -0,0 +1,42 @@ +package com.henriquesebastiao.downtify.core.data.musicai + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.henriquesebastiao.downtify.core.network.NetworkJson +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiHomeDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiPlaylistDto +import com.henriquesebastiao.downtify.core.network.session.Session +import java.security.MessageDigest +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first + +/** Small derived cache in the app's existing private store, scoped to the pairing. */ +@Singleton +class MusicAiCache @Inject constructor(private val preferences: DataStore) { + fun scope(session: Session): String = MessageDigest.getInstance("SHA-256") + .digest("${session.baseUrl}|${session.serverId}|${session.token}".toByteArray()) + .joinToString("") { "%02x".format(it) } + private fun key(owner: String, kind: String) = stringPreferencesKey("music_ai:$owner:$kind") + suspend fun capabilities(owner: String): MusicAiCapabilitiesDto? = + preferences.data.first()[key(owner, "caps")]?.let { + runCatching { NetworkJson.decodeFromString(MusicAiCapabilitiesDto.serializer(), it) }.getOrNull() + } + suspend fun home(owner: String): List? = preferences.data.first()[key(owner, "home")]?.let { + runCatching { NetworkJson.decodeFromString(MusicAiHomeDto.serializer(), it).cards }.getOrNull() + } + suspend fun putCapabilities(owner: String, value: MusicAiCapabilitiesDto) = preferences.edit { + it.asMap().keys.filter { entry -> + entry.name.startsWith("music_ai:") && + !entry.name.startsWith("music_ai:$owner:") + }.forEach { entry -> it.remove(stringPreferencesKey(entry.name)) } + it[key(owner, "caps")] = NetworkJson.encodeToString(MusicAiCapabilitiesDto.serializer(), value) + } + suspend fun putHome(owner: String, cards: List) = preferences.edit { + it[key(owner, "home")] = NetworkJson.encodeToString(MusicAiHomeDto.serializer(), MusicAiHomeDto(cards)) + } + suspend fun clearHome(owner: String) = preferences.edit { it.remove(key(owner, "home")) } +} diff --git a/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt index 1638aaf..461b1f4 100644 --- a/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt +++ b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt @@ -14,11 +14,9 @@ import javax.inject.Singleton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -26,39 +24,97 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException import retrofit2.HttpException -class MusicAiFailure(val status: Int? = null, cause: Exception? = null) : Exception("Music service unavailable", cause) +enum class MusicAiFailureKind { + Network, + Timeout, + Authentication, + Forbidden, + Disabled, + Busy, + NotReady, + Unsupported, + InvalidResponse, + Other, +} + +class MusicAiFailure(val status: Int? = null, cause: Exception? = null) : + Exception("Music service unavailable", cause) { + val kind: MusicAiFailureKind get() = when { + cause is java.net.SocketTimeoutException || status in setOf(408, 504) -> MusicAiFailureKind.Timeout + cause is SerializationException -> MusicAiFailureKind.InvalidResponse + status == 401 -> MusicAiFailureKind.Authentication + status == 403 -> MusicAiFailureKind.Forbidden + status == 409 -> MusicAiFailureKind.Disabled + status == 429 -> MusicAiFailureKind.Busy + status == 503 -> MusicAiFailureKind.NotReady + status in setOf(404, 405) -> MusicAiFailureKind.Unsupported + status != null && status in 300..399 -> MusicAiFailureKind.InvalidResponse + cause is IOException -> MusicAiFailureKind.Network + else -> MusicAiFailureKind.Other + } +} /** No local profile: the server owns preferences and derives recommendations for its account. */ @Singleton class MusicAiRepository @Inject constructor( private val sessions: SessionStore, private val apis: ApiFactory, + private val cache: MusicAiCache, @ApplicationScope scope: CoroutineScope, ) { private val mutableCapabilities = MutableStateFlow(MusicAiCapabilitiesDto()) val capabilities = mutableCapabilities.asStateFlow() val session get() = sessions.session private val refreshMutex = Mutex() + private var refreshedAt = 0L + private var loadedOwner: String? = null + private val homeMutex = Mutex() + private var cachedHome: List? = null + private var cachedHomeOwner: String? = null + private var homeAt = 0L + var homeStale: Boolean = false + private set init { scope.launch(Dispatchers.IO) { sessions.session.collectLatest { paired -> - mutableCapabilities.value = MusicAiCapabilitiesDto() - if (paired != null) { - while (isActive) { - refresh() - delay(30_000) - } + val owner = paired?.let(cache::scope) + if (owner != loadedOwner) { + loadedOwner = owner + mutableCapabilities.value = MusicAiCapabilitiesDto() + refreshedAt = 0L + homeAt = 0L + cachedHome = null + cachedHomeOwner = null + } + if (paired != null && owner != null) { + try { + val saved = cache.capabilities(owner) + val cards = cache.home(owner) + if (sessions.current == paired) { + if (refreshedAt == 0L && saved != null) mutableCapabilities.value = saved + if (cachedHomeOwner != owner && + cards != null + ) { + cachedHome = cards + cachedHomeOwner = owner + } + } + } catch (_: IOException) { /* The network can rebuild a derived cache. */ } + refresh() } } } } - suspend fun refresh(): ServerResult = refreshMutex.withLock { + suspend fun refresh(force: Boolean = false): ServerResult = refreshMutex.withLock { val paired = sessions.current ?: return@withLock ServerResult.Unreachable + val now = System.nanoTime() / 1_000_000 + if (!force && refreshedAt != 0L && now - refreshedAt < 60_000) return@withLock ServerResult.Ok(Unit) val found = try { call { it.capabilities() } } catch (error: MusicAiFailure) { + refreshedAt = now - 45_000 // Bounded retries, while retaining known capabilities. if (sessions.current == paired && error.status in setOf(404, 405)) { mutableCapabilities.value = MusicAiCapabilitiesDto() } @@ -70,16 +126,61 @@ class MusicAiRepository @Inject constructor( ) } } - if (sessions.current == paired) mutableCapabilities.value = found + if (sessions.current == paired) { + mutableCapabilities.value = found + refreshedAt = now + try { + cache.putCapabilities(cache.scope(paired), found) + } catch (_: IOException) { } + } ServerResult.Ok(Unit) } suspend fun preferences() = call { it.preferences().preferences } - suspend fun djSessions() = call { it.djSessions().sessions } + suspend fun djSessions() = call { it.djSessions(capabilities.value.djLightStatus).sessions } suspend fun djCreate(theme: String, count: Int, requestId: String, mode: String = "prepared") = call { - it.djCreate(com.henriquesebastiao.downtify.core.network.dto.MusicDjSelectionDto(requestId, theme, count, mode)) + it.djCreate( + com.henriquesebastiao.downtify.core.network.dto.MusicDjSelectionDto( + requestId, + theme, + count, + mode, + trial = + mode == "interactive" && trial(), + ), + ) } suspend fun djSession(id: String) = call { it.djSession(id) } + private data class PlaybackCache( + val owner: String, + val id: String, + val etag: String?, + val value: com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto, + ) + private var playbackCache: PlaybackCache? = null + suspend fun djPlayback( + id: String, + position: Double, + ): com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto { + if (!capabilities.value.djLightStatus) return djSession(id) + val paired = sessions.current ?: throw MusicAiFailure() + val owner = cache.scope(paired) + val previous = playbackCache?.takeIf { it.owner == owner && it.id == id } + val response = call { it.djPlayback(id, position, previous?.etag) } + val value = if (response.code() == 304) { + previous?.value + } else { + if (!response.isSuccessful) throw MusicAiFailure(response.code()) + response.body() + } + if (value == null) throw MusicAiFailure(cause = SerializationException("Missing playback response")) + if (sessions.current == + paired + ) { + playbackCache = PlaybackCache(owner, id, response.headers()["ETag"] ?: previous?.etag, value) + } + return value + } suspend fun djQueue(ids: List, requestId: String) = call { it.djCreate( com.henriquesebastiao.downtify.core.network.dto.MusicDjSelectionDto( @@ -89,6 +190,7 @@ class MusicAiRepository @Inject constructor( "interactive", ids = ids, queueOrder = true, + trial = trial(), ), ) } @@ -101,37 +203,103 @@ class MusicAiRepository @Inject constructor( "interactive", continuous = true, style = style, + trial = trial(), ), ) } - suspend fun djCommand(id: String, action: String, payload: Map = emptyMap()) = call { - it.djCommand( - id, - com.henriquesebastiao.downtify.core.network.dto.MusicDjCommandDto( - java.util.UUID.randomUUID().toString(), - action, - payload, + suspend fun djCommand(id: String, action: String, payload: Map = emptyMap()) = + call(commands = true) { + it.djCommand( + id, + com.henriquesebastiao.downtify.core.network.dto.MusicDjCommandDto( + java.util.UUID.randomUUID().toString(), + action, + payload, + ), + ) + } + private fun trial() = !capabilities.value.dj.interactiveReady && capabilities.value.enabled("dj_trial") + suspend fun djSeed(seed: String, requestId: String, skipSeed: Boolean = false) = call { + it.djCreate( + com.henriquesebastiao.downtify.core.network.dto.MusicDjSelectionDto( + requestId, + "fete", + 24, + "interactive", + continuous = true, + seedId = seed, + trial = trial(), + skipSeed = skipSeed, ), ) } - suspend fun djStop(id: String) = call { it.djStop(id) } + suspend fun djStop(id: String) = call(commands = true) { it.djStop(id) } + suspend fun djCancelRequest(nonce: String) { + if (capabilities.value.djRequestCancel) call(commands = true) { it.djCancelRequest(nonce) } + } suspend fun set(feature: String, enabled: Boolean) { call { it.preferences(mapOf(feature to enabled)) } - refresh() + refresh(force = true) + invalidateHome() } suspend fun globalFeatures() = call { it.globalFeatures() } suspend fun setGlobal(feature: String, enabled: Boolean) { call { it.globalFeatures(mapOf(feature to enabled)) } - refresh() + refresh(force = true) + invalidateHome() } suspend fun recommendations(seed: String = "") = call { it.recommendations(seed).tracks } suspend fun playlist(cadence: String, mood: String) = call { it.playlist(cadence, mood).tracks } suspend fun playlists() = call { it.playlists().playlists } + suspend fun home() = homeMutex.withLock { + val owner = sessions.current ?: throw MusicAiFailure() + val ownerKey = cache.scope(owner) + val now = System.nanoTime() / 1_000_000 + if (cachedHomeOwner == ownerKey && cachedHome != null && now - homeAt in 0..60_000 && + homeAt != 0L + ) { + return@withLock cachedHome.orEmpty() + } + try { + val cards = call { if (capabilities.value.homeCards) it.home().cards else it.playlists().playlists } + if (sessions.current == owner) { + cachedHome = cards + cachedHomeOwner = ownerKey + homeAt = now + homeStale = false + try { + cache.putHome(cache.scope(owner), cards) + } catch (_: IOException) { } + } + cards + } catch (error: MusicAiFailure) { + val old = cachedHome + if (cachedHomeOwner == ownerKey && old != null && sessions.current == owner && + error.kind in setOf(MusicAiFailureKind.Network, MusicAiFailureKind.Timeout, MusicAiFailureKind.NotReady) + ) { + homeStale = true + old + } else { + throw error + } + } + } + private suspend fun invalidateHome() { + homeAt = 0L + cachedHome = null + cachedHomeOwner = null + sessions.current?.let { + try { + cache.clearHome(cache.scope(it)) + } catch (_: IOException) { } + } + } suspend fun save(name: String, ids: List, source: String) = call { it.save(MusicAiSelectionDto(name, ids, source)) } suspend fun forget() { call { it.forget() } - refresh() + invalidateHome() + refresh(force = true) } suspend fun similar(artist: String, title: String, external: Boolean = false, seed: String = "") = call { if (external) { @@ -149,18 +317,20 @@ class MusicAiRepository @Inject constructor( call { it.listen(event) } } - private suspend fun call(block: suspend (MusicAiApi) -> T): T = withContext(Dispatchers.IO) { - val paired = sessions.current ?: throw MusicAiFailure() - try { - val result = block(apis.createMusicAi(paired.baseUrl)) - if (sessions.current != paired) throw CancellationException("Account changed") - result - } catch (error: HttpException) { - throw MusicAiFailure(error.code(), error) - } catch (error: IOException) { - throw MusicAiFailure(cause = error) - } catch (error: SerializationException) { - throw MusicAiFailure(cause = error) + private suspend fun call(commands: Boolean = false, block: suspend (MusicAiApi) -> T): T = + withContext(Dispatchers.IO) { + val paired = sessions.current ?: throw MusicAiFailure() + try { + val result = block(apis.createMusicAi(paired, commands) { sessions.current }) + if (sessions.current != paired) throw CancellationException("Account changed") + result + } catch (error: HttpException) { + if (error.code() == 401) sessions.onRevoked(paired.token) + throw MusicAiFailure(error.code(), error) + } catch (error: IOException) { + throw MusicAiFailure(cause = error) + } catch (error: SerializationException) { + throw MusicAiFailure(cause = error) + } } - } } diff --git a/core/data/src/test/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarAvailabilityTest.kt b/core/data/src/test/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarAvailabilityTest.kt index b8d490a..c999f77 100644 --- a/core/data/src/test/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarAvailabilityTest.kt +++ b/core/data/src/test/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarAvailabilityTest.kt @@ -42,7 +42,7 @@ class SimilarAvailabilityTest { every { sessions.current } answers { paired.value } every { sessions.session } returns paired val apis = ApiFactory(OkHttpClient()) - ai = MusicAiRepository(sessions, apis, scope) + ai = MusicAiRepository(sessions, apis, mockk(relaxed = true), scope) similar = SimilarRepository(sessions, apis, ai) } @@ -111,8 +111,16 @@ class SimilarAvailabilityTest { answer(200, """{"version":1,"features":{"similarity":true}}""") assertTrue(ai.refresh() is ServerResult.Ok) answer(503) - assertEquals(ServerResult.Failed(503), ai.refresh()) + assertEquals(ServerResult.Failed(503), ai.refresh(force = true)) assertEquals(1, ai.capabilities.value.version) assertTrue(ai.capabilities.value.enabled("similarity")) } + + @Test + fun `a warm capability cache avoids redundant foreground calls`() = runTest { + answer(200, """{"version":1,"features":{"similarity":true}}""") + assertTrue(ai.refresh() is ServerResult.Ok) + repeat(25) { assertTrue(ai.refresh() is ServerResult.Ok) } + assertEquals(1, server.requestCount) + } } diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt index 515bf2b..167aa20 100644 --- a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt @@ -1,9 +1,13 @@ package com.henriquesebastiao.downtify.core.network import com.henriquesebastiao.downtify.core.network.di.DefaultClient +import com.henriquesebastiao.downtify.core.network.session.AuthInterceptor +import com.henriquesebastiao.downtify.core.network.session.Session +import java.io.IOException import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import okhttp3.Dispatcher import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import retrofit2.Retrofit @@ -16,7 +20,13 @@ class ApiFactory @Inject constructor(@DefaultClient private val client: OkHttpCl // Some calls wait on the server: it downloads a podcast episode, or asks Deezer and // Spotify about the whole library, before it answers. They get a longer read timeout. - private val patientClient by lazy { client.newBuilder().readTimeout(PATIENT_SECONDS, TimeUnit.SECONDS).build() } + private val patientClient by lazy { + client.newBuilder().dispatcher( + Dispatcher().apply { + maxRequestsPerHost = 2 + }, + ).readTimeout(PATIENT_SECONDS, TimeUnit.SECONDS).build() + } @Volatile private var cached: Pair? = null @@ -24,6 +34,21 @@ class ApiFactory @Inject constructor(@DefaultClient private val client: OkHttpCl @Volatile private var cachedPatient: Pair? = null + @Volatile private var cachedMusic: Pair? = null + + @Volatile private var cachedOwnedMusic: Pair? = null + + @Volatile private var cachedOwnedCommands: Pair? = null + private val musicClient by lazy { + client.newBuilder().dispatcher(Dispatcher().apply { maxRequestsPerHost = 4 }) + .connectTimeout(5, TimeUnit.SECONDS).readTimeout(8, TimeUnit.SECONDS) + .callTimeout(10, TimeUnit.SECONDS).followRedirects(false).followSslRedirects(false).build() + } + private val commandClient by lazy { + musicClient.newBuilder().dispatcher(Dispatcher().apply { maxRequestsPerHost = 2 }) + .readTimeout(5, TimeUnit.SECONDS).callTimeout(8, TimeUnit.SECONDS).build() + } + /** The mobile client contract's routes (`/api/v1/…`, pairing, sign-in). */ fun create(baseUrl: String): DowntifyApi = retrofit(baseUrl).create(DowntifyApi::class.java) @@ -36,7 +61,38 @@ class ApiFactory @Inject constructor(@DefaultClient private val client: OkHttpCl /** The music trash endpoints ([TrashApi]). */ fun createTrash(baseUrl: String): TrashApi = retrofit(baseUrl).create(TrashApi::class.java) - fun createMusicAi(baseUrl: String): MusicAiApi = retrofit(baseUrl).create(MusicAiApi::class.java) + fun createMusicAi(baseUrl: String): MusicAiApi { + val retrofit = cachedMusic?.takeIf { it.first == baseUrl }?.second ?: Retrofit.Builder() + .baseUrl(baseUrl.trimEnd('/') + "/").client(musicClient).addConverterFactory(converter).build() + .also { cachedMusic = baseUrl to it } + return retrofit.create(MusicAiApi::class.java) + } + + fun createMusicAi(owner: Session, commands: Boolean = false, current: () -> Session?): MusicAiApi { + val slot = if (commands) cachedOwnedCommands else cachedOwnedMusic + val existing = slot?.takeIf { it.first.token == owner.token && it.first.baseUrl == owner.baseUrl }?.second + if (existing != null) return existing.create(MusicAiApi::class.java) + val builder = (if (commands) commandClient else musicClient).newBuilder() + builder.interceptors().add( + 0, + okhttp3.Interceptor { chain -> + val active = current() + if (active == null || active.token != owner.token || active.baseUrl != owner.baseUrl || + !AuthInterceptor.belongsTo(chain.request().url, owner.baseUrl) + ) { + throw IOException("Playback owner changed") + } + chain.proceed( + chain.request().newBuilder().header(AuthInterceptor.AUTHORIZATION, "Bearer ${owner.token}").build(), + ) + }, + ) + val retrofit = Retrofit.Builder().baseUrl( + owner.baseUrl.trimEnd('/') + "/", + ).client(builder.build()).addConverterFactory(converter).build() + if (commands) cachedOwnedCommands = owner to retrofit else cachedOwnedMusic = owner to retrofit + return retrofit.create(MusicAiApi::class.java) + } private fun retrofit(baseUrl: String, patient: Boolean = false): Retrofit { val slot = if (patient) cachedPatient else cached diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiApi.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiApi.kt index 17143d7..ac94256 100644 --- a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiApi.kt +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiApi.kt @@ -19,6 +19,9 @@ import retrofit2.http.Query /** Optional versioned endpoints; pairing and authentication use the normal client. */ interface MusicAiApi : MusicDjApi { + @GET("api/v1/music-ai/home") + suspend fun home(): com.henriquesebastiao.downtify.core.network.dto.MusicAiHomeDto + @GET("api/v1/music-ai/capabilities") suspend fun capabilities(): MusicAiCapabilitiesDto diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicDjApi.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicDjApi.kt index e154735..7daba8d 100644 --- a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicDjApi.kt +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicDjApi.kt @@ -8,12 +8,25 @@ import retrofit2.http.POST import retrofit2.http.Path interface MusicDjApi { + @retrofit2.http.DELETE("api/v1/music-ai/dj/requests/{nonce}") + suspend fun djCancelRequest(@retrofit2.http.Path("nonce") nonce: String) + @GET("api/v1/music-ai/dj/sessions") - suspend fun djSessions(): com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionsDto + suspend fun djSessions( + @retrofit2.http.Query("summary") summary: Boolean = false, + ): com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionsDto @GET("api/v1/music-ai/dj/sessions/{id}") suspend fun djSession(@Path("id") id: String): com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto + @GET("api/v1/music-ai/dj/sessions/{id}") + suspend fun djPlayback( + @Path("id") id: String, + @retrofit2.http.Query("position") position: Double, + @retrofit2.http.Header("If-None-Match") etag: String?, + @retrofit2.http.Query("view") view: String = "playback", + ): retrofit2.Response + @POST("api/v1/music-ai/dj/sessions/{id}/commands") suspend fun djCommand( @Path("id") id: String, diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/di/NetworkModule.kt index 6e32a4f..a95837c 100644 --- a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/di/NetworkModule.kt @@ -24,6 +24,10 @@ annotation class DefaultClient @Retention(AnnotationRetention.BINARY) annotation class StreamingClient +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ArtworkClient + @Module @InstallIn(SingletonComponent::class) object NetworkModule { @@ -38,10 +42,18 @@ object NetworkModule { .addInterceptor(auth) .build() + @Provides + @Singleton + @ArtworkClient + fun artworkClient(@DefaultClient base: OkHttpClient): OkHttpClient = base.newBuilder() + .dispatcher(okhttp3.Dispatcher().apply { maxRequestsPerHost = 6 }) + .callTimeout(10, TimeUnit.SECONDS).build() + @Provides @Singleton @StreamingClient fun streamingClient(@DefaultClient base: OkHttpClient): OkHttpClient = base.newBuilder() + .dispatcher(okhttp3.Dispatcher().apply { maxRequestsPerHost = 3 }) .readTimeout(STREAM_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .addNetworkInterceptor { chain -> val response = chain.proceed(chain.request()) diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicAiDtos.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicAiDtos.kt index 23cb1bb..47b01b0 100644 --- a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicAiDtos.kt +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicAiDtos.kt @@ -10,6 +10,7 @@ import kotlinx.serialization.Serializable @SerialName("interactive_ready") val interactiveReady: Boolean = false, @SerialName("queue_supported") val queueSupported: Boolean = false, @SerialName("continuous_supported") val continuousSupported: Boolean = false, + @SerialName("interactive_trial_ready") val interactiveTrialReady: Boolean = false, ) @Serializable data class MusicAiCapabilitiesDto( @@ -20,8 +21,14 @@ import kotlinx.serialization.Serializable val model: MusicAiStatusDto = MusicAiStatusDto(), val dj: MusicAiStatusDto = MusicAiStatusDto(), val moods: List = emptyList(), + @SerialName("home_cards") val homeCards: Boolean = false, + @SerialName("dj_request_cancel") val djRequestCancel: Boolean = false, + @SerialName("dj_light_status") val djLightStatus: Boolean = false, + @SerialName("dj_seed_mix") val djSeedMix: Boolean = false, ) { fun enabled(feature: String) = version == 1 && features[feature] == true + fun interactiveAvailable() = + enabled("dj_interactive") && (dj.interactiveReady || (enabled("dj_trial") && dj.interactiveTrialReady)) } @Serializable data class MusicAiPreferencesDto( @@ -38,6 +45,7 @@ import kotlinx.serialization.Serializable val album: String = "", val reason: String = "", val file: String = "", + val duration: Double = 0.0, ) @Serializable data class MusicAiTracksDto(val tracks: List = emptyList(), val source: String = "local") @@ -48,8 +56,13 @@ import kotlinx.serialization.Serializable val period: String, val count: Int, val created: Double = 0.0, + @SerialName("cover_track_ids") val coverTrackIds: List = emptyList(), + val state: String = "ready", + val title: String = "", ) +@Serializable data class MusicAiHomeDto(val cards: List = emptyList()) + @Serializable data class MusicAiPlaylistsDto(val playlists: List = emptyList()) @Serializable data class MusicAiSelectionDto(val name: String, val ids: List, val source: String) diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicDjDtos.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicDjDtos.kt index 0065bdc..f3832b7 100644 --- a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicDjDtos.kt +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicDjDtos.kt @@ -12,6 +12,9 @@ import kotlinx.serialization.Serializable @SerialName("queue_order") val queueOrder: Boolean = false, val continuous: Boolean = false, val style: String = "library", + @SerialName("seed_id") val seedId: String = "", + val trial: Boolean = false, + @SerialName("skip_seed") val skipSeed: Boolean = false, ) @Serializable data class MusicDjTransitionDto(val reason: String = "") @@ -21,6 +24,9 @@ import kotlinx.serialization.Serializable val start: Double = 0.0, val end: Double = 0.0, val transition: MusicDjTransitionDto? = null, + val cue: Double = 0.0, + val speed: Double = 1.0, + val occurrence: Int = -1, ) @Serializable data class MusicDjSessionDto( @@ -40,6 +46,8 @@ import kotlinx.serialization.Serializable val timeline: List = emptyList(), @SerialName("media_origin") val mediaOrigin: Double? = null, val continuous: Boolean = false, + @SerialName("queue_order") val queueOrder: Boolean = false, + @SerialName("track_count") val trackCount: Int? = null, ) @Serializable data class MusicDjSessionsDto(val sessions: List = emptyList()) diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt index 4e4d793..353144b 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** Share one server render with web clients while retaining the native foreground media service. */ @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @@ -21,32 +23,46 @@ internal class DjControlTracker( ) : Player.Listener { private var current: DjReference? = null private var polling: Job? = null - private var pausedByServer = false + private var ticker: Job? = null + private var latest: com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto? = null + private var lastMarker: Double? = null + private var generation = 0L + private var terminal = false + private val commands = Mutex() fun start() { player.addListener(this) changed() } fun stop() { + generation++ + current = null player.removeListener(this) polling?.cancel() + ticker?.cancel() + latest = null } private fun command(reference: DjReference, action: String) { if (reference.mode != "interactive") return val owner = music.session.value + val mine = generation scope.launch { - if (music.session.value != owner || !music.capabilities.value.enabled("dj_interactive")) return@launch - try { - music.djCommand(reference.id, action) - } catch ( - _: MusicAiFailure, - ) { /* Next server poll reconciles. */ } + commands.withLock { + if (music.session.value != owner || !music.capabilities.value.enabled("dj_interactive") || + mine != generation || current != reference + ) { + return@withLock + } + try { + music.djCommand(reference.id, action) + } catch (_: MusicAiFailure) { /* A remote failure never overrides local intent. */ } + } } } override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - if (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) { + if (!terminal && reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) { current?.let { command(it, if (playWhenReady) "resume" else "pause") } } } @@ -56,10 +72,37 @@ internal class DjControlTracker( private fun changed() { val next = DjIds.referenceOf(player.currentMediaItem?.mediaId) if (next == current) return - current?.let { command(it, "pause") } + val retired = current polling?.cancel() - pausedByServer = false + ticker?.cancel() + latest = null + lastMarker = null + val mine = ++generation + terminal = false current = next + if (retired != null) { + // System/car controllers can replace media without creating the app coordinator. + // Retire only the old transport, after fencing callbacks with the new generation. + for (index in (0 until player.mediaItemCount).reversed()) { + if (index != player.currentMediaItemIndex && + DjIds.referenceOf(player.getMediaItemAt(index).mediaId) == retired + ) { + player.removeMediaItem(index) + } + } + val owner = music.session.value + if (retired.mode == "interactive") { + scope.launch { + commands.withLock { + if (owner == music.session.value && current != retired) { + try { + music.djStop(retired.id) + } catch (_: MusicAiFailure) { } + } + } + } + } + } (player as? androidx.media3.exoplayer.ExoPlayer)?.pauseAtEndOfMediaItems = next != null if (next == null) return val handover = @@ -68,41 +111,62 @@ internal class DjControlTracker( player.playWhenReady if (handover) command(next, "resume") val owner = music.session.value + ticker = scope.launch { + while (isActive && current == next && owner == music.session.value) { + val value = latest + if (value != null && owner != null) { + val position = DjTimeline.position(player, value.mediaOrigin?.times(1000)?.toLong() ?: 0) + val marker = value.timeline.lastOrNull { it.start * 1000 <= position }?.start + if (marker != lastMarker) { + lastMarker = marker + updateMetadata(value, owner.baseUrl) + } + } + delay(250) + } + } polling = scope.launch { if (handover) delay(1000) while (isActive && current == next && owner == music.session.value) { - if (music.capabilities.value.enabled("dj_" + next.mode)) reconcile(next) - delay(2000) + if (music.capabilities.value.enabled("dj_" + next.mode)) reconcile(next, mine) + delay(3000) } } } - private suspend fun reconcile(reference: DjReference) { + private suspend fun reconcile(reference: DjReference, mine: Long) { try { val owner = music.session.value - val session = music.djSession(reference.id) - if (owner != music.session.value || current != reference || + val position = DjTimeline.position(player, latest?.mediaOrigin?.times(1000)?.toLong() ?: 0) + val session = if (music.capabilities.value.djLightStatus) { + music.djPlayback( + reference.id, + position / 1000.0, + ) + } else { + music.djSession(reference.id) + } + if (mine != generation || owner != music.session.value || current != reference || !music.capabilities.value.enabled("dj_" + reference.mode) ) { return } + latest = session if (owner != null) updateMetadata(session, owner.baseUrl) if (reference.mode != "interactive") return when (session.state) { - "paused" -> { - if (player.playWhenReady) pausedByServer = true + // These are renderer states, not the user's playback intent. + // A late status must never undo a local pause or resume. + "failed", "stopped", "cancelled", "deleted", "deleting" -> { + terminal = true player.pause() } - - "playing" -> if (pausedByServer) { - pausedByServer = false - player.play() - } - - "failed", "stopped", "cancelled", "deleted", "deleting" -> player.pause() } } catch (error: MusicAiFailure) { - if (error.status in setOf(401, 403, 404, 409)) player.pause() + if (mine == generation && current == reference && error.status in setOf(401, 403, 404, 409)) { + terminal = true + player.pause() + } } } diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt new file mode 100644 index 0000000..30a3f5c --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt @@ -0,0 +1,387 @@ +package com.henriquesebastiao.downtify.core.player + +import com.henriquesebastiao.downtify.core.data.di.ApplicationScope +import com.henriquesebastiao.downtify.core.data.library.LibraryRepository +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +enum class DjPhase { Classic, Preparing, Ready, Active, Returning } + +/** Preparation survives navigation; every entry point shares the native player. */ +@Singleton +class DjPlaybackCoordinator @Inject constructor( + private val music: MusicAiRepository, + private val player: PlayerController, + private val library: LibraryRepository, + @ApplicationScope private val scope: CoroutineScope, +) { + val capabilities = music.capabilities + private val mutableMessage = MutableStateFlow("") + val message = mutableMessage.asStateFlow() + private val mutableBusy = MutableStateFlow(false) + val busy = mutableBusy.asStateFlow() + private val mutableArmed = MutableStateFlow(false) + val armed = mutableArmed.asStateFlow() + private val mutablePhase = MutableStateFlow(DjPhase.Classic) + val phase = mutablePhase.asStateFlow() + private var generation = 0L + private var preparation: Job? = null + private var pendingId: String? = null + private var pendingRequest: String? = null + private var originKey: String? = null + private var originIndex = -1 + private var activeId: String? = null + private var activeMode: String? = null + private var activeSelection = -1L + + init { + scope.launch { + music.session.drop(1).collect { + cancel(false) + mutableMessage.value = "" + } + } + scope.launch { + player.selectionVersion.drop(1).collect { version -> + if (mutableBusy.value || mutableArmed.value) { + cancel(true) + } else if (activeId != null && version != activeSelection) { + val retired = activeId + val stopRemote = activeMode == "interactive" + val owner = music.session.value + activeId = null + mutablePhase.value = DjPhase.Classic + scope.launch { + if (stopRemote && owner == music.session.value && + retired != null + ) { + try { + music.djStop(retired) + } catch (_: MusicAiFailure) { } + } + } + } + } + } + scope.launch { + player.state.collect { state -> + if (pendingId != null && state.dj?.id == pendingId) { + pendingId = null + pendingRequest = null + activeId = state.dj?.id + activeMode = state.dj?.mode + activeSelection = + player.selectionVersion.value + mutableArmed.value = false + mutablePhase.value = DjPhase.Active + } else if (activeId != null && state.dj?.id == activeId) { + mutablePhase.value = DjPhase.Active + } else if (activeId != null && mutablePhase.value == DjPhase.Active) { + // System controllers can change media without going through + // the app's selectionVersion. Retire that transport too. + val retired = activeId + val stopRemote = activeMode == "interactive" + val owner = music.session.value + activeId = null + mutablePhase.value = DjPhase.Classic + if (retired != null) { + player.exitDj(retired) + scope.launch { + if (stopRemote && + owner == music.session.value + ) { + try { + music.djStop(retired) + } catch (_: MusicAiFailure) { } + } + } + } + } else if ((mutableBusy.value || mutableArmed.value) && + (state.mediaKey != originKey || state.currentIndex != originIndex) + ) { + cancel(true) + } + } + } + } + + fun toggleQueue() { + if (activeId != null || player.state.value.dj != null || mutableBusy.value || + mutableArmed.value + ) { + stop() + return + } + val snapshot = player.state.value + val defer = snapshot.track != null + val ids = (if (defer) emptyList() else listOfNotNull(snapshot.track?.id)) + snapshot.upNext.map { it.trackId } + if (ids.size !in 2..40 || ids.any { library.library.value?.byId?.containsKey(it) != true }) { + mutableMessage.value = "Choisissez de 2 à 40 titres locaux à suivre." + return + } + prepare("Mode DJ · votre file", defer) { nonce -> music.djQueue(ids, nonce) } + } + + fun startTheme(theme: String, style: String) { + if (activeId != null || mutableBusy.value || mutableArmed.value || + player.state.value.dj != null + ) { + stop() + return + } + prepare("DJ continu · $theme", false) { nonce -> music.djContinuous(theme, style, nonce) } + } + + fun startSeed(seed: String) { + if (!music.capabilities.value.djSeedMix) { + mutableMessage.value = "Ce serveur ne prend pas encore en charge les mixes DJ à partir d’un titre." + return + } + if (mutableBusy.value || mutableArmed.value || player.state.value.dj != null) stop() + val owner = music.session.value + val mine = generation + val selection = player.selectionVersion.value + scope.launch { + val classic = withTimeoutOrNull(2000) { player.state.first { it.dj == null } } ?: return@launch + if (mine != generation || owner != music.session.value || + selection != player.selectionVersion.value + ) { + return@launch + } + val defer = classic.track?.id == seed + prepare("DJ · à partir de ce titre", defer, preserveQueue = false) { nonce -> + music.djSeed(seed, nonce, skipSeed = defer) + } + } + } + + private fun prepare( + title: String, + defer: Boolean, + preserveQueue: Boolean = true, + create: suspend (String) -> MusicDjSessionDto, + ) { + val owner = music.session.value ?: return + val mine = ++generation + val selection = player.selectionVersion.value + originKey = player.state.value.mediaKey + originIndex = player.state.value.currentIndex + val request = UUID.randomUUID().toString() + pendingRequest = request + mutableBusy.value = true + mutablePhase.value = DjPhase.Preparing + mutableMessage.value = "Préparation des transitions ; votre écoute continue." + preparation = scope.launch { + var created: String? = null + try { + val caps = music.capabilities.value + if (!caps.interactiveAvailable()) { + mutableMessage.value = + "Activez le DJ direct dans Paramètres → IA. Le moteur doit être disponible ou autorisé en essai bêta." + return@launch + } + val result = create(request) + created = result.id + if (mine != generation || owner != music.session.value || selection != player.selectionVersion.value) { + if (owner == music.session.value) music.djStop(result.id) + return@launch + } + pendingId = result.id + repeat(450) { + delay(2000) + if (mine != generation || owner != music.session.value || + selection != player.selectionVersion.value + ) { + return@launch + } + val current = music.djSession(result.id) + if (current.state in setOf("failed", "stopped", "cancelled", "deleted")) throw MusicAiFailure() + if (current.state in setOf("playing", "paused")) { + if (defer) { + if (current.state == "playing") music.djCommand(current.id, "pause") + if (mine != generation || selection != player.selectionVersion.value) return@launch + mutableArmed.value = true + mutablePhase.value = DjPhase.Ready + player.queueDj( + current.id, + title, + (current.duration * 1000).toLong(), + sessionPlan = current, + preserveQueue = preserveQueue, + ) + mutableMessage.value = "Mode DJ prêt · relais au prochain titre." + } else { + pendingId = null + pendingRequest = null + activeId = current.id + activeMode = current.mode + activeSelection = selection + 1 + mutableBusy.value = false + mutablePhase.value = DjPhase.Ready + player.playDj( + current.id, + title, + (current.duration * 1000).toLong(), + "interactive", + expectedSelection = selection, + sessionPlan = current, + ) + mutableMessage.value = "DJ actif dans le lecteur." + } + return@launch + } + } + if (owner == music.session.value) music.djStop(result.id) + mutableMessage.value = "Préparation trop longue. La lecture classique reste disponible." + } catch (error: CancellationException) { + if (created != null && + owner == music.session.value + ) { + scope.launch { + try { + music.djStop(created) + } catch (_: MusicAiFailure) { } + } + } + throw error + } catch (_: MusicAiFailure) { + if (mine == generation) { + mutableMessage.value = + "Le DJ est temporairement indisponible. Votre lecture classique reste disponible." + if (created == null && + owner == music.session.value + ) { + scope.launch { + try { + music.djCancelRequest(request) + } catch (_: MusicAiFailure) { } + } + } + if (created != null && + owner == music.session.value + ) { + scope.launch { + try { + music.djStop(created) + } catch (_: MusicAiFailure) { } + } + } + } + } finally { + if (mine == generation) { + mutableBusy.value = false + if (!mutableArmed.value && mutablePhase.value != DjPhase.Active) { + pendingId = null + pendingRequest = null + mutablePhase.value = DjPhase.Classic + } + } + } + } + } + + private fun cancel(remote: Boolean) { + val owner = music.session.value + val id = pendingId + val request = pendingRequest + generation++ + preparation?.cancel() + preparation = null + pendingId = null + pendingRequest = null + activeId = null + activeMode = null + mutableBusy.value = false + mutableArmed.value = false + mutablePhase.value = DjPhase.Classic + if (remote) mutableMessage.value = "Préparation DJ annulée. Votre sélection classique continue." + if (id != null) player.exitDj(id) + if (remote) { + scope.launch { + if (owner != music.session.value) return@launch + try { + if (request != + null + ) { + music.djCancelRequest(request) + } + if (id != null) music.djStop(id) + } catch (_: MusicAiFailure) { } + } + } + } + + fun stop() { + val id = player.state.value.dj?.id ?: activeId + val stopRemote = (player.state.value.dj?.mode ?: activeMode) == "interactive" + val owner = music.session.value + cancel(true) + if (id != null) { + mutablePhase.value = DjPhase.Returning + player.exitDj(id) + mutablePhase.value = DjPhase.Classic + scope.launch { + if (stopRemote && + owner == music.session.value + ) { + try { + music.djStop(id) + } catch (_: MusicAiFailure) { } + } + } + } + mutableMessage.value = "Mode DJ désactivé. Votre file reste disponible." + } + + fun listen(session: MusicDjSessionDto, title: String) { + val previous = activeId ?: player.state.value.dj?.id + val stopPrevious = (activeMode ?: player.state.value.dj?.mode) == "interactive" + if (previous == session.id) { + player.resume() + return + } + cancel(true) + val owner = music.session.value + if (previous != null && + stopPrevious + ) { + scope.launch { + if (owner == + music.session.value + ) { + try { + music.djStop(previous) + } catch (_: MusicAiFailure) { } + } + } + } + val selection = player.selectionVersion.value + activeId = session.id + activeMode = session.mode + activeSelection = selection + 1 + mutablePhase.value = DjPhase.Ready + mutableMessage.value = "DJ actif dans le lecteur." + player.playDj( + session.id, + title, + (session.duration * 1000).toLong(), + session.mode, + expectedSelection = selection, + sessionPlan = session, + ) + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt index 36c2189..aae9e3d 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt @@ -6,8 +6,16 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline +import com.henriquesebastiao.downtify.core.model.SavedPlaybackItem +import com.henriquesebastiao.downtify.core.network.NetworkJson import com.henriquesebastiao.downtify.core.network.ServerUrls +import com.henriquesebastiao.downtify.core.network.dto.MusicAiTrackDto +import com.henriquesebastiao.downtify.core.network.dto.MusicDjMarkerDto import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto +import kotlin.math.roundToLong +import kotlinx.serialization.builtins.ListSerializer + +internal data class DjResumePoint(val id: String, val sourceMs: Long, val occurrence: Int) @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) internal object DjTimeline { @@ -28,7 +36,22 @@ internal object DjTimeline { val track = session.tracks.firstOrNull { it.id == row.id } ?: return item val nextRow = session.timeline.getOrNull(index + 1) val next = session.tracks.firstOrNull { it.id == nextRow?.id } - val extras = Bundle().apply { + val plan = NetworkJson.encodeToString(ListSerializer(MusicDjMarkerDto.serializer()), session.timeline) + val tracks = NetworkJson.encodeToString(ListSerializer(MusicAiTrackDto.serializer()), session.tracks) + val previous = item.mediaMetadata + if (previous.extras?.getString( + "djResumePlan", + ) == plan && previous.extras?.getString("djResumeTracks") == tracks && + previous.extras?.getString("djTrackId") == track.id && + previous.title?.toString() == track.title && previous.artist?.toString() == track.artist && + previous.extras?.getLong("djOrigin") == (session.mediaOrigin?.times(1000)?.toLong() ?: 0) + ) { + return item + } + val extras = Bundle(item.mediaMetadata.extras ?: Bundle.EMPTY).apply { + putString("djResumePlan", plan) + putString("djResumeTracks", tracks) + putBoolean("djQueueOrder", session.queueOrder) putString("djTrackId", track.id) putString("djNextTitle", next?.title) putString("djNextArtist", next?.artist) @@ -46,6 +69,59 @@ internal object DjTimeline { ).build() } + fun resumeRows(item: MediaItem): List = runCatching { + NetworkJson.decodeFromString( + ListSerializer(MusicDjMarkerDto.serializer()), + item.mediaMetadata.extras?.getString("djResumePlan") ?: "[]", + ) + }.getOrDefault(emptyList()) + + fun fallbackItems(item: MediaItem, baseUrl: String): Map = runCatching { + NetworkJson.decodeFromString( + ListSerializer(MusicAiTrackDto.serializer()), + item.mediaMetadata.extras?.getString("djResumeTracks") ?: "[]", + ) + .associate { track -> + track.id to MediaItems.restored( + SavedPlaybackItem( + track.id, + track.title, + track.artist, + album = track.album, + durationMs = (track.duration * 1000).toLong(), + hasCover = true, + ), + baseUrl, + ) + } + }.getOrDefault(emptyMap()) + + fun resumePoint(item: MediaItem, positionMs: Long): DjResumePoint? { + val rows = resumeRows(item) + if (rows.isEmpty()) return null + val seconds = positionMs / 1000.0 + var index = rows.indexOfFirst { it.start <= seconds && seconds < it.end } + if (index < 0) index = if (seconds < rows.first().start) 0 else rows.lastIndex + val row = rows[index] + val next = rows.getOrNull(index + 1) + val selected = if (next != null && row.end > next.start && + seconds >= (next.start + row.end) / 2 + ) { + index++ + next + } else { + row + } + val source = (selected.cue + (seconds - selected.start).coerceAtLeast(0.0) * selected.speed).coerceAtLeast(0.0) + return DjResumePoint( + selected.id, + (source * 1000).roundToLong(), + selected.occurrence.takeIf { + it >= 0 + } ?: index, + ) + } + fun shown(dj: PlayingDj, absolute: Long): PlayingDj = dj.copy( transition = if (dj.end > dj.nextStart && absolute in dj.nextStart until dj.end) { ((absolute - dj.nextStart).toFloat() / (dj.end - dj.nextStart)).coerceIn(0f, 1f) diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt index c8f1067..dcc5596 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt @@ -159,6 +159,12 @@ class PlaybackService : MediaLibraryService() { .build() } val player = ExoPlayer.Builder(this, renderers) + .setLoadControl( + androidx.media3.exoplayer.DefaultLoadControl.Builder() + .setTargetBufferBytes(64 * 1024 * 1024) + .setPrioritizeTimeOverSizeThresholds(false) + .build(), + ) .setMediaSourceFactory(DefaultMediaSourceFactory(audioDataSource)) .setAudioAttributes( AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build(), diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt index 3a7b47e..11f9fea 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt @@ -18,6 +18,7 @@ import com.henriquesebastiao.downtify.core.data.di.ApplicationScope import com.henriquesebastiao.downtify.core.data.library.LibraryRepository import com.henriquesebastiao.downtify.core.data.library.RecentsRepository import com.henriquesebastiao.downtify.core.data.playback.playbackOwner +import com.henriquesebastiao.downtify.core.model.DjIds import com.henriquesebastiao.downtify.core.model.PlaybackContext import com.henriquesebastiao.downtify.core.model.PodcastEpisode import com.henriquesebastiao.downtify.core.model.PodcastShow @@ -72,6 +73,8 @@ class PlayerController @Inject constructor( private var controller: MediaController? = null private var ticker: Job? = null private val mutableState = MutableStateFlow(PlayerState()) + private val mutableSelectionVersion = MutableStateFlow(0L) + val selectionVersion = mutableSelectionVersion.asStateFlow() private val mutableEvents = MutableSharedFlow(extraBufferCapacity = 1) /** Everything, position ticks included (twice a second while playing): collect it only where shown. */ @@ -146,6 +149,7 @@ class PlayerController @Inject constructor( /** Voice intents use the same local search resolver as Android Auto. Never search or download online. */ suspend fun playLibrarySearch(query: String) { if (query.isBlank()) return + mutableSelectionVersion.value++ val owner = withContext(Dispatchers.IO) { sessions.current?.playbackOwner } ?: return val player = connect() if (sessions.current?.playbackOwner != owner) return @@ -185,6 +189,7 @@ class PlayerController @Inject constructor( fun play(tracks: List, startIndex: Int, from: PlaybackContext, shuffle: Boolean = false) { if (tracks.isEmpty()) return + mutableSelectionVersion.value++ main.launch { val player = connect() val baseUrl = sessions.current?.baseUrl @@ -208,6 +213,7 @@ class PlayerController @Inject constructor( */ fun playStreams(entries: List, startIndex: Int, from: PlaybackContext, shuffle: Boolean = false) { if (entries.isEmpty()) return + mutableSelectionVersion.value++ main.launch { val player = connect() val start = if (shuffle && startIndex == 0) { @@ -240,6 +246,7 @@ class PlayerController @Inject constructor( /** Plays a downloaded episode from where the server says the user stopped. */ fun playEpisode(episode: PodcastEpisode, show: PodcastShow) { + mutableSelectionVersion.value++ val baseUrl = sessions.current?.baseUrl ?: return val item = MediaItems.episode(episode, show, baseUrl) ?: return main.launch { @@ -263,7 +270,7 @@ class PlayerController @Inject constructor( fun setSpeed(speed: Float) = command { it.setPlaybackSpeed(speed) } fun togglePlayPause() = command { - if (it.isPlaying) { + if (it.playWhenReady) { it.pause() } else { if (it.playbackState == Player.STATE_IDLE) it.prepare() @@ -275,12 +282,46 @@ class PlayerController @Inject constructor( fun pause() = command { it.pause() } /** Insert a render without discarding the classic queue. All media uses the same session. */ - fun playDj(id: String, title: String, durationMs: Long, mode: String = "prepared") { + fun playDj( + id: String, + title: String, + durationMs: Long, + mode: String = "prepared", + expectedSelection: Long? = null, + sessionPlan: com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto? = null, + ) { val owner = sessions.current?.playbackOwner ?: return - val item = DjMediaItems.from(id, title, durationMs, mode) + val initialItem = DjMediaItems.from(id, title, durationMs, mode) main.launch { val player = connect() if (sessions.current?.playbackOwner != owner) return@launch + if (expectedSelection != null && mutableSelectionVersion.value != expectedSelection) return@launch + mutableSelectionVersion.value++ + val previous = player.currentMediaItem?.mediaMetadata?.extras + val wasDj = DjIds.referenceOf(player.currentMediaItem?.mediaId) != null + val extras = Bundle().apply { + putInt( + "djClassicIndex", + if (wasDj) { + previous?.getInt( + "djClassicIndex", + ) ?: 0 + } else { + player.currentMediaItemIndex.coerceAtLeast(0) + }, + ) + putLong( + "djClassicPosition", + if (wasDj) previous?.getLong("djClassicPosition") ?: 0 else player.currentPosition.coerceAtLeast(0), + ) + putInt("djQueueAnchor", getInt("djClassicIndex")) + if (mode == "interactive") putBoolean("djStartOnLoad", true) + } + val base = initialItem.buildUpon().setMediaMetadata( + initialItem.mediaMetadata.buildUpon().setExtras(extras).build(), + ).build() + val item = + sessionPlan?.let { DjTimeline.metadata(base, it, 0, sessions.current?.baseUrl.orEmpty()) } ?: base val existing = (0 until player.mediaItemCount).firstOrNull { player.getMediaItemAt(it).mediaId == item.mediaId @@ -294,16 +335,90 @@ class PlayerController @Inject constructor( } } - fun queueDj(id: String, title: String, durationMs: Long) = command { + fun queueDj( + id: String, + title: String, + durationMs: Long, + sessionPlan: com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto? = null, + preserveQueue: Boolean = true, + ) = command { val item = DjMediaItems.from(id, title, durationMs, "interactive") - val extras = Bundle().apply { putBoolean("djStartOnLoad", true) } + val extras = Bundle().apply { + putBoolean("djStartOnLoad", true) + putBoolean("djOwnQueue", preserveQueue) + putInt("djClassicIndex", it.currentMediaItemIndex + 1) + putInt("djQueueAnchor", it.currentMediaItemIndex + 1) + putLong("djClassicPosition", 0) + } + val base = item.buildUpon().setMediaMetadata(item.mediaMetadata.buildUpon().setExtras(extras).build()).build() + val planned = + sessionPlan?.let { plan -> DjTimeline.metadata(base, plan, 0, sessions.current?.baseUrl.orEmpty()) } ?: base it.addMediaItem( (it.currentMediaItemIndex + 1).coerceIn(0, it.mediaItemCount), - item.buildUpon().setMediaMetadata(item.mediaMetadata.buildUpon().setExtras(extras).build()).build(), + planned, ) } fun exitDj(id: String) = command { + val current = it.currentMediaItem + if (DjIds.referenceOf(current?.mediaId)?.id == id && current != null) { + val extras = current.mediaMetadata.extras + val absolute = DjTimeline.position(it, extras?.getLong("djOrigin") ?: 0) + val point = DjTimeline.resumePoint(current, absolute) + val classic = (0 until it.mediaItemCount).map(it::getMediaItemAt).filter { row -> + DjIds.referenceOf(row.mediaId) == + null + } + val anchor = extras?.getInt("djQueueAnchor") ?: 0 + val preferred = anchor + (point?.occurrence ?: 0) + var selection = classic + var index = if (point != null) { + if (classic.getOrNull(preferred)?.mediaId == + point.id + ) { + preferred + } else { + classic.indexOfFirst { row -> row.mediaId == point.id } + } + } else { + (extras?.getInt("djClassicIndex") ?: 0).coerceIn(0, (classic.size - 1).coerceAtLeast(0)) + } + if (point != null && + (extras?.getBoolean("djOwnQueue") != true || extras.getBoolean("djQueueOrder") != true || index < 0) + ) { + val rows = DjTimeline.resumeRows(current) + val fallback = DjTimeline.fallbackItems(current, sessions.current?.baseUrl.orEmpty()) + val items = rows.withIndex().mapNotNull { (ordinal, row) -> + val media = + library.library.value?.byId?.get(row.id)?.let { track -> + MediaItems.from(track, sessions.current?.baseUrl) + } + ?: fallback[row.id] + media?.let { ordinal to it } + } + val occurrence = + rows.indexOfFirst { row -> + row.occurrence >= 0 && row.occurrence == point.occurrence + }.takeIf { ordinal -> + ordinal >= + 0 + } + ?: point.occurrence + selection = items.map { pair -> pair.second } + index = items.indexOfFirst { pair -> pair.first == occurrence } + } + val wantsPlay = it.playWhenReady + if (selection.isNotEmpty() && index >= 0) { + it.setMediaItems(selection, index, point?.sourceMs ?: extras?.getLong("djClassicPosition") ?: 0) + it.setPlaybackSpeed(1f) + it.prepare() + it.playWhenReady = wantsPlay + } else { + it.pause() + it.clearMediaItems() + } + return@command + } for (index in (0 until it.mediaItemCount).reversed()) { if (com.henriquesebastiao.downtify.core.model.DjIds.referenceOf(it.getMediaItemAt(index).mediaId)?.id == id @@ -335,7 +450,10 @@ class PlayerController @Inject constructor( mutableState.update { s -> s.copy(positionMs = positionMs) } } - fun skipTo(index: Int) = command { it.seekToDefaultPosition(index) } + fun skipTo(index: Int) { + mutableSelectionVersion.value++ + command { it.seekToDefaultPosition(index) } + } /** "Add to queue": plays after the current song and anything queued before it. */ fun addToQueue(track: Track) { diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt index 526f075..d5e4a67 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt @@ -80,8 +80,11 @@ object PlayerModule { @Provides @Singleton @ArtworkDataSource - fun artworkDataSource(@ApplicationContext context: Context, http: OkHttpDataSource.Factory): DataSource.Factory = - DefaultDataSource.Factory(context, http) + fun artworkDataSource( + @ApplicationContext context: Context, + @com.henriquesebastiao.downtify.core.network.di.ArtworkClient client: OkHttpClient, + ): DataSource.Factory = + DefaultDataSource.Factory(context, OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT)) private const val USER_AGENT = "Downtify-Android" } diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ArtworkDataSourceTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ArtworkDataSourceTest.kt index 4f54669..90d866e 100644 --- a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ArtworkDataSourceTest.kt +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ArtworkDataSourceTest.kt @@ -10,7 +10,6 @@ import android.os.ParcelFileDescriptor import androidx.media3.common.C import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSpec -import androidx.media3.datasource.okhttp.OkHttpDataSource import java.io.File import okhttp3.OkHttpClient import org.junit.Assert.assertArrayEquals @@ -70,11 +69,9 @@ class ArtworkDataSourceTest { private fun read(uri: Uri): ByteArray { val context = RuntimeEnvironment.getApplication() - val http = OkHttpDataSource.Factory( - OkHttpClient.Builder().addInterceptor { - error("Local artwork must not use HTTP") - }.build(), - ) + val http = OkHttpClient.Builder().addInterceptor { + error("Local artwork must not use HTTP") + }.build() val source = PlayerModule.artworkDataSource(context, http).createDataSource() return try { source.open(DataSpec.Builder().setUri(uri).build()) diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTrackerTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTrackerTest.kt index 4695692..b9fd163 100644 --- a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTrackerTest.kt +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTrackerTest.kt @@ -40,6 +40,7 @@ class DjControlTrackerTest { every { player.currentMediaItem } returns DjMediaItems.from(id, "Live", 100000, "interactive") coEvery { music.djSession(id) } answers { status } coEvery { music.djCommand(any(), any(), any()) } returns MusicDjCommandResultDto(true) + coEvery { music.djStop(any()) } returns kotlinx.serialization.json.JsonObject(emptyMap()) } @Test fun `only user transport changes send global commands not buffering or audio focus`() = runTest { @@ -61,7 +62,7 @@ class DjControlTrackerTest { tracker.stop() } - @Test fun `shared server pause resumes only its own loaded direct and stops polling on release`() = runTest { + @Test fun `render status never changes local playback intent and release stops polling`() = runTest { setup() var playing = true every { player.playWhenReady } answers { playing } @@ -71,14 +72,78 @@ class DjControlTrackerTest { val tracker = DjControlTracker(player, music, backgroundScope) tracker.start() runCurrent() - verify(exactly = 1) { player.pause() } + verify(exactly = 0) { player.pause() } status = status.copy(state = "playing") - advanceTimeBy(2001) + advanceTimeBy(3001) runCurrent() - verify(exactly = 1) { player.play() } + verify(exactly = 0) { player.play() } tracker.stop() advanceTimeBy(4000) runCurrent() coVerify(exactly = 2) { music.djSession(id) } } + + @Test fun `25 repeated DJ normal cycles leave no retired polling or server-driven playback`() = runTest { + setup() + var item = DjMediaItems.from(id, "Live", 100000, "interactive") + every { player.currentMediaItem } answers { item } + status = status.copy(state = "paused") + val tracker = DjControlTracker(player, music, backgroundScope) + tracker.start() + runCurrent() + repeat(25) { + item = androidx.media3.common.MediaItem.Builder().setMediaId("normal").build() + tracker.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) + runCurrent() + item = DjMediaItems.from(id, "Live", 100000, "interactive") + tracker.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) + runCurrent() + } + tracker.stop() + val calls = 26 + advanceTimeBy(10000) + runCurrent() + coVerify(exactly = calls) { music.djSession(id) } + coVerify(exactly = 25) { music.djStop(id) } + verify(exactly = 0) { player.pause() } + verify(exactly = 0) { player.play() } + } + + @Test fun `system selection removes retired HLS without controlling the normal title`() = runTest { + setup() + val dj = DjMediaItems.from(id, "Live", 100000, "interactive") + val normal = androidx.media3.common.MediaItem.Builder().setMediaId("normal").build() + var item = dj + every { player.currentMediaItem } answers { item } + every { player.mediaItemCount } returns 2 + every { player.currentMediaItemIndex } returns 1 + every { player.getMediaItemAt(0) } returns dj + every { player.getMediaItemAt(1) } returns normal + val tracker = DjControlTracker(player, music, backgroundScope) + tracker.start() + runCurrent() + tracker.onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + item = normal + tracker.onMediaItemTransition(normal, Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) + runCurrent() + coVerify(exactly = 0) { music.djCommand(id, "pause", any()) } + coVerify(exactly = 1) { music.djStop(id) } + verify(exactly = 1) { player.removeMediaItem(0) } + verify(exactly = 0) { player.pause() } + tracker.stop() + } + + @Test fun `prepared sets remain reusable after a system selection`() = runTest { + setup() + var item = DjMediaItems.from(id, "Prepared", 100000, "prepared") + every { player.currentMediaItem } answers { item } + val tracker = DjControlTracker(player, music, backgroundScope) + tracker.start() + runCurrent() + item = androidx.media3.common.MediaItem.Builder().setMediaId("normal").build() + tracker.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) + runCurrent() + coVerify(exactly = 0) { music.djStop(any()) } + tracker.stop() + } } diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinatorTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinatorTest.kt new file mode 100644 index 0000000..b35aabf --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinatorTest.kt @@ -0,0 +1,116 @@ +package com.henriquesebastiao.downtify.core.player + +import com.henriquesebastiao.downtify.core.data.library.LibraryRepository +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiStatusDto +import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto +import com.henriquesebastiao.downtify.core.network.session.Session +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DjPlaybackCoordinatorTest { + private class Fixture(scope: CoroutineScope) { + val owner = MutableStateFlow(Session("https://fixture.test", "server", "Test", "device", "fixture")) + val selection = MutableStateFlow(0L) + val state = MutableStateFlow(PlayerState()) + val music = mockk(relaxed = true) + val player = mockk(relaxed = true) + init { + every { music.session } returns owner + every { music.capabilities } returns MutableStateFlow( + MusicAiCapabilitiesDto( + version = 1, + features = mapOf("dj_interactive" to true), + dj = MusicAiStatusDto(interactiveReady = true, queueSupported = true), + ), + ) + every { player.selectionVersion } returns selection + every { player.state } returns state + every { player.playDj(any(), any(), any(), any(), any(), any()) } answers { + selection.value++ + state.value = PlayerState(dj = PlayingDj(firstArg(), "Fixture", arg(3))) + } + every { player.exitDj(any()) } answers { state.value = PlayerState() } + } + val coordinator = DjPlaybackCoordinator(music, player, mockk(relaxed = true), scope) + } + + @Test fun `cancelling preparation cancels its nonce before any transport can attach`() = runTest { + val fixture = Fixture(backgroundScope) + val response = CompletableDeferred() + coEvery { fixture.music.djContinuous(any(), any(), any()) } coAnswers { response.await() } + runCurrent() + fixture.coordinator.startTheme("detente", "library") + runCurrent() + fixture.coordinator.stop() + runCurrent() + response.complete(MusicDjSessionDto("a".repeat(32), state = "playing", theme = "detente", mode = "interactive")) + runCurrent() + coVerify(exactly = 1) { fixture.music.djCancelRequest(any()) } + verify(exactly = 0) { fixture.player.playDj(any(), any(), any(), any(), any(), any()) } + assertEquals(DjPhase.Classic, fixture.coordinator.phase.value) + assertFalse(fixture.coordinator.busy.value) + } + + @Test fun `25 mode cycles retire the previous session and keep one playback authority`() = runTest { + val fixture = Fixture(backgroundScope) + val session = MusicDjSessionDto("a".repeat(32), state = "playing", theme = "fete", mode = "interactive") + runCurrent() + repeat(25) { + fixture.coordinator.listen(session, "Fixture") + runCurrent() + assertEquals(DjPhase.Active, fixture.coordinator.phase.value) + fixture.coordinator.stop() + runCurrent() + assertEquals(DjPhase.Classic, fixture.coordinator.phase.value) + } + verify(exactly = 25) { fixture.player.playDj(any(), any(), any(), any(), any(), any()) } + verify(exactly = 25) { fixture.player.exitDj(session.id) } + coVerify(exactly = 25) { fixture.music.djStop(session.id) } + } + + @Test fun `a system controller selecting normal media retires the old DJ transport`() = runTest { + val fixture = Fixture(backgroundScope) + val session = MusicDjSessionDto("a".repeat(32), state = "playing", theme = "fete", mode = "interactive") + runCurrent() + fixture.coordinator.listen(session, "Fixture") + runCurrent() + fixture.state.value = PlayerState(stream = PlayingStream("normal", "", "", "", "")) + runCurrent() + assertEquals(DjPhase.Classic, fixture.coordinator.phase.value) + verify(exactly = 1) { fixture.player.exitDj(session.id) } + coVerify(exactly = 1) { fixture.music.djStop(session.id) } + } + + @Test fun `leaving a prepared set preserves its reusable server render`() = runTest { + val fixture = Fixture(backgroundScope) + val session = MusicDjSessionDto("a".repeat(32), state = "ready", theme = "fete", mode = "prepared") + runCurrent() + fixture.coordinator.listen(session, "Fixture") + runCurrent() + fixture.coordinator.stop() + runCurrent() + verify(exactly = 1) { fixture.player.exitDj(session.id) } + coVerify(exactly = 0) { fixture.music.djStop(any()) } + assertEquals(DjPhase.Classic, fixture.coordinator.phase.value) + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjTimelineTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjTimelineTest.kt index c0bb699..7a11837 100644 --- a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjTimelineTest.kt +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjTimelineTest.kt @@ -5,6 +5,7 @@ import com.henriquesebastiao.downtify.core.network.dto.MusicDjMarkerDto import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertSame import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -44,4 +45,34 @@ class DjTimelineTest { assertEquals(90000L, dj.start) assertEquals("", dj.nextTitle) } + + @Test fun `returning to normal chooses the dominant deck and its original source position`() { + val value = session.copy( + timeline = listOf( + MusicDjMarkerDto("one", 0.0, 100.0, cue = 10.0), + MusicDjMarkerDto("two", 90.0, 200.0, cue = 5.0, speed = 1.02), + ), + ) + val item = DjTimeline.metadata( + DjMediaItems.from(id, "Set", 0, "interactive"), + value, + 97000, + "https://fixture.test", + ) + val point = requireNotNull(DjTimeline.resumePoint(item, 97000)) + assertEquals("two", point.id) + assertEquals(12140L, point.sourceMs) + assertEquals(1, point.occurrence) + assertEquals("one", DjTimeline.resumePoint(item, 93000)?.id) + } + + @Test fun `unchanged metadata reuses the same media item instead of rebuilding the player timeline`() { + val item = DjTimeline.metadata( + DjMediaItems.from(id, "Set", 0, "interactive"), + session, + 20000, + "https://fixture.test", + ) + assertSame(item, DjTimeline.metadata(item, session, 22000, "https://fixture.test")) + } } diff --git a/docs/music-ai.md b/docs/music-ai.md index 8a479e1..4d113c9 100644 --- a/docs/music-ai.md +++ b/docs/music-ai.md @@ -7,8 +7,12 @@ upgrade; the Android app does not silently enable server modules. ## Included -- Home → For you: daily and weekly mood playlists, stable saved copies and - thematic continuous DJ shortcuts. +- Four daily mood cards directly below favorite playlists, with local vector + artwork and readable mood colors. See all opens daily and weekly selections. +- Temporary selections reuse the native playlist screen: play, shuffle, likes, + track menus and queue actions. Save a stable copy under a chosen name; later + daily/weekly renewal never replaces that copy. +- A separate DJ studio beside Discover, Similar tracks and Podcasts. - AI settings with independent user preferences and an experimental badge. - Start a mix from the playing song or its overflow menu. Local similarity uses the exact native track identity; external discovery is a separate opt-in beta. @@ -27,27 +31,41 @@ Models and mixing run on the server, not on the phone. Android uses its existing Media3 audio decoder and authenticated HTTP stack. Live DJ segments bypass the persistent library cache; ordinary offline files retain their existing behavior. There is no on-device model download or new model subscription. Additional phone -CPU, memory, battery and mobile-data consumption have not yet been measured. +CPU, memory, battery and mobile-data consumption have not yet been qualified. An HLS connection and foreground playback remain necessary for interactive DJ. -Continuous DJ currently extends a bounded selection (24 tracks for thematic -shortcuts, up to 40 for a queue), with recent-track avoidance. It is not a radio -or an unlimited catalog service. Inputs are local files. The server permits one +Continuous DJ starts with a bounded selection, then requests fresh authorized +local tracks in the background, with recent-track avoidance. Inputs remain local +files, without a radio fallback. The server permits one render session at a time and applies a separate 5 GiB cache budget by default. ## Validation and remaining acceptance -The delivered beta passed 454 unit tests (two skipped), debug assembly, Spotless, -Detekt and Lint. Tests cover owner changes, exact track identifiers, server -commands, HLS timeline metadata, repeated occurrences, listening events and the -service-owned timer. These checks do not establish physical-device acceptance. +Earlier tests and the corrective four-module suite cover owner changes, +identifiers, commands, HLS metadata, repeated occurrences, listening and the +service-owned timer. Corrective player tests include 25 simulated mode cycles +and system-controller replacement of a DJ transport. The optimized beta enables +R8/resource shrinking and is non-debuggable, with the same package/signature as +the preceding test build. See the README for current results and pending checks. +Simulated cycles do not establish physical-device acceptance. Physical Android background/network recovery and comparative listening remain -to be tested. The latest server DJ engine has not completed its one-hour real -playback qualification. Ordinary interactive creation stays gated until the -administrator completes that validation with zero interruptions and rendering -above 1.5× real time. The administrator validation stream is available for this -purpose. No synthetic test or client time claim unlocks it. +to be tested. An initial official Android Auto DHU check on Pixel 9 Pro, +Android 17, passed browsing, queue display, play and pause on the preceding beta. +It does not qualify this new build, DJ playback, a real receiver or endurance. +The latest DJ engine still lacks its real one-hour qualification. Qualified +interactive use requires zero interruptions and rendering above 1.5× real time. +A separate unqualified trial requires explicit server and user opt-in; it never +marks an engine as qualified. No model is downloaded for the unavailable local +DJ engine. Macrobenchmark is opt-in with `-PincludeBenchmarks=true`. + +The physical optimized beta also passed a visual check of the four colored mood +cards, native temporary-playlist view, save-dialog opening and editable name. +That check cancelled the save and did not create a library playlist. Targeted +native collection/card/control tests passed (six passed); the save-confirmation +UI test is explicitly ignored because Robolectric dialog focus never becomes +idle. Its physical instrumented confirmation remains pending; the ignored test +must not be counted as a pass. This is a draft beta contribution; please report the device, Android version, server capability/version and reproducible steps without credentials or private diff --git a/macrobenchmark/build.gradle.kts b/macrobenchmark/build.gradle.kts new file mode 100644 index 0000000..a06448f --- /dev/null +++ b/macrobenchmark/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + id("com.android.test") +} + +android { + namespace = "com.henriquesebastiao.downtify.benchmark" + compileSdk = 37 + defaultConfig { + minSdk = 28 + targetSdk = 37 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["androidx.benchmark.fullTracing.enable"] = "false" + } + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true + buildTypes { + create("beta") { + isDebuggable = true + signingConfig = signingConfigs.getByName("debug") + } + } +} + +androidComponents { + beforeVariants(selector().all()) { it.enable = it.buildType == "beta" } +} + +dependencies { + implementation("androidx.benchmark:benchmark-macro-junit4:1.5.0") + implementation("androidx.test.ext:junit:1.3.0") + implementation("androidx.test.uiautomator:uiautomator:2.4.0") +} diff --git a/macrobenchmark/src/main/AndroidManifest.xml b/macrobenchmark/src/main/AndroidManifest.xml new file mode 100644 index 0000000..862c31b --- /dev/null +++ b/macrobenchmark/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/macrobenchmark/src/main/java/com/henriquesebastiao/downtify/benchmark/StartupBenchmark.kt b/macrobenchmark/src/main/java/com/henriquesebastiao/downtify/benchmark/StartupBenchmark.kt new file mode 100644 index 0000000..dd62c5c --- /dev/null +++ b/macrobenchmark/src/main/java/com/henriquesebastiao/downtify/benchmark/StartupBenchmark.kt @@ -0,0 +1,49 @@ +package com.henriquesebastiao.downtify.benchmark + +import androidx.benchmark.macro.BaselineProfileMode +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import java.util.regex.Pattern +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Opt-in physical-device measurements. Pair normally before running; no data reset or playback. */ +@RunWith(AndroidJUnit4::class) +class StartupBenchmark { + @get:Rule val benchmark = MacrobenchmarkRule() + + @Test fun coldHome() = startup(StartupMode.COLD) + + @Test fun warmHome() = startup(StartupMode.WARM) + + private fun startup(mode: StartupMode) { + benchmark.measureRepeated( + packageName = "com.henriquesebastiao.downtify.debug", + metrics = listOf(StartupTimingMetric(), FrameTimingMetric()), + compilationMode = CompilationMode.Partial( + baselineProfileMode = BaselineProfileMode.UseIfAvailable, + warmupIterations = 3, + ), + startupMode = mode, + iterations = 10, + setupBlock = { pressHome() }, + ) { + startActivityAndWait() + assertTrue( + "The paired library must become usable", + device.wait( + Until.hasObject(By.text(Pattern.compile("Accueil|Home", Pattern.CASE_INSENSITIVE))), + 5000, + ), + ) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index cc9278d..2c33a55 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,3 +25,4 @@ include(":core:designsystem") include(":core:network") include(":core:data") include(":core:player") +if (providers.gradleProperty("includeBenchmarks").orNull == "true") include(":macrobenchmark") From b1307c96c4f3a483365628738507677b26e7fa49 Mon Sep 17 00:00:00 2001 From: QuietForge7294 <77000426+QuietForge7294@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:12:26 +0100 Subject: [PATCH 03/11] Refactor DJ lifecycle and queue restoration into focused helpers --- .../downtify/feature/home/HomeScreen.kt | 2 - .../feature/musicai/MusicLibraryPages.kt | 7 +- .../musicai/TemporaryMusicPlaylistTest.kt | 3 +- .../core/data/musicai/MusicAiRepository.kt | 30 ++- .../downtify/core/network/ApiFactory.kt | 5 +- .../downtify/core/player/DjControlTracker.kt | 66 +++-- .../core/player/DjPlaybackCoordinator.kt | 249 +++++++++--------- .../downtify/core/player/DjTimeline.kt | 25 +- .../downtify/core/player/PlayerController.kt | 180 +++++++------ 9 files changed, 312 insertions(+), 255 deletions(-) diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt index e25f005..4d9d587 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/home/HomeScreen.kt @@ -131,7 +131,6 @@ fun HomeRoute( } }, onOpenDj = onOpenDj, - forYouRefreshing = aiState.busy, onRefreshForYou = musicAi::refreshHome, ) } @@ -152,7 +151,6 @@ fun HomeScreen( onOpenMenu: () -> Unit = {}, onOpenDj: () -> Unit = {}, forYouContent: (LazyListScope.() -> Unit)? = null, - forYouRefreshing: Boolean = false, onRefreshForYou: () -> Unit = {}, ) { Scaffold( diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt index b7f026c..50dbfc0 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt @@ -46,7 +46,6 @@ fun DailyMusicShelf( onOpen: (String, String) -> Unit, onSeeAll: () -> Unit, ) { - val covers = LocalCoverUrls.current Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text("Pour vous", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium) @@ -161,7 +160,8 @@ fun MusicStudioRoute( ) { item { Text( - "Créez un mix préparé ou continu à partir de votre musique. Les transitions et les titres restent dans votre lecteur habituel.", + "Créez un mix préparé ou continu à partir de votre musique. " + + "Les transitions et les titres restent dans votre lecteur habituel.", ) TextButton(onClick = onSettings) { Text("Paramètres IA") } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -170,7 +170,8 @@ fun MusicStudioRoute( }, label = { Text("Cet appareil · Bêta") }) } Text( - "Le moteur local attend la validation de l’export ONNX et des performances sur votre appareil. Aucun modèle n’est téléchargé. Le serveur reste disponible.", + "Le moteur local attend la validation de l’export ONNX et des performances sur votre appareil. " + + "Aucun modèle n’est téléchargé. Le serveur reste disponible.", style = MaterialTheme.typography.bodySmall, ) TextButton(onClick = queue::toggle, enabled = caps.interactiveAvailable()) { diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylistTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylistTest.kt index 97ec70d..839a73d 100644 --- a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylistTest.kt +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylistTest.kt @@ -55,7 +55,8 @@ class TemporaryMusicPlaylistTest { } @Ignore( - "Robolectric dialog focus does not become idle; opening and name editing checked on physical Android 17, confirmation still needs instrumentation", + "Robolectric dialog focus does not become idle; opening/name editing checked on physical Android 17; " + + "confirmation still needs instrumentation", ) @Test fun `temporary selection uses native playlist controls and saves its named copy`() { diff --git a/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt index 461b1f4..32a6b24 100644 --- a/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt +++ b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt @@ -55,6 +55,8 @@ class MusicAiFailure(val status: Int? = null, cause: Exception? = null) : } /** No local profile: the server owns preferences and derives recommendations for its account. */ +// One owner-scoped facade mirrors the independent wire endpoints. +@Suppress("TooManyFunctions") @Singleton class MusicAiRepository @Inject constructor( private val sessions: SessionStore, @@ -170,8 +172,7 @@ class MusicAiRepository @Inject constructor( val value = if (response.code() == 304) { previous?.value } else { - if (!response.isSuccessful) throw MusicAiFailure(response.code()) - response.body() + playbackBody(response) } if (value == null) throw MusicAiFailure(cause = SerializationException("Missing playback response")) if (sessions.current == @@ -255,9 +256,9 @@ class MusicAiRepository @Inject constructor( val owner = sessions.current ?: throw MusicAiFailure() val ownerKey = cache.scope(owner) val now = System.nanoTime() / 1_000_000 - if (cachedHomeOwner == ownerKey && cachedHome != null && now - homeAt in 0..60_000 && - homeAt != 0L - ) { + val sameCache = cachedHomeOwner == ownerKey && cachedHome != null + val fresh = now - homeAt in 0..60_000 && homeAt != 0L + if (sameCache && fresh) { return@withLock cachedHome.orEmpty() } try { @@ -274,11 +275,15 @@ class MusicAiRepository @Inject constructor( cards } catch (error: MusicAiFailure) { val old = cachedHome - if (cachedHomeOwner == ownerKey && old != null && sessions.current == owner && - error.kind in setOf(MusicAiFailureKind.Network, MusicAiFailureKind.Timeout, MusicAiFailureKind.NotReady) - ) { + val available = old?.takeIf { cachedHomeOwner == ownerKey && sessions.current == owner } + val transient = error.kind in setOf( + MusicAiFailureKind.Network, + MusicAiFailureKind.Timeout, + MusicAiFailureKind.NotReady, + ) + if (available != null && transient) { homeStale = true - old + available } else { throw error } @@ -334,3 +339,10 @@ class MusicAiRepository @Inject constructor( } } } + +private fun playbackBody( + response: retrofit2.Response, +): com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto? { + if (!response.isSuccessful) throw MusicAiFailure(response.code()) + return response.body() +} diff --git a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt index 167aa20..59d63b4 100644 --- a/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/ApiFactory.kt @@ -77,9 +77,8 @@ class ApiFactory @Inject constructor(@DefaultClient private val client: OkHttpCl 0, okhttp3.Interceptor { chain -> val active = current() - if (active == null || active.token != owner.token || active.baseUrl != owner.baseUrl || - !AuthInterceptor.belongsTo(chain.request().url, owner.baseUrl) - ) { + val sameOwner = active?.let { it.token == owner.token && it.baseUrl == owner.baseUrl } == true + if (!sameOwner || !AuthInterceptor.belongsTo(chain.request().url, owner.baseUrl)) { throw IOException("Playback owner changed") } chain.proceed( diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt index 353144b..4867179 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt @@ -6,6 +6,7 @@ import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository import com.henriquesebastiao.downtify.core.model.DjIds import com.henriquesebastiao.downtify.core.model.DjReference +import com.henriquesebastiao.downtify.core.network.session.Session import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -49,9 +50,7 @@ internal class DjControlTracker( val mine = generation scope.launch { commands.withLock { - if (music.session.value != owner || !music.capabilities.value.enabled("dj_interactive") || - mine != generation || current != reference - ) { + if (!isCurrent(reference, mine, owner) || !music.capabilities.value.enabled("dj_interactive")) { return@withLock } try { @@ -80,29 +79,7 @@ internal class DjControlTracker( val mine = ++generation terminal = false current = next - if (retired != null) { - // System/car controllers can replace media without creating the app coordinator. - // Retire only the old transport, after fencing callbacks with the new generation. - for (index in (0 until player.mediaItemCount).reversed()) { - if (index != player.currentMediaItemIndex && - DjIds.referenceOf(player.getMediaItemAt(index).mediaId) == retired - ) { - player.removeMediaItem(index) - } - } - val owner = music.session.value - if (retired.mode == "interactive") { - scope.launch { - commands.withLock { - if (owner == music.session.value && current != retired) { - try { - music.djStop(retired.id) - } catch (_: MusicAiFailure) { } - } - } - } - } - } + retire(retired) (player as? androidx.media3.exoplayer.ExoPlayer)?.pauseAtEndOfMediaItems = next != null if (next == null) return val handover = @@ -110,6 +87,10 @@ internal class DjControlTracker( player.currentMediaItem?.mediaMetadata?.extras?.getBoolean("djStartOnLoad") == true && player.playWhenReady if (handover) command(next, "resume") + watch(next, mine, handover) + } + + private fun watch(next: DjReference, mine: Long, handover: Boolean) { val owner = music.session.value ticker = scope.launch { while (isActive && current == next && owner == music.session.value) { @@ -134,6 +115,35 @@ internal class DjControlTracker( } } + private fun retire(retired: DjReference?) { + if (retired != null) { + // System/car controllers can replace media without creating the app coordinator. + // Retire only the old transport, after fencing callbacks with the new generation. + for (index in (0 until player.mediaItemCount).reversed()) { + if (index != player.currentMediaItemIndex && + DjIds.referenceOf(player.getMediaItemAt(index).mediaId) == retired + ) { + player.removeMediaItem(index) + } + } + val owner = music.session.value + if (retired.mode == "interactive") { + scope.launch { + commands.withLock { + if (owner == music.session.value && current != retired) { + try { + music.djStop(retired.id) + } catch (_: MusicAiFailure) { } + } + } + } + } + } + } + + private fun isCurrent(reference: DjReference, mine: Long, owner: Session?) = + owner == music.session.value && mine == generation && current == reference + private suspend fun reconcile(reference: DjReference, mine: Long) { try { val owner = music.session.value @@ -146,9 +156,7 @@ internal class DjControlTracker( } else { music.djSession(reference.id) } - if (mine != generation || owner != music.session.value || current != reference || - !music.capabilities.value.enabled("dj_" + reference.mode) - ) { + if (!isCurrent(reference, mine, owner) || !music.capabilities.value.enabled("dj_" + reference.mode)) { return } latest = session diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt index 30a3f5c..0da64c3 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt @@ -5,6 +5,7 @@ import com.henriquesebastiao.downtify.core.data.library.LibraryRepository import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto +import com.henriquesebastiao.downtify.core.network.session.Session import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -111,7 +112,7 @@ class DjPlaybackCoordinator @Inject constructor( } } } else if ((mutableBusy.value || mutableArmed.value) && - (state.mediaKey != originKey || state.currentIndex != originIndex) + leftOrigin(state.mediaKey, state.currentIndex) ) { cancel(true) } @@ -120,9 +121,7 @@ class DjPlaybackCoordinator @Inject constructor( } fun toggleQueue() { - if (activeId != null || player.state.value.dj != null || mutableBusy.value || - mutableArmed.value - ) { + if (hasDjWork()) { stop() return } @@ -137,9 +136,7 @@ class DjPlaybackCoordinator @Inject constructor( } fun startTheme(theme: String, style: String) { - if (activeId != null || mutableBusy.value || mutableArmed.value || - player.state.value.dj != null - ) { + if (hasDjWork()) { stop() return } @@ -157,9 +154,7 @@ class DjPlaybackCoordinator @Inject constructor( val selection = player.selectionVersion.value scope.launch { val classic = withTimeoutOrNull(2000) { player.state.first { it.dj == null } } ?: return@launch - if (mine != generation || owner != music.session.value || - selection != player.selectionVersion.value - ) { + if (!matches(Preparation(owner, mine, selection, ""))) { return@launch } val defer = classic.track?.id == seed @@ -169,6 +164,18 @@ class DjPlaybackCoordinator @Inject constructor( } } + private data class Preparation(val owner: Session?, val generation: Long, val selection: Long, val request: String) + + private fun leftOrigin(key: String?, index: Int) = key != originKey || index != originIndex + + private fun hasDjWork(): Boolean { + val pending = mutableBusy.value || mutableArmed.value + return activeId != null || player.state.value.dj != null || pending + } + + private fun matches(token: Preparation) = token.generation == generation && + token.owner == music.session.value && token.selection == player.selectionVersion.value + private fun prepare( title: String, defer: Boolean, @@ -176,124 +183,130 @@ class DjPlaybackCoordinator @Inject constructor( create: suspend (String) -> MusicDjSessionDto, ) { val owner = music.session.value ?: return - val mine = ++generation - val selection = player.selectionVersion.value + val token = Preparation(owner, ++generation, player.selectionVersion.value, UUID.randomUUID().toString()) originKey = player.state.value.mediaKey originIndex = player.state.value.currentIndex - val request = UUID.randomUUID().toString() - pendingRequest = request + pendingRequest = token.request mutableBusy.value = true mutablePhase.value = DjPhase.Preparing mutableMessage.value = "Préparation des transitions ; votre écoute continue." - preparation = scope.launch { - var created: String? = null - try { - val caps = music.capabilities.value - if (!caps.interactiveAvailable()) { - mutableMessage.value = - "Activez le DJ direct dans Paramètres → IA. Le moteur doit être disponible ou autorisé en essai bêta." - return@launch - } - val result = create(request) - created = result.id - if (mine != generation || owner != music.session.value || selection != player.selectionVersion.value) { - if (owner == music.session.value) music.djStop(result.id) - return@launch - } - pendingId = result.id - repeat(450) { - delay(2000) - if (mine != generation || owner != music.session.value || - selection != player.selectionVersion.value - ) { - return@launch - } - val current = music.djSession(result.id) - if (current.state in setOf("failed", "stopped", "cancelled", "deleted")) throw MusicAiFailure() - if (current.state in setOf("playing", "paused")) { - if (defer) { - if (current.state == "playing") music.djCommand(current.id, "pause") - if (mine != generation || selection != player.selectionVersion.value) return@launch - mutableArmed.value = true - mutablePhase.value = DjPhase.Ready - player.queueDj( - current.id, - title, - (current.duration * 1000).toLong(), - sessionPlan = current, - preserveQueue = preserveQueue, - ) - mutableMessage.value = "Mode DJ prêt · relais au prochain titre." - } else { - pendingId = null - pendingRequest = null - activeId = current.id - activeMode = current.mode - activeSelection = selection + 1 - mutableBusy.value = false - mutablePhase.value = DjPhase.Ready - player.playDj( - current.id, - title, - (current.duration * 1000).toLong(), - "interactive", - expectedSelection = selection, - sessionPlan = current, - ) - mutableMessage.value = "DJ actif dans le lecteur." - } - return@launch - } - } - if (owner == music.session.value) music.djStop(result.id) - mutableMessage.value = "Préparation trop longue. La lecture classique reste disponible." - } catch (error: CancellationException) { - if (created != null && - owner == music.session.value - ) { - scope.launch { - try { - music.djStop(created) - } catch (_: MusicAiFailure) { } - } - } - throw error - } catch (_: MusicAiFailure) { - if (mine == generation) { - mutableMessage.value = - "Le DJ est temporairement indisponible. Votre lecture classique reste disponible." - if (created == null && - owner == music.session.value - ) { - scope.launch { - try { - music.djCancelRequest(request) - } catch (_: MusicAiFailure) { } - } - } - if (created != null && - owner == music.session.value - ) { - scope.launch { - try { - music.djStop(created) - } catch (_: MusicAiFailure) { } - } - } - } - } finally { - if (mine == generation) { - mutableBusy.value = false - if (!mutableArmed.value && mutablePhase.value != DjPhase.Active) { - pendingId = null - pendingRequest = null - mutablePhase.value = DjPhase.Classic - } + preparation = scope.launch { performPreparation(token, title, defer, preserveQueue, create) } + } + + private suspend fun performPreparation( + token: Preparation, + title: String, + defer: Boolean, + preserveQueue: Boolean, + create: suspend (String) -> MusicDjSessionDto, + ) { + var created: String? = null + try { + if (!music.capabilities.value.interactiveAvailable()) { + mutableMessage.value = "Activez le DJ direct dans Paramètres → IA. " + + "Le moteur doit être disponible ou autorisé en essai bêta." + return + } + val result = create(token.request) + created = result.id + if (!matches(token)) { + if (token.owner == music.session.value) music.djStop(result.id) + return + } + pendingId = result.id + if (awaitReady(token, result.id, title, defer, preserveQueue)) return + if (token.owner == music.session.value) music.djStop(result.id) + mutableMessage.value = "Préparation trop longue. La lecture classique reste disponible." + } catch (error: CancellationException) { + if (created != null) cleanupPreparation(token, created) + throw error + } catch (_: MusicAiFailure) { + if (token.generation == generation) { + mutableMessage.value = "Le DJ est temporairement indisponible. " + + "Votre lecture classique reste disponible." + cleanupPreparation(token, created) + } + } finally { + if (token.generation == generation) { + mutableBusy.value = false + if (!mutableArmed.value && mutablePhase.value != DjPhase.Active) { + pendingId = null + pendingRequest = null + mutablePhase.value = DjPhase.Classic } } } } + private suspend fun awaitReady( + token: Preparation, + id: String, + title: String, + defer: Boolean, + preserveQueue: Boolean, + ): Boolean { + repeat(450) { + delay(2000) + if (!matches(token)) return true + val current = music.djSession(id) + if (current.state in setOf("failed", "stopped", "cancelled", "deleted")) throw MusicAiFailure() + if (current.state in setOf("playing", "paused")) { + activateReady(token, current, title, defer, preserveQueue) + return true + } + } + return false + } + + private suspend fun activateReady( + token: Preparation, + current: MusicDjSessionDto, + title: String, + defer: Boolean, + preserveQueue: Boolean, + ) { + if (defer) { + if (current.state == "playing") music.djCommand(current.id, "pause") + if (!matches(token)) return + mutableArmed.value = true + mutablePhase.value = DjPhase.Ready + player.queueDj( + current.id, + title, + (current.duration * 1000).toLong(), + sessionPlan = current, + preserveQueue = preserveQueue, + ) + mutableMessage.value = "Mode DJ prêt · relais au prochain titre." + } else { + pendingId = null + pendingRequest = null + activeId = current.id + activeMode = current.mode + activeSelection = token.selection + 1 + mutableBusy.value = false + mutablePhase.value = DjPhase.Ready + player.playDj( + current.id, + title, + (current.duration * 1000).toLong(), + "interactive", + expectedSelection = token.selection, + sessionPlan = current, + ) + mutableMessage.value = "DJ actif dans le lecteur." + } + } + + private fun cleanupPreparation(token: Preparation, created: String?) { + if (token.owner != music.session.value) return + scope.launch { + try { + if (created == null) music.djCancelRequest(token.request) else music.djStop(created) + } catch (_: MusicAiFailure) { } + } + } + private fun cancel(remote: Boolean) { val owner = music.session.value val id = pendingId diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt index aae9e3d..d2384a2 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt @@ -39,15 +39,7 @@ internal object DjTimeline { val plan = NetworkJson.encodeToString(ListSerializer(MusicDjMarkerDto.serializer()), session.timeline) val tracks = NetworkJson.encodeToString(ListSerializer(MusicAiTrackDto.serializer()), session.tracks) val previous = item.mediaMetadata - if (previous.extras?.getString( - "djResumePlan", - ) == plan && previous.extras?.getString("djResumeTracks") == tracks && - previous.extras?.getString("djTrackId") == track.id && - previous.title?.toString() == track.title && previous.artist?.toString() == track.artist && - previous.extras?.getLong("djOrigin") == (session.mediaOrigin?.times(1000)?.toLong() ?: 0) - ) { - return item - } + if (sameMetadata(previous, session, track, plan, tracks)) return item val extras = Bundle(item.mediaMetadata.extras ?: Bundle.EMPTY).apply { putString("djResumePlan", plan) putString("djResumeTracks", tracks) @@ -69,6 +61,21 @@ internal object DjTimeline { ).build() } + private fun sameMetadata( + previous: androidx.media3.common.MediaMetadata, + session: MusicDjSessionDto, + track: MusicAiTrackDto, + plan: String, + tracks: String, + ): Boolean { + val samePlan = previous.extras?.getString("djResumePlan") == plan && + previous.extras?.getString("djResumeTracks") == tracks + val sameTrack = previous.extras?.getString("djTrackId") == track.id && + previous.title?.toString() == track.title && previous.artist?.toString() == track.artist + val sameOrigin = previous.extras?.getLong("djOrigin") == (session.mediaOrigin?.times(1000)?.toLong() ?: 0) + return samePlan && sameTrack && sameOrigin + } + fun resumeRows(item: MediaItem): List = runCatching { NetworkJson.decodeFromString( ListSerializer(MusicDjMarkerDto.serializer()), diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt index 11f9fea..a07bfa0 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt @@ -297,26 +297,7 @@ class PlayerController @Inject constructor( if (sessions.current?.playbackOwner != owner) return@launch if (expectedSelection != null && mutableSelectionVersion.value != expectedSelection) return@launch mutableSelectionVersion.value++ - val previous = player.currentMediaItem?.mediaMetadata?.extras - val wasDj = DjIds.referenceOf(player.currentMediaItem?.mediaId) != null - val extras = Bundle().apply { - putInt( - "djClassicIndex", - if (wasDj) { - previous?.getInt( - "djClassicIndex", - ) ?: 0 - } else { - player.currentMediaItemIndex.coerceAtLeast(0) - }, - ) - putLong( - "djClassicPosition", - if (wasDj) previous?.getLong("djClassicPosition") ?: 0 else player.currentPosition.coerceAtLeast(0), - ) - putInt("djQueueAnchor", getInt("djClassicIndex")) - if (mode == "interactive") putBoolean("djStartOnLoad", true) - } + val extras = djClassicExtras(player, mode) val base = initialItem.buildUpon().setMediaMetadata( initialItem.mediaMetadata.buildUpon().setExtras(extras).build(), ).build() @@ -335,6 +316,29 @@ class PlayerController @Inject constructor( } } + private fun djClassicExtras(player: Player, mode: String): Bundle { + val previous = player.currentMediaItem?.mediaMetadata?.extras + val wasDj = DjIds.referenceOf(player.currentMediaItem?.mediaId) != null + return Bundle().apply { + putInt( + "djClassicIndex", + if (wasDj) { + previous?.getInt( + "djClassicIndex", + ) ?: 0 + } else { + player.currentMediaItemIndex.coerceAtLeast(0) + }, + ) + putLong( + "djClassicPosition", + if (wasDj) previous?.getLong("djClassicPosition") ?: 0 else player.currentPosition.coerceAtLeast(0), + ) + putInt("djQueueAnchor", getInt("djClassicIndex")) + if (mode == "interactive") putBoolean("djStartOnLoad", true) + } + } + fun queueDj( id: String, title: String, @@ -359,74 +363,88 @@ class PlayerController @Inject constructor( ) } - fun exitDj(id: String) = command { - val current = it.currentMediaItem + fun exitDj(id: String) = command { player -> + val current = player.currentMediaItem if (DjIds.referenceOf(current?.mediaId)?.id == id && current != null) { - val extras = current.mediaMetadata.extras - val absolute = DjTimeline.position(it, extras?.getLong("djOrigin") ?: 0) - val point = DjTimeline.resumePoint(current, absolute) - val classic = (0 until it.mediaItemCount).map(it::getMediaItemAt).filter { row -> - DjIds.referenceOf(row.mediaId) == - null - } - val anchor = extras?.getInt("djQueueAnchor") ?: 0 - val preferred = anchor + (point?.occurrence ?: 0) - var selection = classic - var index = if (point != null) { - if (classic.getOrNull(preferred)?.mediaId == - point.id - ) { - preferred - } else { - classic.indexOfFirst { row -> row.mediaId == point.id } - } - } else { - (extras?.getInt("djClassicIndex") ?: 0).coerceIn(0, (classic.size - 1).coerceAtLeast(0)) + restoreClassic(player, current) + return@command + } + for (index in (0 until player.mediaItemCount).reversed()) { + if (DjIds.referenceOf(player.getMediaItemAt(index).mediaId)?.id == id) { + if (index == player.currentMediaItemIndex) player.pause() + player.removeMediaItem(index) } - if (point != null && - (extras?.getBoolean("djOwnQueue") != true || extras.getBoolean("djQueueOrder") != true || index < 0) + } + } + + private fun restoreClassic(player: Player, current: MediaItem) { + val extras = current.mediaMetadata.extras + val absolute = DjTimeline.position(player, extras?.getLong("djOrigin") ?: 0) + val point = DjTimeline.resumePoint(current, absolute) + val (selection, index) = classicSelection(player, current, point) + val wantsPlay = player.playWhenReady + if (selection.isNotEmpty() && index >= 0) { + player.setMediaItems(selection, index, point?.sourceMs ?: extras?.getLong("djClassicPosition") ?: 0) + player.setPlaybackSpeed(1f) + player.prepare() + player.playWhenReady = wantsPlay + } else { + player.pause() + player.clearMediaItems() + } + } + + private fun classicSelection( + player: Player, + current: MediaItem, + point: DjResumePoint?, + ): Pair, Int> { + val extras = current.mediaMetadata.extras + val classic = (0 until player.mediaItemCount).map(player::getMediaItemAt).filter { row -> + DjIds.referenceOf(row.mediaId) == + null + } + val anchor = extras?.getInt("djQueueAnchor") ?: 0 + val preferred = anchor + (point?.occurrence ?: 0) + val index = if (point != null) { + if (classic.getOrNull(preferred)?.mediaId == + point.id ) { - val rows = DjTimeline.resumeRows(current) - val fallback = DjTimeline.fallbackItems(current, sessions.current?.baseUrl.orEmpty()) - val items = rows.withIndex().mapNotNull { (ordinal, row) -> - val media = - library.library.value?.byId?.get(row.id)?.let { track -> - MediaItems.from(track, sessions.current?.baseUrl) - } - ?: fallback[row.id] - media?.let { ordinal to it } - } - val occurrence = - rows.indexOfFirst { row -> - row.occurrence >= 0 && row.occurrence == point.occurrence - }.takeIf { ordinal -> - ordinal >= - 0 - } - ?: point.occurrence - selection = items.map { pair -> pair.second } - index = items.indexOfFirst { pair -> pair.first == occurrence } - } - val wantsPlay = it.playWhenReady - if (selection.isNotEmpty() && index >= 0) { - it.setMediaItems(selection, index, point?.sourceMs ?: extras?.getLong("djClassicPosition") ?: 0) - it.setPlaybackSpeed(1f) - it.prepare() - it.playWhenReady = wantsPlay + preferred } else { - it.pause() - it.clearMediaItems() + classic.indexOfFirst { row -> row.mediaId == point.id } } - return@command + } else { + (extras?.getInt("djClassicIndex") ?: 0).coerceIn(0, (classic.size - 1).coerceAtLeast(0)) } - for (index in (0 until it.mediaItemCount).reversed()) { - if (com.henriquesebastiao.downtify.core.model.DjIds.referenceOf(it.getMediaItemAt(index).mediaId)?.id == - id - ) { - if (index == it.currentMediaItemIndex) it.pause() - it.removeMediaItem(index) - } + val needsRenderQueue = extras?.getBoolean("djOwnQueue") != true || + extras.getBoolean("djQueueOrder") != true || index < 0 + if (point != null && needsRenderQueue) { + return renderedSelection(current, point) } + return classic to index + } + + private fun renderedSelection(current: MediaItem, point: DjResumePoint): Pair, Int> { + val rows = DjTimeline.resumeRows(current) + val fallback = DjTimeline.fallbackItems(current, sessions.current?.baseUrl.orEmpty()) + val items = rows.withIndex().mapNotNull { (ordinal, row) -> + val media = + library.library.value?.byId?.get(row.id)?.let { track -> + MediaItems.from(track, sessions.current?.baseUrl) + } + ?: fallback[row.id] + media?.let { ordinal to it } + } + val occurrence = + rows.indexOfFirst { row -> + row.occurrence >= 0 && row.occurrence == point.occurrence + }.takeIf { ordinal -> + ordinal >= + 0 + } + ?: point.occurrence + return items.map { pair -> pair.second } to items.indexOfFirst { pair -> pair.first == occurrence } } fun resume() = command { From 785a04854a21f95159ac69d45dbb639deaa46630 Mon Sep 17 00:00:00 2001 From: QuietForge7294 <77000426+QuietForge7294@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:54:32 +0100 Subject: [PATCH 04/11] Add device DJ beta and resilient playback cache --- README.md | 17 +- app/build.gradle.kts | 4 +- .../downtify/feature/musicai/MusicDjPanel.kt | 8 +- .../feature/musicai/MusicDjViewModel.kt | 15 +- .../feature/musicai/MusicLibraryPages.kt | 46 ++- .../downtify/feature/player/MiniPlayer.kt | 2 +- .../feature/player/QueueDjViewModel.kt | 50 ++- .../downtify/ui/DowntifyApp.kt | 12 +- .../downtify/ui/common/LocalOpenPlayer.kt | 6 + app/src/main/res/values-bg/strings.xml | 4 + app/src/main/res/values-el/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-hu/strings.xml | 4 + app/src/main/res/values-pt-rBR/strings.xml | 4 + app/src/main/res/values-tr/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../downtify/feature/player/MiniPlayerTest.kt | 25 ++ .../downtify/core/player/DjAudioCacheStore.kt | 77 ++++ .../downtify/core/player/DjControlTracker.kt | 1 + .../downtify/core/player/DjDataSource.kt | 10 +- .../downtify/core/player/DjHttpRecovery.kt | 40 ++ .../downtify/core/player/DjSegmentCache.kt | 177 +++++++++ .../downtify/core/player/DjSegmentIdentity.kt | 45 +++ .../downtify/core/player/LocalDjAudioFocus.kt | 88 +++++ .../downtify/core/player/LocalDjEngine.kt | 273 ++++++++++++++ .../downtify/core/player/LocalDjPlan.kt | 66 ++++ .../downtify/core/player/LocalDjPlayer.kt | 211 +++++++++++ .../downtify/core/player/LocalDjSettings.kt | 34 ++ .../downtify/core/player/LocalDjTimeline.kt | 19 + .../core/player/PlaybackPersistence.kt | 6 +- .../downtify/core/player/PlaybackService.kt | 106 ++++-- .../downtify/core/player/PlayerModule.kt | 4 +- .../core/player/DjHttpRecoveryTest.kt | 55 +++ .../core/player/DjSegmentCacheTest.kt | 348 ++++++++++++++++++ .../downtify/core/player/LocalDjEngineTest.kt | 156 ++++++++ .../downtify/core/player/LocalDjFakeDeck.kt | 137 +++++++ .../downtify/core/player/LocalDjPlanTest.kt | 41 +++ .../downtify/core/player/LocalDjPlayerTest.kt | 69 ++++ .../core/player/PlaybackPersistenceTest.kt | 5 +- docs/music-ai.md | 45 ++- 41 files changed, 2158 insertions(+), 72 deletions(-) create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LocalOpenPlayer.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjAudioCacheStore.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjHttpRecovery.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentCache.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentIdentity.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjAudioFocus.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngine.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlan.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjSettings.kt create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjTimeline.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjHttpRecoveryTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentCacheTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngineTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjFakeDeck.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlanTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayerTest.kt diff --git a/README.md b/README.md index 77746b6..e36e3b0 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,20 @@ the main thread. The optimized beta build is non-debuggable and enables R8/resource shrinking; it uses a profileable manifest for physical-device benchmarks. Its audio -allocator target is 64 MiB. Live HLS bypasses the persistent library audio cache. +allocator target is 64 MiB across the two local decoders. Live HLS uses a separate +64 MiB transient disk cache for immutable, authenticated segments; manifests stay fresh. These are configured budgets, not measured phone memory, battery or temperature. -Model inference and transition rendering remain on the server; no on-device -AI model is downloaded. The local engine remains unavailable pending ONNX and -physical-device validation. +Server mixing remains the initial choice. **This device · Beta** enables a local +dual-deck prototype for the current queue, using the same player and audio session. +It currently uses short fades; on-device beat-model inference is not integrated +and no AI model is downloaded. Shuffle, repeat-one and remote streams retain +ordinary playback. Phone quality, latency, temperature and battery remain unqualified. + +DJ sets now appear in the mini player and open Now Playing after joining. Direct +controls are shown only after this device joins, avoiding a server-only resume. +All 97 player-module tests and seven mini-player tests passed, including native +shuffle restoration through the dual-deck facade. Detekt and formatting passed; +these checks do not replace physical-device acceptance. Corrective player tests cover 25 repeated mode cycles, local pause intent, stale status fencing and dominant-title/source-time mapping. Server/web checks diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b0f692..c973ed1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.henriquesebastiao.downtify" - versionCode = 9 - versionName = "0.3.1-beta3" + versionCode = 10 + versionName = "0.4.0-beta1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt index 6c5b791..95546b5 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt @@ -29,6 +29,8 @@ import kotlinx.coroutines.isActive @Composable fun MusicDjPanel(viewModel: MusicDjViewModel = hiltViewModel()) { + val openPlayer = com.henriquesebastiao.downtify.ui.common.LocalOpenPlayer.current + val listening by viewModel.listening.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle() val caps by viewModel.capabilities.collectAsStateWithLifecycle() val lifecycle = LocalLifecycleOwner.current @@ -120,7 +122,9 @@ fun MusicDjPanel(viewModel: MusicDjViewModel = hiltViewModel()) { viewModel.stop(session.id) }, enabled = !state.busy) { Text("Arrêter le rendu") } } - if (session.mode == "interactive" && session.state in setOf("playing", "paused")) { + if (listening == session.id && session.mode == "interactive" && + session.state in setOf("playing", "paused") + ) { TextButton(onClick = { viewModel.command( session, @@ -157,7 +161,7 @@ fun MusicDjPanel(viewModel: MusicDjViewModel = hiltViewModel()) { else -> false } if (canPlay) { - Button(onClick = { viewModel.play(session) }) { Text("Écouter le set") } + Button(onClick = { viewModel.play(session, openPlayer) }) { Text("Écouter le set") } if (session.mode == "prepared" ) { diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt index d82a86f..7a19549 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt @@ -11,13 +11,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull data class MusicDjUiState( val sessions: List = emptyList(), @@ -34,6 +39,8 @@ class MusicDjViewModel @Inject constructor( private val mutable = MutableStateFlow(MusicDjUiState()) val state = mutable.asStateFlow() val capabilities = music.capabilities + val listening = player.state.map { it.dj?.id } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) private val mutex = Mutex() private var ownBusy = false private var pending: Pair, String>? = null @@ -103,6 +110,10 @@ class MusicDjViewModel @Inject constructor( fun seed(id: String) = coordinator.startSeed(id) fun command(session: MusicDjSessionDto, operation: String, payload: Map = emptyMap()) { + if (operation == "resume" && player.state.value.dj?.id != session.id) { + play(session) + return + } if (player.state.value.dj?.id == session.id && operation in setOf("pause", "resume")) { if (operation == "pause") player.pause() else player.resume() return @@ -148,7 +159,7 @@ class MusicDjViewModel @Inject constructor( } } - fun play(session: MusicDjSessionDto) { + fun play(session: MusicDjSessionDto, onReady: () -> Unit = {}) { if (session.state !in setOf("ready", "playing", "paused") || !music.capabilities.value.enabled("dj_" + session.mode) ) { @@ -163,6 +174,8 @@ class MusicDjViewModel @Inject constructor( owner == music.session.value ) { coordinator.listen(full, "Set DJ · ${full.theme}") + val attached = withTimeoutOrNull(8000) { player.state.first { it.dj?.id == full.id } } + if (attached != null && owner == music.session.value) onReady() } } catch (error: MusicAiFailure) { mutable.update { it.copy(message = musicAiFailureMessage(error)) } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt index 50dbfc0..4240306 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt @@ -146,6 +146,7 @@ fun MusicStudioRoute( val caps by viewModel.capabilities.collectAsStateWithLifecycle() val queue: com.henriquesebastiao.downtify.feature.player.QueueDjViewModel = hiltViewModel() val queueMessage by queue.message.collectAsStateWithLifecycle() + val localDevice by queue.localDevice.collectAsStateWithLifecycle() LaunchedEffect(seed) { viewModel.refresh() if (seed.isNotBlank()) viewModel.seed(seed) @@ -165,16 +166,47 @@ fun MusicStudioRoute( ) TextButton(onClick = onSettings) { Text("Paramètres IA") } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - androidx.compose.material3.FilterChip(selected = true, onClick = {}, label = { Text("Serveur") }) - androidx.compose.material3.FilterChip(selected = false, enabled = false, onClick = { - }, label = { Text("Cet appareil · Bêta") }) + androidx.compose.material3.FilterChip( + selected = !localDevice, + onClick = { + queue.chooseDevice(false) + }, + label = { + Text( + androidx.compose.ui.res.stringResource( + com.henriquesebastiao.downtify.R.string.music_dj_server, + ), + ) + }, + ) + androidx.compose.material3.FilterChip( + selected = localDevice, + onClick = { + queue.chooseDevice(true) + }, + label = { + Text( + androidx.compose.ui.res.stringResource( + com.henriquesebastiao.downtify.R.string.music_dj_device_beta, + ), + ) + }, + ) } Text( - "Le moteur local attend la validation de l’export ONNX et des performances sur votre appareil. " + - "Aucun modèle n’est téléchargé. Le serveur reste disponible.", + androidx.compose.ui.res.stringResource( + if (localDevice) { + com.henriquesebastiao.downtify.R.string.music_dj_local_description + } else { + com.henriquesebastiao.downtify.R.string.music_dj_server_description + }, + ), style = MaterialTheme.typography.bodySmall, ) - TextButton(onClick = queue::toggle, enabled = caps.interactiveAvailable()) { + TextButton( + onClick = queue::toggle, + enabled = if (localDevice) caps.enabled("dj_interactive") else caps.interactiveAvailable(), + ) { Text("Mode DJ sur la file") } if (queueMessage.isNotBlank()) Text(queueMessage) @@ -184,7 +216,7 @@ fun MusicStudioRoute( Text("Activez un mode DJ dans Paramètres → IA, puis autorisez-le sur le serveur.") } } - musicDjThemeItems(caps, state, viewModel::continuous) + if (!localDevice) musicDjThemeItems(caps, state, viewModel::continuous) item { MusicDjPanel() } } } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayer.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayer.kt index b23067f..30f4370 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayer.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayer.kt @@ -40,7 +40,7 @@ fun MiniPlayer( val track = state.track val episode = state.episode val stream = state.stream - if (track == null && episode == null && stream == null) return + if (!state.hasMedia) return val coverUrl = playingCoverUrl(state) CoverTheme(coverUrl) { MiniPlayerCard( diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt index 6074f98..2bad828 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt @@ -1,16 +1,58 @@ package com.henriquesebastiao.downtify.feature.player import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository import com.henriquesebastiao.downtify.core.player.DjPlaybackCoordinator +import com.henriquesebastiao.downtify.core.player.LocalDjSettings +import com.henriquesebastiao.downtify.core.player.PlayerController import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** Navigation owns the UI; the application-scoped coordinator owns preparation. */ @HiltViewModel -class QueueDjViewModel @Inject constructor(private val coordinator: DjPlaybackCoordinator) : ViewModel() { +class QueueDjViewModel @Inject constructor( + private val coordinator: DjPlaybackCoordinator, + private val local: LocalDjSettings, + private val player: PlayerController, + private val music: MusicAiRepository, +) : ViewModel() { val capabilities = coordinator.capabilities - val message = coordinator.message + val localDevice = local.device + val localTransition = local.transition + val message = combine(coordinator.message, local.device) { text, device -> if (device) "" else text } + .stateIn(viewModelScope, SharingStarted.Eagerly, "") val busy = coordinator.busy - val armed = coordinator.armed - fun toggle() = coordinator.toggleQueue() + val armed = combine(coordinator.armed, local.armed) { server, device -> server || device } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun chooseDevice(value: Boolean) { + coordinator.stop() + local.chooseDevice(value) + } + fun toggle() { + if (!local.device.value) { + coordinator.toggleQueue() + return + } + if (local.armed.value) { + local.setArmed(false) + return + } + if (!capabilities.value.enabled("dj_interactive")) return + val owner = music.session.value + val selection = player.selectionVersion.value + if (player.state.value.dj != null || coordinator.busy.value || coordinator.armed.value) coordinator.stop() + viewModelScope.launch { + val classic = withTimeoutOrNull(2000) { player.state.first { it.dj == null } } ?: return@launch + if (owner != music.session.value || selection != player.selectionVersion.value) return@launch + if (classic.track != null && classic.upNext.isNotEmpty()) local.setArmed(true) + } + } } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt index c4d78e8..c84119a 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt @@ -267,6 +267,8 @@ private fun MainShell() { val covered = if (miniPlayer || floatingBar) stackHeight else 0.dp CompositionLocalProvider( LocalBottomBarsInset provides covered, + com.henriquesebastiao.downtify.ui.common.LocalOpenPlayer provides + { scope.launch { expansion.expand() } }, com.henriquesebastiao.downtify.ui.common.LocalMusicAiCapabilities provides aiCapabilities, com.henriquesebastiao.downtify.ui.common.LocalSongMix provides mixAction, com.henriquesebastiao.downtify.ui.common.LocalSongDjMix provides djMixAction, @@ -516,6 +518,7 @@ private fun NowPlayingOverlay( val djMessage by queueDj.message.collectAsStateWithLifecycle() val djArmed by queueDj.armed.collectAsStateWithLifecycle() val djBusy by queueDj.busy.collectAsStateWithLifecycle() + val localDjDevice by queueDj.localDevice.collectAsStateWithLifecycle() var lyricsOpen by rememberSaveable { mutableStateOf(false) } var queueOpen by rememberSaveable { mutableStateOf(false) } val open = expansion.expanded @@ -619,8 +622,13 @@ private fun NowPlayingOverlay( ), controls = controls, dj = com.henriquesebastiao.downtify.feature.player.QueueDjState( - available = djCaps.enabled("dj_interactive") && djCaps.dj.queueSupported, - ready = djCaps.interactiveAvailable() && djCaps.dj.queueSupported, + available = djCaps.enabled("dj_interactive") && (localDjDevice || djCaps.dj.queueSupported), + ready = if (localDjDevice) { + djCaps.enabled("dj_interactive") + } else { + djCaps.interactiveAvailable() && + djCaps.dj.queueSupported + }, active = djArmed || djBusy, message = djMessage, ), diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LocalOpenPlayer.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LocalOpenPlayer.kt new file mode 100644 index 0000000..3931037 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LocalOpenPlayer.kt @@ -0,0 +1,6 @@ +package com.henriquesebastiao.downtify.ui.common + +import androidx.compose.runtime.compositionLocalOf + +/** Every feature opens the existing activity-scoped player rather than creating a second one. */ +val LocalOpenPlayer = compositionLocalOf<() -> Unit> { {} } diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 3edb22c..12cdd39 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -552,4 +552,8 @@ Максимум Увеличава звука до безопасната граница за всяка песен. Аудиофайловете не се променят. Вашата музика + Сървър + Това устройство · Бета + Смесвайте текущата опашка на телефона с кратки плавни преходи. Анализът на ритъма е в подготовка. Няма автоматично изтегляне на модел. Батерията и качеството на звука предстои да се тестват. + Сървърът подготвя преходите. Телефонът възпроизвежда удостоверения аудиопоток. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 475ca3b..d95b483 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -552,4 +552,8 @@ Μέγιστο Αυξάνει την ένταση εντός του ασφαλούς ορίου κάθε τραγουδιού. Τα αρχεία ήχου δεν αλλάζουν. Η μουσική σας + Διακομιστής + Αυτή η συσκευή · Beta + Μίξη της τρέχουσας ουράς στο τηλέφωνο με σύντομες μεταβάσεις. Η ανάλυση ρυθμού ετοιμάζεται. Δεν γίνεται αυτόματη λήψη μοντέλου. Εκκρεμούν δοκιμές μπαταρίας και ήχου. + Ο διακομιστής ετοιμάζει τις μεταβάσεις. Το τηλέφωνο αναπαράγει την πιστοποιημένη ροή ήχου. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6544294..c790f2c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -566,4 +566,8 @@ Máximo Aumenta el volumen dentro del límite seguro de cada canción. Los archivos de audio no cambian. Tu música + Servidor + Este dispositivo · Beta + Mezcla la cola actual en este teléfono con fundidos cortos. El análisis rítmico sigue en preparación. No se descarga ningún modelo automáticamente. Faltan pruebas de batería y sonido. + El servidor prepara las transiciones. El teléfono reproduce el audio autenticado. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ca66774..71797a6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -566,4 +566,8 @@ Maximum Le volume augmente dans la limite propre à chaque titre. Vos fichiers restent inchangés. Votre musique + Serveur + Cet appareil · Bêta + Mixez la file actuelle sur ce téléphone avec des fondus courts. Le calage par analyse est encore en préparation. Aucun modèle n’est téléchargé automatiquement. Batterie et qualité audio restent à tester sur appareil. + Le serveur prépare les transitions. Le téléphone lit le flux audio authentifié. diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index cfc7bda..c2affd8 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -552,4 +552,8 @@ Maximum A hangerőt az egyes számok biztonságos határáig növeli. A hangfájlok változatlanok maradnak. Saját zenék + Kiszolgáló + Ez az eszköz · Béta + A jelenlegi sor keverése ezen a telefonon rövid átmenetekkel. Az ütemelemzés még készül. Nincs automatikus modellletöltés. Az akkumulátor és a hangminőség tesztelése még hátravan. + A kiszolgáló előkészíti az átmeneteket. A telefon a hitelesített hangfolyamot játssza le. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4020ec1..fd2e43e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -566,4 +566,8 @@ Máximo Aumenta o volume dentro do limite seguro de cada faixa. Os arquivos de áudio permanecem intactos. Sua música + Servidor + Este dispositivo · Beta + Mixe a fila atual neste telefone com transições curtas. A análise rítmica ainda está em preparação. Nenhum modelo é baixado automaticamente. Faltam testes de bateria e áudio. + O servidor prepara as transições. O telefone reproduz o fluxo de áudio autenticado. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index f05b1ac..bc8776b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -552,4 +552,8 @@ Maksimum Sesi her parçanın güvenli sınırına kadar artırır. Ses dosyaları değişmez. Müziğiniz + Sunucu + Bu cihaz · Beta + Geçerli kuyruğu bu telefonda kısa geçişlerle miksleyin. Ritim analizi hazırlanıyor. Model otomatik indirilmez. Pil ve ses kalitesi cihazda test edilmeyi bekliyor. + Sunucu geçişleri hazırlar. Telefon kimliği doğrulanmış ses akışını çalar. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea52ea2..35c4387 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -560,4 +560,8 @@ Louder Maximum Raises playback volume within each track’s safe limit. Your audio files stay unchanged. + Server + This device · Beta + Mix the current queue on this phone with short fades. Beat analysis is still in preparation. No model is downloaded automatically. Battery and audio quality await device testing. + The server prepares transitions. Your phone plays the authenticated audio stream. diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayerTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayerTest.kt index 379c5e6..f75ecdd 100644 --- a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayerTest.kt +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayerTest.kt @@ -20,6 +20,31 @@ class MiniPlayerTest { @get:Rule val compose = createComposeRule() + @Test fun aDjSetUsesTheSameVisiblePlayerAndPauseControl() { + var pauses = 0 + var opened = 0 + compose.setContent { + DowntifyTheme { + MiniPlayer( + PlayerState( + dj = com.henriquesebastiao.downtify.core.player.PlayingDj( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "DJ fixture", + artist = "Fixture artist", + ), + isPlaying = true, + ), + onOpen = { opened++ }, + onTogglePlay = { pauses++ }, + ) + } + } + compose.onNodeWithText("DJ fixture").performClick() + compose.onNodeWithContentDescription("Pause").performClick() + assertEquals(1, pauses) + assertEquals(1, opened) + } + @Test fun showsTheTrackAndTogglesPlayback() { val track = PreviewData.tracks[1] diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjAudioCacheStore.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjAudioCacheStore.kt new file mode 100644 index 0000000..4e2dd7a --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjAudioCacheStore.kt @@ -0,0 +1,77 @@ +package com.henriquesebastiao.downtify.core.player + +import android.content.Context +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import com.henriquesebastiao.downtify.core.data.di.ApplicationScope +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.network.di.StreamingClient +import com.henriquesebastiao.downtify.core.network.session.SessionStore +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient + +/** Private, disposable DJ transport cache, separate from kept songs and the ordinary read-ahead cache. */ +@UnstableApi +@Singleton +class DjAudioCacheStore @Inject constructor( + @ApplicationContext context: Context, + sessions: SessionStore, + music: MusicAiRepository, + @StreamingClient client: OkHttpClient, + @ApplicationScope scope: CoroutineScope, +) { + private val cache = SimpleCache( + File(context.cacheDir, "dj-segments-v1"), + LeastRecentlyUsedCacheEvictor(CACHE_BYTES), + StandaloneDatabaseProvider(context), + ) + + // Keep the normal auth interceptor, but do not persist redirect destinations or wait five minutes + // before the player's bounded recovery policy gets an opportunity to retry a DJ chunk. + private val http = OkHttpDataSource.Factory( + client.newBuilder().followRedirects(false).followSslRedirects(false) + .addInterceptor( + DjHttpRecovery({ + sessions.current + }, { music.capabilities.value.enabled("dj_interactive") }), + ) + .connectTimeout(5, TimeUnit.SECONDS).readTimeout(8, TimeUnit.SECONDS) + .callTimeout(12, TimeUnit.SECONDS).build(), + ).setUserAgent("Downtify-Android") + private val segments = DjSegmentCache( + cache, + { sessions.current }, + { music.capabilities.value.enabled("dj_interactive") }, + http, + ) + val factory: DataSource.Factory = segments + + init { + scope.launch(Dispatchers.IO) { + combine(sessions.session, music.capabilities) { owner, caps -> owner to caps.enabled("dj_interactive") } + .distinctUntilChanged().collectLatest { + while (isActive && !segments.purgeRetired()) delay(1000) + } + } + } + + private companion object { + const val CACHE_BYTES = 64L * 1024 * 1024 + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt index 4867179..f473e81 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt @@ -81,6 +81,7 @@ internal class DjControlTracker( current = next retire(retired) (player as? androidx.media3.exoplayer.ExoPlayer)?.pauseAtEndOfMediaItems = next != null + (player as? LocalDjPlayer)?.setServerDjBarrier(next != null) if (next == null) return val handover = next.mode == "interactive" && diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjDataSource.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjDataSource.kt index a0eaa84..16762fd 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjDataSource.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjDataSource.kt @@ -6,11 +6,12 @@ import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec import androidx.media3.datasource.TransferListener -/** DJ bytes must always reach the authenticated server, including range requests after a seek. */ +/** Classic/offline files keep their cache; live DJ uses a separately owned segment cache. */ @UnstableApi internal class DjDataSource( private val regular: DataSource.Factory, private val direct: DataSource.Factory, + private val live: DataSource.Factory = direct, private val liveResource: (Uri) -> Boolean = { false }, ) : DataSource { private var active: DataSource? = null @@ -21,7 +22,12 @@ internal class DjDataSource( } override fun open(dataSpec: DataSpec): Long { check(active == null) - val factory = if (DjMediaItems.idOf(dataSpec.uri) != null || liveResource(dataSpec.uri)) direct else regular + val address = DjMediaItems.addressOf(dataSpec.uri) + val factory = when { + address?.mode == "interactive" || liveResource(dataSpec.uri) -> live + address != null -> direct + else -> regular + } val source = factory.createDataSource() listeners.forEach(source::addTransferListener) active = source diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjHttpRecovery.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjHttpRecovery.kt new file mode 100644 index 0000000..1791e07 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjHttpRecovery.kt @@ -0,0 +1,40 @@ +package com.henriquesebastiao.downtify.core.player + +import com.henriquesebastiao.downtify.core.network.session.Session +import java.io.IOException +import okhttp3.Interceptor +import okhttp3.Response + +/** Small, idempotent audio-load retries; OkHttp's whole-call timeout remains the outer budget. */ +internal class DjHttpRecovery(private val current: () -> Session?, private val enabled: () -> Boolean) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + if (chain.request().method !in setOf("GET", "HEAD")) return chain.proceed(chain.request()) + val owner = current() ?: throw IOException("DJ playback unavailable") + val started = System.nanoTime() + var retry = 0 + while (true) { + if (current() != owner || !enabled()) throw IOException("DJ playback owner changed") + try { + val response = chain.proceed(chain.request()) + if (response.code !in setOf(429, 500, 502, 503, 504) || + !canRetry(chain, retry, started) + ) { + return response + } + response.close() + } catch (error: IOException) { + if (!canRetry(chain, retry, started)) throw error + } + try { + Thread.sleep((250L shl retry).coerceAtMost(2000)) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + throw IOException("DJ retry interrupted", error) + } + retry++ + } + } + + private fun canRetry(chain: Interceptor.Chain, retry: Int, started: Long) = + retry < 3 && !chain.call().isCanceled() && (System.nanoTime() - started) < 9_000_000_000L +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentCache.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentCache.kt new file mode 100644 index 0000000..67a36ae --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentCache.kt @@ -0,0 +1,177 @@ +package com.henriquesebastiao.downtify.core.player + +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheDataSource +import com.henriquesebastiao.downtify.core.network.ServerUrls +import com.henriquesebastiao.downtify.core.network.session.Session +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.security.MessageDigest +import java.util.UUID + +/** Immutable HLS chunks only: a fresh manifest remains the server's current playback window. */ +@UnstableApi +internal class DjSegmentCache( + private val cache: Cache, + private val current: () -> Session?, + private val enabled: () -> Boolean, + private val upstream: DataSource.Factory, +) : DataSource.Factory { + private class Binding(val owner: Session, val prefix: String = "dj-v1:${UUID.randomUUID()}:") { + val identities = mutableMapOf() + } + private var binding: Binding? = null + private val cached = CacheDataSource.Factory().setCache(cache) + .setUpstreamDataSourceFactory(upstream) + .setCacheKeyFactory { checkNotNull(it.key) } + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + + override fun createDataSource(): DataSource = Source() + + /** Removal is housekeeping; the read fence takes effect before this asynchronous cleanup. */ + fun purgeRetired(): Boolean { + val prefix = currentBinding()?.prefix + val retired = cache.keys.filter { prefix == null || !it.startsWith(prefix) } + var complete = true + retired.forEach { + try { + cache.removeResource(it) + } catch (_: Cache.CacheException) { + complete = false + } catch (_: IllegalStateException) { + complete = false + } + } + return complete + } + + @Synchronized + private fun currentBinding(): Binding? { + val owner = current()?.takeIf { enabled() } + if (owner != binding?.owner) binding = owner?.let(::Binding) + return binding + } + + private fun capture(uri: Uri): Binding { + val captured = currentBinding() ?: throw IOException("DJ playback is unavailable for this account") + val decorated = uri.userInfo != null || uri.query != null || uri.fragment != null + if (decorated || !ServerUrls.isDjHlsResource(captured.owner.baseUrl, uri.toString())) { + throw IOException("Invalid DJ playback resource") + } + return captured + } + + private fun checkCurrent(captured: Binding) { + if (currentBinding() !== captured) throw IOException("DJ playback owner or availability changed") + } + + @Synchronized + internal fun observeManifest(uri: Uri, text: String) { + val owner = capture(uri) + owner.identities.clear() + owner.identities.putAll(DjSegmentIdentity.parse(uri, text)) + } + + @Synchronized + private fun identity(owner: Binding, uri: Uri): String? = owner.identities[uri.toString()] + + private fun key(captured: Binding, uri: Uri, identity: String): String = captured.prefix + + MessageDigest.getInstance("SHA-256").digest("$uri:$identity".toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + private inner class Source : DataSource { + private val listeners = mutableListOf() + private var active: DataSource? = null + private var captured: Binding? = null + private var manifest: ByteArrayOutputStream? = null + private var manifestUri: Uri? = null + private var resourceUri: Uri? = null + private var renderIdentity: String? = null + + override fun addTransferListener(transferListener: TransferListener) { + listeners += transferListener + active?.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long { + check(active == null) + val owner = capture(dataSpec.uri) + val identity = identity(owner, dataSpec.uri) + val immutable = + identity != null && dataSpec.uri.lastPathSegment?.matches(Regex("segment-\\d{8}\\.ts")) == true + val spec = dataSpec.buildUpon().setKey( + if (immutable) key(owner, dataSpec.uri, checkNotNull(identity)) else null, + ) + .setFlags(dataSpec.flags or DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN).build() + val source = (if (immutable) cached else upstream).createDataSource() + listeners.forEach(source::addTransferListener) + captured = owner + active = source + resourceUri = dataSpec.uri + renderIdentity = if (immutable) identity else null + if (dataSpec.uri.lastPathSegment == "index.m3u8") { + manifest = ByteArrayOutputStream() + manifestUri = dataSpec.uri + } + try { + val length = source.open(spec) + checkCurrent(owner) + return length + } catch (error: IOException) { + try { + close() + } catch (closeError: IOException) { + error.addSuppressed(closeError) + } + throw error + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val owner = checkNotNull(captured) + checkCurrent(owner) + checkRender(owner) + val count = checkNotNull(active).read(buffer, offset, length) + checkCurrent(owner) + checkRender(owner) + val pending = manifest + if (pending != null) { + if (count == -1) { + observeManifest(checkNotNull(manifestUri), pending.toString("UTF-8")) + } else if (count > 0 && pending.size() + count <= 262144) { + pending.write(buffer, offset, count) + } else if (count > 0) { + manifest = null + observeManifest(checkNotNull(manifestUri), "") + } + } + return count + } + + private fun checkRender(owner: Binding) { + val previous = renderIdentity ?: return + val now = resourceUri?.let { identity(owner, it) } ?: return + if (previous != now) throw IOException("DJ render changed") + } + + override fun getUri(): Uri? = active?.uri + override fun getResponseHeaders(): Map> = active?.responseHeaders ?: emptyMap() + override fun close() { + try { + active?.close() + } finally { + active = null + captured = null + manifest = null + manifestUri = null + resourceUri = null + renderIdentity = null + } + } + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentIdentity.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentIdentity.kt new file mode 100644 index 0000000..6400a13 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentIdentity.kt @@ -0,0 +1,45 @@ +package com.henriquesebastiao.downtify.core.player + +import android.net.Uri +import java.net.URI +import java.time.OffsetDateTime + +/** Reused segment names are cacheable only with identities from a complete fresh manifest. */ +internal object DjSegmentIdentity { + fun parse(manifest: Uri, text: String): Map { + if (!text.startsWith("#EXTM3U") || text.length > 262144 || text.contains("#EXT-X-BYTERANGE")) return emptyMap() + val cursor = Cursor(URI(manifest.toString())) + text.lineSequence().map(String::trim).forEach(cursor::line) + return cursor.identities.takeIf { it.size <= 512 }.orEmpty() + } + + private class Cursor(private val base: URI) { + val identities = mutableMapOf() + private var sequence = 0L + private var time: Long? = null + private var duration = 0L + fun line(value: String) { + when { + value.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> sequence = value.substringAfter(':').toLongOrNull() ?: 0 + + value.startsWith("#EXT-X-PROGRAM-DATE-TIME:") -> time = runCatching { + OffsetDateTime.parse(value.substringAfter(':')).toInstant().toEpochMilli() + }.getOrNull() + + value.startsWith("#EXTINF:") -> + duration = + value.substringAfter(':').substringBefore(',').toDoubleOrNull()?.times(1000)?.toLong() ?: 0 + + value.isNotEmpty() && !value.startsWith('#') -> segment(value) + } + } + private fun segment(value: String) { + val point = time + val uri = runCatching { base.resolve(value) }.getOrNull() + val clean = uri?.takeIf { it.rawQuery == null && it.fragment == null && it.userInfo == null } + if (point != null && duration > 0 && clean != null) identities[clean.toString()] = "$point:$sequence" + time = point?.plus(duration) + sequence++ + } + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjAudioFocus.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjAudioFocus.kt new file mode 100644 index 0000000..fcb42a4 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjAudioFocus.kt @@ -0,0 +1,88 @@ +package com.henriquesebastiao.downtify.core.player + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Handler +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi + +/** One audio focus/noisy owner for the session, independent of the dominant decoder. */ +@UnstableApi +class LocalDjAudioFocus(private val context: Context, private val player: LocalDjPlayer) { + private val manager = context.getSystemService(AudioManager::class.java) + private var registered = false + private var held = false + private val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build(), + ) + .setAcceptsDelayedFocusGain(false) + .setOnAudioFocusChangeListener(::onFocusChange, Handler(player.applicationLooper)) + .build() + private val noisy = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) player.pause() + } + } + private val listener = object : Player.Listener { + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + if (!playWhenReady) abandon() + } + } + + fun start() { + if (registered) return + val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + if (android.os.Build.VERSION.SDK_INT >= 33) { + context.registerReceiver(noisy, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(noisy, filter) + } + registered = true + player.setPlayGate(::requestFocus) + player.addListener(listener) + if (player.playWhenReady && !requestFocus()) player.pause() + } + + fun release() { + player.setPlayGate { false } + player.removeListener(listener) + if (registered) context.unregisterReceiver(noisy) + registered = false + abandon() + } + + private fun requestFocus(): Boolean { + if (held) return true + held = manager?.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + if (held) player.setFocusGain(1f) + return held + } + + internal fun onFocusChange(change: Int) { + if (!registered) return + when (change) { + AudioManager.AUDIOFOCUS_GAIN -> player.setFocusGain(1f) + + // Never resume after a loss. + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> player.setFocusGain(0.2f) + + AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + player.pause() + abandon() + } + } + } + + private fun abandon() { + if (held) manager?.abandonAudioFocusRequest(request) + held = false + player.setFocusGain(1f) + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngine.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngine.kt new file mode 100644 index 0000000..724c2e3 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngine.kt @@ -0,0 +1,273 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** All operations run on the players' application looper. Only the next file is decoded ahead. */ +@UnstableApi +internal class LocalDjEngine(first: Player, second: Player, private val onDominant: (Player) -> Unit) { + private val decks = listOf(first, second) + var active: Player = first + private set + private val standby get() = decks.first { it !== active } + private var analyses = emptyMap() + private var generation = 0L + private var armed: Armed? = null + private var blend: Blend? = null + private var releaseSpeed: SpeedRelease? = null + private var enabled = false + private var released = false + var volume = first.volume + private set + var unmuteVolume = volume.takeIf { it > 0f } ?: 1f + private set + var playbackParameters = first.playbackParameters + private set + private var focusGain = 1f + private val mutableTransition = MutableStateFlow(LocalDjTransition()) + val transition = mutableTransition.asStateFlow() + val playIntent: Boolean? get() = blend?.wantedPlay + + private data class Armed( + val generation: Long, + val outgoing: Player, + val incoming: Player, + val outgoingIndex: Int, + val index: Int, + val plan: LocalDjPlan, + ) + private data class Blend(val armed: Armed, var wantedPlay: Boolean, var switched: Boolean = false) + private data class SpeedRelease(val player: Player, val positionMs: Long, val speed: Float) + + fun setEnabled(value: Boolean) { + enabled = value + reset() + if (!value) analyses = emptyMap() + publishWaiting() + } + + fun updateAnalyses(values: Map) { + val found = values.entries.take(16).associate { (id, value) -> + id to value.copy(downbeatsMs = value.downbeatsMs.take(20_000).filter { it >= 0 }.distinct().sorted()) + } + if (found == analyses) return + analyses = found + if (blend == null) reset() + } + + fun reset() { + generation++ + blend = null + armed = null + releaseSpeed = null + standby.pause() + standby.stop() + standby.clearMediaItems() + active.volume = volume * focusGain + active.playbackParameters = playbackParameters + publishWaiting() + } + + fun setPlaying(value: Boolean) { + blend?.wantedPlay = value + active.playWhenReady = value + if (blend != null) standby.playWhenReady = value else standby.pause() + } + + fun setVolume(value: Float, remember: Boolean = true) { + volume = value.coerceIn(0f, 1f) + if (remember && volume > 0f) unmuteVolume = volume + applyGains() + } + + fun setFocusGain(value: Float) { + focusGain = value.coerceIn(0f, 1f) + applyGains() + } + + fun setParameters(value: PlaybackParameters) { + reset() + playbackParameters = value + active.playbackParameters = value + } + + fun release() { + released = true + enabled = false + analyses = emptyMap() + decks.forEach { + it.pause() + it.release() + } + armed = null + blend = null + releaseSpeed = null + } + + fun pulse() { + if (!enabled || released) return + val mixing = blend + if (mixing != null) { + advance(mixing) + return + } + easeSpeed() + if (!canMix()) return + val next = active.nextMediaItemIndex + val prepared = armed + if (prepared == null || prepared.index != next || prepared.outgoing !== active) arm(next) + val ready = armed ?: return + if (active.currentPosition >= ready.plan.startMs) start(ready) + } + + private fun canMix(): Boolean { + if (!active.isPlaying || active.repeatMode == Player.REPEAT_MODE_ONE) return false + if (active.shuffleModeEnabled) { + publishWaiting(LocalDjFallback.UnsupportedQueue) + return false // Preserve native shuffle order until identical shuffle timelines are supported. + } + val next = active.nextMediaItemIndex + val current = active.currentMediaItem ?: return false + if (next == C.INDEX_UNSET || active.duration <= 4000) return false + val supported = localTrack(current) && localTrack(active.getMediaItemAt(next)) + if (!supported) publishWaiting(LocalDjFallback.UnsupportedQueue) + return supported + } + + private fun arm(index: Int) { + val incoming = standby + val current = active.currentMediaItem ?: return + val next = active.getMediaItemAt(index) + val plan = LocalDjPlan.create(active.duration, analyses[current.mediaId], analyses[next.mediaId]) + incoming.pause() + incoming.volume = 0f + incoming.repeatMode = active.repeatMode + incoming.shuffleModeEnabled = false + incoming.setMediaItems((0 until active.mediaItemCount).map(active::getMediaItemAt), index, plan.cueMs) + incoming.playbackParameters = + PlaybackParameters(playbackParameters.speed * plan.speed, playbackParameters.pitch) + incoming.prepare() + armed = Armed(generation, active, incoming, active.currentMediaItemIndex, index, plan) + mutableTransition.value = LocalDjTransition( + LocalDjPhase.Prepared, + current.mediaId, + next.mediaId, + analysed = plan.analysed, + fallback = plan.fallback, + ) + } + + private fun start(value: Armed) { + if (value.generation != generation) return + if (value.incoming.playerError != null) { + reset() + publishWaiting(LocalDjFallback.DecoderFailure) + return + } + val buffered = value.incoming.bufferedPosition - value.incoming.currentPosition + if (value.incoming.playbackState != Player.STATE_READY || buffered < value.plan.durationMs + 250) { + mutableTransition.value = mutableTransition.value.copy(fallback = LocalDjFallback.NotBuffered) + return + } + // Never start a late long overlap halfway through its plan: leave native playback intact. + if (value.outgoing.currentPosition - value.plan.startMs > 250) return + blend = Blend(value, value.outgoing.playWhenReady) + value.incoming.playWhenReady = value.outgoing.playWhenReady + advance(checkNotNull(blend)) + } + + private fun advance(value: Blend) { + val pair = value.armed + if (pair.generation != generation) return + if (pair.incoming.playerError != null) { + failIncoming(pair) + return + } + if (!value.wantedPlay) { + pair.outgoing.pause() + pair.incoming.pause() + return + } + if (pair.incoming.playbackState != Player.STATE_READY) { + failIncoming(pair) + return + } + val progress = progress(pair) + setBlendGains(pair, progress) + if (progress >= 0.5f && !value.switched) { + value.switched = true + active = pair.incoming + onDominant(active) + } + mutableTransition.value = LocalDjTransition( + LocalDjPhase.Mixing, + pair.outgoing.currentMediaItem?.mediaId, + pair.incoming.currentMediaItem?.mediaId, + progress, + pair.plan.analysed, + pair.plan.fallback, + ) + if (progress >= 1f) finish(pair) + } + + private fun progress(pair: Armed): Float { + if (pair.outgoing.currentMediaItemIndex != pair.outgoingIndex || + pair.outgoing.playbackState == Player.STATE_ENDED + ) { + return 1f + } + return ((pair.outgoing.currentPosition - pair.plan.startMs).toFloat() / pair.plan.durationMs).coerceIn(0f, 1f) + } + + private fun finish(pair: Armed) { + active = pair.incoming + onDominant(active) + pair.outgoing.pause() + pair.outgoing.stop() + pair.outgoing.clearMediaItems() + active.volume = volume * focusGain + releaseSpeed = SpeedRelease(active, active.currentPosition, pair.plan.speed) + blend = null + armed = null + publishWaiting() + } + + private fun failIncoming(pair: Armed) { + // Keep the still-audible source rather than promoting an empty decoder. + active = pair.outgoing + onDominant(active) + reset() + publishWaiting(LocalDjFallback.DecoderFailure) + } + + private fun easeSpeed() { + val value = releaseSpeed ?: return + val progress = ((value.player.currentPosition - value.positionMs) / 4000f).coerceIn(0f, 1f) + val speed = value.speed + (1f - value.speed) * progress + value.player.playbackParameters = PlaybackParameters(playbackParameters.speed * speed, playbackParameters.pitch) + if (progress >= 1f) releaseSpeed = null + } + + private fun applyGains() { + val value = blend + if (value == null) active.volume = volume * focusGain else setBlendGains(value.armed, progress(value.armed)) + } + + private fun setBlendGains(pair: Armed, progress: Float) { + val (first, second) = localDjGains(progress) + pair.outgoing.volume = volume * focusGain * first + pair.incoming.volume = volume * focusGain * second + } + + private fun publishWaiting(reason: LocalDjFallback = LocalDjFallback.None) { + mutableTransition.value = + LocalDjTransition(if (enabled) LocalDjPhase.Waiting else LocalDjPhase.Disabled, fallback = reason) + } + + private fun localTrack(item: MediaItem) = item.localConfiguration?.uri?.let(MediaItems::trackIdOf) != null +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlan.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlan.kt new file mode 100644 index 0000000..d323d19 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlan.kt @@ -0,0 +1,66 @@ +package com.henriquesebastiao.downtify.core.player + +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.roundToLong +import kotlin.math.sin + +/** Timing supplied by a verified analysis, never inferred in the audio callback. */ +data class LocalDjAnalysis(val bpm: Float, val downbeatsMs: List, val confidence: Float, val revision: String) + +enum class LocalDjPhase { Disabled, Waiting, Prepared, Mixing } + +enum class LocalDjFallback { None, MissingAnalysis, TempoMismatch, NotBuffered, UnsupportedQueue, DecoderFailure } + +data class LocalDjTransition( + val phase: LocalDjPhase = LocalDjPhase.Disabled, + val outgoingId: String? = null, + val incomingId: String? = null, + val progress: Float = 0f, + val analysed: Boolean = false, + val fallback: LocalDjFallback = LocalDjFallback.None, +) + +internal data class LocalDjPlan( + val startMs: Long, + val cueMs: Long, + val durationMs: Long, + val speed: Float, + val fallback: LocalDjFallback, +) { + val analysed get() = fallback == LocalDjFallback.None + + companion object { + fun create(durationMs: Long, outgoing: LocalDjAnalysis?, incoming: LocalDjAnalysis?): LocalDjPlan { + val short = 1200L.coerceAtMost(durationMs / 4).coerceAtLeast(1) + val fallback = + LocalDjPlan((durationMs - short).coerceAtLeast(0), 0, short, 1f, LocalDjFallback.MissingAnalysis) + if (!usable(outgoing) || !usable(incoming)) return fallback + val first = checkNotNull(outgoing) + val second = checkNotNull(incoming) + val speed = first.bpm / second.bpm + if (speed !in 0.96f..1.04f) return fallback.copy(fallback = LocalDjFallback.TempoMismatch) + // Four beats keep overlap short when no reliable vocal-zone analysis is available. + val length = (240_000.0 / first.bpm).roundToLong().coerceIn(1500, 4000) + val latest = durationMs - length + val start = first.downbeatsMs.lastOrNull { it in (latest - 4000).coerceAtLeast(0)..latest } + ?: return fallback + val cue = second.downbeatsMs.firstOrNull { it in 0..4000 } ?: return fallback + return LocalDjPlan(start, cue, length, speed, LocalDjFallback.None) + } + + private fun usable(value: LocalDjAnalysis?) = value != null && value.bpm.isFinite() && + value.bpm in 30f..300f && value.confidence.isFinite() && value.confidence >= 0.65f && + value.downbeatsMs.isNotEmpty() && value.downbeatsMs.size <= 20_000 + } +} + +/** Equal-power proportions with a sum-gain ceiling: correlated full-scale inputs cannot add gain. */ +internal fun localDjGains(progress: Float): Pair { + val angle = progress.coerceIn(0f, 1f) * PI / 2 + val outgoing = cos(angle).toFloat().coerceAtLeast(0f) + val incoming = sin(angle).toFloat().coerceAtLeast(0f) + val ceiling = max(1f, outgoing + incoming) + return outgoing / ceiling to incoming / ceiling +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt new file mode 100644 index 0000000..df2b5f4 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt @@ -0,0 +1,211 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.ForwardingSimpleBasePlayer +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer.State +import androidx.media3.common.util.UnstableApi +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * One session-facing player for two audio decks. Both decoders must use the same looper, + * independent audio processors and no internal audio focus/noisy handling. [LocalDjAudioFocus] + * owns those policies. Release that helper before releasing this player; this releases both decks. + */ +// Player facade: the explicit methods mirror Media3 command callbacks. +@Suppress("TooManyFunctions") +@UnstableApi +class LocalDjPlayer(first: Player, second: Player, private val scope: CoroutineScope) : + ForwardingSimpleBasePlayer(first) { + private var initialized = false + private val decks = listOf(first, second) + private val dominant = kotlinx.coroutines.flow.MutableStateFlow(first) + val dominantDecoder = dominant.asStateFlow() + private val occurrenceIds = MutableList(first.mediaItemCount) { Any() } + private val engine = LocalDjEngine(first, second) { + setPlayer(it) + dominant.value = it + } + private var ticker: Job? = null + private var playGate: () -> Boolean = { true } + val transition = engine.transition + + init { + require(first !== second && first.applicationLooper == second.applicationLooper) + initialized = true + } + + fun setEnabled(value: Boolean) { + engine.setEnabled(value) + ticker?.cancel() + ticker = if (value) { + scope.launch { + while (isActive) { + engine.pulse() + delay(50) + } + } + } else { + null + } + invalidateState() + } + + fun setServerDjBarrier(value: Boolean) { + decks.forEach { (it as? androidx.media3.exoplayer.ExoPlayer)?.pauseAtEndOfMediaItems = value } + } + + fun restoreShuffleOrder(order: IntArray) { + (engine.active as? androidx.media3.exoplayer.ExoPlayer)?.setShuffleOrder( + androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder(order, 0L), + ) + } + + fun updateAnalyses(values: Map) = engine.updateAnalyses(values) + + fun resetForOwner() { + setEnabled(false) + engine.setPlaying(false) + clearMediaItems() + occurrenceIds.clear() + } + + fun setFocusGain(value: Float) { + engine.setFocusGain(value) + invalidateState() + } + + internal fun setPlayGate(value: () -> Boolean) { + playGate = value + } + + override fun getState(): State { + val state = super.getState() + if (!initialized) return state + while (occurrenceIds.size < state.playlist.size) occurrenceIds.add(Any()) + val stable = state.playlist.mapIndexed { index, row -> + val builder = row.buildUpon().setUid(occurrenceIds[index]) + if (row.mediaItem.localConfiguration?.uri?.let(MediaItems::trackIdOf) != + null + ) { + builder.setPeriods(emptyList()) + } + builder.build() + } + val normalized = state.buildUpon().setPlaylist(stable).build() + return normalized.buildUpon().setPlaylist( + LocalDjTimeline(normalized.timeline, state.timeline), + engine.active.currentTracks, + engine.active.mediaMetadata, + ).setVolume(engine.volume) + .setUnmuteVolume(engine.unmuteVolume).setPlaybackParameters(engine.playbackParameters) + .setPlayWhenReady(engine.playIntent ?: state.playWhenReady, state.playWhenReadyChangeReason).build() + } + + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + engine.setPlaying(playWhenReady && playGate()) + return Futures.immediateVoidFuture() + } + + override fun handleSetVolume(volume: Float, volumeOperationType: Int): ListenableFuture<*> { + when (volumeOperationType) { + C.VOLUME_OPERATION_TYPE_SET_VOLUME -> engine.setVolume(volume) + C.VOLUME_OPERATION_TYPE_MUTE -> engine.setVolume(0f, remember = false) + C.VOLUME_OPERATION_TYPE_UNMUTE -> engine.setVolume(engine.unmuteVolume) + } + return Futures.immediateVoidFuture() + } + + override fun handleSetPlaybackParameters(playbackParameters: PlaybackParameters): ListenableFuture<*> { + engine.setParameters(playbackParameters) + return Futures.immediateVoidFuture() + } + + override fun handleSetAudioAttributes( + audioAttributes: AudioAttributes, + handleAudioFocus: Boolean, + ): ListenableFuture<*> { + decks.forEach { it.setAudioAttributes(audioAttributes, false) } + return Futures.immediateVoidFuture() + } + + override fun handleStop(): ListenableFuture<*> { + engine.reset() + return super.handleStop() + } + + override fun handleRelease(): ListenableFuture<*> { + ticker?.cancel() + engine.release() + return Futures.immediateVoidFuture() + } + + override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { + engine.reset() + return super.handleSeek(mediaItemIndex, positionMs, seekCommand) + } + + override fun handleSetRepeatMode(repeatMode: Int): ListenableFuture<*> { + engine.reset() + return super.handleSetRepeatMode(repeatMode) + } + + override fun handleSetShuffleModeEnabled(shuffleModeEnabled: Boolean): ListenableFuture<*> { + engine.reset() + return super.handleSetShuffleModeEnabled(shuffleModeEnabled) + } + + override fun handleSetMediaItems( + mediaItems: List, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture<*> { + engine.reset() + occurrenceIds.clear() + repeat(mediaItems.size) { occurrenceIds.add(Any()) } + return super.handleSetMediaItems(mediaItems, startIndex, startPositionMs) + } + + override fun handleAddMediaItems(index: Int, mediaItems: List): ListenableFuture<*> { + engine.reset() + occurrenceIds.addAll(index, List(mediaItems.size) { Any() }) + return super.handleAddMediaItems(index, mediaItems) + } + + override fun handleMoveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int): ListenableFuture<*> { + engine.reset() + val moved = occurrenceIds.subList(fromIndex, toIndex).toList() + occurrenceIds.subList(fromIndex, toIndex).clear() + occurrenceIds.addAll(newIndex, moved) + return super.handleMoveMediaItems(fromIndex, toIndex, newIndex) + } + + override fun handleReplaceMediaItems( + fromIndex: Int, + toIndex: Int, + mediaItems: List, + ): ListenableFuture<*> { + engine.reset() + occurrenceIds.subList(fromIndex, toIndex).clear() + occurrenceIds.addAll(fromIndex, List(mediaItems.size) { Any() }) + return super.handleReplaceMediaItems(fromIndex, toIndex, mediaItems) + } + + override fun handleRemoveMediaItems(fromIndex: Int, toIndex: Int): ListenableFuture<*> { + engine.reset() + occurrenceIds.subList(fromIndex, toIndex).clear() + return super.handleRemoveMediaItems(fromIndex, toIndex) + } + + internal fun pulseForTest() = engine.pulse() +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjSettings.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjSettings.kt new file mode 100644 index 0000000..1c84409 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjSettings.kt @@ -0,0 +1,34 @@ +package com.henriquesebastiao.downtify.core.player + +import android.content.Context +import com.henriquesebastiao.downtify.core.data.di.ApplicationScope +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Engine choice belongs to this device. Playback arming never survives a process/account change. */ +@Singleton +class LocalDjSettings @Inject constructor(@ApplicationContext context: Context) { + private val preferences = context.getSharedPreferences("local-dj-beta-v1", Context.MODE_PRIVATE) + private val selected = MutableStateFlow(preferences.getBoolean("device", false)) + val device = selected.asStateFlow() + private val enabled = MutableStateFlow(false) + val armed = enabled.asStateFlow() + private val progress = MutableStateFlow(LocalDjTransition()) + val transition = progress.asStateFlow() + + fun chooseDevice(value: Boolean) { + enabled.value = false + selected.value = value + preferences.edit().putBoolean("device", value).apply() + } + fun setArmed(value: Boolean) { + enabled.value = value && selected.value + } + internal fun publish(value: LocalDjTransition) { + progress.value = value + } +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjTimeline.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjTimeline.kt new file mode 100644 index 0000000..377047c --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjTimeline.kt @@ -0,0 +1,19 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.source.ForwardingTimeline + +/** Stable occurrence identities must not replace the decoder's native shuffle navigation. */ +@UnstableApi +internal class LocalDjTimeline(stable: Timeline, private val order: Timeline) : ForwardingTimeline(stable) { + override fun getFirstWindowIndex(shuffleModeEnabled: Boolean): Int = order.getFirstWindowIndex(shuffleModeEnabled) + + override fun getLastWindowIndex(shuffleModeEnabled: Boolean): Int = order.getLastWindowIndex(shuffleModeEnabled) + + override fun getNextWindowIndex(windowIndex: Int, repeatMode: Int, shuffleModeEnabled: Boolean): Int = + order.getNextWindowIndex(windowIndex, repeatMode, shuffleModeEnabled) + + override fun getPreviousWindowIndex(windowIndex: Int, repeatMode: Int, shuffleModeEnabled: Boolean): Int = + order.getPreviousWindowIndex(windowIndex, repeatMode, shuffleModeEnabled) +} diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackPersistence.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackPersistence.kt index 10ae010..569a24c 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackPersistence.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackPersistence.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.withTimeout /** One service-owned checkpoint observer; polling never enumerates or serializes the queue. */ @UnstableApi internal class PlaybackPersistence( - private val player: ExoPlayer, + private val player: Player, private val repository: PlaybackRepository, private val sessions: SessionStore, private val scope: CoroutineScope, @@ -62,7 +62,9 @@ internal class PlaybackPersistence( state.index, state.positionMs, ) - player.setShuffleOrder(DefaultShuffleOrder(state.shuffleOrder.toIntArray(), 0L)) + val restoredOrder = state.shuffleOrder.toIntArray() + if (player is ExoPlayer) player.setShuffleOrder(DefaultShuffleOrder(restoredOrder, 0L)) + if (player is LocalDjPlayer) player.restoreShuffleOrder(restoredOrder) player.shuffleModeEnabled = state.shuffle player.repeatMode = state.repeatMode player.setPlaybackSpeed(state.speed) diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt index dcc5596..10815e1 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt @@ -123,6 +123,8 @@ class PlaybackService : MediaLibraryService() { @Inject lateinit var syncCoordinator: SyncCoordinator + @Inject lateinit var localDj: LocalDjSettings + private var localFocus: LocalDjAudioFocus? = null private var session: MediaLibrarySession? = null private var artworkTracker: SessionArtworkTracker? = null private var listens: ListenTracker? = null @@ -141,42 +143,7 @@ class PlaybackService : MediaLibraryService() { override fun onCreate() { super.onCreate() CarDiagnostics.service(true) - val normalizer = NormalizationAudioProcessor( - gainForTrack = loudness::gainDb, - isEnabled = { loudness.enabled.value }, - gainRevision = { loudness.level.value.ordinal }, - ) - val renderers = object : DefaultRenderersFactory(this) { - override fun buildAudioSink( - context: Context, - enableFloatOutput: Boolean, - enableAudioTrackPlaybackParams: Boolean, - ): AudioSink = DefaultAudioSink.Builder(context) - .setAudioCapabilities(AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES) - .setAudioProcessors(arrayOf(normalizer)) - .setEnableFloatOutput(false) - .setAudioOffloadSupportProvider { _, _ -> AudioOffloadSupport.DEFAULT_UNSUPPORTED } - .build() - } - val player = ExoPlayer.Builder(this, renderers) - .setLoadControl( - androidx.media3.exoplayer.DefaultLoadControl.Builder() - .setTargetBufferBytes(64 * 1024 * 1024) - .setPrioritizeTimeOverSizeThresholds(false) - .build(), - ) - .setMediaSourceFactory(DefaultMediaSourceFactory(audioDataSource)) - .setAudioAttributes( - AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build(), - /* handleAudioFocus = */ - true, - ) - .setHandleAudioBecomingNoisy(true) - // For podcast episodes: the buttons in the app and the notification skip by these. - .setSeekBackIncrementMs(SKIP_BACK_MS) - .setSeekForwardIncrementMs(SKIP_FORWARD_MS) - .setWakeMode(C.WAKE_MODE_NETWORK) - .build() + val player = createSessionPlayer() val callback = SessionCallback() session = MediaLibrarySession.Builder(this, player, callback) @@ -210,7 +177,12 @@ class PlaybackService : MediaLibraryService() { TranscodeWarmer(player, resolver, streamingClient, scope, enabled = { !readAhead.enabled }).also { it.start() } - readAhead.start(player, scope) + scope.launch { + player.dominantDecoder.collect { decoder -> + readAhead.stop() + if (decoder is ExoPlayer) readAhead.start(decoder, scope) + } + } keepLikeButtonCurrent(player) stopDisabledDj(player) stopWhenSignedOut(player) @@ -218,6 +190,60 @@ class PlaybackService : MediaLibraryService() { persistence = PlaybackPersistence(player, playback, sessions, scope).also { it.start() } } + private fun createSessionPlayer(): LocalDjPlayer { + val player = LocalDjPlayer(decoder(), decoder(), scope) + localFocus = LocalDjAudioFocus(this, player).also { it.start() } + scope.launch { player.transition.collect { localDj.publish(it) } } + scope.launch { + combine(localDj.device, localDj.armed, musicAi.capabilities) { device, armed, caps -> + if (caps.version > 0 && !caps.enabled("dj_interactive")) localDj.setArmed(false) + device && armed && caps.enabled("dj_interactive") + }.distinctUntilChanged().collect { player.setEnabled(it) } + } + + return player + } + + private fun decoder(): ExoPlayer { + val normalizer = NormalizationAudioProcessor( + gainForTrack = loudness::gainDb, + isEnabled = { loudness.enabled.value }, + gainRevision = { loudness.level.value.ordinal }, + ) + val renderers = object : DefaultRenderersFactory(this) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean, + ): AudioSink = DefaultAudioSink.Builder(context) + .setAudioCapabilities(AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES) + .setAudioProcessors(arrayOf(normalizer)) + .setEnableFloatOutput(false) + .setAudioOffloadSupportProvider { _, _ -> AudioOffloadSupport.DEFAULT_UNSUPPORTED } + .build() + } + return ExoPlayer.Builder(this, renderers) + .setLoadControl( + androidx.media3.exoplayer.DefaultLoadControl.Builder() + .setBufferDurationsMs(15000, 60000, 1000, 2500) + .setTargetBufferBytes(32 * 1024 * 1024) + .setPrioritizeTimeOverSizeThresholds(false) + .build(), + ) + .setMediaSourceFactory(DefaultMediaSourceFactory(audioDataSource)) + .setAudioAttributes( + AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build(), + /* handleAudioFocus = */ + false, + ) + .setHandleAudioBecomingNoisy(false) + // For podcast episodes: the buttons in the app and the notification skip by these. + .setSeekBackIncrementMs(SKIP_BACK_MS) + .setSeekForwardIncrementMs(SKIP_FORWARD_MS) + .setWakeMode(C.WAKE_MODE_NETWORK) + .build() + } + /** * Songs that left the library (deleted, moved to the server's trash, hidden) leave the queue * too — but only once a sync of this session has succeeded: until then the local library may be @@ -241,6 +267,7 @@ class PlaybackService : MediaLibraryService() { private fun stopDisabledDj(player: Player) { fun enforce() { + if (musicAi.capabilities.value.version == 0) return val id = player.currentMediaItem?.mediaId if (!DjIds.isDj(id)) return val reference = DjIds.referenceOf(id) @@ -265,6 +292,8 @@ class PlaybackService : MediaLibraryService() { sessions.session.collect { paired -> if (paired == null || paired.playbackOwner != expected) { sleepTimer.set(null) + localDj.setArmed(false) + (player as? LocalDjPlayer)?.resetForOwner() player.stop() player.clearMediaItems() stopSelf() @@ -295,6 +324,9 @@ class PlaybackService : MediaLibraryService() { episodes?.stop() prefetch?.stop() warmer?.stop() + localFocus?.release() + localDj.setArmed(false) + localDj.publish(LocalDjTransition()) scope.cancel() session?.run { player.release() diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt index d5e4a67..f9d38b7 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerModule.kt @@ -60,6 +60,7 @@ object PlayerModule { http: OkHttpDataSource.Factory, resolver: StreamResolver, store: AudioCacheStore, + dj: DjAudioCacheStore, ): DataSource.Factory { val regular = ResolvingDataSource.Factory( // file:// (offline copies) is read straight from disk; http(s) goes through the streaming cache. @@ -74,7 +75,8 @@ object PlayerModule { resolver, ) val direct = ResolvingDataSource.Factory(http, resolver) - return DataSource.Factory { DjDataSource(regular, direct, resolver::isDjHlsResource) } + val live = ResolvingDataSource.Factory(dj.factory, resolver) + return DataSource.Factory { DjDataSource(regular, direct, live, resolver::isDjHlsResource) } } @Provides diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjHttpRecoveryTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjHttpRecoveryTest.kt new file mode 100644 index 0000000..0a5a5d2 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjHttpRecoveryTest.kt @@ -0,0 +1,55 @@ +package com.henriquesebastiao.downtify.core.player + +import com.henriquesebastiao.downtify.core.network.session.Session +import java.io.IOException +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class DjHttpRecoveryTest { + private fun owner(server: MockWebServer) = + Session(server.url("/").toString(), "fixture", "Fixture", "device", "fixture-token") + + @Test fun `temporary unavailability retries then returns audio`() { + MockWebServer().use { server -> + server.start() + val owner = owner(server) + val client = OkHttpClient.Builder().addInterceptor(DjHttpRecovery({ owner }, { true })).build() + server.enqueue(MockResponse.Builder().code(503).build()) + server.enqueue(MockResponse.Builder().body("audio").build()) + client.newCall(Request.Builder().url(server.url("/segment.ts")).build()).execute().use { + assertEquals("audio", it.body.string()) + } + assertEquals(2, server.requestCount) + } + } + + @Test fun `authentication failures are never retried`() { + MockWebServer().use { server -> + server.start() + val owner = owner(server) + val client = OkHttpClient.Builder().addInterceptor(DjHttpRecovery({ owner }, { true })).build() + server.enqueue(MockResponse.Builder().code(401).build()) + client.newCall(Request.Builder().url(server.url("/segment.ts")).build()).execute().use { + assertEquals(401, it.code) + } + assertEquals(1, server.requestCount) + } + } + + @Test fun `a disabled module cannot create an audio request`() { + MockWebServer().use { server -> + server.start() + val owner = owner(server) + val client = OkHttpClient.Builder().addInterceptor(DjHttpRecovery({ owner }, { false })).build() + assertThrows(IOException::class.java) { + client.newCall(Request.Builder().url(server.url("/segment.ts")).build()).execute() + } + assertEquals(0, server.requestCount) + } + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentCacheTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentCacheTest.kt new file mode 100644 index 0000000..20cdb7a --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjSegmentCacheTest.kt @@ -0,0 +1,348 @@ +package com.henriquesebastiao.downtify.core.player + +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import androidx.media3.datasource.cache.ContentMetadata +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import com.henriquesebastiao.downtify.core.network.session.AuthInterceptor +import com.henriquesebastiao.downtify.core.network.session.Session +import com.henriquesebastiao.downtify.core.network.session.SessionStore +import io.mockk.every +import io.mockk.mockk +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.io.File +import java.io.IOException +import java.util.UUID +import java.util.concurrent.TimeUnit +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DjSegmentCacheTest { + @Test fun `an open cached reader stops when its render is replaced`() { + Fixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().body("old").build()) + fixture.read() + val reader = fixture.factory.createDataSource() + reader.open(fixture.spec()) + fixture.recordWindow(day = "07") + assertThrows(IOException::class.java) { reader.read(ByteArray(3), 0, 3) } + reader.close() + } + } + + @Test fun `reusing a segment name in a new render never reuses old audio`() { + Fixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().body("old render").build()) + assertEquals("old render", drain(fixture.factory, fixture.spec()).toString(Charsets.UTF_8)) + fixture.recordWindow(day = "07") + fixture.server.enqueue(MockResponse.Builder().body("new render").build()) + assertEquals("new render", drain(fixture.factory, fixture.spec()).toString(Charsets.UTF_8)) + assertEquals(2, fixture.server.requestCount) + } + } + + @Test fun `fresh manifest without stable timestamps bypasses cache`() { + Fixture().use { fixture -> + fixture.factory.observeManifest( + Uri.parse(fixture.url("index.m3u8")), + "#EXTM3U\n#EXTINF:6,\nsegment-00000001.ts", + ) + repeat(2) { fixture.server.enqueue(MockResponse.Builder().body("direct $it").build()) } + assertEquals("direct 0", drain(fixture.factory, fixture.spec()).toString(Charsets.UTF_8)) + assertEquals("direct 1", drain(fixture.factory, fixture.spec()).toString(Charsets.UTF_8)) + assertEquals(0, fixture.cache.cacheSpace) + } + } + + @Test fun `an authenticated immutable segment and its ranges survive a network outage`() { + Fixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().body("abcdefgh").build()) + assertEquals("abcdefgh", fixture.read()) + assertEquals("Bearer fixture-token", fixture.server.takeRequest().headers["Authorization"]) + fixture.server.close() + assertEquals("abcdefgh", fixture.read()) + assertEquals("cde", fixture.read(position = 2, length = 3)) + assertEquals(1, fixture.server.requestCount) + assertTrue(fixture.cache.keys.all { it.matches(Regex("dj-v1:[a-f0-9-]{36}:[a-f0-9]{64}")) }) + fixture.cache.keys.forEach { + assertNull(ContentMetadata.getRedirectedUri(fixture.cache.getContentMetadata(it))) + } + } + } + + @Test fun `live manifests always refresh and never enter the audio cache`() { + Fixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().body("#EXTM3U\n# first window").build()) + fixture.server.enqueue(MockResponse.Builder().body("#EXTM3U\n# next window").build()) + assertTrue(fixture.read("index.m3u8").contains("first window")) + assertTrue(fixture.read("index.m3u8").contains("next window")) + assertEquals(2, fixture.server.requestCount) + assertEquals(0, fixture.cache.cacheSpace) + } + } + + @Test fun `a missed segment fails explicitly and can retry when the server recovers`() { + Fixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + assertThrows(IOException::class.java) { fixture.read() } + assertEquals(0, fixture.cache.cacheSpace) + fixture.server.enqueue(MockResponse.Builder().body("recovered").build()) + assertEquals("recovered", fixture.read()) + assertEquals(2, fixture.server.requestCount) + } + } + + @Test fun `partial bytes are not treated as a complete segment and a retry fills the hole`() { + Fixture().use { fixture -> + var fail = true + val starts = mutableListOf() + val payload = "abcdefgh".toByteArray() + val upstream = DataSource.Factory { Bytes(payload, { fail }, starts) } + val cache = fixture.segmentFactory(upstream) + val spec = fixture.spec() + assertThrows(IOException::class.java) { drain(cache, spec) } + val key = fixture.cache.keys.single() + assertTrue(fixture.cache.cacheSpace in 1 until payload.size) + assertFalse(fixture.cache.isCached(key, 0, payload.size.toLong())) + fail = false + assertArrayEquals(payload, drain(cache, spec)) + assertTrue(fixture.cache.isCached(key, 0, payload.size.toLong())) + assertEquals(listOf(0L, 3L), starts) + } + } + + @Test fun `unknown length transfer is playable but never persisted as a trustworthy segment`() { + Fixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().chunkedBody("abcdefgh", 2).build()) + assertEquals("abcdefgh", fixture.read()) + assertEquals(0, fixture.cache.cacheSpace) + } + } + + @Test fun `a different account or token generation cannot reuse cached bytes or an open reader`() { + Fixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().body("account-one").build()) + assertEquals("account-one", fixture.read()) + val firstKey = fixture.cache.keys.single() + val reader = fixture.factory.createDataSource() + reader.open(fixture.spec()) + fixture.owner = fixture.owner!!.copy(username = "another", token = "another-fixture-token") + assertThrows(IOException::class.java) { reader.read(ByteArray(4), 0, 4) } + reader.close() + fixture.server.enqueue(MockResponse.Builder().body("account-two").build()) + assertEquals("account-two", fixture.read()) + assertEquals(2, fixture.server.requestCount) + assertTrue(fixture.cache.keys.any { it != firstKey }) + fixture.factory.purgeRetired() + assertFalse(fixture.cache.isCached(firstKey, 0, 1)) + } + } + + @Test fun `revocation prevents even cached playback and a disabled module starts no request`() { + Fixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().body("cached").build()) + assertEquals("cached", fixture.read()) + fixture.enabled = false + assertThrows(IOException::class.java) { fixture.read() } + assertEquals(1, fixture.server.requestCount) + fixture.enabled = true + fixture.server.enqueue(MockResponse.Builder().code(401).build()) + assertThrows(IOException::class.java) { fixture.read("segment-00000002.ts") } + assertNull(fixture.owner) + assertThrows(IOException::class.java) { fixture.read() } + assertEquals(2, fixture.server.requestCount) + fixture.factory.purgeRetired() + assertEquals(0, fixture.cache.cacheSpace) + } + } + + @Test fun `a different render session never reuses an equally numbered segment`() { + Fixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().body("old-render").build()) + assertEquals("old-render", fixture.read()) + fixture.server.enqueue(MockResponse.Builder().body("new-render").build()) + val spec = fixture.spec().withUri(Uri.parse(fixture.url().replace(ID, "b".repeat(32)))) + assertEquals("new-render", drain(fixture.factory, spec).toString(Charsets.UTF_8)) + assertEquals(2, fixture.server.requestCount) + } + } + + @Test fun `the LRU disk budget evicts old chunks without touching the ordinary audio cache`() { + Fixture(limit = 12).use { fixture -> + fixture.server.enqueue(MockResponse.Builder().body("abcdefgh").build()) + fixture.read() + val old = fixture.cache.keys.single() + fixture.server.enqueue(MockResponse.Builder().body("ijklmnop").build()) + fixture.read("segment-00000002.ts") + assertTrue(fixture.cache.cacheSpace <= 12) + assertFalse(fixture.cache.isCached(old, 0, 1)) + assertEquals("ijklmnop", fixture.read("segment-00000002.ts")) + assertEquals(2, fixture.server.requestCount) + } + } + + @Test fun `query credentials foreign origins and redirects do not enter the cache`() { + Fixture().use { fixture -> + val spec = fixture.spec() + assertThrows(IOException::class.java) { + drain(fixture.factory, spec.withUri(Uri.parse(fixture.url() + "?credential=fixture"))) + } + assertThrows(IOException::class.java) { + drain(fixture.factory, spec.withUri(Uri.parse(fixture.url().replace("localhost", "other.test")))) + } + assertEquals(0, fixture.server.requestCount) + fixture.server.enqueue( + MockResponse.Builder().code(302).addHeader("Location", "/elsewhere?credential=fixture").build(), + ) + assertThrows(IOException::class.java) { fixture.read() } + assertEquals(1, fixture.server.requestCount) + assertEquals(0, fixture.cache.cacheSpace) + } + } + + @Test fun `router preserves regular prepared and interactive transports`() { + val routes = mutableListOf() + fun route(name: String) = DataSource.Factory { + routes += name + Bytes(byteArrayOf(1)) + } + val source = DjDataSource(route("regular"), route("prepared"), route("live")) { + it.path?.contains("/hls/") == + true + } + listOf(Uri.parse("file:///synthetic"), DjMediaItems.uri(ID), DjMediaItems.uri(ID, "interactive")).forEach { + source.open(DataSpec.Builder().setUri(it).build()) + source.close() + } + assertEquals(listOf("regular", "prepared", "live"), routes) + } + + private class Fixture(limit: Long = 64L * 1024 * 1024) : Closeable { + val server = MockWebServer().apply { start() } + var owner: Session? = + Session(server.url("/").toString().trimEnd('/'), "server", "Test", "device", "fixture-token") + var enabled = true + private val context = RuntimeEnvironment.getApplication() + private val sqlite = android.database.sqlite.SQLiteDatabase.create(null) + private val database = object : androidx.media3.database.DatabaseProvider { + override fun getReadableDatabase() = sqlite + override fun getWritableDatabase() = sqlite + } + val cache = SimpleCache( + File(context.cacheDir, "dj-cache-test-${UUID.randomUUID()}"), + LeastRecentlyUsedCacheEvictor(limit), + database, + ) + private val sessions = mockk(relaxed = true).also { + every { it.current } answers { owner } + every { it.onRevoked(any()) } answers { owner = null } + } + private val http = OkHttpDataSource.Factory( + OkHttpClient.Builder().addInterceptor(AuthInterceptor(sessions)).followRedirects(false) + .connectTimeout(1, TimeUnit.SECONDS).readTimeout(1, TimeUnit.SECONDS).build(), + ) + val factory = segmentFactory(http) + + fun segmentFactory(upstream: DataSource.Factory) = DjSegmentCache(cache, { + owner + }, { enabled }, upstream).also { + recordWindow(it) + } + fun recordWindow(target: DjSegmentCache = factory, day: String = "06") { + target.observeManifest( + Uri.parse(url("index.m3u8")), + "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:1\n#EXT-X-PROGRAM-DATE-TIME:2026-11-${day}T00:00:00Z\n" + + (1..5).joinToString("\n") { "#EXTINF:6,\nsegment-${it.toString().padStart(8, '0')}.ts" }, + ) + } + fun url(resource: String = "segment-00000001.ts") = + server.url("/api/v1/music-ai/dj/sessions/$ID/hls/$resource").toString() + fun spec(resource: String = "segment-00000001.ts", position: Long = 0, length: Long = C.LENGTH_UNSET.toLong()) = + DataSpec.Builder().setUri(url(resource)).setPosition(position).setLength(length).build() + fun read( + resource: String = "segment-00000001.ts", + position: Long = 0, + length: Long = C.LENGTH_UNSET.toLong(), + ): String { + if (resource.startsWith("segment-")) recordWindow() + return drain(factory, spec(resource, position, length)).toString(Charsets.UTF_8) + } + + override fun close() { + cache.release() + sqlite.close() + server.close() + } + } + + private class Bytes( + private val bytes: ByteArray, + private val fail: () -> Boolean = { false }, + private val starts: MutableList = mutableListOf(), + ) : DataSource { + private var position = 0 + private var uri: Uri? = null + override fun addTransferListener(transferListener: TransferListener) = Unit + override fun open(dataSpec: DataSpec): Long { + uri = dataSpec.uri + starts += dataSpec.position + position = dataSpec.position.toInt() + return (bytes.size - position).toLong() + } + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (fail() && position >= 3) throw IOException("Synthetic segment disconnect") + if (position == bytes.size) return C.RESULT_END_OF_INPUT + val available = if (fail()) minOf(3, bytes.size) else bytes.size + val count = minOf(length, available - position) + bytes.copyInto(buffer, offset, position, position + count) + position += count + return count + } + override fun getUri(): Uri? = uri + override fun close() { + uri = null + } + } + + private companion object { + const val ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + fun drain(factory: DataSource.Factory, spec: DataSpec): ByteArray { + val source = factory.createDataSource() + try { + source.open(spec) + val output = ByteArrayOutputStream() + val buffer = ByteArray(16) + while (true) { + val size = source.read(buffer, 0, buffer.size) + if (size == C.RESULT_END_OF_INPUT) return output.toByteArray() + output.write(buffer, 0, size) + } + } finally { + source.close() + } + } + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngineTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngineTest.kt new file mode 100644 index 0000000..c6e4467 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngineTest.kt @@ -0,0 +1,156 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class LocalDjEngineTest { + private val first = LocalDjFakeDeck() + private val second = LocalDjFakeDeck() + private var dominant: Player = first + private val engine = LocalDjEngine(first, second) { dominant = it } + + private fun begin(ids: List = listOf("a", "b", "c")) { + first.setMediaItems(ids.map(::localDjItem)) + first.prepare() + first.play() + engine.setEnabled(true) + engine.pulse() + } + + @Test fun `duplicate titles preserve the occurrence index at dominance`() { + begin(listOf("a", "a", "b")) + first.position(8800) + engine.pulse() + first.position(9400) + second.position(600) + engine.pulse() + assertSame(second, dominant) + assertEquals(1, dominant.currentMediaItemIndex) + assertEquals(3, dominant.mediaItemCount) + assertEquals("a", dominant.currentMediaItem?.mediaId) + assertEquals(0.5f, engine.transition.value.progress, 0.001f) + } + + @Test fun `pause freezes both decks and only an explicit resume restarts them`() { + begin() + first.position(8800) + engine.pulse() + first.position(9100) + engine.pulse() + engine.setPlaying(false) + repeat(10) { engine.pulse() } + assertFalse(first.playWhenReady) + assertFalse(second.playWhenReady) + assertEquals(0.25f, engine.transition.value.progress, 0.001f) + engine.setPlaying(true) + assertTrue(first.playWhenReady) + assertTrue(second.playWhenReady) + } + + @Test fun `disabling keeps the dominant original at its source position`() { + begin() + first.position(8800) + engine.pulse() + first.position(9500) + second.position(700) + engine.pulse() + engine.setEnabled(false) + assertSame(second, engine.active) + assertEquals(700L, engine.active.currentPosition) + assertTrue(engine.active.playWhenReady) + assertEquals(0, first.mediaItemCount) + assertEquals(1f, second.volume, 0.001f) + assertEquals(LocalDjPhase.Disabled, engine.transition.value.phase) + } + + @Test fun `a missing incoming buffer never fades the audible source`() { + begin() + second.buffered(200) + first.position(8800) + engine.pulse() + assertEquals(LocalDjFallback.NotBuffered, engine.transition.value.fallback) + assertEquals(1f, first.volume, 0.001f) + assertFalse(second.playWhenReady) + assertSame(first, engine.active) + } + + @Test fun `an incoming decoder error does not take over normal playback`() { + begin() + second.fail() + first.position(8800) + engine.pulse() + assertSame(first, engine.active) + assertTrue(first.playWhenReady) + assertEquals(1f, first.volume, 0.001f) + assertEquals(LocalDjFallback.DecoderFailure, engine.transition.value.fallback) + } + + @Test fun `late readiness after disable cannot restart a retired deck`() { + begin() + engine.setEnabled(false) + second.setMediaItems(listOf(localDjItem("retired"))) + second.prepare() + first.position(9900) + repeat(3) { engine.pulse() } + assertFalse(second.playWhenReady) + assertSame(first, engine.active) + assertEquals(LocalDjPhase.Disabled, engine.transition.value.phase) + } + + @Test fun `a native end between pulses still finishes the handover once`() { + begin() + first.position(8800) + engine.pulse() + first.nativeIndex(1) + second.position(1200) + engine.pulse() + assertSame(second, engine.active) + assertEquals(1200L, second.currentPosition) + assertEquals(0, first.mediaItemCount) + assertTrue(second.playWhenReady) + } + + @Test fun `remote streams shuffle and repeat one keep native playback`() { + begin() + engine.reset() + first.setMediaItems(listOf(localDjItem("a"), MediaItem.fromUri("https://fixture.test/live.m3u8"))) + engine.pulse() + assertEquals(0, second.mediaItemCount) + first.setMediaItems(listOf(localDjItem("a"), localDjItem("b"))) + first.repeatMode = Player.REPEAT_MODE_ONE + engine.pulse() + assertEquals(0, second.mediaItemCount) + first.repeatMode = Player.REPEAT_MODE_OFF + first.shuffleModeEnabled = true + engine.pulse() + assertEquals(0, second.mediaItemCount) + assertTrue(first.playWhenReady) + } + + @Test fun `ducking and user volume remain independent during overlap`() { + begin() + first.position(8800) + engine.pulse() + first.position(9400) + engine.pulse() + engine.setVolume(0.8f) + engine.setFocusGain(0.2f) + assertEquals(0.8f, engine.volume, 0.001f) + assertEquals(0.08f, first.volume, 0.001f) + assertEquals(0.08f, second.volume, 0.001f) + engine.setFocusGain(1f) + assertEquals(0.4f, first.volume, 0.001f) + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjFakeDeck.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjFakeDeck.kt new file mode 100644 index 0000000..86c8d18 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjFakeDeck.kt @@ -0,0 +1,137 @@ +package com.henriquesebastiao.downtify.core.player + +import android.os.Looper +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.util.UnstableApi +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture + +/** Real SimpleBasePlayer event semantics with manually advanced audio positions. */ +@UnstableApi +internal class LocalDjFakeDeck : SimpleBasePlayer(Looper.getMainLooper()) { + private var items = emptyList() + private var index = 0 + private var position = 0L + private var buffered: Long? = null + private var wantsPlay = false + private var ready = Player.STATE_IDLE + private var level = 1f + private var parameters = PlaybackParameters.DEFAULT + private var repeat = Player.REPEAT_MODE_OFF + private var shuffle = false + private var error: PlaybackException? = null + var releases = 0 + private set + + override fun getState(): State { + val playlist = items.mapIndexed { ordinal, item -> + MediaItemData.Builder("fake-$ordinal").setMediaItem(item) + .setDurationUs((item.mediaMetadata.durationMs ?: 10_000) * 1000) + .setIsSeekable(true).build() + } + return State.Builder().setAvailableCommands(Player.Commands.Builder().addAllCommands().build()) + .setPlaylist(playlist).setCurrentMediaItemIndex(if (items.isEmpty()) C.INDEX_UNSET else index) + .setContentPositionMs( + position, + ).setContentBufferedPositionMs( + PositionSupplier.getConstant(buffered ?: (items.getOrNull(index)?.mediaMetadata?.durationMs ?: 10_000)), + ) + .setTotalBufferedDurationMs(PositionSupplier.getConstant(10_000)) + .setPlaybackState(ready).setPlayWhenReady(wantsPlay, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setVolume(level).setPlaybackParameters(parameters).setRepeatMode(repeat).setShuffleModeEnabled(shuffle) + .setPlayerError(error).build() + } + + fun position(value: Long) { + position = value + invalidateState() + } + fun buffered(value: Long) { + buffered = value + invalidateState() + } + fun nativeIndex(value: Int) { + index = value + position = 0 + invalidateState() + } + fun fail() { + ready = Player.STATE_IDLE + error = PlaybackException( + "synthetic decoder failure", + null, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + ) + invalidateState() + } + + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + wantsPlay = playWhenReady + return Futures.immediateVoidFuture() + } + override fun handlePrepare(): ListenableFuture<*> { + ready = Player.STATE_READY + return Futures.immediateVoidFuture() + } + override fun handleStop(): ListenableFuture<*> { + ready = Player.STATE_IDLE + return Futures.immediateVoidFuture() + } + override fun handleRelease(): ListenableFuture<*> { + releases++ + return Futures.immediateVoidFuture() + } + override fun handleSetMediaItems( + mediaItems: List, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture<*> { + items = mediaItems.toList() + if (items.isEmpty()) ready = Player.STATE_IDLE + index = if (startIndex == C.INDEX_UNSET) 0 else startIndex + position = if (startPositionMs == C.TIME_UNSET) 0 else startPositionMs + buffered = null + error = null + return Futures.immediateVoidFuture() + } + override fun handleRemoveMediaItems(fromIndex: Int, toIndex: Int): ListenableFuture<*> { + items = items.filterIndexed { ordinal, _ -> ordinal !in fromIndex until toIndex } + if (items.isEmpty()) ready = Player.STATE_IDLE + index = index.coerceAtMost((items.size - 1).coerceAtLeast(0)) + return Futures.immediateVoidFuture() + } + override fun handleAddMediaItems(index: Int, mediaItems: List): ListenableFuture<*> { + items = items.toMutableList().apply { addAll(index, mediaItems) } + return Futures.immediateVoidFuture() + } + override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { + index = mediaItemIndex + position = if (positionMs == C.TIME_UNSET) 0 else positionMs + return Futures.immediateVoidFuture() + } + override fun handleSetVolume(volume: Float, volumeOperationType: Int): ListenableFuture<*> { + level = volume + return Futures.immediateVoidFuture() + } + override fun handleSetPlaybackParameters(playbackParameters: PlaybackParameters): ListenableFuture<*> { + parameters = playbackParameters + return Futures.immediateVoidFuture() + } + override fun handleSetRepeatMode(repeatMode: Int): ListenableFuture<*> { + repeat = repeatMode + return Futures.immediateVoidFuture() + } + override fun handleSetShuffleModeEnabled(shuffleModeEnabled: Boolean): ListenableFuture<*> { + shuffle = + shuffleModeEnabled + return Futures.immediateVoidFuture() + } +} + +internal fun localDjItem(id: String): MediaItem = MediaItem.Builder().setMediaId(id).setUri(MediaItems.uriFor(id)) + .setMediaMetadata(androidx.media3.common.MediaMetadata.Builder().setTitle(id).setDurationMs(10_000).build()).build() diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlanTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlanTest.kt new file mode 100644 index 0000000..3fc2859 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlanTest.kt @@ -0,0 +1,41 @@ +package com.henriquesebastiao.downtify.core.player + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LocalDjPlanTest { + private val timing = LocalDjAnalysis(120f, listOf(0, 2000, 4000, 6000, 8000), 0.9f, "synthetic") + + @Test fun `trusted downbeats align a four beat overlap within a four percent tempo bound`() { + val plan = LocalDjPlan.create(10_000, timing, timing.copy(bpm = 122f)) + assertTrue(plan.analysed) + assertEquals(8000L, plan.startMs) + assertEquals(2000L, plan.durationMs) + assertEquals(0L, plan.cueMs) + assertEquals(120f / 122, plan.speed, 0.001f) + } + + @Test fun `uncertain or mismatched timing uses a short unchanged speed transition`() { + val missing = LocalDjPlan.create(10_000, timing.copy(confidence = 0.3f), timing) + assertFalse(missing.analysed) + assertEquals(1200L, missing.durationMs) + val incompatible = LocalDjPlan.create(10_000, timing, timing.copy(bpm = 80f)) + assertEquals(LocalDjFallback.TempoMismatch, incompatible.fallback) + assertEquals(1f, incompatible.speed, 0.001f) + } + + @Test fun `gain proportions have smooth endpoints and cannot add full scale peaks`() { + assertEquals(1f, localDjGains(0f).first, 0.001f) + assertEquals(0f, localDjGains(0f).second, 0.001f) + assertEquals(0.5f, localDjGains(0.5f).first, 0.001f) + assertEquals(0.5f, localDjGains(0.5f).second, 0.001f) + assertEquals(1f, localDjGains(1f).second, 0.001f) + (0..1000).forEach { + val (outgoing, incoming) = localDjGains(it / 1000f) + assertTrue(outgoing >= 0f && incoming >= 0f) + assertTrue(outgoing + incoming <= 1.00001f) + } + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayerTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayerTest.kt new file mode 100644 index 0000000..8f39b48 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayerTest.kt @@ -0,0 +1,69 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class LocalDjPlayerTest { + @Test fun `one facade preserves duplicate queue UIDs and exposes the dominant source`() = runTest { + val first = LocalDjFakeDeck() + val second = LocalDjFakeDeck() + val player = LocalDjPlayer(first, second, this) + player.setMediaItems(listOf(localDjItem("a"), localDjItem("a"), localDjItem("b"))) + player.prepare() + player.play() + val before = (0 until player.mediaItemCount).map { player.currentTimeline.getWindow(it, Timeline.Window()).uid } + player.setEnabled(true) + player.pulseForTest() + first.position(8800) + player.pulseForTest() + first.position(9400) + second.position(600) + player.pulseForTest() + assertEquals(1, player.currentMediaItemIndex) + assertEquals(600L, player.currentPosition) + val after = (0 until player.mediaItemCount).map { player.currentTimeline.getWindow(it, Timeline.Window()).uid } + assertEquals(before, after) + assertTrue(before[0] != before[1]) + assertEquals(1f, player.volume, 0.001f) + player.pause() + assertFalse(first.playWhenReady) + assertFalse(second.playWhenReady) + player.seekTo(2, 500) + assertEquals(2, player.currentMediaItemIndex) + assertEquals(500L, player.currentPosition) + assertEquals(0, first.mediaItemCount) + player.release() + assertEquals(1, first.releases) + assertEquals(1, second.releases) + } + + @Test fun `owner reset clears both transports and denies retired preparation`() = runTest { + val first = LocalDjFakeDeck() + val second = LocalDjFakeDeck() + val player = LocalDjPlayer(first, second, this) + player.setMediaItems(listOf(localDjItem("a"), localDjItem("b"))) + player.prepare() + player.play() + player.setEnabled(true) + player.pulseForTest() + assertTrue(second.mediaItemCount > 0) + player.resetForOwner() + player.pulseForTest() + assertEquals(0, first.mediaItemCount) + assertEquals(0, second.mediaItemCount) + assertFalse(player.playWhenReady) + assertEquals(LocalDjPhase.Disabled, player.transition.value.phase) + player.release() + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackPersistenceTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackPersistenceTest.kt index c7dcc04..19253b3 100644 --- a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackPersistenceTest.kt +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackPersistenceTest.kt @@ -92,7 +92,7 @@ class PlaybackPersistenceTest { } @Test - fun `restart restores exact queue and shuffle order paused without opening audio`() = runTest { + fun `facade restart restores exact queue and shuffle order paused without opening audio`() = runTest { val session = Session("https://new.example", "server", "Test", "device", "fixture") val sessions = mockk(relaxed = true) every { sessions.current } returns session @@ -117,8 +117,9 @@ class PlaybackPersistenceTest { } coEvery { repository.load(session.playbackOwner) } returns snapshot val source = mockk() - val player = ExoPlayer.Builder(RuntimeEnvironment.getApplication()) + fun decoder() = ExoPlayer.Builder(RuntimeEnvironment.getApplication()) .setLooper(Looper.getMainLooper()).setMediaSourceFactory(DefaultMediaSourceFactory(source)).build() + val player = LocalDjPlayer(decoder(), decoder(), backgroundScope) val persistence = PlaybackPersistence(player, repository, sessions, backgroundScope) try { persistence.start() diff --git a/docs/music-ai.md b/docs/music-ai.md index 4d113c9..9155cb1 100644 --- a/docs/music-ai.md +++ b/docs/music-ai.md @@ -27,9 +27,9 @@ upgrade; the Android app does not silently enable server modules. ## Resource use and boundaries -Models and mixing run on the server, not on the phone. Android uses its existing -Media3 audio decoder and authenticated HTTP stack. Live DJ segments bypass the -persistent library cache; ordinary offline files retain their existing behavior. +In server mode, models and mixing run on the server. Android uses its existing +Media3 audio decoder and authenticated HTTP stack. Live DJ segments use a +separate bounded DJ cache; ordinary offline files retain their existing behavior. There is no on-device model download or new model subscription. Additional phone CPU, memory, battery and mobile-data consumption have not yet been qualified. An HLS connection and foreground playback remain necessary for interactive DJ. @@ -56,8 +56,43 @@ It does not qualify this new build, DJ playback, a real receiver or endurance. The latest DJ engine still lacks its real one-hour qualification. Qualified interactive use requires zero interruptions and rendering above 1.5× real time. A separate unqualified trial requires explicit server and user opt-in; it never -marks an engine as qualified. No model is downloaded for the unavailable local -DJ engine. Macrobenchmark is opt-in with `-PincludeBenchmarks=true`. +marks an engine as qualified. No model is downloaded for the local prototype. +Macrobenchmark is opt-in with `-PincludeBenchmarks=true`. + +## Device DJ and interruption cache beta + +Home → DJ → **This device · Beta** selects a device-only engine preference, +initially off. Queue → **Mode DJ** arms it for local files in the current queue. +Returning to Server or disabling Mode DJ stops preparation and retains the +dominant original track and position. A process/account change never restores +an armed local session. There is one MediaSession, one audio-focus owner and +two bounded decoders; pause/noisy/focus loss stops both. Incoming decoder failure +keeps the outgoing transport. Queue occurrence UIDs remain stable across deck +changes. Native read-ahead follows the dominant decoder and queue persistence +restores its shuffle order. + +This prototype performs short 1.2-second fades with sum-gain limiting. It does +not yet run a beat model on Android. Analysis-informed plans have a tested API, +but no device inference/model download is wired. Shuffle, repeat-one, podcasts, +HLS and external streams keep normal playback. Physical quality, Bluetooth, +thermal and battery testing remain pending; do not call this qualified AI DJ. + +The server-DJ cache is an app-private 64 MiB LRU disk cache, separate from normal +offline/read-ahead caches. A cache identity includes the pairing generation and +the segment's program date/time and sequence from a complete fresh manifest. +Reused filenames from another render cannot return previous audio. A manifest +without reliable identities bypasses the cache. Owner/module changes fence open +readers and invalidate namespaces; partial data never becomes a complete chunk. +Manifests are always fetched fresh, with bounded GET/HEAD retries on transient +HTTP/network failures. Authorization failures are not retried. Whole-call +timeout is 12 seconds and retries are limited to three within a nine-second +window; cached/prebuffered audio can cover short outages, not indefinite offline DJ. + +Joining a prepared or interactive set attaches it to the common player and opens +Now Playing. The mini player includes DJ content even when no library track, +podcast or external stream is selected. An unjoined device offers Listen instead +of renderer-only pause/resume controls. This is explicit joining, not automatic +sound playback on every device. The physical optimized beta also passed a visual check of the four colored mood cards, native temporary-playlist view, save-dialog opening and editable name. From 2b2381ae0e3174df1f273afa8195c49afea9728b Mon Sep 17 00:00:00 2001 From: QuietForge7294 <77000426+QuietForge7294@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:56:18 +0100 Subject: [PATCH 05/11] Initialize local DJ settings in car service tests --- .../kotlin/com/henriquesebastiao/downtify/CarServiceFixture.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/CarServiceFixture.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/CarServiceFixture.kt index 3a29679..a080fba 100644 --- a/app/src/test/kotlin/com/henriquesebastiao/downtify/CarServiceFixture.kt +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/CarServiceFixture.kt @@ -48,6 +48,9 @@ internal class CarServiceFixture { every { successfulSync } returns MutableStateFlow(0) } service.sleepTimer = com.henriquesebastiao.downtify.core.player.PlaybackSleepTimer() + service.localDj = com.henriquesebastiao.downtify.core.player.LocalDjSettings( + org.robolectric.RuntimeEnvironment.getApplication(), + ) service.sessions = mockk(relaxed = true) { every { current } answers { association.value } every { session } returns association From 96586bbc5da94dce7e01b44a0e52474501df78da Mon Sep 17 00:00:00 2001 From: QuietForge7294 <77000426+QuietForge7294@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:25:42 +0100 Subject: [PATCH 06/11] Fix DJ availability, engine routing and persistent launch feedback --- README.md | 9 +- app/build.gradle.kts | 4 +- .../feature/musicai/MusicLibraryPages.kt | 34 +- .../feature/player/QueueDjViewModel.kt | 177 +++++++++- .../downtify/feature/player/QueueScreen.kt | 32 +- .../downtify/ui/DowntifyApp.kt | 33 +- .../downtify/ui/common/SongDjStatusDialog.kt | 41 +++ .../downtify/ui/common/SongMix.kt | 253 +++++++++++++- .../downtify/QueueDjViewModelTest.kt | 319 ++++++++++++++++++ .../downtify/SongDjStatusDialogTest.kt | 100 ++++++ .../downtify/SongMixViewModelTest.kt | 282 +++++++++++++++- .../feature/player/QueueScreenTest.kt | 58 ++++ .../core/player/DjPlaybackCoordinator.kt | 172 ++++++++-- .../downtify/core/player/LocalDjEngine.kt | 40 ++- .../core/player/DjPlaybackCoordinatorTest.kt | 188 ++++++++++- .../core/player/LocalDjOutgoingStallTest.kt | 158 +++++++++ docs/music-ai.md | 26 ++ 17 files changed, 1834 insertions(+), 92 deletions(-) create mode 100644 app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongDjStatusDialog.kt create mode 100644 app/src/test/kotlin/com/henriquesebastiao/downtify/QueueDjViewModelTest.kt create mode 100644 app/src/test/kotlin/com/henriquesebastiao/downtify/SongDjStatusDialogTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjOutgoingStallTest.kt diff --git a/README.md b/README.md index e36e3b0..574b2a7 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,13 @@ ordinary playback. Phone quality, latency, temperature and battery remain unqual DJ sets now appear in the mini player and open Now Playing after joining. Direct controls are shown only after this device joins, avoiding a server-only resume. -All 97 player-module tests and seven mini-player tests passed, including native -shuffle restoration through the dual-deck facade. Detekt and formatting passed; +The beta3 queue rechecks server capabilities and reports the actual availability +reason. Preparation can be cancelled, and a pending relay is distinct from active +DJ playback. The song-menu action uses the selected engine and shows persistent +progress, errors and an entry to the common player instead of an optimistic toast. +All 114 player-module tests and 216 app tests passed, with three app tests +explicitly skipped. They include seven mini-player checks and native shuffle +restoration through the dual-deck facade. Detekt and formatting passed; these checks do not replace physical-device acceptance. Corrective player tests cover 25 repeated mode cycles, local pause intent, diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c973ed1..7c8300b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.henriquesebastiao.downtify" - versionCode = 10 - versionName = "0.4.0-beta1" + versionCode = 12 + versionName = "0.4.0-beta3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt index 4240306..c32b320 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt @@ -147,6 +147,12 @@ fun MusicStudioRoute( val queue: com.henriquesebastiao.downtify.feature.player.QueueDjViewModel = hiltViewModel() val queueMessage by queue.message.collectAsStateWithLifecycle() val localDevice by queue.localDevice.collectAsStateWithLifecycle() + val queueChecking by queue.checking.collectAsStateWithLifecycle() + val queueArmed by queue.armed.collectAsStateWithLifecycle() + val queueBusy by queue.busy.collectAsStateWithLifecycle() + val queueAvailability by queue.availability.collectAsStateWithLifecycle() + val queuePhase by queue.phase.collectAsStateWithLifecycle() + LaunchedEffect(queue) { queue.onQueueShown() } LaunchedEffect(seed) { viewModel.refresh() if (seed.isNotBlank()) viewModel.seed(seed) @@ -205,11 +211,33 @@ fun MusicStudioRoute( ) TextButton( onClick = queue::toggle, - enabled = if (localDevice) caps.enabled("dj_interactive") else caps.interactiveAvailable(), + enabled = !queueChecking || queueArmed || queueBusy, ) { - Text("Mode DJ sur la file") + Text( + when { + queuePhase == com.henriquesebastiao.downtify.core.player.DjPhase.Preparing -> + "Annuler la préparation DJ" + + queuePhase == com.henriquesebastiao.downtify.core.player.DjPhase.Ready -> + "Annuler le relais DJ" + + queueArmed || queueBusy || + queuePhase == com.henriquesebastiao.downtify.core.player.DjPhase.Active -> + "Désactiver le mode DJ" + + queueChecking -> "Vérification du DJ…" + + queueAvailability.ready -> "Activer le DJ sur la file" + + else -> "Vérifier le DJ" + }, + ) + } + if (queueChecking || queueBusy) { + androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) } - if (queueMessage.isNotBlank()) Text(queueMessage) + val queueDetail = queueMessage.ifBlank { queueAvailability.reason } + if (queueDetail.isNotBlank()) Text(queueDetail) if (!caps.enabled("dj_prepared") && !caps.enabled("dj_interactive") ) { diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt index 2bad828..78830b1 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt @@ -2,19 +2,52 @@ package com.henriquesebastiao.downtify.feature.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.henriquesebastiao.downtify.core.data.ServerResult import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.session.Session import com.henriquesebastiao.downtify.core.player.DjPlaybackCoordinator import com.henriquesebastiao.downtify.core.player.LocalDjSettings import com.henriquesebastiao.downtify.core.player.PlayerController +import com.henriquesebastiao.downtify.core.player.PlayerState +import com.henriquesebastiao.downtify.core.player.RepeatMode import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +data class QueueDjAvailability(val ready: Boolean = false, val reason: String = "") + +internal fun queueDjAvailability( + caps: MusicAiCapabilitiesDto, + device: Boolean, + state: PlayerState, + signedIn: Boolean, +): QueueDjAvailability { + val reason = when { + !signedIn -> "Connectez-vous à un serveur pour activer le DJ." + caps.version != 1 -> "Vérifiez la disponibilité du DJ sur ce serveur." + !caps.enabled("dj_interactive") -> "Activez le DJ direct dans Paramètres → IA." + !device && !caps.interactiveAvailable() -> "Le moteur DJ serveur n’est pas prêt ou autorisé en essai bêta." + !device && !caps.dj.queueSupported -> "Ce serveur ne prend pas encore en charge le DJ depuis la file." + device && state.shuffle -> "Désactivez la lecture aléatoire pour le DJ sur cet appareil." + device && state.repeat == RepeatMode.One -> "Désactivez la répétition du titre pour le DJ sur cet appareil." + device && state.track == null -> "Lancez un titre de la bibliothèque pour le DJ sur cet appareil." + device && state.upNext.isEmpty() -> "Ajoutez au moins un titre local à suivre pour le fondu sur cet appareil." + !device && state.upNext.size < 2 -> "Ajoutez au moins deux titres locaux à suivre pour le DJ serveur." + else -> "" + } + return QueueDjAvailability(reason.isEmpty(), reason) +} + /** Navigation owns the UI; the application-scoped coordinator owns preparation. */ @HiltViewModel class QueueDjViewModel @Inject constructor( @@ -26,33 +59,147 @@ class QueueDjViewModel @Inject constructor( val capabilities = coordinator.capabilities val localDevice = local.device val localTransition = local.transition - val message = combine(coordinator.message, local.device) { text, device -> if (device) "" else text } - .stateIn(viewModelScope, SharingStarted.Eagerly, "") + val phase = coordinator.phase + private val feedback = MutableStateFlow("") + private val mutableChecking = MutableStateFlow(false) + val checking = mutableChecking.asStateFlow() + val message = combine(coordinator.message, feedback) { server, device -> + device.ifBlank { server } + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") val busy = coordinator.busy val armed = combine(coordinator.armed, local.armed) { server, device -> server || device } .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val availability = combine(capabilities, local.device, player.state, music.session) { caps, device, state, owner -> + queueDjAvailability(caps, device, state, owner != null) + }.stateIn(viewModelScope, SharingStarted.Eagerly, currentAvailability()) + private var operation: Job? = null + private var generation = 0L + + fun onQueueShown() { + if (!checking.value) check(activate = false) + } fun chooseDevice(value: Boolean) { + cancelCheck("Moteur DJ changé. Vérifiez la disponibilité avant de l’activer.") coordinator.stop() local.chooseDevice(value) } + fun toggle() { - if (!local.device.value) { + when { + local.armed.value -> { + cancelCheck("") + local.setArmed(false) + feedback.value = "DJ sur cet appareil désactivé." + } + + !local.device.value && serverActive() -> { + cancelCheck("") + coordinator.stop() + } + + checking.value -> cancelCheck("Vérification DJ annulée.") + + else -> check(activate = true) + } + } + + private fun serverActive() = coordinator.busy.value || coordinator.armed.value || player.state.value.dj != null + + private fun currentAvailability() = queueDjAvailability( + capabilities.value, + local.device.value, + player.state.value, + music.session.value != null, + ) + + private fun cancelCheck(reason: String) { + generation++ + operation?.cancel() + operation = null + mutableChecking.value = false + feedback.value = reason + } + + private fun check(activate: Boolean) { + val token = Check(++generation, music.session.value, player.selectionVersion.value, local.device.value) + mutableChecking.value = true + feedback.value = "Vérification de la disponibilité du DJ…" + operation = viewModelScope.launch { + try { + if (!valid(token)) return@launch + val refreshed = withTimeoutOrNull(10_000) { music.refresh(force = true) } + if (!valid(token)) return@launch + val failure = refreshFailure(refreshed) + if (failure != null) { + feedback.value = failure + } else if (activate) { + activate(token) + } else { + feedback.value = currentAvailability().reason + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + if (valid(token)) { + feedback.value = + "Impossible de vérifier le DJ. Réessayez lorsque le serveur est joignable." + } + } finally { + if (token.generation == generation) mutableChecking.value = false + } + } + } + + private suspend fun activate(token: Check) { + if (token.device && serverActive()) { + feedback.value = "Retour au lecteur classique avant activation du DJ sur cet appareil…" + coordinator.stop() + val classic = withTimeoutOrNull(2000) { player.state.first { it.dj == null } } + if (!valid(token)) return + if (classic == null) { + feedback.value = "Le retour au lecteur classique tarde. Réessayez après la fermeture du mix serveur." + return + } + } + val available = currentAvailability() + feedback.value = available.reason + if (!available.ready) return + if (token.device) { + local.setArmed(true) + feedback.value = "DJ sur cet appareil activé · fondu à la fin du titre en cours." + } else { coordinator.toggleQueue() - return } - if (local.armed.value) { - local.setArmed(false) - return + } + + private fun valid(token: Check): Boolean { + if (token.generation != generation) return false + val reason = when { + token.owner == null -> "Connectez-vous à un serveur pour activer le DJ." + token.owner != music.session.value -> "Compte ou serveur changé : activation DJ annulée." + token.device != local.device.value -> "Moteur DJ changé : activation annulée." + token.selection != player.selectionVersion.value -> "Sélection musicale changée : activation DJ annulée." + else -> null } - if (!capabilities.value.enabled("dj_interactive")) return - val owner = music.session.value - val selection = player.selectionVersion.value - if (player.state.value.dj != null || coordinator.busy.value || coordinator.armed.value) coordinator.stop() - viewModelScope.launch { - val classic = withTimeoutOrNull(2000) { player.state.first { it.dj == null } } ?: return@launch - if (owner != music.session.value || selection != player.selectionVersion.value) return@launch - if (classic.track != null && classic.upNext.isNotEmpty()) local.setArmed(true) + if (reason != null) feedback.value = reason + return reason == null + } + + private fun refreshFailure(result: ServerResult?): String? = when (result) { + is ServerResult.Ok -> null + + is ServerResult.Failed -> when (result.status) { + 401 -> "Session expirée. Reconnectez-vous pour vérifier le DJ." + 403 -> "Ce compte n’est pas autorisé à utiliser le DJ." + 503 -> "Le serveur DJ est temporairement indisponible. Réessayez." + else -> "Le serveur a refusé la vérification du DJ. Réessayez." } + + ServerResult.Unreachable -> "Serveur injoignable. Vérifiez la connexion puis réessayez." + + null -> "La vérification du DJ a dépassé le délai. Réessayez." } + + private data class Check(val generation: Long, val owner: Session?, val selection: Long, val device: Boolean) } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreen.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreen.kt index 6b84ddf..2ffb3ba 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreen.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -94,7 +95,15 @@ data class QueueDjState( val ready: Boolean = false, val active: Boolean = false, val message: String = "", -) + val checking: Boolean = false, + val preparing: Boolean = false, + val waiting: Boolean = false, + val local: Boolean = false, + val reason: String = "", +) { + val visible: Boolean + get() = available || checking || message.isNotBlank() || reason.isNotBlank() +} /** * The queue, as Spotify shows it: what's playing, the songs the user queued @@ -128,20 +137,33 @@ fun QueueScreen( ) { Column(Modifier.fillMaxSize().safeDrawingPadding()) { TopBar(state.context, actions.onClose) - if (dj.available || state.dj != null) { + if (dj.visible || state.dj != null) { TextButton( onClick = actions.onToggleDj, - enabled = dj.ready || dj.active || state.dj != null, + enabled = !dj.checking || dj.active || state.dj != null, modifier = Modifier.padding(horizontal = Spacing.screen), ) { - Text(if (dj.active || state.dj != null) "Désactiver le mode DJ" else "Mode DJ") + Text( + when { + dj.preparing -> "Annuler la préparation DJ" + dj.waiting -> "Annuler le relais DJ" + dj.active || state.dj != null -> "Désactiver le mode DJ" + dj.checking -> "Vérification du DJ…" + !dj.ready -> "Vérifier le DJ" + dj.local -> "Activer le DJ sur cet appareil" + else -> "Activer le DJ sur cette file" + }, + ) + } + if (dj.checking || dj.preparing) { + LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = Spacing.screen)) } if (dj.message.isNotBlank() || !dj.ready ) { Text( dj.message.ifBlank { - "Disponible après validation du moteur sur le serveur." + dj.reason.ifBlank { "Vérifiez les réglages DJ dans Paramètres → IA." } }, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = Spacing.screen), diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt index c84119a..04e8199 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/DowntifyApp.kt @@ -176,10 +176,20 @@ private fun MainShell() { } // Now Playing, open or closed or anywhere between: it grows out of the mini player and shrinks back into it. val expansion = rememberPlayerExpansion() - val mixAction = com.henriquesebastiao.downtify.ui.common.songMixAction() - val djMixAction = com.henriquesebastiao.downtify.ui.common.songDjMixAction() + val songMix: com.henriquesebastiao.downtify.ui.common.SongMixViewModel = hiltViewModel() + val mixAction = com.henriquesebastiao.downtify.ui.common.songMixAction(songMix) + val djMixAction = com.henriquesebastiao.downtify.ui.common.songDjMixAction(songMix) val scope = rememberCoroutineScope() LaunchedEffect(hasMedia) { if (!hasMedia) expansion.snapTo(open = false) } + com.henriquesebastiao.downtify.ui.common.SongDjStatusDialog( + status = songMix.djStatus, + onDismiss = songMix::dismissDjStatus, + onCancel = songMix::cancelDjMix, + onOpenPlayer = { + songMix.dismissDjStatus() + scope.launch { expansion.expand() } + }, + ) // The side menu, opened from Home's menu button. val drawerState = rememberDrawerState(DrawerValue.Closed) var currentTab by rememberSaveable { mutableStateOf(TopLevelDestination.Home) } @@ -518,11 +528,16 @@ private fun NowPlayingOverlay( val djMessage by queueDj.message.collectAsStateWithLifecycle() val djArmed by queueDj.armed.collectAsStateWithLifecycle() val djBusy by queueDj.busy.collectAsStateWithLifecycle() + val djChecking by queueDj.checking.collectAsStateWithLifecycle() + val djPhase by queueDj.phase.collectAsStateWithLifecycle() + val djAvailability by queueDj.availability.collectAsStateWithLifecycle() val localDjDevice by queueDj.localDevice.collectAsStateWithLifecycle() var lyricsOpen by rememberSaveable { mutableStateOf(false) } var queueOpen by rememberSaveable { mutableStateOf(false) } val open = expansion.expanded + LaunchedEffect(queueOpen) { if (queueOpen) queueDj.onQueueShown() } + LaunchedEffect(open) { if (!open) { queueOpen = false @@ -622,15 +637,15 @@ private fun NowPlayingOverlay( ), controls = controls, dj = com.henriquesebastiao.downtify.feature.player.QueueDjState( - available = djCaps.enabled("dj_interactive") && (localDjDevice || djCaps.dj.queueSupported), - ready = if (localDjDevice) { - djCaps.enabled("dj_interactive") - } else { - djCaps.interactiveAvailable() && - djCaps.dj.queueSupported - }, + available = djCaps.version == 1 || djArmed || djBusy, + ready = djAvailability.ready, active = djArmed || djBusy, message = djMessage, + checking = djChecking, + preparing = djPhase == com.henriquesebastiao.downtify.core.player.DjPhase.Preparing, + waiting = djPhase == com.henriquesebastiao.downtify.core.player.DjPhase.Ready, + local = localDjDevice, + reason = djAvailability.reason, ), ) } diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongDjStatusDialog.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongDjStatusDialog.kt new file mode 100644 index 0000000..1464c5b --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongDjStatusDialog.kt @@ -0,0 +1,41 @@ +package com.henriquesebastiao.downtify.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun SongDjStatusDialog(status: SongDjStatus, onDismiss: () -> Unit, onCancel: () -> Unit, onOpenPlayer: () -> Unit) { + if (!status.visible) return + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(status.title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(status.message) + if (status.preparing) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + }, + confirmButton = { + if (status.canOpenPlayer) { + TextButton(onClick = onOpenPlayer) { Text("Ouvrir le lecteur") } + } else { + TextButton(onClick = onDismiss) { Text("Fermer") } + } + }, + dismissButton = { + Row { + if (status.canCancel) TextButton(onClick = onCancel) { Text("Annuler") } + if (status.canOpenPlayer) TextButton(onClick = onDismiss) { Text("Fermer") } + } + }, + ) +} diff --git a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt index 3a30c9b..7662c89 100644 --- a/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt @@ -17,16 +17,36 @@ import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository import com.henriquesebastiao.downtify.core.model.Track import com.henriquesebastiao.downtify.core.network.CatalogJson +import com.henriquesebastiao.downtify.core.network.session.Session import com.henriquesebastiao.downtify.core.network.session.SessionStore +import com.henriquesebastiao.downtify.core.player.DjPhase +import com.henriquesebastiao.downtify.core.player.LocalDjSettings import com.henriquesebastiao.downtify.core.player.PlayerController +import com.henriquesebastiao.downtify.core.player.PlayerState import com.henriquesebastiao.downtify.core.player.mediaKey import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull val LocalSongMix = staticCompositionLocalOf<(Track) -> Unit> { {} } val LocalSongDjMix = staticCompositionLocalOf<(Track) -> Unit> { {} } +data class SongDjStatus( + val visible: Boolean = false, + val title: String = "Mix DJ", + val message: String = "", + val preparing: Boolean = false, + val canCancel: Boolean = false, + val canOpenPlayer: Boolean = false, +) + @HiltViewModel class SongMixViewModel @Inject constructor( private val musicAi: MusicAiRepository, @@ -34,21 +54,250 @@ class SongMixViewModel @Inject constructor( private val player: PlayerController, private val sessions: SessionStore, private val dj: com.henriquesebastiao.downtify.core.player.DjPlaybackCoordinator, + private val local: LocalDjSettings, ) : ViewModel() { var busy by mutableStateOf(false) private set var message by mutableStateOf(null) private set + var djStatus by mutableStateOf(SongDjStatus()) + private set + private var djGeneration = 0L + private var djPreparation: Job? = null + private var serverRequest = false + private var localPrepared = false + private var requestOwner: Session? = null + + init { + viewModelScope.launch { + combine(dj.busy, dj.phase, dj.message, dj.armed) { preparing, phase, detail, armed -> + Triple(preparing, phase, detail to armed) + }.collect { (preparing, phase, details) -> + if (!serverRequest || requestOwner != sessions.current) return@collect + val (detail, armed) = details + val ready = phase == DjPhase.Ready || phase == DjPhase.Active || armed + if (preparing || ready || detail.isNotBlank()) { + djStatus = djStatus.copy( + message = detail.ifBlank { + if (ready) "Mix DJ prêt dans le lecteur." else "Préparation des transitions…" + }, + preparing = preparing, + canCancel = preparing || ready, + canOpenPlayer = ready, + ) + } + } + } + viewModelScope.launch { + musicAi.session.collect { + if (requestOwner != null && it != requestOwner) { + // The coordinator/service owns account cleanup. Never stop + // playback belonging to the newly selected account here. + djGeneration++ + djPreparation?.cancel() + djPreparation = null + serverRequest = false + localPrepared = false + requestOwner = null + djFailure("Compte ou serveur changé. Relancez le mix DJ pour ce compte.") + } + } + } + } fun clearMessage() { message = null } fun startDj(seed: Track) { - dj.startSeed(seed.id) - message = "Préparation du mix DJ. Suivez son état dans la file du lecteur." + val owner = sessions.current + djPreparation?.cancel() + if (requestOwner == owner) { + if (serverRequest) dj.stop() + if (localPrepared) local.setArmed(false) + } + localPrepared = false + serverRequest = !local.device.value + val mine = ++djGeneration + requestOwner = owner + val original = player.state.value + val selection = player.selectionVersion.value + djStatus = SongDjStatus( + visible = true, + message = "Vérification du mode DJ…", + preparing = true, + canCancel = true, + ) + if (owner == null) { + djFailure("Reconnectez-vous au serveur pour préparer ce mix.") + } else if (serverRequest) { + dj.startSeed(seed.id) + } else { + djPreparation = viewModelScope.launch { prepareLocalDj(seed, mine, owner, original, selection) } + } + } + + fun dismissDjStatus() { + djStatus = djStatus.copy(visible = false) } + fun cancelDjMix() { + djGeneration++ + djPreparation?.cancel() + djPreparation = null + if (requestOwner == sessions.current) { + if (serverRequest) dj.stop() + if (localPrepared) local.setArmed(false) + } + serverRequest = false + localPrepared = false + djFailure("Préparation DJ annulée. Votre file reste disponible.") + } + + private fun djFailure(reason: String) { + djStatus = djStatus.copy(message = reason, preparing = false, canCancel = false, canOpenPlayer = false) + } + + private suspend fun prepareLocalDj( + seed: Track, + mine: Long, + owner: Session, + original: PlayerState, + selection: Long, + ) { + val request = LocalPreparation(seed, mine, owner, original, selection) + try { + val tracks = withTimeout(30_000) { loadLocalCandidates(request) } + installLocalQueue(request, tracks) + } catch (failure: LocalPreparationFailure) { + reportLocalFailure(request, failure.reason) + } catch (_: TimeoutCancellationException) { + reportLocalFailure(request, "Préparation trop longue. Vérifiez la connexion puis réessayez.") + } catch (error: CancellationException) { + throw error + } catch (_: MusicAiFailure) { + reportLocalFailure(request, "Mix indisponible : vérifiez la connexion et l’analyse de ce titre.") + } catch (_: Exception) { + reportLocalFailure(request, "Le mix DJ n’a pas pu être préparé. Réessayez depuis ce titre.") + } + } + + private suspend fun loadLocalCandidates(request: LocalPreparation): List { + val refreshed = musicAi.refresh(force = true) + ensureLocalRequest(request) + requireLocalRefresh(refreshed) + requireLocalSeedSource(request.original) + djStatus = djStatus.copy(message = "Recherche de titres de votre bibliothèque…") + val result = CatalogJson.similarTracks(musicAi.similar("", "", seed = request.seed.id)) + ensureLocalRequest(request) + val snapshot = library.library.value + if (snapshot?.byId?.containsKey(request.seed.id) != true) { + throw LocalPreparationFailure("Ce titre n’est plus disponible dans la bibliothèque synchronisée.") + } + val tracks = result.mapNotNull { snapshot.byId[it.id] } + .filter { it.id != request.seed.id }.distinctBy { it.id } + if (tracks.isEmpty()) { + throw LocalPreparationFailure( + "Aucun titre similaire disponible dans votre bibliothèque. Analysez ce titre puis réessayez.", + ) + } + return tracks + } + + private fun requireLocalRefresh(result: ServerResult) { + if (result !is ServerResult.Ok) { + throw LocalPreparationFailure( + "Impossible de vérifier les réglages DJ. Vérifiez la connexion au serveur puis réessayez.", + ) + } + } + + private fun requireLocalSeedSource(original: PlayerState) { + requireLocalCapabilities("Activez le DJ direct et la similarité dans Paramètres → IA.") + if (original.dj != null) { + throw LocalPreparationFailure("Désactivez le DJ serveur avant de préparer un mix sur cet appareil.") + } + } + + private suspend fun installLocalQueue(request: LocalPreparation, tracks: List) { + ensureLocalRequest(request) + requireLocalCapabilities() + val seed = library.library.value?.byId?.get(request.seed.id) + ?: throw LocalPreparationFailure("Ce titre n’est plus disponible dans la bibliothèque synchronisée.") + val expectedSelection = request.selection + if (request.original.track?.id == seed.id) 0 else 1 + djStatus = djStatus.copy(message = "Mise en place de la file locale…") + player.startMix(seed, tracks) + val ready = withTimeoutOrNull(5_000) { + awaitLocalQueue(request, expectedSelection, tracks.map { it.id }) + } == true + ensureLocalRequest(request, unchanged = false) + if (!ready) { + throw LocalPreparationFailure( + "La file n’a pas pu être confirmée ou la sélection a changé. Réessayez depuis le lecteur.", + ) + } + requireLocalCapabilities() + local.setArmed(true) + localPrepared = true + djStatus = djStatus.copy( + message = "Mix DJ local prêt · transitions à la fin du titre en cours.", + preparing = false, + canCancel = true, + canOpenPlayer = true, + ) + } + + private suspend fun awaitLocalQueue( + request: LocalPreparation, + expectedSelection: Long, + queue: List, + ): Boolean { + while (localRequestCurrent(request)) { + val state = player.state.value + val version = player.selectionVersion.value + val validSelection = version == request.selection || version == expectedSelection + val validMedia = state.mediaKey == request.original.mediaKey || state.mediaKey == "track:${request.seed.id}" + if (!validSelection || !validMedia) break + if (version == expectedSelection && state.track?.id == request.seed.id && + state.upNext.map { it.trackId } == queue + ) { + return true + } + delay(50) + } + return false + } + + private fun localRequestCurrent(request: LocalPreparation) = request.generation == djGeneration && + sessions.current == request.owner && local.device.value + + private fun ensureLocalRequest(request: LocalPreparation, unchanged: Boolean = true) { + val sameSelection = player.selectionVersion.value == request.selection && + player.state.value.mediaKey == request.original.mediaKey + if (!localRequestCurrent(request) || (unchanged && !sameSelection)) { + throw LocalPreparationFailure("Préparation annulée : le compte, le moteur ou le titre a changé.") + } + } + + private fun requireLocalCapabilities(reason: String = "Le DJ direct ou la similarité a été désactivé.") { + val caps = musicAi.capabilities.value + if (!caps.enabled("dj_interactive") || !caps.enabled("similarity")) throw LocalPreparationFailure(reason) + } + + private fun reportLocalFailure(request: LocalPreparation, reason: String) { + if (request.generation == djGeneration) djFailure(reason) + } + + private data class LocalPreparation( + val seed: Track, + val generation: Long, + val owner: Session, + val original: PlayerState, + val selection: Long, + ) + + private class LocalPreparationFailure(val reason: String) : Exception(reason) + fun start(seed: Track) { if (busy) return val owner = sessions.current ?: return diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/QueueDjViewModelTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/QueueDjViewModelTest.kt new file mode 100644 index 0000000..28f9b1d --- /dev/null +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/QueueDjViewModelTest.kt @@ -0,0 +1,319 @@ +package com.henriquesebastiao.downtify + +import androidx.lifecycle.viewModelScope +import com.henriquesebastiao.downtify.core.data.ServerResult +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.model.Track +import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.dto.MusicAiStatusDto +import com.henriquesebastiao.downtify.core.network.session.Session +import com.henriquesebastiao.downtify.core.player.DjPhase +import com.henriquesebastiao.downtify.core.player.DjPlaybackCoordinator +import com.henriquesebastiao.downtify.core.player.LocalDjSettings +import com.henriquesebastiao.downtify.core.player.LocalDjTransition +import com.henriquesebastiao.downtify.core.player.PlayerController +import com.henriquesebastiao.downtify.core.player.PlayerState +import com.henriquesebastiao.downtify.core.player.PlayingDj +import com.henriquesebastiao.downtify.core.player.QueueEntry +import com.henriquesebastiao.downtify.core.player.RepeatMode +import com.henriquesebastiao.downtify.feature.player.QueueDjViewModel +import com.henriquesebastiao.downtify.feature.player.queueDjAvailability +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class QueueDjViewModelTest { + @Test fun `queue opening refreshes trial availability without starting DJ`() = check { f -> + f.device.value = false + f.caps.value = MusicAiCapabilitiesDto() + coEvery { f.music.refresh(true) } coAnswers { + f.caps.value = f.readyCaps + ServerResult.Ok(Unit) + } + f.vm.onQueueShown() + assertTrue(f.vm.checking.value) + runCurrent() + coVerify(exactly = 1) { f.music.refresh(true) } + assertTrue(f.vm.availability.value.ready) + assertFalse(f.vm.checking.value) + verify(exactly = 0) { f.coordinator.toggleQueue() } + verify(exactly = 0) { f.local.setArmed(true) } + assertSame(f.serverPhase, f.vm.phase) + } + + @Test fun `local toggle refreshes stale gates before arming and explains the end of track handover`() = check { f -> + f.serverMessage.value = "Mode DJ désactivé" + f.caps.value = MusicAiCapabilitiesDto() + coEvery { f.music.refresh(true) } coAnswers { + f.caps.value = f.readyCaps + ServerResult.Ok(Unit) + } + f.vm.toggle() + runCurrent() + coVerify(exactly = 1) { f.music.refresh(true) } + verify(exactly = 1) { f.local.setArmed(true) } + assertTrue(f.vm.message.value.contains("fin du titre")) + assertFalse(f.vm.message.value.contains("désactivé")) + } + + @Test fun `server toggle refreshes before delegating and permits trial readiness`() = check { f -> + f.device.value = false + f.caps.value = MusicAiCapabilitiesDto() + coEvery { f.music.refresh(true) } coAnswers { + verify(exactly = 0) { f.coordinator.toggleQueue() } + f.caps.value = f.readyCaps + ServerResult.Ok(Unit) + } + f.vm.toggle() + runCurrent() + verify(exactly = 1) { f.coordinator.toggleQueue() } + } + + @Test fun `fresh disabled gates prevent local activation with visible reason`() = check { f -> + coEvery { f.music.refresh(true) } coAnswers { + f.caps.value = MusicAiCapabilitiesDto(version = 1) + ServerResult.Ok(Unit) + } + f.vm.toggle() + runCurrent() + verify(exactly = 0) { f.local.setArmed(true) } + assertTrue(f.vm.message.value.contains("Paramètres")) + assertFalse(f.vm.availability.value.ready) + } + + @Test fun `server errors are not masked by local device selection`() = check { f -> + f.serverMessage.value = "Erreur du mix serveur" + runCurrent() + assertEquals("Erreur du mix serveur", f.vm.message.value) + } + + @Test fun `refresh authentication and unavailable errors stay visible and never arm`() = check { f -> + for ((code, word) in listOf(401 to "Session expirée", 503 to "indisponible")) { + coEvery { f.music.refresh(true) } returns ServerResult.Failed(code) + f.vm.toggle() + runCurrent() + assertTrue(f.vm.message.value.contains(word)) + assertFalse(f.vm.checking.value) + } + verify(exactly = 0) { f.local.setArmed(true) } + } + + @Test fun `local queue requirements explain track shuffle and repeat constraints`() = check { f -> + val cases = listOf( + f.state.value.copy(track = null) to "bibliothèque", + f.state.value.copy(upNext = emptyList()) to "au moins un", + f.state.value.copy(shuffle = true) to "aléatoire", + f.state.value.copy(repeat = RepeatMode.One) to "répétition", + ) + for ((state, word) in cases) { + f.state.value = state + f.vm.toggle() + runCurrent() + assertTrue(f.vm.message.value.contains(word)) + } + verify(exactly = 0) { f.local.setArmed(true) } + } + + @Test fun `server needs two future tracks while local needs current and one future`() = check { f -> + val oneNext = f.state.value.copy(upNext = f.state.value.upNext.take(1)) + assertTrue(queueDjAvailability(f.readyCaps, true, oneNext, true).ready) + val server = queueDjAvailability(f.readyCaps, false, oneNext, true) + assertFalse(server.ready) + assertTrue(server.reason.contains("deux")) + assertFalse(queueDjAvailability(f.readyCaps.copy(features = emptyMap()), true, oneNext, true).ready) + val unsupported = f.readyCaps.copy(dj = f.readyCaps.dj.copy(queueSupported = false)) + assertFalse(queueDjAvailability(unsupported, false, f.state.value, true).ready) + } + + @Test fun `refresh timeout is visible and cancellation prevents late activation`() = check { f -> + val response = CompletableDeferred() + coEvery { f.music.refresh(true) } coAnswers { + response.await() + ServerResult.Ok(Unit) + } + f.vm.toggle() + runCurrent() + advanceTimeBy(10_001) + runCurrent() + assertTrue(f.vm.message.value.contains("délai")) + assertFalse(f.vm.checking.value) + f.vm.toggle() + runCurrent() + f.vm.toggle() + response.complete(Unit) + runCurrent() + assertTrue(f.vm.message.value.contains("annulée")) + verify(exactly = 0) { f.local.setArmed(true) } + } + + @Test fun `account selection and engine changes fence delayed responses`() = check { f -> + val changes: List Unit, String>> = listOf( + { + f.selection.value++ + Unit + } to "Sélection", + { f.device.value = false } to "Moteur", + { f.owner.value = null } to "Compte", + ) + for ((change, word) in changes) { + f.device.value = true + f.owner.value = f.session + val response = CompletableDeferred() + coEvery { f.music.refresh(true) } coAnswers { + response.await() + ServerResult.Ok(Unit) + } + f.vm.toggle() + runCurrent() + change() + response.complete(Unit) + runCurrent() + assertTrue(f.vm.message.value.contains(word)) + assertFalse(f.vm.checking.value) + } + verify(exactly = 0) { f.local.setArmed(true) } + verify(exactly = 0) { f.coordinator.toggleQueue() } + } + + @Test fun `changing device cancels pending activation and retains explanation`() = check { f -> + val response = CompletableDeferred() + coEvery { f.music.refresh(true) } coAnswers { + response.await() + ServerResult.Ok(Unit) + } + f.vm.toggle() + runCurrent() + f.vm.chooseDevice(false) + response.complete(Unit) + runCurrent() + assertFalse(f.vm.checking.value) + assertTrue(f.vm.message.value.contains("Moteur DJ changé")) + verify(exactly = 0) { f.local.setArmed(true) } + } + + @Test fun `return from server timeout is visible without arming local DJ`() = check { f -> + f.state.value = PlayerState(dj = PlayingDj("mix", "Mix")) + f.vm.toggle() + runCurrent() + assertTrue(f.vm.checking.value) + advanceTimeBy(2001) + runCurrent() + assertTrue(f.vm.message.value.contains("retour au lecteur classique tarde")) + assertFalse(f.vm.checking.value) + verify(exactly = 1) { f.coordinator.stop() } + verify(exactly = 0) { f.local.setArmed(true) } + } + + @Test fun `disarming and server stop are immediate without refresh`() = check { f -> + f.localArmed.value = true + f.vm.toggle() + verify(exactly = 1) { f.local.setArmed(false) } + f.device.value = false + f.serverBusy.value = true + f.vm.toggle() + runCurrent() + verify(exactly = 1) { f.coordinator.stop() } + coVerify(exactly = 0) { f.music.refresh(any()) } + } + + @Test fun `active playback can be disabled while opening the queue refreshes`() = check { f -> + val response = CompletableDeferred() + coEvery { f.music.refresh(true) } coAnswers { + response.await() + ServerResult.Ok(Unit) + } + f.localArmed.value = true + f.vm.onQueueShown() + runCurrent() + f.vm.toggle() + verify(exactly = 1) { f.local.setArmed(false) } + assertFalse(f.vm.checking.value) + f.device.value = false + f.serverBusy.value = true + f.vm.onQueueShown() + runCurrent() + f.vm.toggle() + verify(exactly = 1) { f.coordinator.stop() } + response.complete(Unit) + runCurrent() + verify(exactly = 0) { f.local.setArmed(true) } + verify(exactly = 0) { f.coordinator.toggleQueue() } + } + + private fun check(block: suspend TestScope.(Fixture) -> Unit) = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val fixture = Fixture() + try { + block(fixture) + } finally { + fixture.vm.viewModelScope.cancel() + runCurrent() + Dispatchers.resetMain() + } + } + + private class Fixture { + val music = mockk(relaxed = true) + val player = mockk(relaxed = true) + val coordinator = mockk(relaxed = true) + val local = mockk(relaxed = true) + val session = Session("https://fixture.test", "server", "Test", "device", "fixture") + val owner = MutableStateFlow(session) + val device = MutableStateFlow(true) + val localArmed = MutableStateFlow(false) + val serverBusy = MutableStateFlow(false) + val serverMessage = MutableStateFlow("") + val serverPhase = MutableStateFlow(DjPhase.Classic) + val selection = MutableStateFlow(0L) + val readyCaps = MusicAiCapabilitiesDto( + version = 1, + features = mapOf("dj_interactive" to true, "dj_trial" to true), + dj = MusicAiStatusDto(interactiveTrialReady = true, queueSupported = true), + ) + val caps = MutableStateFlow(readyCaps) + val state = MutableStateFlow( + PlayerState( + track = mockk(relaxed = true), + upNext = listOf(QueueEntry(1, "one", "One", ""), QueueEntry(2, "two", "Two", "")), + ), + ) + val vm: QueueDjViewModel + init { + every { music.session } returns owner + every { coordinator.capabilities } returns caps + every { coordinator.message } returns serverMessage + every { coordinator.phase } returns serverPhase + every { coordinator.busy } returns serverBusy + every { coordinator.armed } returns MutableStateFlow(false) + every { local.device } returns device + every { local.armed } returns localArmed + every { local.transition } returns MutableStateFlow(LocalDjTransition()) + every { local.setArmed(any()) } answers { localArmed.value = firstArg() } + every { local.chooseDevice(any()) } answers { device.value = firstArg() } + every { player.state } returns state + every { player.selectionVersion } returns selection + coEvery { music.refresh(true) } returns ServerResult.Ok(Unit) + vm = QueueDjViewModel(coordinator, local, player, music) + } + } +} diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/SongDjStatusDialogTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/SongDjStatusDialogTest.kt new file mode 100644 index 0000000..784b885 --- /dev/null +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/SongDjStatusDialogTest.kt @@ -0,0 +1,100 @@ +package com.henriquesebastiao.downtify + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.henriquesebastiao.downtify.core.designsystem.theme.DowntifyTheme +import com.henriquesebastiao.downtify.ui.common.SongDjStatus +import com.henriquesebastiao.downtify.ui.common.SongDjStatusDialog +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@Config(qualifiers = "w411dp-h914dp", sdk = [34]) +@RunWith(AndroidJUnit4::class) +class SongDjStatusDialogTest { + @get:Rule + val compose = createComposeRule() + + @Test fun hiddenStatusDoesNotShowADialog() { + compose.setContent { + DowntifyTheme { SongDjStatusDialog(SongDjStatus(), {}, {}, {}) } + } + compose.onNodeWithText("Mix DJ").assertDoesNotExist() + } + + @Test fun preparationShowsProgressAndClosingDoesNotCancel() { + var dismissed = 0 + var cancelled = 0 + compose.setContent { + DowntifyTheme { + SongDjStatusDialog( + SongDjStatus( + visible = true, + message = "Préparation des transitions", + preparing = true, + canCancel = true, + ), + onDismiss = { dismissed++ }, + onCancel = { cancelled++ }, + onOpenPlayer = {}, + ) + } + } + compose.onNodeWithText("Préparation des transitions").assertExists() + compose.onNode(SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo)).assertExists() + compose.onNodeWithText("Ouvrir le lecteur").assertDoesNotExist() + compose.onNodeWithText("Fermer").performClick() + assertEquals(1, dismissed) + assertEquals(0, cancelled) + compose.onNodeWithText("Annuler").performClick() + assertEquals(1, cancelled) + assertEquals(1, dismissed) + } + + @Test fun anErrorRemainsVisibleUntilExplicitDismissal() { + val status = + mutableStateOf(SongDjStatus(visible = true, message = "Préparation", preparing = true, canCancel = true)) + compose.setContent { + DowntifyTheme { + SongDjStatusDialog(status.value, { status.value = status.value.copy(visible = false) }, {}, {}) + } + } + compose.runOnIdle { + status.value = status.value.copy(message = "Serveur indisponible", preparing = false, canCancel = false) + } + compose.mainClock.advanceTimeBy(10_000) + compose.onNodeWithText("Serveur indisponible").assertExists() + compose.onNode(SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo)).assertDoesNotExist() + compose.onNodeWithText("Annuler").assertDoesNotExist() + compose.onNodeWithText("Fermer").performClick() + compose.onNodeWithText("Serveur indisponible").assertDoesNotExist() + } + + @Test fun openingThePlayerDoesNotCancelOrDismissImplicitly() { + var opened = 0 + var dismissed = 0 + var cancelled = 0 + compose.setContent { + DowntifyTheme { + SongDjStatusDialog( + SongDjStatus(visible = true, message = "Mix prêt", canCancel = true, canOpenPlayer = true), + onDismiss = { dismissed++ }, + onCancel = { cancelled++ }, + onOpenPlayer = { opened++ }, + ) + } + } + compose.onNodeWithText("Ouvrir le lecteur").performClick() + assertEquals(1, opened) + assertEquals(0, cancelled) + assertEquals(0, dismissed) + compose.onNodeWithText("Fermer").assertExists() + } +} diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt index cfae276..572e9b2 100644 --- a/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt @@ -6,26 +6,36 @@ import com.henriquesebastiao.downtify.core.data.library.LibrarySnapshot import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository import com.henriquesebastiao.downtify.core.model.Track import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto +import com.henriquesebastiao.downtify.core.network.session.Session import com.henriquesebastiao.downtify.core.network.session.SessionStore +import com.henriquesebastiao.downtify.core.player.DjPhase +import com.henriquesebastiao.downtify.core.player.DjPlaybackCoordinator +import com.henriquesebastiao.downtify.core.player.LocalDjSettings import com.henriquesebastiao.downtify.core.player.PlayerController import com.henriquesebastiao.downtify.core.player.PlayerState import com.henriquesebastiao.downtify.core.player.PlayingStream +import com.henriquesebastiao.downtify.core.player.QueueEntry import com.henriquesebastiao.downtify.ui.common.SongMixViewModel import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) @@ -62,19 +72,275 @@ class SongMixViewModelTest { } } - private class Fixture(enabled: Boolean) { + @Test fun `local seed refreshes capabilities and arms only after the library queue arrives`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val f = Fixture(enabled = false, device = true) + f.state.value = PlayerState(track = f.seed, positionMs = 42000) + coEvery { f.ai.refresh(true) } coAnswers { + f.capabilities.value = + MusicAiCapabilitiesDto(1, features = mapOf("similarity" to true, "dj_interactive" to true)) + ServerResult.Ok(Unit) + } + f.viewModel.startDj(f.seed) + assertTrue(f.viewModel.djStatus.visible) + assertTrue(f.viewModel.djStatus.preparing) + runCurrent() + coVerify(exactly = 1) { f.ai.refresh(true) } + verify(exactly = 1) { f.player.startMix(f.seed, listOf(f.next)) } + verify(exactly = 0) { f.local.setArmed(true) } + verify(exactly = 0) { f.dj.startSeed(any()) } + f.publishQueue() + advanceTimeBy(50) + runCurrent() + verify(exactly = 1) { f.local.setArmed(true) } + verify(exactly = 0) { f.local.chooseDevice(any()) } + verify(exactly = 0) { f.player.seekTo(any()) } + assertEquals(42000L, f.state.value.positionMs) + assertTrue(f.viewModel.djStatus.canOpenPlayer) + assertFalse(f.viewModel.djStatus.preparing) + } finally { + Dispatchers.resetMain() + } + } + + @Test fun `disabled local DJ gates never create a server seed request`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val f = Fixture(enabled = false, device = true) + f.viewModel.startDj(f.seed) + runCurrent() + coVerify(exactly = 0) { f.ai.similar(any(), any(), any(), any()) } + verify(exactly = 0) { f.dj.startSeed(any()) } + verify(exactly = 0) { f.local.setArmed(true) } + assertTrue(f.viewModel.djStatus.message.contains("Activez")) + assertFalse(f.viewModel.djStatus.preparing) + } finally { + Dispatchers.resetMain() + } + } + + @Test fun `a selection change while local suggestions load cannot replace the queue`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val f = Fixture(enabled = true, device = true) + coEvery { f.ai.similar(any(), any(), any(), any()) } coAnswers { + f.selection.value++ + f.suggestions + } + f.viewModel.startDj(f.seed) + runCurrent() + verify(exactly = 0) { f.player.startMix(any(), any()) } + verify(exactly = 0) { f.local.setArmed(true) } + assertFalse(f.viewModel.djStatus.preparing) + } finally { + Dispatchers.resetMain() + } + } + + @Test fun `local cancellation retires delayed suggestions`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val f = Fixture(enabled = true, device = true) + val response = CompletableDeferred() + coEvery { f.ai.similar(any(), any(), any(), any()) } coAnswers { + response.await() + f.suggestions + } + f.viewModel.startDj(f.seed) + runCurrent() + f.viewModel.cancelDjMix() + response.complete(Unit) + runCurrent() + verify(exactly = 0) { f.player.startMix(any(), any()) } + verify(exactly = 0) { f.local.setArmed(true) } + assertTrue(f.viewModel.djStatus.message.contains("annulée")) + } finally { + Dispatchers.resetMain() + } + } + + @Test fun `account change retires a local request`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val f = Fixture(enabled = true, device = true) + coEvery { f.ai.similar(any(), any(), any(), any()) } coAnswers { + f.owner.value = null + f.suggestions + } + f.viewModel.startDj(f.seed) + runCurrent() + verify(exactly = 0) { f.player.startMix(any(), any()) } + verify(exactly = 0) { f.local.setArmed(true) } + assertFalse(f.viewModel.djStatus.preparing) + } finally { + Dispatchers.resetMain() + } + } + + @Test fun `unconfirmed queue times out without arming local DJ`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val f = Fixture(enabled = true, device = true) + f.viewModel.startDj(f.seed) + runCurrent() + advanceTimeBy(5001) + runCurrent() + verify(exactly = 1) { f.player.startMix(any(), any()) } + verify(exactly = 0) { f.local.setArmed(true) } + assertFalse(f.viewModel.djStatus.preparing) + assertTrue(f.viewModel.djStatus.message.contains("confirmée")) + } finally { + Dispatchers.resetMain() + } + } + + @Test fun `account change retires preparing and ready server status without stopping the new account`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + for (phase in listOf(DjPhase.Preparing, DjPhase.Ready)) { + val f = Fixture(enabled = true) + f.viewModel.startDj(f.seed) + f.djBusy.value = phase == DjPhase.Preparing + f.djPhase.value = phase + f.djMessage.value = "Ancien mix serveur" + runCurrent() + f.owner.value = mockk(relaxed = true) + f.djBusy.value = false + f.djPhase.value = DjPhase.Classic + f.djMessage.value = "" + runCurrent() + assertFalse(f.viewModel.djStatus.preparing) + assertFalse(f.viewModel.djStatus.canCancel) + assertFalse(f.viewModel.djStatus.canOpenPlayer) + assertTrue(f.viewModel.djStatus.message.contains("Compte ou serveur changé")) + val retired = f.viewModel.djStatus + f.djBusy.value = true + f.djPhase.value = DjPhase.Preparing + f.djMessage.value = "Mix du nouveau compte" + runCurrent() + assertEquals(retired, f.viewModel.djStatus) + verify(exactly = 0) { f.dj.stop() } + verify(exactly = 0) { f.local.setArmed(any()) } + } + } finally { + Dispatchers.resetMain() + } + } + + @Test fun `account change retires ready local status without disarming the new account`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val f = Fixture(enabled = true, device = true) + f.viewModel.startDj(f.seed) + runCurrent() + f.publishQueue() + advanceTimeBy(50) + runCurrent() + assertTrue(f.viewModel.djStatus.canOpenPlayer) + verify(exactly = 1) { f.local.setArmed(true) } + f.owner.value = mockk(relaxed = true) + runCurrent() + assertFalse(f.viewModel.djStatus.canOpenPlayer) + assertFalse(f.viewModel.djStatus.canCancel) + assertTrue(f.viewModel.djStatus.message.contains("Compte ou serveur changé")) + verify(exactly = 0) { f.local.setArmed(false) } + verify(exactly = 0) { f.dj.stop() } + } finally { + Dispatchers.resetMain() + } + } + + @Test fun `server status persists and follows preparation ready and error flows`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val f = Fixture(enabled = true) + f.viewModel.startDj(f.seed) + assertTrue(f.viewModel.djStatus.visible) + verify(exactly = 1) { f.dj.startSeed("seed") } + f.djBusy.value = true + f.djPhase.value = DjPhase.Preparing + f.djMessage.value = "Préparation des transitions" + runCurrent() + assertTrue(f.viewModel.djStatus.preparing) + f.djBusy.value = false + f.djPhase.value = DjPhase.Ready + f.djMessage.value = "Relais au prochain titre" + runCurrent() + assertTrue(f.viewModel.djStatus.canOpenPlayer) + f.djPhase.value = DjPhase.Classic + f.djMessage.value = "Serveur indisponible" + runCurrent() + assertEquals("Serveur indisponible", f.viewModel.djStatus.message) + assertFalse(f.viewModel.djStatus.canOpenPlayer) + assertEquals(null, f.viewModel.message) + f.viewModel.dismissDjStatus() + assertFalse(f.viewModel.djStatus.visible) + } finally { + Dispatchers.resetMain() + } + } + + private class Fixture(enabled: Boolean, device: Boolean = false) { val seed = mockk(relaxed = true) { every { id } returns "seed" } + val next = mockk(relaxed = true) { every { id } returns "next" } val state = MutableStateFlow(PlayerState(stream = PlayingStream("one", "", "", "", ""))) - val player = mockk(relaxed = true) { every { state } returns this@Fixture.state } + val selection = MutableStateFlow(0L) + val owner = MutableStateFlow(mockk(relaxed = true)) + val player = mockk(relaxed = true) { + every { state } returns this@Fixture.state + every { selectionVersion } returns selection + } + val capabilities = MutableStateFlow( + MusicAiCapabilitiesDto( + 1, + features = mapOf( + "similarity" to enabled, + "dj_interactive" to enabled, + ), + ), + ) + val suggestions = Json.parseToJsonElement( + """ + {"tracks":[ + {"song_id":"next","name":"Next","source":"library"}, + {"song_id":"missing","source":"library"}, + {"song_id":"seed","source":"library"}, + {"song_id":"next","source":"library"} + ]} + """.trimIndent(), + ).jsonObject val ai = mockk { - every { capabilities } returns - MutableStateFlow(MusicAiCapabilitiesDto(1, features = mapOf("similarity" to enabled))) - coEvery { refresh() } returns ServerResult.Ok(Unit) + every { capabilities } returns this@Fixture.capabilities + every { session } returns owner + coEvery { refresh(any()) } returns ServerResult.Ok(Unit) + coEvery { similar(any(), any(), any(), any()) } returns suggestions } private val library = mockk { - every { library } returns MutableStateFlow(LibrarySnapshot.Empty) + every { library } returns MutableStateFlow(LibrarySnapshot(listOf(seed, next))) + } + private val sessions = mockk { every { current } answers { owner.value } } + val djBusy = MutableStateFlow(false) + val djPhase = MutableStateFlow(DjPhase.Classic) + val djMessage = MutableStateFlow("") + val dj = mockk(relaxed = true) { + every { busy } returns djBusy + every { phase } returns djPhase + every { message } returns djMessage + every { armed } returns MutableStateFlow(false) + } + val local = mockk(relaxed = true) { + every { this@mockk.device } returns MutableStateFlow(device) + } + val viewModel = SongMixViewModel(ai, library, player, sessions, dj, local) + + fun publishQueue() { + if (state.value.track?.id != seed.id) selection.value++ + state.value = state.value.copy( + track = seed, + stream = null, + upNext = listOf(QueueEntry(1, "next", "Next", "")), + ) } - private val sessions = mockk { every { current } returns mockk(relaxed = true) } - val viewModel = SongMixViewModel(ai, library, player, sessions, mockk(relaxed = true)) } } diff --git a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreenTest.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreenTest.kt index 132b7fe..acf73ea 100644 --- a/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreenTest.kt +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/player/QueueScreenTest.kt @@ -128,4 +128,62 @@ class QueueScreenTest { } compose.onNodeWithText("Nothing plays after this song.").assertExists() } + + @Test + fun unavailableDjCanBeRecheckedAndExplainsWhy() { + var checked = 0 + compose.setContent { + DowntifyTheme { + QueueScreen( + state = state(0), + actions = QueueActions(onToggleDj = { checked++ }), + controls = NowPlayingActions(), + dj = QueueDjState(available = false, reason = "Activez l’essai bêta dans Paramètres → IA."), + ) + } + } + compose.onNodeWithText("Activez l’essai bêta dans Paramètres → IA.").assertExists() + compose.onNodeWithText("Vérifier le DJ").performClick() + assertEquals(1, checked) + } + + @Test + fun preparingDjCanBeCancelledEvenWhileChecking() { + var cancelled = 0 + compose.setContent { + DowntifyTheme { + QueueScreen( + state = state(0), + actions = QueueActions(onToggleDj = { cancelled++ }), + controls = NowPlayingActions(), + dj = QueueDjState(available = true, active = true, preparing = true, checking = true), + ) + } + } + compose.onNodeWithText("Annuler la préparation DJ").performClick() + assertEquals(1, cancelled) + } + + @Test + fun readyRelayDoesNotPretendAudioIsAlreadyDj() { + compose.setContent { + DowntifyTheme { + QueueScreen( + state = state(0), + actions = QueueActions(), + controls = NowPlayingActions(), + dj = QueueDjState( + available = true, + ready = true, + active = true, + waiting = true, + message = "Le DJ prendra le relais à la fin du titre en cours.", + ), + ) + } + } + compose.onNodeWithText("Annuler le relais DJ").assertExists() + compose.onNodeWithText("Le DJ prendra le relais à la fin du titre en cours.").assertExists() + compose.onNodeWithText("Désactiver le mode DJ").assertDoesNotExist() + } } diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt index 0da64c3..fd5d0f7 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt @@ -1,11 +1,14 @@ package com.henriquesebastiao.downtify.core.player +import com.henriquesebastiao.downtify.core.data.ServerResult import com.henriquesebastiao.downtify.core.data.di.ApplicationScope import com.henriquesebastiao.downtify.core.data.library.LibraryRepository import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailureKind import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto import com.henriquesebastiao.downtify.core.network.session.Session +import java.io.IOException import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -111,7 +114,7 @@ class DjPlaybackCoordinator @Inject constructor( } } } - } else if ((mutableBusy.value || mutableArmed.value) && + } else if (awaitingHandover() && leftOrigin(state.mediaKey, state.currentIndex) ) { cancel(true) @@ -120,6 +123,9 @@ class DjPlaybackCoordinator @Inject constructor( } } + private fun awaitingHandover() = + (mutableBusy.value || mutableArmed.value) && mutablePhase.value != DjPhase.Returning + fun toggleQueue() { if (hasDjWork()) { stop() @@ -127,9 +133,14 @@ class DjPlaybackCoordinator @Inject constructor( } val snapshot = player.state.value val defer = snapshot.track != null - val ids = (if (defer) emptyList() else listOfNotNull(snapshot.track?.id)) + snapshot.upNext.map { it.trackId } - if (ids.size !in 2..40 || ids.any { library.library.value?.byId?.containsKey(it) != true }) { - mutableMessage.value = "Choisissez de 2 à 40 titres locaux à suivre." + val ids = snapshot.upNext.take(40).map { it.trackId } + if (ids.size < 2) { + mutableMessage.value = "Ajoutez au moins deux titres locaux à suivre. " + + "Le DJ prendra le relais à la fin du titre en cours." + return + } + if (ids.any { library.library.value?.byId?.containsKey(it) != true }) { + mutableMessage.value = "Le mode DJ de la file utilise des titres présents dans votre bibliothèque locale." return } prepare("Mode DJ · votre file", defer) { nonce -> music.djQueue(ids, nonce) } @@ -144,23 +155,38 @@ class DjPlaybackCoordinator @Inject constructor( } fun startSeed(seed: String) { - if (!music.capabilities.value.djSeedMix) { - mutableMessage.value = "Ce serveur ne prend pas encore en charge les mixes DJ à partir d’un titre." + if (hasDjWork()) stop() + val owner = music.session.value + if (owner == null) { + mutableMessage.value = "Connectez-vous au serveur pour lancer le DJ." return } - if (mutableBusy.value || mutableArmed.value || player.state.value.dj != null) stop() - val owner = music.session.value - val mine = generation - val selection = player.selectionVersion.value - scope.launch { - val classic = withTimeoutOrNull(2000) { player.state.first { it.dj == null } } ?: return@launch - if (!matches(Preparation(owner, mine, selection, ""))) { - return@launch + val token = Preparation(owner, ++generation, player.selectionVersion.value, "") + originKey = player.state.value.mediaKey + originIndex = player.state.value.currentIndex + mutableBusy.value = true + mutablePhase.value = DjPhase.Returning + mutableMessage.value = "Retour au lecteur avant la préparation du nouveau mix DJ." + preparation = scope.launch { seedAfterReturn(seed, token) } + } + + private suspend fun seedAfterReturn(seed: String, token: Preparation) { + try { + val classic = withTimeoutOrNull(2000) { player.state.first { it.dj == null } } + if (!matches(token)) return + if (classic == null) { + mutableMessage.value = "Le lecteur n’a pas quitté le mix DJ. Arrêtez le mix actuel puis réessayez." + return } val defer = classic.track?.id == seed - prepare("DJ · à partir de ce titre", defer, preserveQueue = false) { nonce -> + prepare("DJ · à partir de ce titre", defer, preserveQueue = false, requireSeed = true) { nonce -> music.djSeed(seed, nonce, skipSeed = defer) } + } finally { + if (token.generation == generation && token.owner == music.session.value) { + mutableBusy.value = false + mutablePhase.value = DjPhase.Classic + } } } @@ -180,9 +206,14 @@ class DjPlaybackCoordinator @Inject constructor( title: String, defer: Boolean, preserveQueue: Boolean = true, + requireSeed: Boolean = false, create: suspend (String) -> MusicDjSessionDto, ) { - val owner = music.session.value ?: return + val owner = music.session.value + if (owner == null) { + mutableMessage.value = "Connectez-vous au serveur pour lancer le DJ." + return + } val token = Preparation(owner, ++generation, player.selectionVersion.value, UUID.randomUUID().toString()) originKey = player.state.value.mediaKey originIndex = player.state.value.currentIndex @@ -190,7 +221,7 @@ class DjPlaybackCoordinator @Inject constructor( mutableBusy.value = true mutablePhase.value = DjPhase.Preparing mutableMessage.value = "Préparation des transitions ; votre écoute continue." - preparation = scope.launch { performPreparation(token, title, defer, preserveQueue, create) } + preparation = scope.launch { performPreparation(token, title, defer, preserveQueue, requireSeed, create) } } private suspend fun performPreparation( @@ -198,15 +229,14 @@ class DjPlaybackCoordinator @Inject constructor( title: String, defer: Boolean, preserveQueue: Boolean, + requireSeed: Boolean, create: suspend (String) -> MusicDjSessionDto, ) { var created: String? = null try { - if (!music.capabilities.value.interactiveAvailable()) { - mutableMessage.value = "Activez le DJ direct dans Paramètres → IA. " + - "Le moteur doit être disponible ou autorisé en essai bêta." - return - } + refreshCapabilities() + if (!matches(token)) return + if (!preparationAvailable(requireSeed)) return val result = create(token.request) created = result.id if (!matches(token)) { @@ -220,24 +250,70 @@ class DjPlaybackCoordinator @Inject constructor( } catch (error: CancellationException) { if (created != null) cleanupPreparation(token, created) throw error - } catch (_: MusicAiFailure) { - if (token.generation == generation) { - mutableMessage.value = "Le DJ est temporairement indisponible. " + - "Votre lecture classique reste disponible." + } catch (error: MusicAiFailure) { + if (matches(token)) { + mutableMessage.value = failureMessage(error) cleanupPreparation(token, created) } } finally { - if (token.generation == generation) { - mutableBusy.value = false - if (!mutableArmed.value && mutablePhase.value != DjPhase.Active) { - pendingId = null - pendingRequest = null - mutablePhase.value = DjPhase.Classic - } + finishPreparation(token) + } + } + + private fun finishPreparation(token: Preparation) { + if (token.generation == generation && token.owner == music.session.value) { + mutableBusy.value = false + if (!mutableArmed.value && mutablePhase.value != DjPhase.Active) { + pendingId = null + pendingRequest = null + mutablePhase.value = DjPhase.Classic } } } + private fun preparationAvailable(requireSeed: Boolean): Boolean { + if (requireSeed && !music.capabilities.value.djSeedMix) { + mutableMessage.value = "Ce serveur ne prend pas encore en charge les mixes DJ à partir d’un titre." + return false + } + if (!music.capabilities.value.interactiveAvailable()) { + mutableMessage.value = "Activez le DJ direct dans Paramètres → IA. " + + "Le moteur doit être disponible ou autorisé en essai bêta." + return false + } + return true + } + + private suspend fun refreshCapabilities() { + when (val result = music.refresh(force = true)) { + is ServerResult.Ok -> Unit + is ServerResult.Failed -> throw MusicAiFailure(result.status) + ServerResult.Unreachable -> throw MusicAiFailure(cause = IOException("DJ server unreachable")) + } + } + + private fun failureMessage(error: MusicAiFailure): String = when (error.kind) { + MusicAiFailureKind.Authentication -> "Connexion expirée. Reconnectez-vous au serveur pour lancer le DJ." + + MusicAiFailureKind.Forbidden -> "Le serveur refuse l’accès au DJ pour ce compte. Vérifiez les autorisations." + + MusicAiFailureKind.Disabled -> "Le DJ est désactivé pour ce compte ou ce serveur. Vérifiez Paramètres → IA." + + MusicAiFailureKind.NotReady -> "Le moteur DJ n’est pas prêt sur le serveur. Réessayez dans quelques instants." + + MusicAiFailureKind.Busy -> "Le serveur prépare déjà un autre mix DJ. Réessayez après sa préparation." + + MusicAiFailureKind.Network -> "Connexion au serveur impossible. Vérifiez le réseau puis relancez le DJ." + + MusicAiFailureKind.Timeout -> + "Le serveur DJ ne répond pas à temps. Votre lecture continue ; vous pouvez réessayer." + + MusicAiFailureKind.Unsupported -> + "Cette commande DJ n’est pas disponible sur ce serveur. Vérifiez sa mise à jour." + + else -> "Le serveur n’a pas pu préparer le mix DJ. Votre lecture classique reste disponible ; réessayez." + } + private suspend fun awaitReady( token: Preparation, id: String, @@ -249,15 +325,39 @@ class DjPlaybackCoordinator @Inject constructor( delay(2000) if (!matches(token)) return true val current = music.djSession(id) - if (current.state in setOf("failed", "stopped", "cancelled", "deleted")) throw MusicAiFailure() + if (!matches(token)) return true + if (current.state in setOf("failed", "stopped", "cancelled", "deleted", "deleting", "ready")) { + mutableMessage.value = terminalMessage(current) + cleanupPreparation(token, id) + return true + } if (current.state in setOf("playing", "paused")) { activateReady(token, current, title, defer, preserveQueue) return true } + mutableMessage.value = if (defer) { + "Préparation DJ en cours. Le mix prendra le relais à la fin du titre courant lorsqu’il sera prêt." + } else { + "Préparation DJ en cours sur le serveur ; votre écoute continue." + } } return false } + private fun terminalMessage(session: MusicDjSessionDto): String = when { + session.state == "ready" -> + "Le mix s’est terminé avant son démarrage dans le lecteur. Relancez une préparation DJ." + + session.error in setOf("dj_cache_full", "dj_storage_reserve") -> + "Le serveur manque d’espace temporaire pour préparer le DJ. Libérez de l’espace puis réessayez." + + session.error == "dj_models_unavailable" -> "Les modèles audio du moteur DJ sont indisponibles sur le serveur." + + session.error == "source_changed" -> "Un titre a changé pendant la préparation DJ. Relancez le mix." + + else -> "La préparation DJ a été arrêtée par le serveur. Votre lecture classique reste disponible ; réessayez." + } + private suspend fun activateReady( token: Preparation, current: MusicDjSessionDto, @@ -265,6 +365,7 @@ class DjPlaybackCoordinator @Inject constructor( defer: Boolean, preserveQueue: Boolean, ) { + if (!matches(token)) return if (defer) { if (current.state == "playing") music.djCommand(current.id, "pause") if (!matches(token)) return @@ -277,7 +378,7 @@ class DjPlaybackCoordinator @Inject constructor( sessionPlan = current, preserveQueue = preserveQueue, ) - mutableMessage.value = "Mode DJ prêt · relais au prochain titre." + mutableMessage.value = "DJ prêt · démarrage à la fin du titre en cours." } else { pendingId = null pendingRequest = null @@ -301,6 +402,7 @@ class DjPlaybackCoordinator @Inject constructor( private fun cleanupPreparation(token: Preparation, created: String?) { if (token.owner != music.session.value) return scope.launch { + if (token.owner != music.session.value) return@launch try { if (created == null) music.djCancelRequest(token.request) else music.djStop(created) } catch (_: MusicAiFailure) { } diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngine.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngine.kt index 724c2e3..170c141 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngine.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngine.kt @@ -74,9 +74,15 @@ internal class LocalDjEngine(first: Player, second: Player, private val onDomina } fun setPlaying(value: Boolean) { - blend?.wantedPlay = value - active.playWhenReady = value - if (blend != null) standby.playWhenReady = value else standby.pause() + val mixing = blend + if (mixing == null) { + active.playWhenReady = value + standby.pause() + } else { + mixing.wantedPlay = value + mixing.armed.outgoing.playWhenReady = value + mixing.armed.incoming.playWhenReady = value && !waitingForOutgoing(mixing.armed) + } } fun setVolume(value: Float, remember: Boolean = true) { @@ -193,10 +199,12 @@ internal class LocalDjEngine(first: Player, second: Player, private val onDomina pair.incoming.pause() return } + if (!outgoingReady(pair)) return if (pair.incoming.playbackState != Player.STATE_READY) { failIncoming(pair) return } + pair.incoming.playWhenReady = true val progress = progress(pair) setBlendGains(pair, progress) if (progress >= 0.5f && !value.switched) { @@ -215,6 +223,32 @@ internal class LocalDjEngine(first: Player, second: Player, private val onDomina if (progress >= 1f) finish(pair) } + private fun waitingForOutgoing(pair: Armed) = pair.outgoing.currentMediaItemIndex == pair.outgoingIndex && + pair.outgoing.playbackState == Player.STATE_BUFFERING + + private fun outgoingReady(pair: Armed): Boolean { + if (pair.outgoing.playerError != null) { + // A failed decoder cannot provide the fade clock. Promote only audio + // already ready; otherwise stop instead of retrying a silent overlap. + if (pair.incoming.playbackState == Player.STATE_READY) { + pair.incoming.playWhenReady = true + finish(pair) + } else { + pair.outgoing.pause() + failIncoming(pair) + } + publishWaiting(LocalDjFallback.DecoderFailure) + return false + } + if (waitingForOutgoing(pair)) { + // Keep playback intent, but do not let the other clock drift while + // progress is frozen. A later pulse resumes only if intent is play. + pair.incoming.pause() + return false + } + return true + } + private fun progress(pair: Armed): Float { if (pair.outgoing.currentMediaItemIndex != pair.outgoingIndex || pair.outgoing.playbackState == Player.STATE_ENDED diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinatorTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinatorTest.kt index b35aabf..574e3db 100644 --- a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinatorTest.kt +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinatorTest.kt @@ -1,7 +1,11 @@ package com.henriquesebastiao.downtify.core.player +import com.henriquesebastiao.downtify.core.data.ServerResult import com.henriquesebastiao.downtify.core.data.library.LibraryRepository +import com.henriquesebastiao.downtify.core.data.library.LibrarySnapshot +import com.henriquesebastiao.downtify.core.data.musicai.MusicAiFailure import com.henriquesebastiao.downtify.core.data.musicai.MusicAiRepository +import com.henriquesebastiao.downtify.core.model.Track import com.henriquesebastiao.downtify.core.network.dto.MusicAiCapabilitiesDto import com.henriquesebastiao.downtify.core.network.dto.MusicAiStatusDto import com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto @@ -15,10 +19,12 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -34,15 +40,18 @@ class DjPlaybackCoordinatorTest { val state = MutableStateFlow(PlayerState()) val music = mockk(relaxed = true) val player = mockk(relaxed = true) + val library = mockk(relaxed = true) + val caps = MutableStateFlow( + MusicAiCapabilitiesDto( + version = 1, + features = mapOf("dj_interactive" to true), + dj = MusicAiStatusDto(interactiveReady = true, queueSupported = true), + ), + ) init { every { music.session } returns owner - every { music.capabilities } returns MutableStateFlow( - MusicAiCapabilitiesDto( - version = 1, - features = mapOf("dj_interactive" to true), - dj = MusicAiStatusDto(interactiveReady = true, queueSupported = true), - ), - ) + every { music.capabilities } returns caps + coEvery { music.refresh(force = true) } returns ServerResult.Ok(Unit) every { player.selectionVersion } returns selection every { player.state } returns state every { player.playDj(any(), any(), any(), any(), any(), any()) } answers { @@ -51,7 +60,170 @@ class DjPlaybackCoordinatorTest { } every { player.exitDj(any()) } answers { state.value = PlayerState() } } - val coordinator = DjPlaybackCoordinator(music, player, mockk(relaxed = true), scope) + val coordinator = DjPlaybackCoordinator(music, player, library, scope) + } + + @Test fun `seed refreshes stale capabilities and accepts the permitted beta trial`() = runTest { + val fixture = Fixture(backgroundScope) + fixture.caps.value = MusicAiCapabilitiesDto() + coEvery { fixture.music.refresh(force = true) } coAnswers { + fixture.caps.value = MusicAiCapabilitiesDto( + version = 1, + features = mapOf("dj_interactive" to true, "dj_trial" to true), + djSeedMix = true, + dj = MusicAiStatusDto(interactiveTrialReady = true), + ) + ServerResult.Ok(Unit) + } + val session = MusicDjSessionDto("a".repeat(32), state = "playing", theme = "fete", mode = "interactive") + coEvery { fixture.music.djSeed("seed", any(), skipSeed = false) } returns session + coEvery { fixture.music.djSession(session.id) } returns session + runCurrent() + fixture.coordinator.startSeed("seed") + runCurrent() + advanceTimeBy(2001) + runCurrent() + coVerify(exactly = 1) { fixture.music.refresh(force = true) } + verify(exactly = 1) { fixture.player.playDj(session.id, any(), any(), "interactive", any(), session) } + assertEquals(DjPhase.Active, fixture.coordinator.phase.value) + } + + @Test fun `trial readiness never bypasses a disabled interactive feature`() = runTest { + val fixture = Fixture(backgroundScope) + fixture.caps.value = MusicAiCapabilitiesDto( + version = 1, + features = mapOf("dj_interactive" to false, "dj_trial" to true), + dj = MusicAiStatusDto(interactiveTrialReady = true), + ) + runCurrent() + fixture.coordinator.startTheme("fete", "library") + runCurrent() + coVerify(exactly = 0) { fixture.music.djContinuous(any(), any(), any()) } + assertFalse(fixture.coordinator.busy.value) + assertTrue(fixture.coordinator.message.value.contains("Paramètres")) + } + + @Test fun `authentication route failure shows a reconnect action`() = runTest { + val fixture = Fixture(backgroundScope) + coEvery { fixture.music.djContinuous(any(), any(), any()) } throws MusicAiFailure(401) + runCurrent() + fixture.coordinator.startTheme("fete", "library") + runCurrent() + assertTrue(fixture.coordinator.message.value.contains("Reconnectez")) + assertFalse(fixture.coordinator.busy.value) + assertEquals(DjPhase.Classic, fixture.coordinator.phase.value) + } + + @Test fun `unavailable engine route failure has a distinct visible reason`() = runTest { + val fixture = Fixture(backgroundScope) + coEvery { fixture.music.djContinuous(any(), any(), any()) } throws MusicAiFailure(503) + runCurrent() + fixture.coordinator.startTheme("fete", "library") + runCurrent() + assertTrue(fixture.coordinator.message.value.contains("moteur DJ")) + assertTrue(fixture.coordinator.message.value.contains("prêt")) + assertFalse(fixture.coordinator.busy.value) + } + + @Test fun `seed return timeout is visible and never attaches another transport`() = runTest { + val fixture = Fixture(backgroundScope) + fixture.caps.value = fixture.caps.value.copy(djSeedMix = true) + fixture.state.value = PlayerState(dj = PlayingDj("a".repeat(32), "Fixture", "interactive")) + every { fixture.player.exitDj(any()) } answers { } + runCurrent() + fixture.coordinator.startSeed("seed") + runCurrent() + advanceTimeBy(2001) + runCurrent() + assertTrue(fixture.coordinator.message.value.contains("quitté")) + assertFalse(fixture.coordinator.busy.value) + coVerify(exactly = 0) { fixture.music.djSeed(any(), any(), any()) } + verify(exactly = 0) { fixture.player.playDj(any(), any(), any(), any(), any(), any()) } + } + + @Test fun `missing account gives an explicit connection message`() = runTest { + val fixture = Fixture(backgroundScope) + runCurrent() + fixture.owner.value = null + runCurrent() + fixture.coordinator.startTheme("fete", "library") + runCurrent() + assertTrue(fixture.coordinator.message.value.contains("Connectez")) + assertFalse(fixture.coordinator.busy.value) + } + + @Test fun `owner change during capability refresh fences late preparation`() = runTest { + val fixture = Fixture(backgroundScope) + val response = CompletableDeferred>() + coEvery { fixture.music.refresh(force = true) } coAnswers { response.await() } + runCurrent() + fixture.coordinator.startTheme("fete", "library") + runCurrent() + fixture.owner.value = Session("https://other.test", "other", "Other", "new-device", "other-token") + runCurrent() + response.complete(ServerResult.Ok(Unit)) + runCurrent() + coVerify(exactly = 0) { fixture.music.djContinuous(any(), any(), any()) } + verify(exactly = 0) { fixture.player.playDj(any(), any(), any(), any(), any(), any()) } + assertEquals("", fixture.coordinator.message.value) + assertFalse(fixture.coordinator.busy.value) + } + + @Test fun `large queue prepares only forty following tracks and waits for the current title`() = runTest { + val fixture = Fixture(backgroundScope) + val current = mockk(relaxed = true) + every { current.id } returns "current" + val ids = (1..50).map { "next-$it" } + val library = mockk() + every { library.byId } returns ids.associateWith { mockk() } + every { fixture.library.library } returns MutableStateFlow(library) + fixture.state.value = PlayerState( + track = current, + currentIndex = 0, + upNext = ids.mapIndexed { index, id -> QueueEntry(index + 1, id, id, "") }, + ) + val session = MusicDjSessionDto("a".repeat(32), state = "playing", theme = "fete", mode = "interactive") + coEvery { fixture.music.djQueue(ids.take(40), any()) } returns session + coEvery { fixture.music.djSession(session.id) } returns session + runCurrent() + fixture.coordinator.toggleQueue() + runCurrent() + advanceTimeBy(2001) + runCurrent() + coVerify(exactly = 1) { fixture.music.djQueue(ids.take(40), any()) } + coVerify(exactly = 1) { fixture.music.djCommand(session.id, "pause", any()) } + verify(exactly = 1) { fixture.player.queueDj(session.id, any(), any(), session, true) } + verify(exactly = 0) { fixture.player.playDj(any(), any(), any(), any(), any(), any()) } + assertTrue(fixture.coordinator.armed.value) + assertFalse(fixture.coordinator.busy.value) + assertTrue(fixture.coordinator.message.value.contains("fin du titre")) + } + + @Test fun `one following title explains the minimum instead of replaying the current title`() = runTest { + val fixture = Fixture(backgroundScope) + fixture.state.value = PlayerState(upNext = listOf(QueueEntry(1, "next", "Next", ""))) + runCurrent() + fixture.coordinator.toggleQueue() + runCurrent() + coVerify(exactly = 0) { fixture.music.djQueue(any(), any()) } + assertTrue(fixture.coordinator.message.value.contains("deux titres")) + assertFalse(fixture.coordinator.busy.value) + } + + @Test fun `an already ended server render is reported instead of polling silently`() = runTest { + val fixture = Fixture(backgroundScope) + val session = MusicDjSessionDto("a".repeat(32), state = "ready", theme = "fete", mode = "interactive") + coEvery { fixture.music.djContinuous(any(), any(), any()) } returns session + coEvery { fixture.music.djSession(session.id) } returns session + runCurrent() + fixture.coordinator.startTheme("fete", "library") + runCurrent() + advanceTimeBy(2001) + runCurrent() + assertTrue(fixture.coordinator.message.value.contains("terminé")) + assertFalse(fixture.coordinator.busy.value) + coVerify(exactly = 1) { fixture.music.djStop(session.id) } + verify(exactly = 0) { fixture.player.playDj(any(), any(), any(), any(), any(), any()) } } @Test fun `cancelling preparation cancels its nonce before any transport can attach`() = runTest { diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjOutgoingStallTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjOutgoingStallTest.kt new file mode 100644 index 0000000..edddae2 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjOutgoingStallTest.kt @@ -0,0 +1,158 @@ +package com.henriquesebastiao.downtify.core.player + +import android.os.Looper +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class LocalDjOutgoingStallTest { + private val first = LocalDjFakeDeck() + private val second = LocalDjFakeDeck() + private val engine = LocalDjEngine(first, second) {} + + private fun begin() { + first.setMediaItems(listOf(localDjItem("a"), localDjItem("b"), localDjItem("c"))) + first.prepare() + first.play() + engine.setEnabled(true) + engine.pulse() + first.position(8800) + engine.pulse() + first.position(9100) + second.position(300) + engine.pulse() + assertEquals(0.25f, engine.transition.value.progress, 0.001f) + } + + private fun playbackState(deck: LocalDjFakeDeck, state: Int) { + var observed = false + val listener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == state) observed = true + } + } + deck.addListener(listener) + // Preserve the shared fixture and exercise SimpleBasePlayer's actual event dispatch. + LocalDjFakeDeck::class.java.getDeclaredField("ready").apply { + isAccessible = true + setInt(deck, state) + } + deck.position(deck.currentPosition) + shadowOf(Looper.getMainLooper()).idle() + assertTrue("Expected a real playback-state event", observed) + deck.removeListener(listener) + } + + @Test fun `outgoing buffering pauses incoming until playback can advance again`() { + begin() + playbackState(first, Player.STATE_BUFFERING) + engine.pulse() + assertFalse(first.isPlaying) + assertFalse(second.playWhenReady) + assertEquals(300L, second.currentPosition) + assertEquals(0.25f, engine.transition.value.progress, 0.001f) + playbackState(first, Player.STATE_READY) + engine.pulse() + assertTrue(first.playWhenReady) + assertTrue(second.playWhenReady) + assertEquals(0.25f, engine.transition.value.progress, 0.001f) + } + + @Test fun `manual pause prevents an automatic restart after outgoing recovery`() { + begin() + playbackState(first, Player.STATE_BUFFERING) + engine.pulse() + engine.setPlaying(false) + playbackState(first, Player.STATE_READY) + repeat(3) { engine.pulse() } + assertFalse(first.playWhenReady) + assertFalse(second.playWhenReady) + engine.setPlaying(true) + assertTrue(first.playWhenReady) + assertTrue(second.playWhenReady) + } + + @Test fun `manual resume during buffering does not start the incoming decoder early`() { + begin() + playbackState(first, Player.STATE_BUFFERING) + engine.pulse() + engine.setPlaying(false) + engine.setPlaying(true) + assertTrue(first.playWhenReady) + assertFalse(second.playWhenReady) + engine.pulse() + assertFalse(second.playWhenReady) + playbackState(first, Player.STATE_READY) + engine.pulse() + assertTrue(second.playWhenReady) + } + + @Test fun `disable while buffering fences late readiness and keeps the original position`() { + begin() + playbackState(first, Player.STATE_BUFFERING) + engine.pulse() + engine.setEnabled(false) + playbackState(first, Player.STATE_READY) + repeat(3) { engine.pulse() } + assertEquals(LocalDjPhase.Disabled, engine.transition.value.phase) + assertSame(first, engine.active) + assertEquals(9100L, first.currentPosition) + assertEquals(0, second.mediaItemCount) + assertFalse(second.playWhenReady) + } + + @Test fun `outgoing error promotes an already ready incoming deck once`() { + begin() + playbackState(first, Player.STATE_BUFFERING) + engine.pulse() + first.fail() + shadowOf(Looper.getMainLooper()).idle() + engine.pulse() + assertSame(second, engine.active) + assertEquals(1, second.currentMediaItemIndex) + assertEquals(300L, second.currentPosition) + assertEquals(3, second.mediaItemCount) + assertTrue(second.playWhenReady) + assertEquals(1f, second.volume, 0.001f) + assertEquals(0, first.mediaItemCount) + assertEquals(LocalDjFallback.DecoderFailure, engine.transition.value.fallback) + } + + @Test fun `outgoing error with no ready replacement stops both without retrying`() { + begin() + playbackState(second, Player.STATE_BUFFERING) + first.fail() + engine.pulse() + assertFalse(first.playWhenReady) + assertFalse(second.playWhenReady) + assertSame(first, engine.active) + assertEquals(LocalDjFallback.DecoderFailure, engine.transition.value.fallback) + repeat(3) { engine.pulse() } + assertFalse(first.playWhenReady) + assertFalse(second.playWhenReady) + } + + @Test fun `outgoing error cannot override a user pause or later disable`() { + begin() + engine.setPlaying(false) + first.fail() + repeat(3) { engine.pulse() } + assertFalse(first.playWhenReady) + assertFalse(second.playWhenReady) + engine.setEnabled(false) + repeat(3) { engine.pulse() } + assertFalse(first.playWhenReady) + assertFalse(second.playWhenReady) + } +} diff --git a/docs/music-ai.md b/docs/music-ai.md index 9155cb1..d27a8d9 100644 --- a/docs/music-ai.md +++ b/docs/music-ai.md @@ -94,6 +94,32 @@ podcast or external stream is selected. An unjoined device offers Listen instead of renderer-only pause/resume controls. This is explicit joining, not automatic sound playback on every device. +The 0.4.0-beta2 device-fade correction coordinates both decoder clocks when +the outgoing track buffers: the incoming track pauses instead of advancing +with a frozen gain, and resumes only if playback is still requested. A manual +pause or disarm cannot be undone by recovery. An outgoing decoder error switches +only to an incoming track already ready, or stops the overlap cleanly. +Seven real SimpleBasePlayer event regressions cover this behavior: four failed +before the fix and all seven pass after it. The complete player module passes +104 tests, with formatting and Detekt checks. This is automated evidence; +physical network, Bluetooth/Auto, thermal and battery acceptance remain pending. + +The 0.4.0-beta3 correction refreshes capabilities when opening the queue and +before preparing a DJ session. An unavailable queue control can recheck the +server and explains its gate instead of remaining silently grey. The control +distinguishes preparation, a relay waiting for the current title to finish and +active playback. Cancellation remains available during preparation and a relay. +The unqualified server trial still requires both the module and explicit trial +permission; a device-selected local engine does not require server qualification. + +The song-menu DJ action uses the selected engine. Its persistent progress dialog +reports preparation, failure and readiness, with cancellation and an entry to +the common player. Closing that dialog does not cancel a mix. Device seed mixes +resolve only authorized local-library tracks, confirm the resulting queue before +arming transitions, and reject late work after selection/account/engine changes. +This fixes control and feedback; it does not qualify the Android fade prototype +as on-device beat-model inference or establish physical-device performance. + The physical optimized beta also passed a visual check of the four colored mood cards, native temporary-playlist view, save-dialog opening and editable name. That check cancelled the save and did not create a library playlist. Targeted From 995acc0b33b22ad91ee249e290fae5be8238a12f Mon Sep 17 00:00:00 2001 From: Downtify agent Date: Sun, 8 Nov 2026 18:41:28 +0000 Subject: [PATCH 07/11] =?UTF-8?q?fix(player):=20al=C3=A9atoire=20complet,?= =?UTF-8?q?=20pochette=20DJ=20et=20Suivant=20dans=20le=20mix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlayOrder.startShuffled: la file aléatoire démarre au premier élément de la permutation construite par Media3. L'index tiré au hasard perdait en moyenne 17,9 titres sur 40 (jusqu'à 38). - SessionCommand SHUFFLE_FROM_HEAD: la commande est traitée dans le service, seul endroit où la timeline est à jour de façon synchrone. - SessionArtworkTracker: la pochette DJ vient du titre audible (extras djTrackId) et non de l'identifiant du rendu; réévaluation sur changement de métadonnées. - LocalDjPlayer: Suivant/Précédent interceptés dans la façade qui porte la MediaSession (préparé: repère suivant; interactif: même chemin serveur que DJ_NEXT). - Tests: ShuffleStartTest, SessionArtworkCoverTest, DjNextControlTest. --- .../downtify/core/player/LocalDjPlayer.kt | 55 ++++++++++++ .../downtify/core/player/PlayOrder.kt | 14 +++ .../downtify/core/player/PlaybackService.kt | 42 +++++---- .../downtify/core/player/PlayerController.kt | 15 ++-- .../core/player/SessionArtworkTracker.kt | 25 ++++-- .../downtify/core/player/DjNextControlTest.kt | 83 +++++++++++++++++ .../core/player/SessionArtworkCoverTest.kt | 50 +++++++++++ .../downtify/core/player/ShuffleStartTest.kt | 89 +++++++++++++++++++ 8 files changed, 342 insertions(+), 31 deletions(-) create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjNextControlTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/SessionArtworkCoverTest.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ShuffleStartTest.kt diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt index df2b5f4..fb44a62 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt @@ -151,6 +151,13 @@ class LocalDjPlayer(first: Player, second: Player, private val scope: CoroutineS } override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { + when (seekCommand) { + Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> + nextInsideDj()?.let { return it } + + Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> + previousInsideDj()?.let { return it } + } engine.reset() return super.handleSeek(mediaItemIndex, positionMs, seekCommand) } @@ -208,4 +215,52 @@ class LocalDjPlayer(first: Player, second: Player, private val scope: CoroutineS } internal fun pulseForTest() = engine.pulse() + + /** Asked when a controller presses Next inside an interactive DJ render. */ + private var djNext: (() -> Unit)? = null + + fun setDjNextHandler(handler: (() -> Unit)?) { + djNext = handler + } + + /** + * Next inside a DJ render stays inside the mix: a prepared render jumps to its next marker, + * an interactive one asks the server for the next transition. Letting the player advance + * would leave the mix for the classic queue item stored right after it — the reported + * Android Auto behaviour. + */ + private fun nextInsideDj(): ListenableFuture<*>? { + val dj = DjMediaItems.of(currentMediaItem ?: return null) ?: return null + if (dj.mode == "interactive") { + djNext?.invoke() ?: return null + return Futures.immediateVoidFuture() + } + if (dj.nextStart == Long.MAX_VALUE || dj.nextStart <= 0) return null + scope.launch { seekTo(currentMediaItemIndex, dj.nextStart) } + return Futures.immediateVoidFuture() + } + + /** Previous inside a DJ render restarts the track, or returns to the previous marker. */ + private fun previousInsideDj(): ListenableFuture<*>? { + val item = currentMediaItem ?: return null + if (DjMediaItems.of(item) == null) return null + val positionMs = currentPosition + val rows = DjTimeline.resumeRows(item) + val seconds = positionMs / 1000.0 + val row = rows.indexOfFirst { it.start <= seconds && seconds < it.end } + val target = when { + rows.isEmpty() -> return null + row < 0 -> rows.last().start + positionMs - (rows[row].start * 1000).toLong() > RESTART_MS -> rows[row].start + row > 0 -> rows[row - 1].start + else -> rows[row].start + } + scope.launch { seekTo(currentMediaItemIndex, (target * 1000).toLong()) } + return Futures.immediateVoidFuture() + } + + private companion object { + /** Listening longer than this restarts the track instead of stepping back. */ + const val RESTART_MS = 3_000L + } } diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayOrder.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayOrder.kt index ac71719..a528dbb 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayOrder.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayOrder.kt @@ -30,6 +30,20 @@ internal object PlayOrder { fun shuffled(timeline: Timeline): List = if (timeline.isEmpty) emptyList() else walk(timeline, timeline.getFirstWindowIndex(true), true) + /** + * Moves a freshly shuffled queue onto the first item of its permutation. Media3 builds that + * permutation itself when shuffle turns on; starting anywhere else silently drops every item + * that precedes the current one in it, so a random start plays as little as one song. + */ + fun startShuffled(player: Player) { + val timeline = player.currentTimeline + if (timeline.isEmpty || !player.shuffleModeEnabled) return + val first = timeline.getFirstWindowIndex(true) + if (first != C.INDEX_UNSET && first != player.currentMediaItemIndex) { + player.seekTo(first, C.TIME_UNSET) + } + } + private fun walk(timeline: Timeline, from: Int, shuffle: Boolean): List { val order = ArrayList() var index = from diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt index 10815e1..ed41e79 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt @@ -139,6 +139,7 @@ class PlaybackService : MediaLibraryService() { private val addToQueueCommand = SessionCommand(ACTION_ADD_TO_QUEUE, Bundle.EMPTY) private val moveInQueueCommand = SessionCommand(ACTION_MOVE_IN_QUEUE, Bundle.EMPTY) private val djNextCommand = SessionCommand(ACTION_DJ_NEXT, Bundle.EMPTY) + private val shuffleFromHeadCommand = SessionCommand(ACTION_SHUFFLE_FROM_HEAD, Bundle.EMPTY) override fun onCreate() { super.onCreate() @@ -155,6 +156,10 @@ class PlaybackService : MediaLibraryService() { .apply { launchIntent()?.let(::setSessionActivity) } .build() + player.setDjNextHandler { + callback.requestDjNext(DjIds.referenceOf(player.currentMediaItem?.mediaId)) + } + callback.observe(checkNotNull(session)) scope.launch { sleepTimer.run { player.pause() } } syncCoordinator.setMediaServiceActive(true) @@ -470,6 +475,7 @@ class PlaybackService : MediaLibraryService() { val commands = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() if (own(controller)) { commands.add(likeCommand).add(addToQueueCommand).add(moveInQueueCommand).add(djNextCommand) + commands.add(shuffleFromHeadCommand) } else { commands.add(likeCommand) } @@ -486,34 +492,31 @@ class PlaybackService : MediaLibraryService() { if (!own(controller) && customCommand.customAction != ACTION_TOGGLE_LIKE) { return Futures.immediateFuture(SessionResult(SessionError.ERROR_PERMISSION_DENIED)) } - if (customCommand.customAction == ACTION_TOGGLE_LIKE) { - val id = session.player.currentMediaItem?.mediaId - if (id != null && MediaIds.isLibrary(id)) { - val liked = id in library.likedIds.value - scope.launch { library.setLiked(id, !liked) } + when (customCommand.customAction) { + ACTION_TOGGLE_LIKE -> { + val id = session.player.currentMediaItem?.mediaId + if (id != null && MediaIds.isLibrary(id)) { + val liked = id in library.likedIds.value + scope.launch { library.setLiked(id, !liked) } + } } - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - if (customCommand.customAction == ACTION_DJ_NEXT) { - val reference = DjIds.referenceOf(session.player.currentMediaItem?.mediaId) - return requestDjNext(reference) - } - if (customCommand.customAction == ACTION_ADD_TO_QUEUE) { - return requestQueueAdd(session, args) - } - if (customCommand.customAction == ACTION_MOVE_IN_QUEUE) { - moveInQueue( + ACTION_SHUFFLE_FROM_HEAD -> PlayOrder.startShuffled(session.player) + ACTION_DJ_NEXT -> return requestDjNext( + DjIds.referenceOf(session.player.currentMediaItem?.mediaId), + ) + ACTION_ADD_TO_QUEUE -> return requestQueueAdd(session, args) + ACTION_MOVE_IN_QUEUE -> moveInQueue( session.player as ExoPlayer, from = args.getInt(EXTRA_FROM, C.INDEX_UNSET), after = args.getInt(EXTRA_AFTER, C.INDEX_UNSET), queued = args.getBoolean(EXTRA_QUEUED), ) - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + else -> return super.onCustomCommand(session, controller, customCommand, args) } - return super.onCustomCommand(session, controller, customCommand, args) + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } - private fun requestDjNext( + internal fun requestDjNext( reference: com.henriquesebastiao.downtify.core.model.DjReference?, ): ListenableFuture { if (reference?.mode != @@ -590,6 +593,7 @@ class PlaybackService : MediaLibraryService() { const val ACTION_ADD_TO_QUEUE = "com.henriquesebastiao.downtify.ADD_TO_QUEUE" const val ACTION_MOVE_IN_QUEUE = "com.henriquesebastiao.downtify.MOVE_IN_QUEUE" const val ACTION_DJ_NEXT = "com.henriquesebastiao.downtify.DJ_NEXT" + const val ACTION_SHUFFLE_FROM_HEAD = "com.henriquesebastiao.downtify.SHUFFLE_FROM_HEAD" const val EXTRA_TRACK_ID = "track_id" const val EXTRA_FROM = "from" const val EXTRA_AFTER = "after" diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt index a07bfa0..fb94d3f 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt @@ -193,10 +193,11 @@ class PlayerController @Inject constructor( main.launch { val player = connect() val baseUrl = sessions.current?.baseUrl - val start = if (shuffle && startIndex == 0) tracks.indices.random() else startIndex.coerceIn(tracks.indices) + val start = startIndex.coerceIn(tracks.indices) player.setMediaItems(tracks.map { MediaItems.from(it, baseUrl) }, start, C.TIME_UNSET) player.setPlaybackSpeed(1f) player.shuffleModeEnabled = shuffle + if (shuffle && startIndex == 0) player.shuffleFromHead() player.playlistMetadata = MediaItems.contextMetadata(from) player.prepare() player.play() @@ -216,14 +217,11 @@ class PlayerController @Inject constructor( mutableSelectionVersion.value++ main.launch { val player = connect() - val start = if (shuffle && startIndex == 0) { - entries.indices.random() - } else { - startIndex.coerceIn(entries.indices) - } + val start = startIndex.coerceIn(entries.indices) player.setMediaItems(entries.map { MediaItems.stream(it.song, it.videoId) }, start, C.TIME_UNSET) player.setPlaybackSpeed(1f) player.shuffleModeEnabled = shuffle + if (shuffle && startIndex == 0) player.shuffleFromHead() player.playlistMetadata = MediaItems.contextMetadata(from) player.prepare() player.play() @@ -462,6 +460,11 @@ class PlayerController @Inject constructor( fun previous() = command { if (state.value.dj == null) it.seekToPrevious() } + /** Starts a freshly shuffled queue at the first item of the permutation the player built. */ + private fun MediaController.shuffleFromHead() { + sendCustomCommand(SessionCommand(PlaybackService.ACTION_SHUFFLE_FROM_HEAD, Bundle.EMPTY), Bundle.EMPTY) + } + fun seekTo(positionMs: Long) = command { if (state.value.dj?.mode == "interactive") return@command it.seekTo(positionMs + (state.value.dj?.start ?: 0)) diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/SessionArtworkTracker.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/SessionArtworkTracker.kt index a3227d9..46f37b1 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/SessionArtworkTracker.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/SessionArtworkTracker.kt @@ -1,8 +1,10 @@ package com.henriquesebastiao.downtify.core.player import android.net.Uri +import androidx.media3.common.MediaItem import androidx.media3.common.Player import com.henriquesebastiao.downtify.core.data.NetworkMonitor +import com.henriquesebastiao.downtify.core.model.DjIds import com.henriquesebastiao.downtify.core.model.EpisodeIds import com.henriquesebastiao.downtify.core.model.StreamIds import com.henriquesebastiao.downtify.core.network.ServerUrls @@ -58,6 +60,7 @@ internal class SessionArtworkTracker( Player.EVENT_MEDIA_ITEM_TRANSITION, Player.EVENT_SHUFFLE_MODE_ENABLED_CHANGED, Player.EVENT_REPEAT_MODE_CHANGED, + Player.EVENT_MEDIA_METADATA_CHANGED, ) ) { update() @@ -71,12 +74,7 @@ internal class SessionArtworkTracker( } else { PlayOrder.ahead(player, 4).mapNotNull { index -> val item = player.getMediaItemAt(index) - val original = MediaItems.originalArtwork(item) ?: return@mapNotNull null - val source = if (!StreamIds.isStream(item.mediaId) && !EpisodeIds.isEpisode(item.mediaId)) { - Uri.parse(ServerUrls.cover(paired.baseUrl, item.mediaId, ServerUrls.COVER_LARGE)) - } else { - original - } + val source = coverSource(item, paired.baseUrl) ?: return@mapNotNull null Target(index, item.mediaId, source, paired.artworkOwner) } } @@ -98,3 +96,18 @@ internal class SessionArtworkTracker( } } } + +/** + * The cover of what is audible in [item]. A DJ stream's media id names the render, not a track: + * asking the server for that id returns no cover, which is why a mix played with no artwork. + * The audible track travels in the DJ extras; a mix without one keeps the cover its plan gave it. + */ +internal fun coverSource(item: MediaItem, baseUrl: String): Uri? { + DjMediaItems.of(item)?.trackId?.let { + return Uri.parse(ServerUrls.cover(baseUrl, it, ServerUrls.COVER_LARGE)) + } + if (DjIds.isDj(item.mediaId) || StreamIds.isStream(item.mediaId) || EpisodeIds.isEpisode(item.mediaId)) { + return MediaItems.originalArtwork(item) + } + return Uri.parse(ServerUrls.cover(baseUrl, item.mediaId, ServerUrls.COVER_LARGE)) +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjNextControlTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjNextControlTest.kt new file mode 100644 index 0000000..82855ed --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjNextControlTest.kt @@ -0,0 +1,83 @@ +package com.henriquesebastiao.downtify.core.player + +import android.os.Bundle +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Android Auto, Bluetooth and the notification all press the player's own "next": it must stay + * inside the mix. Before this, the player advanced to the classic queue item stored after the DJ + * render, which is exactly the reported jump out of the DJ. + */ +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DjNextControlTest { + private val render = "0123456789abcdef0123456789abcdef" + + private fun djItem(mode: String, nextStart: Long): MediaItem { + val base = DjMediaItems.from(render, "Mix DJ", 600_000L, mode) + val extras = Bundle().apply { + putLong("djStart", 0) + putLong("djEnd", 180_000) + putLong("djNextStart", nextStart) + putString("djTrackId", "track-1") + } + return base.buildUpon() + .setMediaMetadata(base.mediaMetadata.buildUpon().setExtras(extras).build()) + .build() + } + + private fun player(scope: kotlinx.coroutines.test.TestScope): LocalDjPlayer = + LocalDjPlayer(LocalDjFakeDeck(), LocalDjFakeDeck(), scope) + + @Test fun `next inside a prepared mix moves to the next marker`() = runTest { + val player = player(this) + player.setMediaItems(listOf(djItem("prepared", 180_000), localDjItem("classic")), 0, 0) + player.prepare() + player.play() + + player.seekToNext() + advanceUntilIdle() + + assertEquals("reste sur le mix", 0, player.currentMediaItemIndex) + assertEquals(180_000L, player.currentPosition) + player.release() + } + + @Test fun `next inside an interactive mix asks the server and stays on the mix`() = runTest { + val player = player(this) + var asked = 0 + player.setDjNextHandler { asked++ } + player.setMediaItems(listOf(djItem("interactive", 180_000), localDjItem("classic")), 0, 0) + player.prepare() + player.play() + + player.seekToNext() + advanceUntilIdle() + + assertEquals(1, asked) + assertEquals(0, player.currentMediaItemIndex) + player.release() + } + + @Test fun `next outside a mix still advances the queue`() = runTest { + val player = player(this) + player.setMediaItems(listOf(localDjItem("a"), localDjItem("b")), 0, 0) + player.prepare() + player.play() + + player.seekToNext() + advanceUntilIdle() + + assertEquals(1, player.currentMediaItemIndex) + player.release() + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/SessionArtworkCoverTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/SessionArtworkCoverTest.kt new file mode 100644 index 0000000..902a4e2 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/SessionArtworkCoverTest.kt @@ -0,0 +1,50 @@ +package com.henriquesebastiao.downtify.core.player + +import android.net.Uri +import android.os.Bundle +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The cover of a DJ stream belongs to the track that is audible, not to the render id. Using the + * render id asked the server for a cover that does not exist, so Android Auto showed none or a + * stale one. + */ +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SessionArtworkCoverTest { + private val render = "0123456789abcdef0123456789abcdef" + + private fun djItem(trackId: String?, artwork: Uri? = null): MediaItem { + val base = DjMediaItems.from(render, "Mix DJ", 1_000L) + val extras = Bundle().apply { trackId?.let { putString("djTrackId", it) } } + val metadata = base.mediaMetadata.buildUpon().setExtras(extras) + artwork?.let { metadata.setArtworkUri(it) } + return base.buildUpon().setMediaMetadata(metadata.build()).build() + } + + @Test fun `a dj item is covered by the track that is audible`() { + val uri = coverSource(djItem("track-1"), "https://host")!! + assertTrue("attendu le titre, obtenu $uri", uri.toString().contains("track-1")) + assertFalse("le rendu DJ ne doit pas servir d'identifiant de pochette", uri.toString().contains(render)) + } + + @Test fun `a dj item without a known track keeps the cover of its mix`() { + val mix = Uri.parse("https://host/covers/mix-cover") + assertEquals(mix, coverSource(djItem(null, mix), "https://host")) + } + + @Test fun `a library track is still covered by its own id`() { + val item = MediaItem.Builder().setMediaId("library-track").setUri("https://host/audio").build() + val uri = coverSource(item, "https://host")!! + assertTrue(uri.toString().contains("library-track")) + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ShuffleStartTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ShuffleStartTest.kt new file mode 100644 index 0000000..cf391d5 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ShuffleStartTest.kt @@ -0,0 +1,89 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Shuffle on a new queue must play every occurrence. Media3 builds the permutation itself; a + * start index picked outside it drops everything before that index — measured at 17.9 of 40 + * songs lost per launch before [PlayOrder.startShuffled] was used. + */ +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ShuffleStartTest { + private fun item(id: String) = MediaItem.Builder() + .setMediaId(id) + .setUri("https://fixture.test/$id.mp3") + .build() + + private fun player() = ExoPlayer.Builder(RuntimeEnvironment.getApplication()).build() + + @Test fun `a shuffled queue starts on the first item of its permutation and plays to the end`() { + val count = 40 + repeat(30) { round -> + val player = player() + try { + player.setMediaItems((0 until count).map { item("t$it") }, 0, C.TIME_UNSET) + player.shuffleModeEnabled = true + PlayOrder.startShuffled(player) + val first = player.currentTimeline.getFirstWindowIndex(true) + assertEquals("round $round", first, player.currentMediaItemIndex) + val ahead = PlayOrder.ahead(player, count * 2) + assertEquals("round $round: file entière", count, ahead.size) + assertEquals("round $round: sans doublon", count, ahead.toSet().size) + } finally { + player.release() + } + } + } + + @Test fun `a shuffled queue keeps duplicate occurrences as separate items`() { + val player = player() + try { + player.setMediaItems(listOf(item("a"), item("b"), item("a"), item("a")), 0, C.TIME_UNSET) + player.shuffleModeEnabled = true + PlayOrder.startShuffled(player) + assertEquals(4, PlayOrder.ahead(player, 8).size) + } finally { + player.release() + } + } + + @Test fun `an ordered queue is left where the caller asked`() { + val player = player() + try { + player.setMediaItems((0 until 10).map { item("t$it") }, 3, C.TIME_UNSET) + PlayOrder.startShuffled(player) + assertEquals(3, player.currentMediaItemIndex) + assertEquals(7, PlayOrder.ahead(player, 20).size) + } finally { + player.release() + } + } + + @Test fun `shuffle never plays the same occurrence twice before the queue ends`() { + val player = player() + try { + player.setMediaItems((0 until 5).map { item("t$it") }, 0, C.TIME_UNSET) + player.shuffleModeEnabled = true + PlayOrder.startShuffled(player) + val ahead = PlayOrder.ahead(player, 10) + assertEquals(5, ahead.size) + assertNotEquals(ahead[0], ahead[1]) + assertTrue(ahead.all { it in 0 until 5 }) + } finally { + player.release() + } + } +} From ee0c05daf22b44d488a5f6c5754f006c7344ba1a Mon Sep 17 00:00:00 2001 From: Downtify agent Date: Sun, 8 Nov 2026 19:57:45 +0000 Subject: [PATCH 08/11] =?UTF-8?q?feat(player):=20bouton=20al=C3=A9atoire?= =?UTF-8?q?=20et=20r=C3=A9p=C3=A9tition=20pour=20Android=20Auto,=20r=C3=A8?= =?UTF-8?q?gle=20de=20r=C3=A9p=C3=A9tition=20au=20lancement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Boutons aléatoire et répétition exposés via setMediaButtonPreferences, prioritaires sur le favori; leur état suit le lecteur (icônes et libellés mis à jour sur changement). - Commandes de session TOGGLE_SHUFFLE et CYCLE_REPEAT (hors -> tout -> un -> hors). - Un lancement explicite de l'aléatoire transforme « répéter un titre » en « répéter tout » et conserve « répéter tout ». - Tests d'échelle: 300 et 1 000 occurrences, doublons compris, sans perte; restauration de la permutation vérifiée. --- .../downtify/core/player/PlayOrder.kt | 7 ++ .../downtify/core/player/PlaybackService.kt | 85 ++++++++++++++++--- .../downtify/core/player/PlayerController.kt | 10 ++- core/player/src/main/res/values/strings.xml | 5 ++ .../downtify/core/player/ShuffleStartTest.kt | 54 ++++++++++++ 5 files changed, 146 insertions(+), 15 deletions(-) diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayOrder.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayOrder.kt index a528dbb..8483473 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayOrder.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayOrder.kt @@ -44,6 +44,13 @@ internal object PlayOrder { } } + /** + * An explicit Shuffle launch drops "repeat one" — it would replay the same song forever inside + * a queue meant to be heard once — but keeps "repeat all" if the user had chosen it. + */ + fun repeatAfterShuffle(repeatMode: Int): Int = + if (repeatMode == Player.REPEAT_MODE_ONE) Player.REPEAT_MODE_ALL else repeatMode + private fun walk(timeline: Timeline, from: Int, shuffle: Boolean): List { val order = ArrayList() var index = from diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt index ed41e79..eff6bfe 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt @@ -140,6 +140,8 @@ class PlaybackService : MediaLibraryService() { private val moveInQueueCommand = SessionCommand(ACTION_MOVE_IN_QUEUE, Bundle.EMPTY) private val djNextCommand = SessionCommand(ACTION_DJ_NEXT, Bundle.EMPTY) private val shuffleFromHeadCommand = SessionCommand(ACTION_SHUFFLE_FROM_HEAD, Bundle.EMPTY) + private val shuffleCommand = SessionCommand(ACTION_TOGGLE_SHUFFLE, Bundle.EMPTY) + private val repeatCommand = SessionCommand(ACTION_CYCLE_REPEAT, Bundle.EMPTY) override fun onCreate() { super.onCreate() @@ -188,7 +190,7 @@ class PlaybackService : MediaLibraryService() { if (decoder is ExoPlayer) readAhead.start(decoder, scope) } } - keepLikeButtonCurrent(player) + keepButtonsCurrent(player) stopDisabledDj(player) stopWhenSignedOut(player) removeDeletedLibraryTracks(player) @@ -352,31 +354,71 @@ class PlaybackService : MediaLibraryService() { } /** The heart in the notification follows the current track's like. */ - private fun keepLikeButtonCurrent(player: Player) { + private fun keepButtonsCurrent(player: Player) { val currentId = MutableStateFlow(player.currentMediaItem?.mediaId) + val shuffleOn = MutableStateFlow(player.shuffleModeEnabled) + val repeatFlow = MutableStateFlow(player.repeatMode) player.addListener( object : Player.Listener { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { currentId.value = mediaItem?.mediaId } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + shuffleOn.value = shuffleModeEnabled + } + + override fun onRepeatModeChanged(repeatMode: Int) { + repeatFlow.value = repeatMode + } }, ) - // Songs get a heart; an episode or a stream has nothing to like. - combine(currentId, library.likedIds) { id, liked -> - when { - EpisodeIds.isEpisode(id) || StreamIds.isStream(id) || - DjIds.isDj(id) -> null - - else -> id != null && id in liked - } + // Shuffle and repeat follow the real player state; a song also gets a heart. + combine(currentId, library.likedIds, shuffleOn, repeatFlow) { id, liked, shuffle, mode -> + Triple(likeable(id, liked), shuffle, mode) } .distinctUntilChanged() - .onEach { liked -> - session?.setMediaButtonPreferences(if (liked == null) emptyList() else listOf(likeButton(liked))) + .onEach { (liked, shuffle, mode) -> + session?.setMediaButtonPreferences( + listOfNotNull(shuffleButton(shuffle), repeatButton(mode), liked?.let(::likeButton)), + ) } .launchIn(scope) } + /** Songs get a heart; an episode, a stream or a DJ render has nothing to like. */ + private fun likeable(id: String?, liked: Collection): Boolean? = when { + EpisodeIds.isEpisode(id) || StreamIds.isStream(id) || DjIds.isDj(id) -> null + else -> id != null && id in liked + } + + private fun shuffleButton(enabled: Boolean): CommandButton = + CommandButton.Builder(if (enabled) CommandButton.ICON_SHUFFLE_ON else CommandButton.ICON_SHUFFLE_OFF) + .setDisplayName(getString(if (enabled) R.string.player_shuffle_on else R.string.player_shuffle_off)) + .setSessionCommand(shuffleCommand) + .setSlots(CommandButton.SLOT_OVERFLOW) + .build() + + private fun repeatButton(mode: Int): CommandButton = CommandButton.Builder( + when (mode) { + Player.REPEAT_MODE_ONE -> CommandButton.ICON_REPEAT_ONE + Player.REPEAT_MODE_ALL -> CommandButton.ICON_REPEAT_ALL + else -> CommandButton.ICON_REPEAT_OFF + }, + ) + .setDisplayName( + getString( + when (mode) { + Player.REPEAT_MODE_ONE -> R.string.player_repeat_one + Player.REPEAT_MODE_ALL -> R.string.player_repeat_all + else -> R.string.player_repeat_off + }, + ), + ) + .setSessionCommand(repeatCommand) + .setSlots(CommandButton.SLOT_OVERFLOW) + .build() + /** * Puts [item] after the current song and anything queued before it, ahead of the rest of * the album or playlist — in the shuffled order too, where ExoPlayer alone would put it @@ -475,7 +517,7 @@ class PlaybackService : MediaLibraryService() { val commands = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() if (own(controller)) { commands.add(likeCommand).add(addToQueueCommand).add(moveInQueueCommand).add(djNextCommand) - commands.add(shuffleFromHeadCommand) + commands.add(shuffleFromHeadCommand).add(shuffleCommand).add(repeatCommand) } else { commands.add(likeCommand) } @@ -500,17 +542,32 @@ class PlaybackService : MediaLibraryService() { scope.launch { library.setLiked(id, !liked) } } } + ACTION_SHUFFLE_FROM_HEAD -> PlayOrder.startShuffled(session.player) + + ACTION_TOGGLE_SHUFFLE -> + session.player.shuffleModeEnabled = + !session.player.shuffleModeEnabled + + ACTION_CYCLE_REPEAT -> session.player.repeatMode = when (session.player.repeatMode) { + Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL + Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE + else -> Player.REPEAT_MODE_OFF + } + ACTION_DJ_NEXT -> return requestDjNext( DjIds.referenceOf(session.player.currentMediaItem?.mediaId), ) + ACTION_ADD_TO_QUEUE -> return requestQueueAdd(session, args) + ACTION_MOVE_IN_QUEUE -> moveInQueue( session.player as ExoPlayer, from = args.getInt(EXTRA_FROM, C.INDEX_UNSET), after = args.getInt(EXTRA_AFTER, C.INDEX_UNSET), queued = args.getBoolean(EXTRA_QUEUED), ) + else -> return super.onCustomCommand(session, controller, customCommand, args) } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) @@ -594,6 +651,8 @@ class PlaybackService : MediaLibraryService() { const val ACTION_MOVE_IN_QUEUE = "com.henriquesebastiao.downtify.MOVE_IN_QUEUE" const val ACTION_DJ_NEXT = "com.henriquesebastiao.downtify.DJ_NEXT" const val ACTION_SHUFFLE_FROM_HEAD = "com.henriquesebastiao.downtify.SHUFFLE_FROM_HEAD" + const val ACTION_TOGGLE_SHUFFLE = "com.henriquesebastiao.downtify.TOGGLE_SHUFFLE" + const val ACTION_CYCLE_REPEAT = "com.henriquesebastiao.downtify.CYCLE_REPEAT" const val EXTRA_TRACK_ID = "track_id" const val EXTRA_FROM = "from" const val EXTRA_AFTER = "after" diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt index fb94d3f..70968e5 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayerController.kt @@ -197,7 +197,10 @@ class PlayerController @Inject constructor( player.setMediaItems(tracks.map { MediaItems.from(it, baseUrl) }, start, C.TIME_UNSET) player.setPlaybackSpeed(1f) player.shuffleModeEnabled = shuffle - if (shuffle && startIndex == 0) player.shuffleFromHead() + if (shuffle) { + player.repeatMode = PlayOrder.repeatAfterShuffle(player.repeatMode) + if (startIndex == 0) player.shuffleFromHead() + } player.playlistMetadata = MediaItems.contextMetadata(from) player.prepare() player.play() @@ -221,7 +224,10 @@ class PlayerController @Inject constructor( player.setMediaItems(entries.map { MediaItems.stream(it.song, it.videoId) }, start, C.TIME_UNSET) player.setPlaybackSpeed(1f) player.shuffleModeEnabled = shuffle - if (shuffle && startIndex == 0) player.shuffleFromHead() + if (shuffle) { + player.repeatMode = PlayOrder.repeatAfterShuffle(player.repeatMode) + if (startIndex == 0) player.shuffleFromHead() + } player.playlistMetadata = MediaItems.contextMetadata(from) player.prepare() player.play() diff --git a/core/player/src/main/res/values/strings.xml b/core/player/src/main/res/values/strings.xml index dcff411..e8bd5c0 100644 --- a/core/player/src/main/res/values/strings.xml +++ b/core/player/src/main/res/values/strings.xml @@ -2,4 +2,9 @@ Add to Liked songs Remove from Liked songs + Shuffle on + Shuffle off + Repeat off + Repeat all + Repeat one diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ShuffleStartTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ShuffleStartTest.kt index cf391d5..7fe7ca5 100644 --- a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ShuffleStartTest.kt +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ShuffleStartTest.kt @@ -2,8 +2,10 @@ package com.henriquesebastiao.downtify.core.player import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue @@ -86,4 +88,56 @@ class ShuffleStartTest { player.release() } } + + @Test fun `300 and 1000 occurrences are all played, duplicates included`() { + for (count in listOf(300, 1_000)) { + val player = player() + try { + // 50 distinct songs repeated 20 times: one occurrence each, never merged. + val items = (0 until count).map { item("song-${it % 50}") } + player.setMediaItems(items, 0, C.TIME_UNSET) + player.shuffleModeEnabled = true + PlayOrder.startShuffled(player) + val ahead = PlayOrder.ahead(player, count * 2) + assertEquals("$count occurrences", count, ahead.size) + assertEquals("$count indices distincts", count, ahead.toSet().size) + } finally { + player.release() + } + } + } + + @Test fun `a restored permutation still covers the whole queue`() { + val count = 300 + val source = player() + val order: List + try { + source.setMediaItems((0 until count).map { item("t$it") }, 0, C.TIME_UNSET) + source.shuffleModeEnabled = true + PlayOrder.startShuffled(source) + order = PlayOrder.shuffled(source.currentTimeline) + } finally { + source.release() + } + assertEquals(count, order.size) + val restored = player() + try { + restored.setMediaItems((0 until count).map { item("t$it") }, 0, C.TIME_UNSET) + restored.setShuffleOrder(DefaultShuffleOrder(order.toIntArray(), 0L)) + restored.shuffleModeEnabled = true + assertEquals("la permutation restaurée est conservée", order, PlayOrder.shuffled(restored.currentTimeline)) + // A resume continues where the queue stopped: the tail of the same permutation. + val position = order.indexOf(restored.currentMediaItemIndex) + assertTrue("l'index courant appartient à la permutation", position >= 0) + assertEquals(order.drop(position), PlayOrder.ahead(restored, count * 2)) + } finally { + restored.release() + } + } + + @Test fun `an explicit shuffle drops repeat one but keeps repeat all`() { + assertEquals(Player.REPEAT_MODE_ALL, PlayOrder.repeatAfterShuffle(Player.REPEAT_MODE_ONE)) + assertEquals(Player.REPEAT_MODE_ALL, PlayOrder.repeatAfterShuffle(Player.REPEAT_MODE_ALL)) + assertEquals(Player.REPEAT_MODE_OFF, PlayOrder.repeatAfterShuffle(Player.REPEAT_MODE_OFF)) + } } From cc9a28e7234baa45f8157ee9ef51a771d2765275 Mon Sep 17 00:00:00 2001 From: Downtify agent Date: Sun, 8 Nov 2026 20:01:17 +0000 Subject: [PATCH 09/11] chore: version 0.4.0-beta4 (versionCode 13) --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c8300b..9cf5bb9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.henriquesebastiao.downtify" - versionCode = 12 - versionName = "0.4.0-beta3" + versionCode = 13 + versionName = "0.4.0-beta4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } From 9c27dcad2a3952d2b561c68be254f15d6fc485a4 Mon Sep 17 00:00:00 2001 From: Downtify agent Date: Sun, 8 Nov 2026 20:17:55 +0000 Subject: [PATCH 10/11] =?UTF-8?q?i18n(player):=20traductions=20des=20libel?= =?UTF-8?q?l=C3=A9s=20al=C3=A9atoire=20et=20r=C3=A9p=C3=A9tition=20(7=20la?= =?UTF-8?q?ngues)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/player/src/main/res/values-bg/car.xml | 5 +++++ core/player/src/main/res/values-el/car.xml | 5 +++++ core/player/src/main/res/values-es/car.xml | 5 +++++ core/player/src/main/res/values-fr/car.xml | 5 +++++ core/player/src/main/res/values-hu/car.xml | 5 +++++ core/player/src/main/res/values-pt-rBR/car.xml | 5 +++++ core/player/src/main/res/values-tr/car.xml | 5 +++++ 7 files changed, 35 insertions(+) diff --git a/core/player/src/main/res/values-bg/car.xml b/core/player/src/main/res/values-bg/car.xml index 59d3ebc..a8f411d 100644 --- a/core/player/src/main/res/values-bg/car.xml +++ b/core/player/src/main/res/values-bg/car.xml @@ -13,5 +13,10 @@ Няколко резултата: изберете песен от търсенето. Добавяне в любими песни Премахване от любими песни + Разбъркване включено + Разбъркване изключено + Повторение изключено + Повтаряне на всички + Повтаряне на една песен Твърде много резултати. Уточнете търсенето. diff --git a/core/player/src/main/res/values-el/car.xml b/core/player/src/main/res/values-el/car.xml index ea2f8dc..9b88d46 100644 --- a/core/player/src/main/res/values-el/car.xml +++ b/core/player/src/main/res/values-el/car.xml @@ -13,5 +13,10 @@ Πολλά αποτελέσματα: επιλέξτε ένα τραγούδι από την αναζήτηση. Προσθήκη στα αγαπημένα τραγούδια Αφαίρεση από τα αγαπημένα τραγούδια + Τυχαία σειρά ενεργή + Τυχαία σειρά ανενεργή + Επανάληψη ανενεργή + Επανάληψη όλων + Επανάληψη ενός τραγουδιού Πάρα πολλά αποτελέσματα. Περιορίστε την αναζήτηση. diff --git a/core/player/src/main/res/values-es/car.xml b/core/player/src/main/res/values-es/car.xml index e431f03..b0ca1ad 100644 --- a/core/player/src/main/res/values-es/car.xml +++ b/core/player/src/main/res/values-es/car.xml @@ -13,5 +13,10 @@ Varios resultados: elige una canción en la búsqueda. Añadir a canciones favoritas Quitar de canciones favoritas + Aleatorio activado + Aleatorio desactivado + Repetición desactivada + Repetir todo + Repetir una canción Demasiados resultados. Precisa la búsqueda. diff --git a/core/player/src/main/res/values-fr/car.xml b/core/player/src/main/res/values-fr/car.xml index 9fe8b9a..5706cca 100644 --- a/core/player/src/main/res/values-fr/car.xml +++ b/core/player/src/main/res/values-fr/car.xml @@ -13,5 +13,10 @@ Plusieurs résultats : choisissez un morceau dans la recherche. Ajouter aux titres favoris Retirer des titres favoris + Aléatoire activé + Aléatoire désactivé + Répétition désactivée + Répéter tout + Répéter un titre Trop de résultats. Précisez votre recherche. diff --git a/core/player/src/main/res/values-hu/car.xml b/core/player/src/main/res/values-hu/car.xml index a36eb7f..ece4d15 100644 --- a/core/player/src/main/res/values-hu/car.xml +++ b/core/player/src/main/res/values-hu/car.xml @@ -13,5 +13,10 @@ Több találat: válassz számot a keresési eredményekből. Hozzáadás a kedvelt számokhoz Eltávolítás a kedvelt számok közül + Véletlen sorrend be + Véletlen sorrend ki + Ismétlés ki + Összes ismétlése + Egy szám ismétlése Túl sok találat. Pontosítsd a keresést. diff --git a/core/player/src/main/res/values-pt-rBR/car.xml b/core/player/src/main/res/values-pt-rBR/car.xml index 6318cb0..17ce317 100644 --- a/core/player/src/main/res/values-pt-rBR/car.xml +++ b/core/player/src/main/res/values-pt-rBR/car.xml @@ -13,5 +13,10 @@ Vários resultados: escolha uma faixa na pesquisa. Adicionar às músicas curtidas Remover das músicas curtidas + Aleatório ativado + Aleatório desativado + Repetição desativada + Repetir tudo + Repetir uma música Muitos resultados. Refine sua pesquisa. diff --git a/core/player/src/main/res/values-tr/car.xml b/core/player/src/main/res/values-tr/car.xml index 8f6fe46..0685b0e 100644 --- a/core/player/src/main/res/values-tr/car.xml +++ b/core/player/src/main/res/values-tr/car.xml @@ -13,5 +13,10 @@ Birden çok sonuç: arama sonuçlarından bir parça seçin. Beğenilen parçalara ekle Beğenilen parçalardan kaldır + Karışık açık + Karışık kapalı + Tekrar kapalı + Tümünü tekrarla + Bir şarkıyı tekrarla Çok fazla sonuç. Aramanızı daraltın. From 71b128ac85e0f52f249d87b8940ca49d865ab946 Mon Sep 17 00:00:00 2001 From: Downtify agent Date: Mon, 9 Nov 2026 07:35:21 +0000 Subject: [PATCH 11/11] =?UTF-8?q?fix(player):=20file=20d'attente=20et=20or?= =?UTF-8?q?dre=20restaur=C3=A9=20sur=20la=20fa=C3=A7ade=20de=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - « Ajouter à la file » et le déplacement d'un titre ne convertissent plus le lecteur de session en ExoPlayer : la session porte LocalDjPlayer, donc le transtypage échouait avant de toucher la file. Les deux commandes passent par QueueEdit sur l'interface Player, qui met aussi l'ordre mélangé à jour sur le pont audio actif. - LocalDjPlayer.restoreShuffleOrder reconstruit son état : la façade gardait l'ancienne permutation, donc l'ordre restauré n'était pas celui qui jouait ni celui que Suivant suivait. - Tests QueueEditTest : ajout avec et sans aléatoire, file vide, déplacement en ordre direct et mélangé, refus du titre courant, opérations via la façade, permutation restaurée. - docs/android-auto.md : boutons Aléatoire/Répétition, règle « répéter un titre » au lancement de l'aléatoire, aléatoire complet, Suivant dans le mix. - Version 0.4.0-beta5, versionCode 14. --- app/build.gradle.kts | 4 +- .../downtify/core/player/LocalDjPlayer.kt | 2 + .../downtify/core/player/PlaybackService.kt | 53 +----- .../downtify/core/player/QueueEdit.kt | 63 +++++++ .../downtify/core/player/QueueEditTest.kt | 157 ++++++++++++++++++ docs/android-auto.md | 8 + 6 files changed, 235 insertions(+), 52 deletions(-) create mode 100644 core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/QueueEdit.kt create mode 100644 core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/QueueEditTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9cf5bb9..df507a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.henriquesebastiao.downtify" - versionCode = 13 - versionName = "0.4.0-beta4" + versionCode = 14 + versionName = "0.4.0-beta5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt index fb44a62..0f2d109 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt @@ -69,6 +69,8 @@ class LocalDjPlayer(first: Player, second: Player, private val scope: CoroutineS (engine.active as? androidx.media3.exoplayer.ExoPlayer)?.setShuffleOrder( androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder(order, 0L), ) + // The facade caches its state, so it keeps the deck's previous order until rebuilt. + invalidateState() } fun updateAnalyses(values: Map) = engine.updateAnalyses(values) diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt index eff6bfe..1a915c9 100644 --- a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlaybackService.kt @@ -19,7 +19,6 @@ import androidx.media3.exoplayer.audio.AudioOffloadSupport import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import androidx.media3.session.CacheBitmapLoader import androidx.media3.session.CommandButton import androidx.media3.session.DefaultMediaNotificationProvider @@ -45,13 +44,11 @@ import com.henriquesebastiao.downtify.core.data.sync.SyncCoordinator import com.henriquesebastiao.downtify.core.model.DjIds import com.henriquesebastiao.downtify.core.model.EpisodeIds import com.henriquesebastiao.downtify.core.model.MediaIds -import com.henriquesebastiao.downtify.core.model.PlayQueue import com.henriquesebastiao.downtify.core.model.StreamIds import com.henriquesebastiao.downtify.core.network.di.StreamingClient import com.henriquesebastiao.downtify.core.network.session.SessionStore import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlin.random.Random import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -419,50 +416,6 @@ class PlaybackService : MediaLibraryService() { .setSlots(CommandButton.SLOT_OVERFLOW) .build() - /** - * Puts [item] after the current song and anything queued before it, ahead of the rest of - * the album or playlist — in the shuffled order too, where ExoPlayer alone would put it - * at a random spot. With nothing loaded it becomes the queue, paused. - */ - private fun addToQueue(player: ExoPlayer, item: MediaItem) { - if (player.mediaItemCount == 0) { - player.setMediaItem(item) - player.prepare() - return - } - val anchor = PlayQueue.anchor(PlayOrder.fromCurrent(player)) { - MediaItems.isQueued(player.getMediaItemAt(it)) - } ?: return - if (player.shuffleModeEnabled) { - val shuffled = PlayOrder.shuffled(player.currentTimeline) - val newIndex = player.mediaItemCount - player.addMediaItem(item) - val order = PlayQueue.shuffleOrderWith(shuffled, anchor, newIndex) - player.setShuffleOrder(DefaultShuffleOrder(order.toIntArray(), Random.nextLong())) - } else { - player.addMediaItem(anchor + 1, item) - } - } - - /** - * Moves the item at [from] to play right after the item at [after] (indices in the - * player's list), marked as queued or not. With shuffle on only the shuffled order - * changes; otherwise the item moves in the list. - */ - private fun moveInQueue(player: ExoPlayer, from: Int, after: Int, queued: Boolean) { - val count = player.mediaItemCount - if (from !in 0 until count || after !in 0 until count || from == after) return - if (from == player.currentMediaItemIndex) return - val item = player.getMediaItemAt(from) - if (MediaItems.isQueued(item) != queued) player.replaceMediaItem(from, MediaItems.withQueued(item, queued)) - if (player.shuffleModeEnabled) { - val order = PlayQueue.moveAfter(PlayOrder.shuffled(player.currentTimeline), from, after) - player.setShuffleOrder(DefaultShuffleOrder(order.toIntArray(), Random.nextLong())) - } else { - player.moveMediaItem(from, PlayQueue.moveTarget(from, after)) - } - } - private fun likeButton(liked: Boolean): CommandButton = CommandButton.Builder(if (liked) CommandButton.ICON_HEART_FILLED else CommandButton.ICON_HEART_UNFILLED) .setDisplayName(getString(if (liked) R.string.player_unlike else R.string.player_like)) @@ -561,8 +514,8 @@ class PlaybackService : MediaLibraryService() { ACTION_ADD_TO_QUEUE -> return requestQueueAdd(session, args) - ACTION_MOVE_IN_QUEUE -> moveInQueue( - session.player as ExoPlayer, + ACTION_MOVE_IN_QUEUE -> QueueEdit.move( + session.player, from = args.getInt(EXTRA_FROM, C.INDEX_UNSET), after = args.getInt(EXTRA_AFTER, C.INDEX_UNSET), queued = args.getBoolean(EXTRA_QUEUED), @@ -594,7 +547,7 @@ class PlaybackService : MediaLibraryService() { private fun requestQueueAdd(session: MediaSession, args: Bundle): ListenableFuture { val track = args.getString(EXTRA_TRACK_ID)?.let { library.library.value?.byId?.get(it) } ?: return Futures.immediateFuture(SessionResult(SessionError.ERROR_BAD_VALUE)) - addToQueue(session.player as ExoPlayer, MediaItems.from(track, sessions.current?.baseUrl, queued = true)) + QueueEdit.add(session.player, MediaItems.from(track, sessions.current?.baseUrl, queued = true)) return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } diff --git a/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/QueueEdit.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/QueueEdit.kt new file mode 100644 index 0000000..9e869bd --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/QueueEdit.kt @@ -0,0 +1,63 @@ +package com.henriquesebastiao.downtify.core.player + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import com.henriquesebastiao.downtify.core.model.PlayQueue + +/** + * "Add to queue" and queue moves. They work on the [Player] the session exposes, which is the + * [LocalDjPlayer] facade and not an [ExoPlayer]: casting the session player was a class cast + * error, so both commands failed before touching the queue. + */ +@UnstableApi +internal object QueueEdit { + /** Plays [item] right after the current one, then after anything already queued. */ + fun add(player: Player, item: MediaItem) { + if (player.mediaItemCount == 0) { + player.setMediaItem(item) + player.prepare() + return + } + val anchor = PlayQueue.anchor(PlayOrder.fromCurrent(player)) { + MediaItems.isQueued(player.getMediaItemAt(it)) + } ?: return + if (player.shuffleModeEnabled) { + val shuffled = PlayOrder.shuffled(player.currentTimeline) + val newIndex = player.mediaItemCount + player.addMediaItem(item) + applyShuffleOrder(player, PlayQueue.shuffleOrderWith(shuffled, anchor, newIndex)) + } else { + player.addMediaItem(anchor + 1, item) + } + } + + /** + * Moves the item at [from] to play right after the item at [after] (indices in the player's + * list), marked as queued or not. With shuffle on only the shuffled order changes. + */ + fun move(player: Player, from: Int, after: Int, queued: Boolean) { + val count = player.mediaItemCount + if (from !in 0 until count || after !in 0 until count || from == after) return + if (from == player.currentMediaItemIndex) return + val item = player.getMediaItemAt(from) + if (MediaItems.isQueued(item) != queued) player.replaceMediaItem(from, MediaItems.withQueued(item, queued)) + if (player.shuffleModeEnabled) { + val order = PlayQueue.moveAfter(PlayOrder.shuffled(player.currentTimeline), from, after) + applyShuffleOrder(player, order) + } else { + player.moveMediaItem(from, PlayQueue.moveTarget(from, after)) + } + } + + /** Media3 keeps the shuffled order on the deck; the facade owns the deck that is playing. */ + private fun applyShuffleOrder(player: Player, order: List) { + val array = order.toIntArray() + when (player) { + is LocalDjPlayer -> player.restoreShuffleOrder(array) + is ExoPlayer -> player.setShuffleOrder(DefaultShuffleOrder(array, java.util.Random().nextLong())) + } + } +} diff --git a/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/QueueEditTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/QueueEditTest.kt new file mode 100644 index 0000000..9525c7f --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/QueueEditTest.kt @@ -0,0 +1,157 @@ +package com.henriquesebastiao.downtify.core.player + +import android.os.Looper +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * "Add to queue" and queue moves used to cast the session player to ExoPlayer, so both commands + * failed on the LocalDjPlayer facade. These tests drive the real operations on a deck and through + * the facade, including the shuffled order restored from a previous session. + */ +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class QueueEditTest { + private fun deck(): ExoPlayer = ExoPlayer.Builder(RuntimeEnvironment.getApplication()) + .setLooper(Looper.getMainLooper()) + .setMediaSourceFactory(DefaultMediaSourceFactory(mockk())) + .build() + + private fun list(player: Player) = (0 until player.mediaItemCount).map { player.getMediaItemAt(it).mediaId } + + /** The item that plays right after [index], in the order the player will follow. */ + private fun successor(player: Player, index: Int): Int = + player.currentTimeline.getNextWindowIndex(index, Player.REPEAT_MODE_OFF, true) + + /** What plays next, as the player will play it, starting with the current item. */ + private fun playing(player: Player) = PlayOrder.fromCurrent(player).map { player.getMediaItemAt(it).mediaId } + + @Test fun `add to queue plays right after the current song`() { + val player = deck() + try { + player.setMediaItems(listOf("a", "b", "c").map(::localDjItem), 0, C.TIME_UNSET) + QueueEdit.add(player, localDjItem("q")) + assertEquals(listOf("a", "q", "b", "c"), list(player)) + } finally { + player.release() + } + } + + @Test fun `add to queue in shuffle plays right after the current song`() { + val player = deck() + try { + player.setMediaItems((0 until 6).map { localDjItem("t$it") }, 0, C.TIME_UNSET) + player.shuffleModeEnabled = true + QueueEdit.add(player, localDjItem("q")) + assertEquals( + "le titre mis en file suit immédiatement le titre courant", + listOf("t0", "q"), + playing(player).take(2), + ) + } finally { + player.release() + } + } + + @Test fun `add to queue works through the session facade`() = runTest { + val facade = LocalDjPlayer(deck(), deck(), backgroundScope) + try { + facade.setMediaItems((0 until 5).map { localDjItem("t$it") }, 0, C.TIME_UNSET) + facade.shuffleModeEnabled = true + QueueEdit.add(facade, localDjItem("q")) + assertEquals(6, facade.mediaItemCount) + assertEquals(listOf("t0", "q"), playing(facade).take(2)) + } finally { + facade.release() + } + } + + @Test fun `an empty queue takes the added song and prepares it`() { + val player = deck() + try { + QueueEdit.add(player, localDjItem("q")) + assertEquals(listOf("q"), list(player)) + } finally { + player.release() + } + } + + @Test fun `moving a song without shuffle moves it in the list`() { + val player = deck() + try { + player.setMediaItems(listOf("a", "b", "c", "d").map(::localDjItem), 0, C.TIME_UNSET) + QueueEdit.move(player, from = 3, after = 1, queued = false) + assertEquals(listOf("a", "b", "d", "c"), list(player)) + } finally { + player.release() + } + } + + @Test fun `moving a song in shuffle puts it right after the chosen one and marks it queued`() { + val player = deck() + try { + player.setMediaItems((0 until 5).map { localDjItem("t$it") }, 0, C.TIME_UNSET) + player.shuffleModeEnabled = true + QueueEdit.move(player, from = 4, after = 1, queued = true) + assertTrue( + "le titre déplacé est marqué comme mis en file", + MediaItems.isQueued(player.getMediaItemAt(4)), + ) + assertEquals("t4 suit t1 dans l'ordre de lecture", 4, successor(player, 1)) + } finally { + player.release() + } + } + + @Test fun `moving the current song is refused`() { + val player = deck() + try { + player.setMediaItems(listOf("a", "b", "c").map(::localDjItem), 0, C.TIME_UNSET) + QueueEdit.move(player, from = 0, after = 2, queued = false) + assertEquals(listOf("a", "b", "c"), list(player)) + } finally { + player.release() + } + } + + @Test fun `moving in the shuffled order works through the session facade`() = runTest { + val facade = LocalDjPlayer(deck(), deck(), backgroundScope) + try { + facade.setMediaItems((0 until 5).map { localDjItem("t$it") }, 0, C.TIME_UNSET) + facade.shuffleModeEnabled = true + QueueEdit.move(facade, from = 3, after = 1, queued = true) + assertEquals(5, facade.mediaItemCount) + assertEquals("t3 suit t1 dans l'ordre de lecture", 3, successor(facade, 1)) + } finally { + facade.release() + } + } + + @Test fun `a saved permutation restored on the session player is the one that plays`() = runTest { + val facade = LocalDjPlayer(deck(), deck(), backgroundScope) + try { + facade.setMediaItems((0 until 6).map { localDjItem("t$it") }, 0, C.TIME_UNSET) + facade.shuffleModeEnabled = true + val saved = listOf(3, 0, 5, 2, 4, 1) + facade.restoreShuffleOrder(saved.toIntArray()) + // The facade must publish the deck's order: playback continues with the saved successor. + assertEquals("le successeur enregistré est celui qui joue", 5, successor(facade, 0)) + } finally { + facade.release() + } + } +} diff --git a/docs/android-auto.md b/docs/android-auto.md index 6e8f573..8300232 100644 --- a/docs/android-auto.md +++ b/docs/android-auto.md @@ -10,6 +10,14 @@ Text and voice search match local title, artist and album metadata. A unique pla The existing sync coordinator runs while either the phone UI or the media service is active, with one shared sync loop. Reopening a catalog page does not rescan files or call server search. No server endpoint changes are required. +## Player controls + +The overflow menu exposes Shuffle and Repeat next to Like. Their icons and labels follow the real player state: they are rebuilt from the player's shuffle and repeat callbacks, not from a guess. Repeat cycles off → all → one → off. + +Starting a collection with Shuffle explicitly is a fresh listening session: a "repeat one" is turned into "repeat all" so the queue is heard once instead of the same song forever, and a "repeat all" is kept. Shuffle starts on the first item of Media3's permutation, so every entry is heard — picking a random start index dropped everything before it (measured: 17.9 of 40 songs lost per launch, 0 after the fix). Duplicate occurrences stay separate entries, and a saved permutation is restored exactly. + +Next and Previous stay inside a prepared DJ render: they jump to the next marker of the plan instead of leaving the mix. Adding to the queue and moving a song inside it work on the session player itself (the DJ facade), shuffled order included. + ## Artwork and controllers Trusted system/media controllers can browse and play. App-specific queue commands remain private to the phone app. External requests resolve known catalog IDs; supplied audio URLs are discarded and unknown IDs are rejected.