mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
300 lines
12 KiB
Kotlin
300 lines
12 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.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalResources
|
|
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.R
|
|
import com.subtracks.data.model.Album
|
|
import com.subtracks.data.model.BulkDownloadAction
|
|
import com.subtracks.data.model.CoverArtRef
|
|
import com.subtracks.data.model.Disc
|
|
import com.subtracks.data.model.ListDownloadStatus
|
|
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.ArtistChooserSheet
|
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
|
import com.subtracks.ui.components.ContextMenuHost
|
|
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
import com.subtracks.ui.components.DismissOnRequest
|
|
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 kotlinx.coroutines.flow.emptyFlow
|
|
import kotlinx.coroutines.launch
|
|
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: (StarType, String, Boolean) -> Unit,
|
|
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
|
playbackController: PlaybackController = koinInject(),
|
|
) {
|
|
val album by viewModel.album.collectAsStateWithLifecycle()
|
|
val songs by viewModel.songs.collectAsStateWithLifecycle()
|
|
val discs by viewModel.discs.collectAsStateWithLifecycle()
|
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
|
val scope = rememberCoroutineScope()
|
|
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
|
var showArtistChooser by remember { mutableStateOf(false) }
|
|
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
|
|
pendingDelete = null
|
|
showArtistChooser = false
|
|
}
|
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
|
val context = playback.context
|
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
|
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
|
if (action == BulkDownloadAction.Delete) {
|
|
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
|
} else {
|
|
viewModel.onDownloadAction(action)
|
|
}
|
|
}
|
|
val actions =
|
|
ItemActions(
|
|
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,
|
|
bulkDownload = { _, action -> requestDownloadAction(action) },
|
|
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,
|
|
fallbackName = album?.name,
|
|
),
|
|
downloads = downloads,
|
|
downloadStatus = downloadStatus,
|
|
onDownloadAction = requestDownloadAction,
|
|
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), downloadStatus),
|
|
actions,
|
|
)
|
|
}
|
|
},
|
|
onArtistClick =
|
|
album
|
|
?.takeIf { it.artistRefs.isNotEmpty() }
|
|
?.let {
|
|
{
|
|
val artists = it.artistRefs
|
|
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
|
}
|
|
},
|
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
|
)
|
|
|
|
pendingDelete?.let { bytes ->
|
|
DeleteDownloadsDialog(
|
|
name = album?.name.orEmpty(),
|
|
bytes = bytes,
|
|
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
|
onDismiss = { pendingDelete = null },
|
|
)
|
|
}
|
|
|
|
if (showArtistChooser) {
|
|
ArtistChooserSheet(
|
|
artists = album?.artistRefs.orEmpty(),
|
|
onArtistClick = { artist ->
|
|
showArtistChooser = false
|
|
onViewArtist(artist.id)
|
|
},
|
|
onDismiss = { showArtistChooser = false },
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun AlbumDetailScreen(
|
|
album: Album?,
|
|
songs: List<Song>,
|
|
modifier: Modifier = Modifier,
|
|
discs: List<Disc> = emptyList(),
|
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
|
artwork: ArtworkColors?,
|
|
downloads: Map<String, SongDownload> = emptyMap(),
|
|
onBack: () -> Unit,
|
|
onSongClick: (String) -> Unit,
|
|
onSongLongClick: (MenuTarget) -> Unit = {},
|
|
onShuffle: () -> Unit = {},
|
|
onPlay: () -> Unit = {},
|
|
downloadStatus: ListDownloadStatus = ListDownloadStatus(),
|
|
onDownloadAction: (BulkDownloadAction) -> Unit = {},
|
|
onMore: () -> Unit = {},
|
|
onArtistClick: (() -> Unit)? = null,
|
|
playingSongId: String? = null,
|
|
) {
|
|
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
|
|
val resources = LocalResources.current
|
|
val discLabels =
|
|
remember(discs, resources) {
|
|
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) resources.getString(R.string.disc_track, title, disc.disc) else title
|
|
}
|
|
}
|
|
}
|
|
val discOrdinals =
|
|
remember(songs) {
|
|
val counters = mutableMapOf<Long, Long>()
|
|
songs.associate { song ->
|
|
val disc = song.disc ?: 1L
|
|
val ordinal = (counters[disc] ?: 0L) + 1
|
|
counters[disc] = ordinal
|
|
song.id to ordinal
|
|
}
|
|
}
|
|
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,
|
|
downloadStatus = downloadStatus,
|
|
onDownloadAction = onDownloadAction,
|
|
onMore = onMore,
|
|
topInset = topInset,
|
|
controlsModifier = controlsModifier,
|
|
nameMaxLines = Int.MAX_VALUE,
|
|
)
|
|
},
|
|
content = { rowModifier ->
|
|
var lastDisc: Long? = null
|
|
songs.forEach { 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 ?: resources.getString(R.string.disc_header, disc), first = isFirst) }
|
|
}
|
|
lastDisc = disc
|
|
item(key = song.id) {
|
|
SongRow(
|
|
song = song,
|
|
isPlaying = song.id == playingSongId,
|
|
trackNumber = song.track ?: discOrdinals[song.id],
|
|
durationSeconds = song.duration,
|
|
download = downloads[song.id],
|
|
modifier =
|
|
rowModifier.combinedClickable(
|
|
onClick = { onSongClick(song.id) },
|
|
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,
|
|
)
|
|
}
|
|
}
|