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