feat(library): extract track duration via ffprobe #13

Merged
bvandeusen merged 262 commits from dev into main 2026-04-20 08:11:10 -04:00
2 changed files with 136 additions and 119 deletions
Showing only changes of commit 786a73dad7 - Show all commits
@@ -1,6 +1,5 @@
package com.fabledsword.minstrel.discover.ui package com.fabledsword.minstrel.discover.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -9,15 +8,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
@@ -38,18 +32,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import com.composables.icons.lucide.ArrowLeft import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search as LucideSearch import com.composables.icons.lucide.Search as LucideSearch
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.discover.data.RequestOutcome import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind import com.fabledsword.minstrel.models.LidarrRequestKind
@@ -247,114 +236,6 @@ private fun ResultsList(
} }
} }
@Composable
private fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(imageUrl = s.imageUrl, fallback = Lucide.User, shape = AvatarShape.CIRCLE)
Column(modifier = Modifier.weight(1f)) {
Text(
text = s.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (s.attributionText.isNotEmpty()) {
Text(
text = s.attributionText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Button(onClick = onRequest) { Text("Request") }
}
}
@Composable
private fun ResultTile(
row: LidarrSearchResultRef,
locallyRequested: Boolean,
onRequest: () -> Unit,
) {
val effectivelyRequested = row.requested || locallyRequested
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(imageUrl = row.imageUrl, fallback = Lucide.Disc3, shape = AvatarShape.ROUNDED)
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (row.secondaryText.isNotEmpty()) {
Text(
text = row.secondaryText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
when {
row.inLibrary -> AssistChip(onClick = {}, enabled = false, label = { Text("In library") })
effectivelyRequested -> AssistChip(onClick = {}, enabled = false, label = { Text("Requested") })
else -> Button(onClick = onRequest) { Text("Request") }
}
}
}
private enum class AvatarShape { CIRCLE, ROUNDED }
@Composable
private fun Avatar(
imageUrl: String,
fallback: androidx.compose.ui.graphics.vector.ImageVector,
shape: AvatarShape,
) {
val clip = when (shape) {
AvatarShape.CIRCLE -> CircleShape
AvatarShape.ROUNDED -> RoundedCornerShape(6.dp)
}
Box(
modifier = Modifier
.size(56.dp)
.clip(clip)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (imageUrl.isEmpty()) {
Icon(
imageVector = fallback,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Composable @Composable
private fun LoadingCentered() { private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -0,0 +1,136 @@
package com.fabledsword.minstrel.discover.ui
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrSearchResultRef
internal enum class AvatarShape { CIRCLE, ROUNDED }
@Composable
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(imageUrl = s.imageUrl, fallback = Lucide.User, shape = AvatarShape.CIRCLE)
Column(modifier = Modifier.weight(1f)) {
Text(
text = s.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (s.attributionText.isNotEmpty()) {
Text(
text = s.attributionText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Button(onClick = onRequest) { Text("Request") }
}
}
@Composable
internal fun ResultTile(
row: LidarrSearchResultRef,
locallyRequested: Boolean,
onRequest: () -> Unit,
) {
val effectivelyRequested = row.requested || locallyRequested
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(imageUrl = row.imageUrl, fallback = Lucide.Disc3, shape = AvatarShape.ROUNDED)
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (row.secondaryText.isNotEmpty()) {
Text(
text = row.secondaryText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
when {
row.inLibrary -> AssistChip(onClick = {}, enabled = false, label = { Text("In library") })
effectivelyRequested ->
AssistChip(onClick = {}, enabled = false, label = { Text("Requested") })
else -> Button(onClick = onRequest) { Text("Request") }
}
}
}
@Composable
private fun Avatar(imageUrl: String, fallback: ImageVector, shape: AvatarShape) {
val clip = when (shape) {
AvatarShape.CIRCLE -> CircleShape
AvatarShape.ROUNDED -> RoundedCornerShape(6.dp)
}
Box(
modifier = Modifier
.size(56.dp)
.clip(clip)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (imageUrl.isEmpty()) {
Icon(
imageVector = fallback,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
}
}