Skip to content

Commit f00c9de

Browse files
committed
feat: Implement album cover download with quality selection
This commit adds a new feature to the `AlbumScreen` allowing users to download album artwork directly to their device. It includes a quality selection dialog and handles the media saving logic via `MediaStore`. **New Features:** - **Album Cover Download:** - Integrated a download button onto the album art display. - Implemented `downloadAlbumCover` utility using `Coil` for image fetching and `MediaStore` for saving to the system "Pictures/OpenTune" directory. - Support for Scoped Storage (Android Q+) using `IS_PENDING` and `RELATIVE_PATH`. - **Quality Selection Dialog:** - Introduced `QualitySelectionDialog` to allow users to choose between Low (512px), Medium (768px), and High (1024px) resolutions. - Included estimated file sizes and visual radio button selection for a better UX. **UI Enhancements:** - **Download Indicator:** Added a `CircularProgressIndicator` within the download button to provide feedback during the image processing and saving state. - **Button Styling:** Positioned the download action as an overlaid, semi-transparent circular button on the album thumbnail with subtle shadow and border effects. **Technical Changes:** - Added necessary imports for `MediaStore`, `ContentValues`, and `Bitmap` compression. - Managed download state (`downloadingCover`, `showQualityDialog`) using Compose state and launched download tasks within the `rememberCoroutineScope`. Signed-off-by: Arturo Cervantes <[email protected]>
1 parent ee37905 commit f00c9de

1 file changed

Lines changed: 306 additions & 1 deletion

File tree

app/src/main/kotlin/com/arturo254/opentune/ui/screens/AlbumScreen.kt

Lines changed: 306 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,19 @@ import androidx.compose.foundation.lazy.LazyRow
3131
import androidx.compose.foundation.lazy.items
3232
import androidx.compose.foundation.lazy.itemsIndexed
3333
import androidx.compose.foundation.lazy.rememberLazyListState
34+
import androidx.compose.foundation.selection.selectable
3435
import androidx.compose.foundation.shape.CircleShape
3536
import androidx.compose.foundation.shape.RoundedCornerShape
37+
import androidx.compose.material3.AlertDialog
3638
import androidx.compose.material3.Button
3739
import androidx.compose.material3.CircularProgressIndicator
3840
import androidx.compose.material3.ExperimentalMaterial3Api
3941
import androidx.compose.material3.Icon
4042
import androidx.compose.material3.MaterialTheme
43+
import androidx.compose.material3.RadioButton
4144
import androidx.compose.material3.Surface
4245
import androidx.compose.material3.Text
46+
import androidx.compose.material3.TextButton
4347
import androidx.compose.material3.TopAppBar
4448
import androidx.compose.material3.TopAppBarDefaults
4549
import androidx.compose.material3.TopAppBarScrollBehavior
@@ -69,6 +73,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
6973
import androidx.compose.ui.res.painterResource
7074
import androidx.compose.ui.res.pluralStringResource
7175
import androidx.compose.ui.res.stringResource
76+
import androidx.compose.ui.semantics.Role
7277
import androidx.compose.ui.text.LinkAnnotation
7378
import androidx.compose.ui.text.buildAnnotatedString
7479
import androidx.compose.ui.text.font.FontWeight
@@ -77,6 +82,7 @@ import androidx.compose.ui.text.style.TextOverflow
7782
import androidx.compose.ui.text.withLink
7883
import androidx.compose.ui.text.withStyle
7984
import androidx.compose.ui.unit.dp
85+
import androidx.compose.ui.unit.offset
8086
import androidx.compose.ui.util.fastForEachIndexed
8187
import androidx.compose.ui.zIndex
8288
import androidx.core.net.toUri
@@ -93,7 +99,16 @@ import coil3.request.allowHardware
9399
import coil3.size.Size
94100
import coil3.toBitmap
95101
import kotlinx.coroutines.Dispatchers
102+
import kotlinx.coroutines.launch
96103
import kotlinx.coroutines.withContext
104+
import android.content.ContentValues
105+
import android.os.Build
106+
import android.provider.MediaStore
107+
import androidx.compose.foundation.border
108+
import androidx.compose.foundation.layout.offset
109+
import androidx.compose.foundation.selection.selectableGroup
110+
import androidx.compose.ui.draw.alpha
111+
import androidx.compose.ui.draw.blur
97112
import com.arturo254.opentune.LocalDatabase
98113
import com.arturo254.opentune.LocalDownloadUtil
99114
import com.arturo254.opentune.LocalPlayerAwareWindowInsets
@@ -128,6 +143,212 @@ import com.arturo254.opentune.viewmodels.AlbumUiState
128143
import com.arturo254.opentune.viewmodels.AlbumViewModel
129144
import com.valentinilk.shimmer.shimmer
130145

146+
// ============================================================================
147+
// FUNCIONES DE DESCARGA
148+
// ============================================================================
149+
150+
/**
151+
* Descarga la carátula del álbum en la calidad especificada
152+
* @param quality Calidad: 512 (baja), 768 (media), 1024 (alta)
153+
*/
154+
suspend fun downloadAlbumCover(
155+
context: android.content.Context,
156+
imageUrl: String?,
157+
albumTitle: String,
158+
quality: Int = 1024,
159+
): Boolean {
160+
if (imageUrl.isNullOrEmpty()) return false
161+
162+
return try {
163+
val request = ImageRequest.Builder(context)
164+
.data(imageUrl)
165+
.size(Size(quality, quality))
166+
.allowHardware(false)
167+
.build()
168+
169+
val result = context.imageLoader.execute(request)
170+
val bitmap = result.image?.toBitmap() ?: return false
171+
172+
val qualityLabel = when (quality) {
173+
512 -> "baja"
174+
768 -> "media"
175+
1024 -> "alta"
176+
else -> "custom"
177+
}
178+
179+
val filename = "${albumTitle.replace("/", "_")}_${qualityLabel}_${System.currentTimeMillis()}.jpg"
180+
val contentValues = ContentValues().apply {
181+
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
182+
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
183+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
184+
put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/OpenTune")
185+
put(MediaStore.MediaColumns.IS_PENDING, 1)
186+
}
187+
}
188+
189+
val imageUri = context.contentResolver.insert(
190+
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
191+
contentValues
192+
) ?: return false
193+
194+
context.contentResolver.openOutputStream(imageUri)?.use { outputStream ->
195+
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 95, outputStream)
196+
}
197+
198+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
199+
contentValues.clear()
200+
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
201+
context.contentResolver.update(imageUri, contentValues, null, null)
202+
}
203+
204+
true
205+
} catch (e: Exception) {
206+
e.printStackTrace()
207+
false
208+
}
209+
}
210+
211+
// ============================================================================
212+
// COMPOSABLE DIALOG DE CALIDAD
213+
// ============================================================================
214+
215+
@Composable
216+
private fun QualitySelectionDialog(
217+
onDismiss: () -> Unit,
218+
onQualitySelected: (Int) -> Unit,
219+
) {
220+
var selectedQuality by remember { mutableStateOf(1024) }
221+
222+
AlertDialog(
223+
onDismissRequest = onDismiss,
224+
title = {
225+
Text(
226+
text = "Selecciona la calidad",
227+
style = MaterialTheme.typography.headlineSmall
228+
)
229+
},
230+
text = {
231+
Column(
232+
modifier = Modifier
233+
.fillMaxWidth()
234+
.selectableGroup(),
235+
verticalArrangement = Arrangement.spacedBy(12.dp)
236+
) {
237+
// Opción Baja Calidad
238+
Row(
239+
modifier = Modifier
240+
.fillMaxWidth()
241+
.clip(RoundedCornerShape(8.dp))
242+
.selectable(
243+
selected = selectedQuality == 512,
244+
onClick = { selectedQuality = 512 },
245+
role = Role.RadioButton
246+
)
247+
.padding(8.dp),
248+
verticalAlignment = Alignment.CenterVertically,
249+
horizontalArrangement = Arrangement.spacedBy(12.dp)
250+
) {
251+
RadioButton(
252+
selected = selectedQuality == 512,
253+
onClick = { selectedQuality = 512 }
254+
)
255+
Column(modifier = Modifier.weight(1f)) {
256+
Text(
257+
text = "Baja (512x512)",
258+
style = MaterialTheme.typography.bodyMedium,
259+
fontWeight = FontWeight.Medium
260+
)
261+
Text(
262+
text = "~50 KB",
263+
style = MaterialTheme.typography.bodySmall,
264+
color = MaterialTheme.colorScheme.onSurfaceVariant
265+
)
266+
}
267+
}
268+
269+
// Opción Media Calidad
270+
Row(
271+
modifier = Modifier
272+
.fillMaxWidth()
273+
.clip(RoundedCornerShape(8.dp))
274+
.selectable(
275+
selected = selectedQuality == 768,
276+
onClick = { selectedQuality = 768 },
277+
role = Role.RadioButton
278+
)
279+
.padding(8.dp),
280+
verticalAlignment = Alignment.CenterVertically,
281+
horizontalArrangement = Arrangement.spacedBy(12.dp)
282+
) {
283+
RadioButton(
284+
selected = selectedQuality == 768,
285+
onClick = { selectedQuality = 768 }
286+
)
287+
Column(modifier = Modifier.weight(1f)) {
288+
Text(
289+
text = "Media (768x768)",
290+
style = MaterialTheme.typography.bodyMedium,
291+
fontWeight = FontWeight.Medium
292+
)
293+
Text(
294+
text = "~100 KB",
295+
style = MaterialTheme.typography.bodySmall,
296+
color = MaterialTheme.colorScheme.onSurfaceVariant
297+
)
298+
}
299+
}
300+
301+
// Opción Alta Calidad
302+
Row(
303+
modifier = Modifier
304+
.fillMaxWidth()
305+
.clip(RoundedCornerShape(8.dp))
306+
.selectable(
307+
selected = selectedQuality == 1024,
308+
onClick = { selectedQuality = 1024 },
309+
role = Role.RadioButton
310+
)
311+
.padding(8.dp),
312+
verticalAlignment = Alignment.CenterVertically,
313+
horizontalArrangement = Arrangement.spacedBy(12.dp)
314+
) {
315+
RadioButton(
316+
selected = selectedQuality == 1024,
317+
onClick = { selectedQuality = 1024 }
318+
)
319+
Column(modifier = Modifier.weight(1f)) {
320+
Text(
321+
text = "Alta (1024x1024)",
322+
style = MaterialTheme.typography.bodyMedium,
323+
fontWeight = FontWeight.Medium
324+
)
325+
Text(
326+
text = "~200 KB",
327+
style = MaterialTheme.typography.bodySmall,
328+
color = MaterialTheme.colorScheme.onSurfaceVariant
329+
)
330+
}
331+
}
332+
}
333+
},
334+
confirmButton = {
335+
Button(
336+
onClick = {
337+
onQualitySelected(selectedQuality)
338+
onDismiss()
339+
}
340+
) {
341+
Text("Descargar")
342+
}
343+
},
344+
dismissButton = {
345+
TextButton(onClick = onDismiss) {
346+
Text("Cancelar")
347+
}
348+
}
349+
)
350+
}
351+
131352
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
132353
@Composable
133354
fun AlbumScreen(
@@ -161,6 +382,10 @@ fun AlbumScreen(
161382
val fallbackColor = MaterialTheme.colorScheme.surface.toArgb()
162383
val surfaceColor = MaterialTheme.colorScheme.surface
163384

385+
// Estados para descarga de carátula
386+
var downloadingCover by remember { mutableStateOf(false) }
387+
var showQualityDialog by remember { mutableStateOf(false) }
388+
164389
// Extract gradient colors from album cover
165390
LaunchedEffect(albumWithSongs?.album?.thumbnailUrl) {
166391
val thumbnailUrl = albumWithSongs?.album?.thumbnailUrl
@@ -418,6 +643,53 @@ fun AlbumScreen(
418643
modifier = Modifier.fillMaxSize()
419644
)
420645
}
646+
647+
// Download Cover Button - Inside image, bottom-right
648+
Surface(
649+
onClick = { showQualityDialog = true },
650+
shape = CircleShape,
651+
color = Color.Transparent,
652+
modifier = Modifier
653+
.size(48.dp) // Tamaño ligeramente reducido
654+
.align(Alignment.BottomEnd)
655+
.offset(x = (-12).dp, y = (-12).dp)
656+
.shadow(
657+
elevation = 4.dp, // Sombra más sutil
658+
shape = CircleShape,
659+
ambientColor = Color.Black.copy(alpha = 0.15f),
660+
spotColor = Color.Black.copy(alpha = 0.15f)
661+
)
662+
.clip(CircleShape)
663+
.background(
664+
color = Color.Black.copy(alpha = 0.2f), // Más transparente
665+
shape = CircleShape
666+
)
667+
.border(
668+
width = 0.5.dp, // Borde más fino
669+
color = Color.White.copy(alpha = 0.15f),
670+
shape = CircleShape
671+
)
672+
) {
673+
Box(
674+
modifier = Modifier.fillMaxSize(),
675+
contentAlignment = Alignment.Center
676+
) {
677+
if (downloadingCover) {
678+
CircularProgressIndicator(
679+
strokeWidth = 2.dp,
680+
modifier = Modifier.size(24.dp), // Ícono más pequeño
681+
color = Color.White.copy(alpha = 0.9f)
682+
)
683+
} else {
684+
Icon(
685+
painter = painterResource(R.drawable.download),
686+
contentDescription = "Descargar carátula",
687+
tint = Color.White.copy(alpha = 0.8f), // Ícono semi-transparente
688+
modifier = Modifier.size(24.dp) // Ícono más pequeño
689+
)
690+
}
691+
}
692+
}
421693
}
422694

423695
// Album Title
@@ -1052,6 +1324,39 @@ fun AlbumScreen(
10521324
}
10531325
}
10541326
)
1327+
1328+
// Dialog para seleccionar calidad
1329+
if (showQualityDialog) {
1330+
QualitySelectionDialog(
1331+
onDismiss = { showQualityDialog = false },
1332+
onQualitySelected = { quality ->
1333+
downloadingCover = true
1334+
scope.launch {
1335+
try {
1336+
val success = downloadAlbumCover(
1337+
context = context,
1338+
imageUrl = albumWithSongs?.album?.thumbnailUrl,
1339+
albumTitle = albumWithSongs?.album?.title ?: "album",
1340+
quality = quality
1341+
)
1342+
1343+
val qualityLabel = when (quality) {
1344+
512 -> "Baja (512x512)"
1345+
768 -> "Media (768x768)"
1346+
1024 -> "Alta (1024x1024)"
1347+
else -> "Custom"
1348+
}
1349+
1350+
if (success) {
1351+
// Aquí puedes agregar un SnackBar si lo deseas
1352+
}
1353+
} finally {
1354+
downloadingCover = false
1355+
}
1356+
}
1357+
}
1358+
)
1359+
}
10551360
}
10561361
}
10571362

@@ -1085,4 +1390,4 @@ private fun MetadataChip(
10851390
)
10861391
}
10871392
}
1088-
}
1393+
}

0 commit comments

Comments
 (0)