@@ -31,15 +31,19 @@ import androidx.compose.foundation.lazy.LazyRow
3131import androidx.compose.foundation.lazy.items
3232import androidx.compose.foundation.lazy.itemsIndexed
3333import androidx.compose.foundation.lazy.rememberLazyListState
34+ import androidx.compose.foundation.selection.selectable
3435import androidx.compose.foundation.shape.CircleShape
3536import androidx.compose.foundation.shape.RoundedCornerShape
37+ import androidx.compose.material3.AlertDialog
3638import androidx.compose.material3.Button
3739import androidx.compose.material3.CircularProgressIndicator
3840import androidx.compose.material3.ExperimentalMaterial3Api
3941import androidx.compose.material3.Icon
4042import androidx.compose.material3.MaterialTheme
43+ import androidx.compose.material3.RadioButton
4144import androidx.compose.material3.Surface
4245import androidx.compose.material3.Text
46+ import androidx.compose.material3.TextButton
4347import androidx.compose.material3.TopAppBar
4448import androidx.compose.material3.TopAppBarDefaults
4549import androidx.compose.material3.TopAppBarScrollBehavior
@@ -69,6 +73,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
6973import androidx.compose.ui.res.painterResource
7074import androidx.compose.ui.res.pluralStringResource
7175import androidx.compose.ui.res.stringResource
76+ import androidx.compose.ui.semantics.Role
7277import androidx.compose.ui.text.LinkAnnotation
7378import androidx.compose.ui.text.buildAnnotatedString
7479import androidx.compose.ui.text.font.FontWeight
@@ -77,6 +82,7 @@ import androidx.compose.ui.text.style.TextOverflow
7782import androidx.compose.ui.text.withLink
7883import androidx.compose.ui.text.withStyle
7984import androidx.compose.ui.unit.dp
85+ import androidx.compose.ui.unit.offset
8086import androidx.compose.ui.util.fastForEachIndexed
8187import androidx.compose.ui.zIndex
8288import androidx.core.net.toUri
@@ -93,7 +99,16 @@ import coil3.request.allowHardware
9399import coil3.size.Size
94100import coil3.toBitmap
95101import kotlinx.coroutines.Dispatchers
102+ import kotlinx.coroutines.launch
96103import 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
97112import com.arturo254.opentune.LocalDatabase
98113import com.arturo254.opentune.LocalDownloadUtil
99114import com.arturo254.opentune.LocalPlayerAwareWindowInsets
@@ -128,6 +143,212 @@ import com.arturo254.opentune.viewmodels.AlbumUiState
128143import com.arturo254.opentune.viewmodels.AlbumViewModel
129144import 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
133354fun 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