feat(library): extract track duration via ffprobe #13
@@ -1,5 +1,8 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // Compose screen + section helper composables + skeletons
|
||||||
|
|
||||||
package com.fabledsword.minstrel.home.ui
|
package com.fabledsword.minstrel.home.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -12,9 +15,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -54,6 +57,9 @@ import com.fabledsword.minstrel.shared.widgets.EmptyState
|
|||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -180,12 +186,14 @@ fun HomeScreen(
|
|||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refresh().join() },
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
Crossfade(targetState = state, label = "home-state") { s ->
|
||||||
HomeUiState.Loading -> LoadingCentered()
|
when (s) {
|
||||||
|
HomeUiState.Loading -> HomeSkeletonContent()
|
||||||
HomeUiState.Empty -> EmptyState(
|
HomeUiState.Empty -> EmptyState(
|
||||||
title = "Welcome to Minstrel",
|
title = "Welcome to Minstrel",
|
||||||
body = "Nothing to show yet — scan a folder in your server settings, " +
|
body = "Nothing to show yet — scan a folder in your server " +
|
||||||
"then come back here for system playlists and recommendations.",
|
"settings, then come back here for system playlists " +
|
||||||
|
"and recommendations.",
|
||||||
)
|
)
|
||||||
is HomeUiState.Error -> EmptyState(
|
is HomeUiState.Error -> EmptyState(
|
||||||
title = "Couldn't load home",
|
title = "Couldn't load home",
|
||||||
@@ -201,11 +209,49 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton stand-in shown during cold-load. Mirrors the success
|
||||||
|
* layout's section structure (Playlists carousel → Recently added
|
||||||
|
* carousel → Rediscover albums carousel → Last played artists
|
||||||
|
* carousel) so the Crossfade reveal doesn't reflow.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun HomeSkeletonContent() {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
item { SkeletonSectionHeader() }
|
||||||
|
item { SkeletonAlbumRow() }
|
||||||
|
item { SkeletonSectionHeader() }
|
||||||
|
item { SkeletonAlbumRow() }
|
||||||
|
item { SkeletonSectionHeader() }
|
||||||
|
item { SkeletonArtistRow() }
|
||||||
|
item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val SKELETON_TILES_PER_ROW = 5
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoadingCentered() {
|
private fun SkeletonAlbumRow() {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
LazyRow(
|
||||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
|
items(SKELETON_TILES_PER_ROW) { SkeletonAlbumTile() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SkeletonArtistRow() {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
|
items(SKELETON_TILES_PER_ROW) { SkeletonArtistTile() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
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.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton placeholders for cold-load states. Mirror Flutter's
|
||||||
|
* `SkeletonAlbumTile` / `SkeletonArtistTile` / `_CompactTrackSkeleton`
|
||||||
|
* — same geometry as the real cards so the layout doesn't reflow when
|
||||||
|
* real data lands and the screen-level Crossfade reveals.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private const val PULSE_MIN_ALPHA = 0.3f
|
||||||
|
private const val PULSE_MAX_ALPHA = 0.55f
|
||||||
|
private const val PULSE_DURATION_MS = 800
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solid-color box with a slow alpha pulse. Building block for the
|
||||||
|
* tile-shape composites below. Mirrors the surfaceVariant fill the
|
||||||
|
* real cards use as their cover-loading background.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SkeletonBox(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
shape: Shape = RoundedCornerShape(FabledSwordFlatTokens.radiusSm),
|
||||||
|
) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "skeleton-pulse")
|
||||||
|
val alpha by transition.animateFloat(
|
||||||
|
initialValue = PULSE_MIN_ALPHA,
|
||||||
|
targetValue = PULSE_MAX_ALPHA,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(PULSE_DURATION_MS, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "skeleton-alpha",
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in for AlbumCard / PlaylistCard (same 176dp×144dp cover +
|
||||||
|
* 2 caption lines geometry).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SkeletonAlbumTile(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.width(176.dp)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
|
SkeletonBox(modifier = Modifier.size(144.dp))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SkeletonBox(modifier = Modifier.fillMaxWidth().height(14.dp))
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
SkeletonBox(modifier = Modifier.width(96.dp).height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in for ArtistCard (128dp circular cover + 1 name line).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SkeletonArtistTile(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.width(144.dp)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
SkeletonBox(
|
||||||
|
modifier = Modifier.size(128.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SkeletonBox(modifier = Modifier.width(96.dp).height(14.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in for a compact track row (28dp cover thumb + title + subtitle).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SkeletonTrackRow(modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
SkeletonBox(modifier = Modifier.size(40.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = true),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
SkeletonBox(modifier = Modifier.fillMaxWidth().height(14.dp))
|
||||||
|
SkeletonBox(modifier = Modifier.width(120.dp).height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section-heading skeleton (~40% width title bar). */
|
||||||
|
@Composable
|
||||||
|
fun SkeletonSectionHeader(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
SkeletonBox(modifier = Modifier.width(160.dp).height(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user