Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
11 changes: 9 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions app/src/beta/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<profileable android:shell="true" />
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,7 +32,7 @@ class DowntifyApplication :
@Inject lateinit var offline: OfflineRepository

@Inject
@DefaultClient
@ArtworkClient
lateinit var httpClient: Lazy<OkHttpClient>

override val workManagerConfiguration: Configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -216,6 +219,9 @@ fun CollectionScreen(
onPlayOrPause,
onShuffle,
onToggleOffline,
headerArtwork,
headerExtras,
showOffline,
)
}
}
Expand Down Expand Up @@ -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(
Expand All @@ -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),
Expand All @@ -327,7 +340,8 @@ private fun Header(
}
Details(state)
}
Actions(state, title, onPlayOrPause, onShuffle, onToggleOffline)
headerExtras?.invoke()
Actions(state, title, onPlayOrPause, onShuffle, onToggleOffline, showOffline)
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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,
)
}

Expand All @@ -113,6 +149,9 @@ fun HomeScreen(
onOpenPodcasts: () -> Unit = {},
onOpenSimilar: () -> Unit = {},
onOpenMenu: () -> Unit = {},
onOpenDj: () -> Unit = {},
forYouContent: (LazyListScope.() -> Unit)? = null,
onRefreshForYou: () -> Unit = {},
) {
Scaffold(
modifier = modifier,
Expand Down Expand Up @@ -153,7 +192,10 @@ fun HomeScreen(
) { padding ->
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
onRefresh = {
onRefresh()
onRefreshForYou()
},
modifier = Modifier.fillMaxSize().padding(padding),
) {
LazyColumn(
Expand All @@ -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) {
Expand All @@ -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) }
}
Expand All @@ -197,6 +241,7 @@ fun HomeScreen(
onOpenDiscover = onOpenDiscover,
onOpenPodcasts = onOpenPodcasts,
onOpenSimilar = onOpenSimilar,
onOpenDj = onOpenDj,
)
}
}
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand All @@ -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)
}
}
}

Expand Down
Loading
Loading