Files
subtracks/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt
T
austinried 7ac79288b3 download a song to the device and play it from there
First block of #65: the state machine, the platform download engine, a
single-song download from the song context menu, the downloaded indicator in
song lists, and local-first playback.

- `song_downloads`, keyed `(sourceId, songId)` with CASCADE on the source, and
  the 16 -> 17 migration.
- `DownloadEngine` over `android.app.DownloadManager`, with a fake for tests.
  Files land in app-specific external storage at
  `.../Music/downloads/<sourceId>/<songId>`, plus a `.nomedia` so media scanners
  on pre-Android-10 devices leave them alone.
- `DownloadRepository` enqueues, mirrors progress by polling, cancels, deletes
  and reconciles on start and on source change: a queued row whose engine entry
  is gone is started again, a completed row whose file is gone becomes failed
  rather than being resurrected, and files with no row are swept.
- Playback prefers the downloaded file: the media item build takes the local URI
  when the download is complete and leaves it unclipped.
- Deleting a download reloads the player window at its position, so the item
  falls back to streaming instead of failing mid-read.
- Deleting a source cancels its downloads and removes their files.

Downloads are the system's own notifications and stay off metered networks for
now. Artwork, bulk downloads and the management screen are later blocks.
2026-10-04 00:30:01 +00:00

216 lines
8.5 KiB
Kotlin

package com.subtracks.ui.library
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FiberSmartRecord
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Disc
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
private const val DOT = "\u00B7"
private const val THEME_TRANSITION_MS = 100
@Composable
fun AlbumDetailRoute(
albumId: String,
coverArtId: String?,
onBack: () -> Unit,
onViewArtist: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
setStar: suspend (StarType, String, Boolean) -> Result<Unit>,
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
playbackController: PlaybackController = koinInject(),
) {
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val actions =
ItemActions(
playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) },
playAlbum = { viewModel.playAll() },
shuffleAlbum = { viewModel.shuffle() },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
download = viewModel::download,
cancelDownload = viewModel::cancelDownload,
deleteDownload = viewModel::deleteDownload,
setStar = setStar,
viewArtist = onViewArtist,
)
AlbumDetailScreen(
album = album,
songs = songs,
discs = discs,
coverArt = viewModel::coverArt,
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
downloads = downloads,
onBack = onBack,
onSongClick = viewModel::play,
onSongLongClick = { contextMenuHost?.show(it, actions) },
onShuffle = viewModel::shuffle,
onPlay = viewModel::playAll,
onMore = { album?.let { contextMenuHost?.show(MenuTarget.Album(it, viewModel.coverArt(it.coverArt, true)), actions) } },
onArtistClick =
album
?.takeIf { !it.albumArtist.isNullOrBlank() }
?.let { it.artistId }
?.let { id -> { onViewArtist(id) } },
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
)
}
@Composable
fun AlbumDetailScreen(
album: Album?,
songs: List<Song>,
discs: List<Disc> = emptyList(),
coverArt: (String?, Boolean) -> CoverArtRef?,
artwork: ArtworkColors?,
downloads: Map<String, SongDownload> = emptyMap(),
onBack: () -> Unit,
onSongClick: (Int) -> Unit,
onSongLongClick: (MenuTarget) -> Unit = {},
onShuffle: () -> Unit = {},
onPlay: () -> Unit = { onSongClick(0) },
onDownload: () -> Unit = {},
onMore: () -> Unit = {},
onArtistClick: (() -> Unit)? = null,
playingSongId: String? = null,
modifier: Modifier = Modifier,
) {
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
val discLabels =
remember(discs) {
val repeated =
discs
.map { it.title }
.filter { it.isNotBlank() }
.groupingBy { it }
.eachCount()
.filterValues { it > 1 }
.keys
discs.associate { disc ->
disc.disc to
disc.title.takeIf { it.isNotBlank() }?.let { title ->
if (title in repeated) "$title: Disc ${disc.disc}" else title
}
}
}
HeroDetailScaffold(
artwork = artwork,
title = album?.name.orEmpty(),
onBack = onBack,
header = { controlsModifier, topInset ->
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
HeroHeader(
art = coverArt(album?.coverArt, false),
thumbnailRef = coverArt(album?.coverArt, true),
name = album?.name.orEmpty(),
subtitle =
listOfNotNull(
artistName,
album?.year?.takeIf { it > 0 }?.toString(),
).joinToString(" $DOT "),
onSubtitleClick = onArtistClick,
hasSongs = songs.isNotEmpty(),
onPlay = onPlay,
onShuffle = onShuffle,
onDownload = onDownload,
onMore = onMore,
topInset = topInset,
controlsModifier = controlsModifier,
)
},
content = { rowModifier ->
var lastDisc: Long? = null
songs.forEachIndexed { index, song ->
val disc = song.disc ?: 1L
val discLabel = discLabels[disc]
if (disc != lastDisc && (multiDisc || discLabel != null)) {
val isFirst = lastDisc == null
item(key = "disc:$disc") { DiscHeader(discLabel ?: "Disc $disc", first = isFirst) }
}
lastDisc = disc
item(key = song.id) {
SongRow(
song = song,
isPlaying = song.id == playingSongId,
trackNumber = song.track,
download = downloads[song.id],
modifier =
rowModifier.combinedClickable(
onClick = { onSongClick(index) },
onLongClick = {
onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true), downloads[song.id]))
},
),
)
}
}
},
modifier = modifier,
)
}
@Composable
private fun DiscHeader(
text: String,
first: Boolean,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (first) 16.dp else 40.dp, bottom = 4.dp),
) {
Icon(
imageVector = Icons.Outlined.FiberSmartRecord,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(16.dp))
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}