diff --git a/README.md b/README.md index ac126f4..574b2a7 100644 --- a/README.md +++ b/README.md @@ -118,3 +118,59 @@ 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 + +**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 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. +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. +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, +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..df507a0 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 = 14 + versionName = "0.4.0-beta5" 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 34990fe..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 @@ -15,6 +15,7 @@ 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 @@ -31,6 +32,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 +47,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 +69,14 @@ 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.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 @Composable fun HomeRoute( @@ -82,8 +90,22 @@ fun HomeRoute( onOpenSimilar: () -> Unit, modifier: Modifier = Modifier, viewModel: HomeViewModel = hiltViewModel(), + onOpenForYou: () -> Unit = {}, + onOpenMoodPlaylist: (String, String) -> Unit = { _, _ -> }, + onOpenDj: () -> Unit = {}, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val musicAi: MusicAiViewModel = hiltViewModel() + val aiState by musicAi.state.collectAsStateWithLifecycle() + val aiCaps by musicAi.capabilities.collectAsStateWithLifecycle() + LaunchedEffect(aiCaps.features, aiCaps.homeCards) { musicAi.refreshHome() } + val lifecycle = LocalLifecycleOwner.current + LaunchedEffect(lifecycle, musicAi) { + lifecycle.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + musicAi.refreshHome() + kotlinx.coroutines.awaitCancellation() + } + } HomeScreen( state, onOpenSettings, @@ -96,6 +118,20 @@ fun HomeRoute( onOpenPodcasts, onOpenSimilar, onOpenMenu = onOpenMenu, + forYouContent = { + if (listOf("daily", "weekly", "personalization").any(aiCaps::enabled)) { + item(key = "daily-mixes") { + com.henriquesebastiao.downtify.feature.musicai.DailyMusicShelf( + aiState, + aiCaps, + onOpenMoodPlaylist, + onOpenForYou, + ) + } + } + }, + onOpenDj = onOpenDj, + onRefreshForYou = musicAi::refreshHome, ) } @@ -113,6 +149,9 @@ fun HomeScreen( onOpenPodcasts: () -> Unit = {}, onOpenSimilar: () -> Unit = {}, onOpenMenu: () -> Unit = {}, + onOpenDj: () -> Unit = {}, + forYouContent: (LazyListScope.() -> Unit)? = null, + onRefreshForYou: () -> Unit = {}, ) { Scaffold( modifier = modifier, @@ -153,7 +192,10 @@ fun HomeScreen( ) { padding -> PullToRefreshBox( isRefreshing = state.refreshing, - onRefresh = onRefresh, + onRefresh = { + onRefresh() + onRefreshForYou() + }, modifier = Modifier.fillMaxSize().padding(padding), ) { LazyColumn( @@ -163,6 +205,7 @@ 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) { @@ -184,6 +227,7 @@ fun HomeScreen( if (state.jumpBackIn.isNotEmpty() || state.favoritePlaylists.isNotEmpty()) { item(key = "access") { HomeAccess(state, onOpenRecent) } } + forYouContent?.invoke(this) if (state.recentlyAdded.isNotEmpty()) { item(key = "recent") { RecentlyAdded(state.recentlyAdded, onOpenAlbum, onSeeAll) } } @@ -197,6 +241,7 @@ fun HomeScreen( onOpenDiscover = onOpenDiscover, onOpenPodcasts = onOpenPodcasts, onOpenSimilar = onOpenSimilar, + onOpenDj = onOpenDj, ) } } @@ -239,6 +284,7 @@ private fun Shortcuts( onOpenDiscover: () -> Unit, onOpenPodcasts: () -> Unit, onOpenSimilar: () -> Unit, + onOpenDj: () -> Unit, ) { Column( Modifier.padding(horizontal = Spacing.screen).padding(top = Spacing.xl), @@ -252,12 +298,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, @@ -266,6 +314,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/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/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 new file mode 100644 index 0000000..14c1f17 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiScreen.kt @@ -0,0 +1,176 @@ +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", + "dj_trial" to "DJ non qualifié — essai bêta volontaire", +) + +@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..4c3e3db --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicAiViewModel.kt @@ -0,0 +1,271 @@ +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.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 + +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 = "", + val selectionId: 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() + 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 { + 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 = musicAiFailureMessage(error), + ) + } + } 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 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) } + } + } + 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 = "", + 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, + selectionId = java.util.UUID.randomUUID().toString(), + ) + } + } + 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), + 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 new file mode 100644 index 0000000..95546b5 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjPanel.kt @@ -0,0 +1,188 @@ +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 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 + 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.interactiveAvailable() + 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.trackCount ?: 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 (listening == session.id && 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, openPlayer) }) { 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..7a19549 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicDjViewModel.kt @@ -0,0 +1,191 @@ +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.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(), + val busy: Boolean = false, + val message: String = "", +) + +@HiltViewModel +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 + 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 + + 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() + 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.interactiveAvailable() + } + 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) { + 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 (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 + } + action { commandRemote(session, operation, payload) } + } + + 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() + 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 + ownBusy = true + mutable.update { it.copy(busy = true, message = "") } + viewModelScope.launch { + try { + mutex.withLock { work() } + refresh() + } catch (error: MusicAiFailure) { + mutable.update { + it.copy( + message = musicAiFailureMessage(error), + ) + } + } finally { + ownBusy = false + mutable.update { it.copy(busy = coordinator.busy.value) } + } + } + } + + fun play(session: MusicDjSessionDto, onReady: () -> Unit = {}) { + if (session.state !in setOf("ready", "playing", "paused") || + !music.capabilities.value.enabled("dj_" + session.mode) + ) { + return + } + 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}") + 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)) } + } + } + } + + 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..64787ce --- /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 + +internal val moodNames = mapOf( + "calme" to "Calme", + "concentration" to "Concentration", + "energie" to "Motivation", + "fete" to "Fête", + "melancolie" to "Mélancolie", + "detente" to "Détente", +) +internal 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), +) +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) + +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.interactiveAvailable(), + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Text(label, maxLines = 1) + Text("DJ continu", style = MaterialTheme.typography.labelSmall) + } + } + } + } + } + } + if (!caps.interactiveAvailable()) { + Text( + "Activez un moteur qualifié ou l’essai bêta volontaire dans Paramètres → IA.", + 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, { + 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/musicai/MusicLibraryPages.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt new file mode 100644 index 0000000..c32b320 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/musicai/MusicLibraryPages.kt @@ -0,0 +1,251 @@ +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, +) { + 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() + 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) + } + 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 = !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( + 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 = !queueChecking || queueArmed || queueBusy, + ) { + 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()) + } + val queueDetail = queueMessage.ifBlank { queueAvailability.reason } + if (queueDetail.isNotBlank()) Text(queueDetail) + if (!caps.enabled("dj_prepared") && + !caps.enabled("dj_interactive") + ) { + Text("Activez un mode DJ dans Paramètres → IA, puis autorisez-le sur le serveur.") + } + } + if (!localDevice) 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/MiniPlayer.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/MiniPlayer.kt index f4ac9d5..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,12 +40,14 @@ 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( - 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..65f9469 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/PlayerOptions.kt @@ -0,0 +1,77 @@ +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 + val startDj = com.henriquesebastiao.downtify.ui.common.LocalSongDjMix.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() + } + 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 = { + 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..78830b1 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/feature/player/QueueDjViewModel.kt @@ -0,0 +1,205 @@ +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( + private val coordinator: DjPlaybackCoordinator, + private val local: LocalDjSettings, + private val player: PlayerController, + private val music: MusicAiRepository, +) : ViewModel() { + val capabilities = coordinator.capabilities + val localDevice = local.device + val localTransition = local.transition + 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() { + 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() + } + } + + 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 (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 237b1b2..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 @@ -86,8 +87,24 @@ 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 = "", + 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 * ("Next in queue"), then the rest of the album or playlist. Tap a row to play @@ -100,6 +117,7 @@ fun QueueScreen( actions: QueueActions, controls: NowPlayingActions, modifier: Modifier = Modifier, + dj: QueueDjState = QueueDjState(), ) { val now = nowPlayingRow(state) ?: return val listState = rememberLazyListState() @@ -119,6 +137,39 @@ fun QueueScreen( ) { Column(Modifier.fillMaxSize().safeDrawingPadding()) { TopBar(state.context, actions.onClose) + if (dj.visible || state.dj != null) { + TextButton( + onClick = actions.onToggleDj, + enabled = !dj.checking || dj.active || state.dj != null, + modifier = Modifier.padding(horizontal = Spacing.screen), + ) { + 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 { + dj.reason.ifBlank { "Vérifiez les réglages DJ dans Paramètres → IA." } + }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = Spacing.screen), + ) + } + } LazyColumn( state = listState, contentPadding = PaddingValues(horizontal = Spacing.screen, vertical = Spacing.sm), @@ -137,7 +188,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 +214,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 +473,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..04e8199 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 @@ -175,8 +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 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) } @@ -213,6 +226,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 +235,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 +275,14 @@ 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.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, + ) { Box(Modifier.fillMaxSize().then(content)) { MoveToTrashHost(onMessage = { text -> scope.launch { snackbar.showSnackbar(text) } }) { AppNavHost(navController, onOpenMenu = { scope.launch { drawerState.open() } }) @@ -289,17 +313,23 @@ 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, + com.henriquesebastiao.downtify.ui.common.LocalSongDjMix provides djMixAction, + ) { + 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 +351,11 @@ private fun AppNavHost(navController: NavHostController, onOpenMenu: () -> Unit) onOpenDiscover = { navController.navigate(DiscoverDestination) }, onOpenPodcasts = { navController.navigate(PodcastsDestination) }, onOpenSimilar = { navController.navigate(SimilarRoute()) }, + 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 { @@ -349,6 +384,35 @@ private fun AppNavHost(navController: NavHostController, onOpenMenu: () -> Unit) composable { SimilarScreenRoute(onBack = navController::popBackStack) } + 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, @@ -370,7 +434,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 +455,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,10 +522,22 @@ 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() + 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 @@ -483,6 +560,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 +607,7 @@ private fun NowPlayingOverlay( full = { coverModifier -> NowPlayingScreen( state = state, + sleepActive = sleepDeadline != null, isLiked = isLiked, serverName = serverName, downloadJob = streamJob, @@ -554,8 +633,20 @@ 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.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/LibraryItems.kt b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/LibraryItems.kt index 84bb5e2..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 @@ -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,8 @@ 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 + val startDj = LocalSongDjMix.current var open by remember { mutableStateOf(false) } Box { CircleButton( @@ -301,6 +303,24 @@ 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) + }, + ) + } + 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)) }, @@ -350,14 +370,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/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/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/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 new file mode 100644 index 0000000..7662c89 --- /dev/null +++ b/app/src/main/kotlin/com/henriquesebastiao/downtify/ui/common/SongMix.kt @@ -0,0 +1,347 @@ +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.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, + private val library: LibraryRepository, + 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) { + 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 + 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 +} + +@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 47d6607..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 @@ -14,6 +14,12 @@ import kotlinx.serialization.Serializable @Serializable data object DownloadsRoute +@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 @@ -41,7 +47,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 +72,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..12cdd39 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -551,4 +551,9 @@ По-силно Максимум Увеличава звука до безопасната граница за всяка песен. Аудиофайловете не се променят. + Вашата музика + Сървър + Това устройство · Бета + Смесвайте текущата опашка на телефона с кратки плавни преходи. Анализът на ритъма е в подготовка. Няма автоматично изтегляне на модел. Батерията и качеството на звука предстои да се тестват. + Сървърът подготвя преходите. Телефонът възпроизвежда удостоверения аудиопоток. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index b9bad1f..d95b483 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -551,4 +551,9 @@ Πιο δυνατά Μέγιστο Αυξάνει την ένταση εντός του ασφαλούς ορίου κάθε τραγουδιού. Τα αρχεία ήχου δεν αλλάζουν. + Η μουσική σας + Διακομιστής + Αυτή η συσκευή · Beta + Μίξη της τρέχουσας ουράς στο τηλέφωνο με σύντομες μεταβάσεις. Η ανάλυση ρυθμού ετοιμάζεται. Δεν γίνεται αυτόματη λήψη μοντέλου. Εκκρεμούν δοκιμές μπαταρίας και ήχου. + Ο διακομιστής ετοιμάζει τις μεταβάσεις. Το τηλέφωνο αναπαράγει την πιστοποιημένη ροή ήχου. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index bfc3e0e..c790f2c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -565,4 +565,9 @@ 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 + 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 5c9bcae..71797a6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -565,4 +565,9 @@ Plus fort 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 2511c7a..c2affd8 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -551,4 +551,9 @@ 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 + 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 80108a0..fd2e43e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -565,4 +565,9 @@ Mais alto 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 7a1683d..bc8776b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -551,4 +551,9 @@ 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 + 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 8af7328..35c4387 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ + Your music Downtify @@ -559,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/CarServiceFixture.kt b/app/src/test/kotlin/com/henriquesebastiao/downtify/CarServiceFixture.kt index c6bdc1e..a080fba 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,18 @@ internal class CarServiceFixture { every { likedIds } returns MutableStateFlow(listOf("a")) 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 } + 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/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 new file mode 100644 index 0000000..572e9b2 --- /dev/null +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/SongMixViewModelTest.kt @@ -0,0 +1,346 @@ +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.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) +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() + } + } + + @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 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 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(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", "")), + ) + } + } +} 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/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/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..839a73d --- /dev/null +++ b/app/src/test/kotlin/com/henriquesebastiao/downtify/feature/musicai/TemporaryMusicPlaylistTest.kt @@ -0,0 +1,93 @@ +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/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/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/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/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/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 new file mode 100644 index 0000000..32a6b24 --- /dev/null +++ b/core/data/src/main/kotlin/com/henriquesebastiao/downtify/core/data/musicai/MusicAiRepository.kt @@ -0,0 +1,348 @@ +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +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 + +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. */ +// One owner-scoped facade mirrors the independent wire endpoints. +@Suppress("TooManyFunctions") +@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 -> + 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(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() + } + return@withLock if (error.cause is IOException) { + ServerResult.Unreachable + } else { + ServerResult.Failed( + error.status, + ) + } + } + 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(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, + 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 { + playbackBody(response) + } + 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( + requestId, + "fete", + ids.size, + "interactive", + ids = ids, + queueOrder = true, + trial = trial(), + ), + ) + } + 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, + trial = trial(), + ), + ) + } + 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(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(force = true) + invalidateHome() + } + suspend fun globalFeatures() = call { it.globalFeatures() } + suspend fun setGlobal(feature: String, enabled: Boolean) { + call { it.globalFeatures(mapOf(feature to enabled)) } + 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 + val sameCache = cachedHomeOwner == ownerKey && cachedHome != null + val fresh = now - homeAt in 0..60_000 && homeAt != 0L + if (sameCache && fresh) { + 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 + 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 + available + } 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() } + invalidateHome() + refresh(force = true) + } + 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(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) + } + } +} + +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/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..c999f77 --- /dev/null +++ b/core/data/src/test/kotlin/com/henriquesebastiao/downtify/core/data/similar/SimilarAvailabilityTest.kt @@ -0,0 +1,126 @@ +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, mockk(relaxed = true), 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(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/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..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 @@ -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,6 +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 { + 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() + 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( + 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 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..ac94256 --- /dev/null +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicAiApi.kt @@ -0,0 +1,67 @@ +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/home") + suspend fun home(): com.henriquesebastiao.downtify.core.network.dto.MusicAiHomeDto + + @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..7daba8d --- /dev/null +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/MusicDjApi.kt @@ -0,0 +1,43 @@ +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 { + @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( + @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, + @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/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 new file mode 100644 index 0000000..47b01b0 --- /dev/null +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicAiDtos.kt @@ -0,0 +1,79 @@ +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, + @SerialName("interactive_trial_ready") val interactiveTrialReady: 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(), + @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( + 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 = "", + val duration: Double = 0.0, +) + +@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, + @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) + +@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..f3832b7 --- /dev/null +++ b/core/network/src/main/kotlin/com/henriquesebastiao/downtify/core/network/dto/MusicDjDtos.kt @@ -0,0 +1,66 @@ +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", + @SerialName("seed_id") val seedId: String = "", + val trial: Boolean = false, + @SerialName("skip_seed") val skipSeed: Boolean = false, +) + +@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, + val cue: Double = 0.0, + val speed: Double = 1.0, + val occurrence: Int = -1, +) + +@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, + @SerialName("queue_order") val queueOrder: Boolean = false, + @SerialName("track_count") val trackCount: Int? = null, +) + +@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/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 new file mode 100644 index 0000000..f473e81 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTracker.kt @@ -0,0 +1,192 @@ +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 com.henriquesebastiao.downtify.core.network.session.Session +import kotlinx.coroutines.CoroutineScope +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) +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 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 { + commands.withLock { + if (!isCurrent(reference, mine, owner) || !music.capabilities.value.enabled("dj_interactive")) { + 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 (!terminal && 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 + val retired = current + polling?.cancel() + ticker?.cancel() + latest = null + lastMarker = null + val mine = ++generation + terminal = false + 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" && + 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) { + 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, mine) + delay(3000) + } + } + } + + 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 + 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 (!isCurrent(reference, mine, owner) || !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) { + // 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() + } + } + } catch (error: MusicAiFailure) { + if (mine == generation && current == reference && error.status in setOf(401, 403, 404, 409)) { + terminal = true + 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..16762fd --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjDataSource.kt @@ -0,0 +1,46 @@ +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 + +/** 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 + private val listeners = mutableListOf() + override fun addTransferListener(transferListener: TransferListener) { + listeners += transferListener + active?.addTransferListener(transferListener) + } + override fun open(dataSpec: DataSpec): Long { + check(active == null) + 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 + 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/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/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/DjPlaybackCoordinator.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt new file mode 100644 index 0000000..fd5d0f7 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinator.kt @@ -0,0 +1,502 @@ +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 +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 (awaitingHandover() && + leftOrigin(state.mediaKey, state.currentIndex) + ) { + cancel(true) + } + } + } + } + + private fun awaitingHandover() = + (mutableBusy.value || mutableArmed.value) && mutablePhase.value != DjPhase.Returning + + fun toggleQueue() { + if (hasDjWork()) { + stop() + return + } + val snapshot = player.state.value + val defer = snapshot.track != null + 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) } + } + + fun startTheme(theme: String, style: String) { + if (hasDjWork()) { + stop() + return + } + prepare("DJ continu · $theme", false) { nonce -> music.djContinuous(theme, style, nonce) } + } + + fun startSeed(seed: String) { + if (hasDjWork()) stop() + 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, "") + 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, 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 + } + } + } + + 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, + preserveQueue: Boolean = true, + requireSeed: Boolean = false, + create: suspend (String) -> MusicDjSessionDto, + ) { + 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 + pendingRequest = token.request + mutableBusy.value = true + mutablePhase.value = DjPhase.Preparing + mutableMessage.value = "Préparation des transitions ; votre écoute continue." + preparation = scope.launch { performPreparation(token, title, defer, preserveQueue, requireSeed, create) } + } + + private suspend fun performPreparation( + token: Preparation, + title: String, + defer: Boolean, + preserveQueue: Boolean, + requireSeed: Boolean, + create: suspend (String) -> MusicDjSessionDto, + ) { + var created: String? = null + try { + refreshCapabilities() + if (!matches(token)) return + if (!preparationAvailable(requireSeed)) 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 (error: MusicAiFailure) { + if (matches(token)) { + mutableMessage.value = failureMessage(error) + cleanupPreparation(token, created) + } + } finally { + 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, + title: String, + defer: Boolean, + preserveQueue: Boolean, + ): Boolean { + repeat(450) { + delay(2000) + if (!matches(token)) return true + val current = music.djSession(id) + 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, + title: String, + defer: Boolean, + preserveQueue: Boolean, + ) { + if (!matches(token)) return + 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 = "DJ prêt · démarrage à la fin du titre en cours." + } 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 { + if (token.owner != music.session.value) return@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 + 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/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/DjTimeline.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt new file mode 100644 index 0000000..d2384a2 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/DjTimeline.kt @@ -0,0 +1,139 @@ +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.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 { + 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 plan = NetworkJson.encodeToString(ListSerializer(MusicDjMarkerDto.serializer()), session.timeline) + val tracks = NetworkJson.encodeToString(ListSerializer(MusicAiTrackDto.serializer()), session.tracks) + val previous = item.mediaMetadata + if (sameMetadata(previous, session, track, plan, tracks)) 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) + 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() + } + + 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()), + 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) + } 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/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..170c141 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjEngine.kt @@ -0,0 +1,307 @@ +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) { + 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) { + 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 (!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) { + 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 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 + ) { + 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..0f2d109 --- /dev/null +++ b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/LocalDjPlayer.kt @@ -0,0 +1,268 @@ +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), + ) + // The facade caches its state, so it keeps the deck's previous order until rebuilt. + invalidateState() + } + + 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<*> { + 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) + } + + 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() + + /** 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/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/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/PlayOrder.kt b/core/player/src/main/kotlin/com/henriquesebastiao/downtify/core/player/PlayOrder.kt index ac71719..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 @@ -30,6 +30,27 @@ 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) + } + } + + /** + * 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/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 a43d08c..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 @@ -42,14 +41,14 @@ 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.PlayQueue +import com.henriquesebastiao.downtify.core.model.MediaIds 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 @@ -85,8 +84,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 @@ -117,9 +120,13 @@ 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 + 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,40 +135,15 @@ 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) + 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() 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) - .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) @@ -173,7 +155,12 @@ 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) scope.launch { withContext(Dispatchers.IO) { sessions.ensureLoaded() } @@ -185,6 +172,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() } @@ -192,13 +181,73 @@ class PlaybackService : MediaLibraryService() { TranscodeWarmer(player, resolver, streamingClient, scope, enabled = { !readAhead.enabled }).also { it.start() } - readAhead.start(player, scope) - keepLikeButtonCurrent(player) + scope.launch { + player.dominantDecoder.collect { decoder -> + readAhead.stop() + if (decoder is ExoPlayer) readAhead.start(decoder, scope) + } + } + keepButtonsCurrent(player) + stopDisabledDj(player) stopWhenSignedOut(player) removeDeletedLibraryTracks(player) 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 @@ -220,6 +269,25 @@ class PlaybackService : MediaLibraryService() { }.launchIn(scope) } + 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) + 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 +295,9 @@ class PlaybackService : MediaLibraryService() { val expected = sessions.current?.playbackOwner 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() @@ -244,16 +315,22 @@ 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() warmer?.stop() + localFocus?.release() + localDj.setArmed(false) + localDj.publish(LocalDjTransition()) scope.cancel() session?.run { player.release() @@ -274,72 +351,70 @@ 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) -> 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) } - /** - * 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) - } + /** 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 } - /** - * 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 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() private fun likeButton(liked: Boolean): CommandButton = CommandButton.Builder(if (liked) CommandButton.ICON_HEART_FILLED else CommandButton.ICON_HEART_UNFILLED) @@ -394,7 +469,8 @@ 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) + commands.add(shuffleFromHeadCommand).add(shuffleCommand).add(repeatCommand) } else { commands.add(likeCommand) } @@ -411,33 +487,68 @@ 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 && !EpisodeIds.isEpisode(id) && !StreamIds.isStream(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_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), + + 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), ) - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - if (customCommand.customAction == ACTION_MOVE_IN_QUEUE) { - moveInQueue( - session.player as ExoPlayer, + + ACTION_ADD_TO_QUEUE -> return requestQueueAdd(session, args) + + 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), ) - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + + else -> return super.onCustomCommand(session, controller, customCommand, args) + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + internal 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) + } } - return super.onCustomCommand(session, controller, customCommand, args) + } + + 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)) + QueueEdit.add(session.player, MediaItems.from(track, sessions.current?.baseUrl, queued = true)) + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } /** @@ -453,6 +564,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 +602,10 @@ 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 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/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..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 @@ -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 @@ -156,15 +160,47 @@ 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 + mutableSelectionVersion.value++ 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) { + player.repeatMode = PlayOrder.repeatAfterShuffle(player.repeatMode) + if (startIndex == 0) player.shuffleFromHead() + } player.playlistMetadata = MediaItems.contextMetadata(from) player.prepare() player.play() @@ -181,16 +217,17 @@ 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) { - 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) { + player.repeatMode = PlayOrder.repeatAfterShuffle(player.repeatMode) + if (startIndex == 0) player.shuffleFromHead() + } player.playlistMetadata = MediaItems.contextMetadata(from) player.prepare() player.play() @@ -213,6 +250,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 { @@ -236,7 +274,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() @@ -247,21 +285,202 @@ 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", + expectedSelection: Long? = null, + sessionPlan: com.henriquesebastiao.downtify.core.network.dto.MusicDjSessionDto? = null, + ) { + val owner = sessions.current?.playbackOwner ?: return + 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 extras = djClassicExtras(player, mode) + 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 + } + 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() + } + } + + 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, + 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) + 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), + planned, + ) + } + + fun exitDj(id: String) = command { player -> + val current = player.currentMediaItem + if (DjIds.referenceOf(current?.mediaId)?.id == id && current != null) { + 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) + } + } + } + + 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 + ) { + preferred + } else { + classic.indexOfFirst { row -> row.mediaId == point.id } + } + } else { + (extras?.getInt("djClassicIndex") ?: 0).coerceIn(0, (classic.size - 1).coerceAtLeast(0)) + } + 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 { 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() } + + /** 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 { - 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) } } - 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) { @@ -313,27 +532,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 +572,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 +625,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..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,24 +60,33 @@ 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, - ) + dj: DjAudioCacheStore, + ): 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) + val live = ResolvingDataSource.Factory(dj.factory, resolver) + return DataSource.Factory { DjDataSource(regular, direct, live, resolver::isDjHlsResource) } + } @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/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/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/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/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/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. 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/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 new file mode 100644 index 0000000..b9fd163 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjControlTrackerTest.kt @@ -0,0 +1,149 @@ +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) + 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 { + 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 `render status never changes local playback intent and release stops polling`() = 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 = 0) { player.pause() } + status = status.copy(state = "playing") + advanceTimeBy(3001) + runCurrent() + 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/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/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/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/DjPlaybackCoordinatorTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinatorTest.kt new file mode 100644 index 0000000..574e3db --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjPlaybackCoordinatorTest.kt @@ -0,0 +1,288 @@ +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 +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.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 +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) + 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 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 { + selection.value++ + state.value = PlayerState(dj = PlayingDj(firstArg(), "Fixture", arg(3))) + } + every { player.exitDj(any()) } answers { state.value = PlayerState() } + } + 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 { + 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/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/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/DjTimelineTest.kt b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjTimelineTest.kt new file mode 100644 index 0000000..7a11837 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/DjTimelineTest.kt @@ -0,0 +1,78 @@ +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.Assert.assertSame +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) + } + + @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/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/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/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/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/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/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/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/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..7fe7ca5 --- /dev/null +++ b/core/player/src/test/kotlin/com/henriquesebastiao/downtify/core/player/ShuffleStartTest.kt @@ -0,0 +1,143 @@ +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 +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() + } + } + + @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)) + } +} 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. diff --git a/docs/music-ai.md b/docs/music-ai.md new file mode 100644 index 0000000..d27a8d9 --- /dev/null +++ b/docs/music-ai.md @@ -0,0 +1,133 @@ +# 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 + +- 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. +- 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 + +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. + +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 + +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. 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 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 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 +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 +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" } 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")