mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
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.
216 lines
8.5 KiB
Kotlin
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,
|
|
)
|
|
}
|
|
}
|