From 4e4d79c98de74d6cc6368619d6bee61252c0a502 Mon Sep 17 00:00:00 2001 From: theo Date: Fri, 24 Apr 2026 11:47:17 -0300 Subject: [PATCH] Redesign DeviceCapabilitiesScreen.kt --- .../pixelplay/data/database/MusicDao.kt | 23 + .../screens/DeviceCapabilitiesScreen.kt | 1372 ++++++++++++----- .../viewmodel/DeviceCapabilitiesViewModel.kt | 457 +++++- .../viewmodel/PlaybackStateHolder.kt | 11 +- .../strings_presentation_batch_g.xml | 91 +- .../values/strings_presentation_batch_g.xml | 53 + 6 files changed, 1576 insertions(+), 431 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt index 3567e3a6b..32114b7a9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt @@ -67,6 +67,16 @@ private const val SONG_LIST_PROJECTION = """ telegram_file_id, artists_json, source_type """ +data class DeviceCapabilitySongRow( + val filePath: String, + val contentUriString: String, + val mimeType: String?, + val duration: Long, + val bitrate: Int?, + val sampleRate: Int?, + val sourceType: Int +) + @Dao interface MusicDao { @@ -442,6 +452,19 @@ interface MusicDao { @Query("SELECT COUNT(*) FROM songs") suspend fun getSongCountOnce(): Int + @Query(""" + SELECT + file_path AS filePath, + content_uri_string AS contentUriString, + mime_type AS mimeType, + duration, + bitrate, + sample_rate AS sampleRate, + source_type AS sourceType + FROM songs + """) + suspend fun getDeviceCapabilitySongRows(): List + /** * Returns random songs for efficient shuffle without loading all songs into memory. * Uses SQLite RANDOM() for true randomness. diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt index 9736d7b64..e51a062ae 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt @@ -1,47 +1,55 @@ package com.theveloper.pixelplay.presentation.screens +import android.text.format.Formatter import androidx.annotation.OptIn import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring -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.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.GraphicEq +import androidx.compose.material.icons.rounded.Headphones import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Memory import androidx.compose.material.icons.rounded.Speaker -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -50,26 +58,37 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import androidx.navigation.NavController -import androidx.compose.ui.res.stringResource import com.theveloper.pixelplay.R import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight -import com.theveloper.pixelplay.presentation.viewmodel.CodecInfo +import com.theveloper.pixelplay.presentation.viewmodel.AudioCapabilities +import com.theveloper.pixelplay.presentation.viewmodel.AudioOutputCategory +import com.theveloper.pixelplay.presentation.viewmodel.DeviceCapabilitiesState import com.theveloper.pixelplay.presentation.viewmodel.DeviceCapabilitiesViewModel +import com.theveloper.pixelplay.presentation.viewmodel.ExoPlayerInfo +import com.theveloper.pixelplay.presentation.viewmodel.FormatSupportInfo +import com.theveloper.pixelplay.presentation.viewmodel.LocalMusicStorageSummary +import com.theveloper.pixelplay.presentation.viewmodel.MemorySummary +import com.theveloper.pixelplay.presentation.viewmodel.PlaybackCompatibilitySummary import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape @@ -80,27 +99,26 @@ import kotlin.math.roundToInt fun DeviceCapabilitiesScreen( navController: NavController, viewModel: DeviceCapabilitiesViewModel = hiltViewModel(), - playerViewModel: PlayerViewModel // Kept for consistency if needed for player sheet handling + playerViewModel: PlayerViewModel ) { val state by viewModel.state.collectAsStateWithLifecycle() - - // Top Bar Logic (Reused Pattern) + val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() val lazyListState = rememberLazyListState() - + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val minTopBarHeight = 64.dp + statusBarHeight - val maxTopBarHeight = 180.dp - + val maxTopBarHeight = 188.dp val minTopBarHeightPx = with(density) { minTopBarHeight.toPx() } val maxTopBarHeightPx = with(density) { maxTopBarHeight.toPx() } - + val topBarHeight = remember { Animatable(maxTopBarHeightPx) } - var collapseFraction by remember { mutableStateOf(0f) } + var collapseFraction by remember { mutableFloatStateOf(0f) } LaunchedEffect(topBarHeight.value) { - collapseFraction = 1f - ((topBarHeight.value - minTopBarHeightPx) / (maxTopBarHeightPx - minTopBarHeightPx)).coerceIn(0f, 1f) + collapseFraction = 1f - ((topBarHeight.value - minTopBarHeightPx) / (maxTopBarHeightPx - minTopBarHeightPx)) + .coerceIn(0f, 1f) } val nestedScrollConnection = remember { @@ -126,7 +144,7 @@ fun DeviceCapabilitiesScreen( } } } - + LaunchedEffect(lazyListState.isScrollInProgress) { if (!lazyListState.isScrollInProgress) { val shouldExpand = topBarHeight.value > (minTopBarHeightPx + maxTopBarHeightPx) / 2 @@ -138,333 +156,667 @@ fun DeviceCapabilitiesScreen( } } - Box(modifier = Modifier.nestedScroll(nestedScrollConnection).fillMaxSize()) { + Box( + modifier = Modifier + .nestedScroll(nestedScrollConnection) + .fillMaxSize() + ) { val currentTopBarHeightDp = with(density) { topBarHeight.value.toDp() } - + if (state.isLoading) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = currentTopBarHeightDp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } } else { - val supportedCodecs = state.audioCapabilities?.supportedCodecs.orEmpty() - LazyColumn( - state = lazyListState, - contentPadding = PaddingValues( - top = currentTopBarHeightDp + 8.dp, - start = 16.dp, - end = 16.dp, - bottom = MiniPlayerHeight + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp - ), - verticalArrangement = Arrangement.spacedBy(14.dp), + DeviceCapabilitiesContent( + state = state, + lazyListState = lazyListState, + topPadding = currentTopBarHeightDp, modifier = Modifier.fillMaxSize() - ) { - // Device Info Section - item { - DeviceInfoExpressiveSection(deviceInfo = state.deviceInfo) - } - - // Audio Capabilities - item { - state.audioCapabilities?.let { audio -> - CapabilitySection(title = stringResource(R.string.presentation_batch_g_device_section_audio_output), icon = Icons.Rounded.Speaker) { - InfoRow(stringResource(R.string.presentation_batch_g_device_label_sample_rate), stringResource(R.string.presentation_batch_g_device_value_hz, audio.outputSampleRate)) - InfoRow(stringResource(R.string.presentation_batch_g_device_label_frames_per_buffer), "${audio.outputFramesPerBuffer}") - InfoRow(stringResource(R.string.presentation_batch_g_device_label_low_latency), if (audio.isLowLatencySupported) stringResource(R.string.presentation_batch_g_yes) else stringResource(R.string.presentation_batch_g_no)) - InfoRow(stringResource(R.string.presentation_batch_g_device_label_pro_audio), if (audio.isProAudioSupported) stringResource(R.string.presentation_batch_g_yes) else stringResource(R.string.presentation_batch_g_no)) - } - } - } - - // ExoPlayer Info - item { - state.exoPlayerInfo?.let { exo -> - CapabilitySection(title = stringResource(R.string.presentation_batch_g_device_section_exoplayer), icon = Icons.Rounded.Memory) { - InfoRow(stringResource(R.string.presentation_batch_g_device_label_version), exo.version) - InfoRow(stringResource(R.string.presentation_batch_g_device_label_active_renderers), exo.renderers) - InfoRow(stringResource(R.string.presentation_batch_g_device_label_decoder_counters), exo.decoderCounters) - } - } - } - - // Codecs Header - item { - Text( - text = stringResource(R.string.presentation_batch_g_device_supported_codecs_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 8.dp, bottom = 2.dp, start = 4.dp) - ) - } - - // Codec List - if (supportedCodecs.isNotEmpty()) { - item { - SegmentedCodecList(codecs = supportedCodecs) - } - } - } - } - - // Top Bar + ) + } + CollapsibleCommonTopBar( title = stringResource(R.string.settings_category_device_capabilities_title), collapseFraction = collapseFraction, headerHeight = currentTopBarHeightDp, onBackClick = { navController.popBackStack() }, expandedTitleStartPadding = 20.dp, - collapsedTitleStartPadding = 68.dp + collapsedTitleStartPadding = 68.dp, + maxLines = 2 ) } } @Composable -fun CapabilitySection( - title: String, - icon: androidx.compose.ui.graphics.vector.ImageVector, - content: @Composable () -> Unit +private fun DeviceCapabilitiesContent( + state: DeviceCapabilitiesState, + lazyListState: LazyListState, + topPadding: Dp, + modifier: Modifier = Modifier ) { - val sectionShape = AbsoluteSmoothCornerShape(28.dp, 60) - Card( - shape = sectionShape, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues( + top = topPadding + 8.dp, + start = 16.dp, + end = 16.dp, + bottom = MiniPlayerHeight + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier ) { - Box( - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.linearGradient( - colors = listOf( - MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - MaterialTheme.colorScheme.tertiary.copy(alpha = 0.08f), - MaterialTheme.colorScheme.surfaceContainer - ) + item { + PlaybackReadinessCard( + deviceInfo = state.deviceInfo, + audioCapabilities = state.audioCapabilities, + storageSummary = state.storageSummary, + playbackCompatibility = state.playbackCompatibility + ) + } + + state.storageSummary?.let { storage -> + item { + LocalMusicStorageCard(storageSummary = storage) + } + } + + state.audioCapabilities?.let { audio -> + item { + PlaybackPathCard( + audioCapabilities = audio, + memorySummary = state.memorySummary, + exoPlayerInfo = state.exoPlayerInfo + ) + } + } + + if (state.formatSupport.isNotEmpty()) { + item { + FormatCompatibilityCard( + formats = state.formatSupport, + playbackCompatibility = state.playbackCompatibility + ) + } + } + + state.playbackCompatibility?.let { compatibility -> + item { + PlaybackFindingsCard(compatibility = compatibility) + } + } + + item { + DeviceInfoPanel(deviceInfo = state.deviceInfo) + } + } +} + +@Composable +private fun PlaybackReadinessCard( + deviceInfo: Map, + audioCapabilities: AudioCapabilities?, + storageSummary: LocalMusicStorageSummary?, + playbackCompatibility: PlaybackCompatibilitySummary?, + modifier: Modifier = Modifier +) { + val needsReview = playbackCompatibility?.let { + it.unsupportedLibrarySongCount > 0 || it.resampledLocalSongCount > 0 + } ?: false + val containerColor = if (needsReview) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + } + val contentColor = if (needsReview) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + } + Surface( + modifier = modifier.fillMaxWidth(), + shape = AbsoluteSmoothCornerShape(32.dp, 60), + color = containerColor + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatusIcon( + icon = if (needsReview) Icons.Rounded.Warning else Icons.Rounded.CheckCircle, + containerColor = contentColor.copy(alpha = 0.12f), + contentColor = contentColor + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (needsReview) { + stringResource(R.string.device_capabilities_review_title) + } else { + stringResource(R.string.device_capabilities_ready_title) + }, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = contentColor, + modifier = Modifier.semantics { heading() } ) + } + } + + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + HeroMetricTile( + label = stringResource(R.string.device_capabilities_metric_formats), + value = audioCapabilities?.supportedCodecs + ?.flatMap { it.supportedTypes } + ?.distinct() + ?.size + ?.toString() + ?: stringResource(R.string.device_capabilities_unknown_short), + modifier = Modifier.weight(1f), + containerColor = contentColor.copy(alpha = 0.10f), + contentColor = contentColor ) + HeroMetricTile( + label = stringResource(R.string.device_capabilities_metric_hw_decoders), + value = audioCapabilities?.supportedCodecs + ?.count { it.isHardwareAccelerated } + ?.toString() + ?: stringResource(R.string.device_capabilities_unknown_short), + modifier = Modifier.weight(1f), + containerColor = contentColor.copy(alpha = 0.10f), + contentColor = contentColor + ) + HeroMetricTile( + label = stringResource(R.string.device_capabilities_metric_local_music), + value = storageSummary?.localSongCount?.toString() + ?: stringResource(R.string.device_capabilities_unknown_short), + modifier = Modifier.weight(1f), + containerColor = contentColor.copy(alpha = 0.10f), + contentColor = contentColor + ) + } + } + } +} + +@Composable +private fun LocalMusicStorageCard( + storageSummary: LocalMusicStorageSummary, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val musicSize = remember(storageSummary.localMusicBytes) { + Formatter.formatShortFileSize(context, storageSummary.localMusicBytes) + } + val availableSize = remember(storageSummary.deviceAvailableBytes) { + Formatter.formatShortFileSize(context, storageSummary.deviceAvailableBytes) + } + val totalSize = remember(storageSummary.deviceTotalBytes) { + Formatter.formatShortFileSize(context, storageSummary.deviceTotalBytes) + } + val musicPercent = storagePercentLabel(storageSummary.localMusicStorageFraction) + val usedPercent = storagePercentLabel(storageSummary.deviceUsedFraction) + + CapabilityCard( + title = stringResource(R.string.device_capabilities_storage_title), + icon = Icons.Rounded.Storage, + modifier = modifier + ) { + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Column(modifier = Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Surface( - shape = AbsoluteSmoothCornerShape(16.dp, 60), - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f) - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(10.dp) + InfoTile( + label = stringResource(R.string.device_capabilities_storage_music_size), + value = musicSize, + supporting = stringResource( + R.string.device_capabilities_storage_music_count, + storageSummary.localSongCount + ), + modifier = Modifier.weight(1f) + ) + InfoTile( + label = stringResource(R.string.device_capabilities_storage_available), + value = availableSize, + supporting = stringResource(R.string.device_capabilities_storage_total, totalSize), + modifier = Modifier.weight(1f) + ) + } + + Column( + modifier = Modifier.padding(start = 6.dp, end = 6.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + ProgressReadout( + label = stringResource(R.string.device_capabilities_storage_music_footprint), + value = musicPercent, + progress = storageSummary.localMusicStorageFraction + ) + ProgressReadout( + label = stringResource(R.string.device_capabilities_storage_device_used), + value = usedPercent, + progress = storageSummary.deviceUsedFraction, + color = MaterialTheme.colorScheme.secondary + ) + } + + if (storageSummary.cloudSongCount > 0 || storageSummary.unavailableLocalFileCount > 0) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (storageSummary.cloudSongCount > 0) { + TonalChip( + text = stringResource( + R.string.device_capabilities_storage_cloud_count, + storageSummary.cloudSongCount ) - } - Spacer(Modifier.width(12.dp)) - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface ) } - Spacer(Modifier.height(12.dp)) -// HorizontalDivider( -// color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.32f) -// ) - Spacer(Modifier.height(12.dp)) - content() + if (storageSummary.unavailableLocalFileCount > 0) { + TonalChip( + text = stringResource( + R.string.device_capabilities_storage_unavailable_count, + storageSummary.unavailableLocalFileCount + ), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } } } } } @Composable -fun InfoRow(label: String, value: String) { - Surface( - shape = AbsoluteSmoothCornerShape(16.dp, 60), - color = MaterialTheme.colorScheme.surfaceContainerLow +private fun PlaybackPathCard( + audioCapabilities: AudioCapabilities, + memorySummary: MemorySummary?, + exoPlayerInfo: ExoPlayerInfo?, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val availableRam = memorySummary?.availableRamBytes?.let { + Formatter.formatShortFileSize(context, it) + } ?: stringResource(R.string.device_capabilities_unknown_short) + val totalRam = memorySummary?.totalRamBytes?.let { + Formatter.formatShortFileSize(context, it) + } ?: stringResource(R.string.device_capabilities_unknown_short) + + CapabilityCard( + title = stringResource(R.string.device_capabilities_playback_path_title), + icon = Icons.Rounded.Speaker, + modifier = modifier ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(0.44f) + InfoTile( + label = stringResource(R.string.presentation_batch_g_device_label_sample_rate), + value = stringResource(R.string.presentation_batch_g_device_value_hz, audioCapabilities.outputSampleRate), + supporting = stringResource( + R.string.device_capabilities_buffer_frames, + audioCapabilities.outputFramesPerBuffer + ), + modifier = Modifier.weight(1f) + ) + InfoTile( + label = stringResource(R.string.device_capabilities_hifi_pcm_float), + value = yesNo(audioCapabilities.isPcmFloatSupported), + supporting = stringResource(R.string.device_capabilities_hifi_pcm_float_supporting), + modifier = Modifier.weight(1f) + ) + } + + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InfoTile( + label = stringResource(R.string.presentation_batch_g_device_label_low_latency), + value = yesNo(audioCapabilities.isLowLatencySupported), + supporting = stringResource(R.string.presentation_batch_g_device_label_pro_audio) + + ": " + yesNo(audioCapabilities.isProAudioSupported), + modifier = Modifier.weight(1f) ) + InfoTile( + label = stringResource(R.string.device_capabilities_memory_title), + value = availableRam, + supporting = stringResource(R.string.device_capabilities_memory_available_of, totalRam), + modifier = Modifier.weight(1f) + ) + } + + SectionLabel(text = stringResource(R.string.device_capabilities_offload_title)) + ChipRow( + emptyText = stringResource(R.string.device_capabilities_offload_empty), + chips = audioCapabilities.offloadSupportedFormats + ) + + SectionLabel(text = stringResource(R.string.device_capabilities_outputs_title)) + if (audioCapabilities.outputRoutes.isEmpty()) { Text( - text = value, + text = stringResource(R.string.device_capabilities_outputs_empty), style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.End, - modifier = Modifier.weight(0.56f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + audioCapabilities.outputRoutes.take(5).forEach { route -> + OutputRouteRow( + name = route.name, + category = route.category + ) + } + } + } + + exoPlayerInfo?.let { exo -> + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InfoTile( + label = stringResource(R.string.presentation_batch_g_device_section_exoplayer), + value = exo.version, + supporting = stringResource(R.string.device_capabilities_renderers_count, exo.renderers), + icon = Icons.Rounded.Memory, + modifier = Modifier.weight(1f) + ) + } } } - Spacer(Modifier.height(8.dp)) } @Composable -fun DeviceInfoExpressiveSection(deviceInfo: Map) { - val orderedEntries = remember(deviceInfo) { orderedDeviceInfoEntries(deviceInfo) } - val lManufacturer = stringResource(R.string.presentation_batch_g_device_key_manufacturer) - val lModel = stringResource(R.string.presentation_batch_g_device_key_model) - val lBrand = stringResource(R.string.presentation_batch_g_device_key_brand) - val lDevice = stringResource(R.string.presentation_batch_g_device_key_device) - val lAndroid = stringResource(R.string.presentation_batch_g_device_key_android_version) - val lSdk = stringResource(R.string.presentation_batch_g_device_key_sdk_version) - val lHardware = stringResource(R.string.presentation_batch_g_device_key_hardware) - val localized = remember( - orderedEntries, - lManufacturer, - lModel, - lBrand, - lDevice, - lAndroid, - lSdk, - lHardware +private fun FormatCompatibilityCard( + formats: List, + playbackCompatibility: PlaybackCompatibilitySummary?, + modifier: Modifier = Modifier +) { + CapabilityCard( + title = stringResource(R.string.device_capabilities_formats_title), + icon = Icons.Rounded.GraphicEq, + verticalSpacing = 0.dp, + modifier = modifier ) { - orderedEntries.map { (k, v) -> - val label = when (k) { - "Manufacturer" -> lManufacturer - "Model" -> lModel - "Brand" -> lBrand - "Device" -> lDevice - "Android Version" -> lAndroid - "SDK Version" -> lSdk - "Hardware" -> lHardware - else -> k + val rows = formats.chunked(2) + Spacer( + modifier = Modifier.height(12.dp) + ) + rows.forEachIndexed { index, rowFormats -> + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + rowFormats.forEach { format -> + FormatSupportTile( + format = format, + modifier = Modifier.weight(1f) + ) + } + if (rowFormats.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + if (index != rows.lastIndex) { + Spacer(Modifier.height(8.dp)) + } + } + + Spacer( + modifier = Modifier.height(12.dp) + ) + + playbackCompatibility?.let { compatibility -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TonalChip( + text = stringResource( + R.string.device_capabilities_formats_supported_count, + compatibility.supportedLibrarySongCount + ), + leadingIcon = Icons.Rounded.CheckCircle + ) + if (compatibility.unknownFormatSongCount > 0) { + TonalChip( + text = stringResource( + R.string.device_capabilities_formats_unknown_count, + compatibility.unknownFormatSongCount + ), + leadingIcon = Icons.Rounded.Info + ) + } } - label to v } } - val heroEntries = localized.take(2) - val detailEntries = localized.drop(2) - val sectionShape = AbsoluteSmoothCornerShape(30.dp, 60) +} - Card( - shape = sectionShape, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) +@Composable +private fun PlaybackFindingsCard( + compatibility: PlaybackCompatibilitySummary, + modifier: Modifier = Modifier +) { + val hasFindings = compatibility.unsupportedLibrarySongCount > 0 || + compatibility.unknownFormatSongCount > 0 || + compatibility.resampledLocalSongCount > 0 + + CapabilityCard( + title = stringResource(R.string.device_capabilities_findings_title), + icon = if (hasFindings) Icons.Rounded.Warning else Icons.Rounded.CheckCircle, + modifier = modifier ) { - Box( - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.linearGradient( - colors = listOf( - MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.52f), - MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.34f), - MaterialTheme.colorScheme.surfaceContainer - ) - ) - ) - ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + if (!hasFindings) { + FindingRow( + icon = Icons.Rounded.CheckCircle, + title = stringResource(R.string.device_capabilities_finding_clear_title), + body = stringResource(R.string.device_capabilities_finding_clear_body), + tone = FindingTone.Success + ) + return@CapabilityCard + } + + if (compatibility.unsupportedLibrarySongCount > 0) { + FindingRow( + icon = Icons.Rounded.ErrorOutline, + title = stringResource( + R.string.device_capabilities_finding_unsupported_title, + compatibility.unsupportedLibrarySongCount + ), + body = stringResource( + R.string.device_capabilities_finding_unsupported_body, + compatibility.unsupportedFormats.take(4).joinToString(", ") + ), + tone = FindingTone.Error + ) + } + + if (compatibility.resampledLocalSongCount > 0) { + Spacer(Modifier.height(8.dp)) + FindingRow( + icon = Icons.Rounded.Warning, + title = stringResource( + R.string.device_capabilities_finding_resample_title, + compatibility.resampledLocalSongCount + ), + body = stringResource( + R.string.device_capabilities_finding_resample_body, + compatibility.maxLocalSampleRate ?: 0 + ), + tone = FindingTone.Warning + ) + } + + if (compatibility.unknownFormatSongCount > 0) { + Spacer(Modifier.height(8.dp)) + FindingRow( + icon = Icons.Rounded.Info, + title = stringResource( + R.string.device_capabilities_finding_unknown_title, + compatibility.unknownFormatSongCount + ), + body = stringResource(R.string.device_capabilities_finding_unknown_body), + tone = FindingTone.Info + ) + } + } +} + +@Composable +private fun DeviceInfoPanel( + deviceInfo: Map, + modifier: Modifier = Modifier +) { + val orderedEntries = remember(deviceInfo) { orderedDeviceInfoEntries(deviceInfo) } + val localized = localizedDeviceInfoEntries(orderedEntries) + + CapabilityCard( + title = stringResource(R.string.presentation_batch_g_device_info_title), + icon = Icons.Rounded.Info, + verticalSpacing = 0.dp, + enableTopSpacer = true, + modifier = modifier + ) { + val rows = localized.chunked(2) + rows.forEachIndexed { index, entries -> + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Surface( - shape = AbsoluteSmoothCornerShape(16.dp, 60), - color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.94f) - ) { - Icon( - imageVector = Icons.Rounded.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.padding(10.dp) - ) - } - Spacer(Modifier.width(12.dp)) - Text( - text = stringResource(R.string.presentation_batch_g_device_info_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + entries.forEach { (label, value) -> + InfoTile( + label = label, + value = value, + modifier = Modifier.weight(1f) ) } - //HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)) - if (heroEntries.isNotEmpty()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - heroEntries.forEach { (label, value) -> - DeviceInfoHeroTile( - label = label, - value = value, - modifier = Modifier.weight(1f) - ) - } - if (heroEntries.size == 1) { - Spacer(modifier = Modifier.weight(1f)) - } - } - } - detailEntries.chunked(2).forEach { rowEntries -> - if (rowEntries.size == 2) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - DeviceInfoStatTile( - label = rowEntries[0].first, - value = rowEntries[0].second, - modifier = Modifier.weight(1f) - ) - DeviceInfoStatTile( - label = rowEntries[1].first, - value = rowEntries[1].second, - modifier = Modifier.weight(1f) - ) - } - } else { - DeviceInfoStatTile( - label = rowEntries[0].first, - value = rowEntries[0].second, - modifier = Modifier.fillMaxWidth() - ) - } + if (entries.size == 1) { + Spacer(modifier = Modifier.weight(1f)) } } + if (index != rows.lastIndex) { + Spacer(Modifier.height(8.dp)) + } } } } @Composable -private fun DeviceInfoHeroTile( +private fun CapabilityCard( + modifier: Modifier = Modifier, + title: String, + icon: ImageVector, + enableTopSpacer: Boolean = false, + verticalSpacing: Dp = 10.dp, + content: @Composable ColumnScope.() -> Unit +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = AbsoluteSmoothCornerShape(28.dp, 60), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(verticalSpacing) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatusIcon( + icon = icon, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .weight(1f) + .semantics { heading() } + ) + } + + if (enableTopSpacer) { + Spacer( + modifier = Modifier.height(12.dp) + ) + } + + content() + } + } +} + +@Composable +private fun StatusIcon( + icon: ImageVector, + containerColor: Color, + contentColor: Color, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.size(44.dp), + shape = CircleShape, + color = containerColor + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(24.dp) + ) + } + } +} + +@Composable +private fun HeroMetricTile( label: String, value: String, + containerColor: Color, + contentColor: Color, modifier: Modifier = Modifier ) { Surface( - shape = AbsoluteSmoothCornerShape(22.dp, 60), - color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = modifier + .fillMaxHeight() + .defaultMinSize(minHeight = 82.dp), + shape = AbsoluteSmoothCornerShape(18.dp, 60), + color = containerColor ) { Column( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) Text( text = value, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.76f), maxLines = 2, + textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis ) } @@ -472,25 +824,44 @@ private fun DeviceInfoHeroTile( } @Composable -private fun DeviceInfoStatTile( +private fun InfoTile( label: String, value: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + supporting: String? = null, + icon: ImageVector? = null ) { Surface( - shape = AbsoluteSmoothCornerShape(14.dp, 60), - color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = modifier + .fillMaxHeight() + .defaultMinSize(minHeight = 86.dp), + shape = AbsoluteSmoothCornerShape(18.dp, 60), + color = MaterialTheme.colorScheme.surfaceContainerLow ) { Column( modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icon?.let { + Icon( + imageVector = it, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } Text( text = value, style = MaterialTheme.typography.titleMedium, @@ -499,86 +870,186 @@ private fun DeviceInfoStatTile( maxLines = 2, overflow = TextOverflow.Ellipsis ) + supporting?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } } } @Composable -fun SegmentedCodecList(codecs: List) { - val listShape = RoundedCornerShape(18.dp) - Column( - modifier = Modifier - .fillMaxWidth() - .clip(listShape), - verticalArrangement = Arrangement.spacedBy(4.dp) +private fun FormatSupportTile( + format: FormatSupportInfo, + modifier: Modifier = Modifier +) { + val statusColor = when { + !format.isDecoderAvailable -> MaterialTheme.colorScheme.errorContainer + format.isHardwareAccelerated -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.tertiaryContainer + } + val statusContentColor = when { + !format.isDecoderAvailable -> MaterialTheme.colorScheme.onErrorContainer + format.isHardwareAccelerated -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onTertiaryContainer + } + + Surface( + modifier = modifier + .fillMaxHeight() + .defaultMinSize(minHeight = 112.dp), + shape = AbsoluteSmoothCornerShape(18.dp, 60), + color = MaterialTheme.colorScheme.surfaceContainerLow ) { - codecs.forEachIndexed { index, codec -> - CodecCard( - codec = codec, - shape = settingsSegmentShape( - index = index, - count = codecs.size, - outerCorner = 18.dp, - innerCorner = 8.dp + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = format.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) + Surface( + shape = CircleShape, + color = statusColor + ) { + Icon( + imageVector = if (format.isDecoderAvailable) Icons.Rounded.CheckCircle else Icons.Rounded.ErrorOutline, + contentDescription = null, + tint = statusContentColor, + modifier = Modifier + .padding(4.dp) + .size(16.dp) + ) + } + } + Text( + text = when { + !format.isDecoderAvailable -> stringResource(R.string.device_capabilities_format_unsupported) + format.isHardwareAccelerated -> stringResource(R.string.device_capabilities_format_hardware) + else -> stringResource(R.string.device_capabilities_format_software) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (format.isOffloadSupported) { + TonalChip( + text = stringResource(R.string.device_capabilities_format_offload), + compact = true + ) + } + if (format.librarySongCount > 0) { + TonalChip( + text = stringResource(R.string.device_capabilities_format_library_count, format.librarySongCount), + compact = true + ) + } + } } } } @Composable -fun CodecCard( - codec: CodecInfo, - shape: Shape = RoundedCornerShape(8.dp) +private fun ProgressReadout( + label: String, + value: String, + progress: Float, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary ) { - Card( - shape = shape, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) - ) { - Box( + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = value, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(Modifier.height(6.dp)) + LinearProgressIndicator( + progress = { progress.visibleProgress() }, modifier = Modifier .fillMaxWidth() - .background( - brush = Brush.horizontalGradient( - colors = listOf( - MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.36f), - MaterialTheme.colorScheme.surfaceContainerLow - ) - ) - ) + .height(8.dp) + .clip(CircleShape), + color = color, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + } +} + +@Composable +private fun storagePercentLabel(fraction: Float): String { + val percent = fraction * 100f + return when { + fraction <= 0f -> stringResource(R.string.device_capabilities_storage_percent, 0) + percent < 1f -> stringResource(R.string.device_capabilities_storage_less_than_one_percent) + else -> stringResource(R.string.device_capabilities_storage_percent, percent.roundToInt()) + } +} + +private fun Float.visibleProgress(): Float { + val clamped = coerceIn(0f, 1f) + return if (clamped > 0f && clamped < 0.01f) 0.01f else clamped +} + +@Composable +private fun OutputRouteRow( + name: String, + category: AudioOutputCategory, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = AbsoluteSmoothCornerShape(16.dp, 60), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = codec.name, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - if (codec.isHardwareAccelerated) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer - ) { - Icon( - imageVector = Icons.Rounded.CheckCircle, - contentDescription = stringResource(R.string.presentation_batch_g_device_cd_hw_accelerated), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(4.dp).size(16.dp) - ) - } - } - } - Spacer(Modifier.height(6.dp)) + Icon( + imageVector = if (category == AudioOutputCategory.BuiltIn) Icons.Rounded.Speaker else Icons.Rounded.Headphones, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Column(modifier = Modifier.weight(1f)) { Text( - text = codec.supportedTypes.joinToString(", "), + text = name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = audioOutputCategoryLabel(category), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -587,27 +1058,192 @@ fun CodecCard( } } -private fun settingsSegmentShape( - index: Int, - count: Int, - outerCorner: Dp, - innerCorner: Dp -): RoundedCornerShape { - return when { - count <= 1 -> RoundedCornerShape(outerCorner) - index == 0 -> RoundedCornerShape( - topStart = outerCorner, - topEnd = outerCorner, - bottomStart = innerCorner, - bottomEnd = innerCorner - ) - index == count - 1 -> RoundedCornerShape( - topStart = innerCorner, - topEnd = innerCorner, - bottomStart = outerCorner, - bottomEnd = outerCorner +private enum class FindingTone { + Success, + Warning, + Error, + Info +} + +@Composable +private fun FindingRow( + icon: ImageVector, + title: String, + body: String, + tone: FindingTone, + modifier: Modifier = Modifier +) { + val containerColor = when (tone) { + FindingTone.Success -> MaterialTheme.colorScheme.primaryContainer + FindingTone.Warning -> MaterialTheme.colorScheme.tertiaryContainer + FindingTone.Error -> MaterialTheme.colorScheme.errorContainer + FindingTone.Info -> MaterialTheme.colorScheme.secondaryContainer + } + val contentColor = when (tone) { + FindingTone.Success -> MaterialTheme.colorScheme.onPrimaryContainer + FindingTone.Warning -> MaterialTheme.colorScheme.onTertiaryContainer + FindingTone.Error -> MaterialTheme.colorScheme.onErrorContainer + FindingTone.Info -> MaterialTheme.colorScheme.onSecondaryContainer + } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = AbsoluteSmoothCornerShape(18.dp, 60), + color = containerColor + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(22.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = contentColor + ) + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + color = contentColor.copy(alpha = 0.78f) + ) + } + } + } +} + +@Composable +private fun TonalChip( + text: String, + modifier: Modifier = Modifier, + leadingIcon: ImageVector? = null, + compact: Boolean = false, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant +) { + Surface( + modifier = modifier, + shape = CircleShape, + color = containerColor + ) { + Row( + modifier = Modifier.padding( + horizontal = if (compact) 8.dp else 10.dp, + vertical = if (compact) 5.dp else 7.dp + ), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + leadingIcon?.let { + Icon( + imageVector = it, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(if (compact) 14.dp else 16.dp) + ) + } + Text( + text = text, + style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ChipRow( + chips: List, + emptyText: String, + modifier: Modifier = Modifier +) { + if (chips.isEmpty()) { + Text( + text = emptyText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier ) - else -> RoundedCornerShape(innerCorner) + return + } + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + chips.take(3).forEach { + TonalChip(text = it) + } + if (chips.size > 3) { + TonalChip(text = stringResource(R.string.device_capabilities_more_count, chips.size - 3)) + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.semantics { heading() } + ) +} + +@Composable +private fun yesNo(value: Boolean): String { + return if (value) { + stringResource(R.string.presentation_batch_g_yes) + } else { + stringResource(R.string.presentation_batch_g_no) + } +} + +@Composable +private fun audioOutputCategoryLabel(category: AudioOutputCategory): String { + return when (category) { + AudioOutputCategory.BuiltIn -> stringResource(R.string.device_capabilities_output_builtin) + AudioOutputCategory.Bluetooth -> stringResource(R.string.device_capabilities_output_bluetooth) + AudioOutputCategory.Usb -> stringResource(R.string.device_capabilities_output_usb) + AudioOutputCategory.Wired -> stringResource(R.string.device_capabilities_output_wired) + AudioOutputCategory.Cast -> stringResource(R.string.device_capabilities_output_digital) + AudioOutputCategory.Other -> stringResource(R.string.device_capabilities_output_other) + } +} + +@Composable +private fun localizedDeviceInfoEntries(entries: List>): List> { + val lManufacturer = stringResource(R.string.presentation_batch_g_device_key_manufacturer) + val lModel = stringResource(R.string.presentation_batch_g_device_key_model) + val lBrand = stringResource(R.string.presentation_batch_g_device_key_brand) + val lDevice = stringResource(R.string.presentation_batch_g_device_key_device) + val lAndroid = stringResource(R.string.presentation_batch_g_device_key_android_version) + val lSdk = stringResource(R.string.presentation_batch_g_device_key_sdk_version) + val lHardware = stringResource(R.string.presentation_batch_g_device_key_hardware) + + return entries.map { (key, value) -> + val label = when (key) { + "Manufacturer" -> lManufacturer + "Model" -> lModel + "Brand" -> lBrand + "Device" -> lDevice + "Android Version" -> lAndroid + "SDK Version" -> lSdk + "Hardware" -> lHardware + else -> key + } + label to value } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt index 67edaaea5..73c69acb8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt @@ -1,21 +1,35 @@ package com.theveloper.pixelplay.presentation.viewmodel +import android.app.ActivityManager import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioDeviceInfo +import android.media.AudioFormat import android.media.AudioManager -import android.media.MediaCodecList -import android.media.MediaFormat +import android.net.Uri import android.os.Build +import android.os.Environment +import android.os.StatFs +import android.provider.OpenableColumns +import androidx.annotation.OptIn import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.media3.common.util.UnstableApi +import com.theveloper.pixelplay.data.database.DeviceCapabilitySongRow +import com.theveloper.pixelplay.data.database.MusicDao +import com.theveloper.pixelplay.data.database.SourceType import com.theveloper.pixelplay.data.service.player.DualPlayerEngine +import com.theveloper.pixelplay.data.service.player.HiFiCapabilityChecker import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.DecoderCounters +import kotlinx.coroutines.withContext data class CodecInfo( val name: String, @@ -24,14 +38,86 @@ data class CodecInfo( val maxSupportedInstances: Int ) +data class AudioOutputInfo( + val name: String, + val category: AudioOutputCategory +) + +enum class AudioOutputCategory { + BuiltIn, + Bluetooth, + Usb, + Wired, + Cast, + Other +} + data class AudioCapabilities( val outputSampleRate: Int, val outputFramesPerBuffer: Int, val isLowLatencySupported: Boolean, val isProAudioSupported: Boolean, + val isPcmFloatSupported: Boolean, + val offloadSupportedFormats: List, + val outputRoutes: List, val supportedCodecs: List ) +data class FormatSupportInfo( + val label: String, + val mimeType: String, + val isDecoderAvailable: Boolean, + val isHardwareAccelerated: Boolean, + val isOffloadSupported: Boolean, + val librarySongCount: Int +) + +data class LocalMusicStorageSummary( + val localSongCount: Int, + val cloudSongCount: Int, + val knownLocalFileCount: Int, + val unavailableLocalFileCount: Int, + val localMusicBytes: Long, + val deviceAvailableBytes: Long, + val deviceTotalBytes: Long +) { + val deviceUsedBytes: Long + get() = (deviceTotalBytes - deviceAvailableBytes).coerceAtLeast(0L) + + val localMusicStorageFraction: Float + get() = if (deviceTotalBytes <= 0L) { + 0f + } else { + (localMusicBytes.toDouble() / deviceTotalBytes.toDouble()).coerceIn(0.0, 1.0).toFloat() + } + + val deviceUsedFraction: Float + get() = if (deviceTotalBytes <= 0L) { + 0f + } else { + (deviceUsedBytes.toDouble() / deviceTotalBytes.toDouble()).coerceIn(0.0, 1.0).toFloat() + } +} + +data class PlaybackCompatibilitySummary( + val supportedLibrarySongCount: Int, + val unsupportedLibrarySongCount: Int, + val unknownFormatSongCount: Int, + val unsupportedFormats: List, + val localHiResSongCount: Int, + val resampledLocalSongCount: Int, + val maxLocalSampleRate: Int?, + val maxLocalBitrate: Int? +) + +data class MemorySummary( + val availableRamBytes: Long, + val totalRamBytes: Long, + val memoryClassMb: Int, + val isLowRamDevice: Boolean, + val isSystemLowMemory: Boolean +) + data class ExoPlayerInfo( val version: String, val renderers: String, @@ -42,13 +128,24 @@ data class DeviceCapabilitiesState( val deviceInfo: Map = emptyMap(), val audioCapabilities: AudioCapabilities? = null, val exoPlayerInfo: ExoPlayerInfo? = null, + val storageSummary: LocalMusicStorageSummary? = null, + val playbackCompatibility: PlaybackCompatibilitySummary? = null, + val formatSupport: List = emptyList(), + val memorySummary: MemorySummary? = null, val isLoading: Boolean = true ) +private data class AudioFormatCandidate( + val label: String, + val mimeType: String, + val offloadEncoding: Int? +) + @HiltViewModel class DeviceCapabilitiesViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val engine: DualPlayerEngine + private val engine: DualPlayerEngine, + private val musicDao: MusicDao ) : ViewModel() { private val _state = MutableStateFlow(DeviceCapabilitiesState()) @@ -60,16 +157,29 @@ class DeviceCapabilitiesViewModel @Inject constructor( private fun loadCapabilities() { viewModelScope.launch { - val deviceInfo = getDeviceInfo() - val audioCaps = getAudioCapabilities() val exoInfo = getExoPlayerInfo() + val loadedState = withContext(Dispatchers.IO) { + val deviceInfo = getDeviceInfo() + val audioCaps = getAudioCapabilities() + val libraryRows = musicDao.getDeviceCapabilitySongRows() + val storage = getLocalMusicStorageSummary(libraryRows) + val playback = getPlaybackCompatibilitySummary(libraryRows, audioCaps) + val formatSupport = getFormatSupport(libraryRows, audioCaps) + val memorySummary = getMemorySummary() - _state.value = DeviceCapabilitiesState( - deviceInfo = deviceInfo, - audioCapabilities = audioCaps, - exoPlayerInfo = exoInfo, - isLoading = false - ) + DeviceCapabilitiesState( + deviceInfo = deviceInfo, + audioCapabilities = audioCaps, + exoPlayerInfo = exoInfo, + storageSummary = storage, + playbackCompatibility = playback, + formatSupport = formatSupport, + memorySummary = memorySummary, + isLoading = false + ) + } + + _state.value = loadedState } } @@ -87,11 +197,12 @@ class DeviceCapabilitiesViewModel @Inject constructor( private fun getAudioCapabilities(): AudioCapabilities { val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val sampleRate = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)?.toIntOrNull() ?: 44100 + val sampleRate = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)?.toIntOrNull() ?: 44_100 val framesPerBuffer = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)?.toIntOrNull() ?: 256 - val hasLowLatency = context.packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_AUDIO_LOW_LATENCY) - val hasProAudio = context.packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_AUDIO_PRO) - + val packageManager = context.packageManager + val hasLowLatency = packageManager.hasSystemFeature(PackageManager.FEATURE_AUDIO_LOW_LATENCY) + val hasProAudio = packageManager.hasSystemFeature(PackageManager.FEATURE_AUDIO_PRO) + val offloadSupportedFormats = getOffloadSupportedFormats() val supportedCodecs = getSupportedAudioCodecs() return AudioCapabilities( @@ -99,30 +210,36 @@ class DeviceCapabilitiesViewModel @Inject constructor( outputFramesPerBuffer = framesPerBuffer, isLowLatencySupported = hasLowLatency, isProAudioSupported = hasProAudio, + isPcmFloatSupported = HiFiCapabilityChecker.isSupported(), + offloadSupportedFormats = offloadSupportedFormats, + outputRoutes = getOutputRoutes(audioManager), supportedCodecs = supportedCodecs ) } private fun getSupportedAudioCodecs(): List { - val codecList = MediaCodecList(MediaCodecList.ALL_CODECS) + val codecList = android.media.MediaCodecList(android.media.MediaCodecList.ALL_CODECS) val codecs = mutableListOf() for (codecInfo in codecList.codecInfos) { - if (codecInfo.isEncoder) continue // Skip encorders + if (codecInfo.isEncoder) continue - val types = codecInfo.supportedTypes.filter { it.startsWith("audio/") } + val types = codecInfo.supportedTypes + .filter { it.startsWith("audio/") } + .map { normalizeMimeType(it) } + .distinct() if (types.isEmpty()) continue - var isHardware = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - isHardware = codecInfo.isHardwareAccelerated + val isHardware = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + codecInfo.isHardwareAccelerated + } else { + false } - // Estimate instances (not always available/accurate via public API directly without capabilities, but usually safe default) val instances = try { - val caps = codecInfo.getCapabilitiesForType(types[0]) - caps.maxSupportedInstances - } catch (e: Exception) { + codecInfo.getCapabilitiesForType(codecInfo.supportedTypes.first { it.startsWith("audio/") }) + .maxSupportedInstances + } catch (_: Exception) { -1 } @@ -138,24 +255,286 @@ class DeviceCapabilitiesViewModel @Inject constructor( return codecs.sortedBy { it.name } } - @androidx.annotation.OptIn(UnstableApi::class) + private fun getLocalMusicStorageSummary(rows: List): LocalMusicStorageSummary { + val localRows = rows.filter { it.sourceType == SourceType.LOCAL } + val cloudCount = rows.count { it.sourceType != SourceType.LOCAL } + var totalBytes = 0L + var knownFiles = 0 + var unavailableFiles = 0 + + localRows.forEach { row -> + val bytes = resolveLocalFileSize(row) + if (bytes > 0L) { + totalBytes += bytes + knownFiles += 1 + } else { + unavailableFiles += 1 + } + } + + val storageStats = getDeviceStorageStats() + + return LocalMusicStorageSummary( + localSongCount = localRows.size, + cloudSongCount = cloudCount, + knownLocalFileCount = knownFiles, + unavailableLocalFileCount = unavailableFiles, + localMusicBytes = totalBytes, + deviceAvailableBytes = storageStats.first, + deviceTotalBytes = storageStats.second + ) + } + + private fun getPlaybackCompatibilitySummary( + rows: List, + audioCapabilities: AudioCapabilities + ): PlaybackCompatibilitySummary { + val supportedTypes = audioCapabilities.supportedCodecs.flatMap { it.supportedTypes }.toSet() + val localRows = rows.filter { it.sourceType == SourceType.LOCAL } + var supportedCount = 0 + var unsupportedCount = 0 + var unknownCount = 0 + val unsupportedFormats = linkedSetOf() + + rows.forEach { row -> + val mimeType = row.mimeType?.let(::normalizeMimeType) + when { + mimeType.isNullOrBlank() -> unknownCount += 1 + isMimeTypeSupported(mimeType, supportedTypes) -> supportedCount += 1 + else -> { + unsupportedCount += 1 + unsupportedFormats += mimeType + } + } + } + + val localSampleRates = localRows.mapNotNull { it.sampleRate }.filter { it > 0 } + val maxSampleRate = localSampleRates.maxOrNull() + val maxBitrate = localRows.mapNotNull { it.bitrate }.filter { it > 0 }.maxOrNull() + val outputRate = audioCapabilities.outputSampleRate.coerceAtLeast(1) + val hiResSongCount = localSampleRates.count { it > 48_000 } + val resampledSongCount = localSampleRates.count { it > outputRate } + + return PlaybackCompatibilitySummary( + supportedLibrarySongCount = supportedCount, + unsupportedLibrarySongCount = unsupportedCount, + unknownFormatSongCount = unknownCount, + unsupportedFormats = unsupportedFormats.toList(), + localHiResSongCount = hiResSongCount, + resampledLocalSongCount = resampledSongCount, + maxLocalSampleRate = maxSampleRate, + maxLocalBitrate = maxBitrate + ) + } + + private fun getFormatSupport( + rows: List, + audioCapabilities: AudioCapabilities + ): List { + val supportedTypes = audioCapabilities.supportedCodecs.flatMap { it.supportedTypes }.toSet() + val hardwareTypes = audioCapabilities.supportedCodecs + .filter { it.isHardwareAccelerated } + .flatMap { it.supportedTypes } + .toSet() + val offloadFormats = audioCapabilities.offloadSupportedFormats.toSet() + val libraryCountsByMime = rows + .mapNotNull { it.mimeType?.let(::normalizeMimeType) } + .groupingBy { it } + .eachCount() + + return audioFormatCandidates().map { candidate -> + val acceptedMimes = compatibleMimeTypes(candidate.mimeType) + FormatSupportInfo( + label = candidate.label, + mimeType = candidate.mimeType, + isDecoderAvailable = acceptedMimes.any { isMimeTypeSupported(it, supportedTypes) }, + isHardwareAccelerated = acceptedMimes.any { isMimeTypeSupported(it, hardwareTypes) }, + isOffloadSupported = candidate.label in offloadFormats, + librarySongCount = acceptedMimes.sumOf { libraryCountsByMime[it] ?: 0 } + ) + } + } + + private fun getMemorySummary(): MemorySummary { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memoryInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memoryInfo) + return MemorySummary( + availableRamBytes = memoryInfo.availMem, + totalRamBytes = memoryInfo.totalMem, + memoryClassMb = activityManager.memoryClass, + isLowRamDevice = activityManager.isLowRamDevice, + isSystemLowMemory = memoryInfo.lowMemory + ) + } + + private fun getOutputRoutes(audioManager: AudioManager): List { + return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .map { device -> + AudioOutputInfo( + name = device.productName?.toString()?.takeIf { it.isNotBlank() } + ?: device.type.toAudioOutputCategory().name, + category = device.type.toAudioOutputCategory() + ) + } + .distinctBy { it.category to it.name.lowercase(Locale.US) } + .sortedBy { it.category.ordinal } + } + + private fun getOffloadSupportedFormats(): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return emptyList() + + val attributes = android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + + return audioFormatCandidates() + .mapNotNull { candidate -> + val encoding = candidate.offloadEncoding ?: return@mapNotNull null + val isSupported = runCatching { + val audioFormat = AudioFormat.Builder() + .setEncoding(encoding) + .setSampleRate(44_100) + .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) + .build() + AudioManager.isOffloadedPlaybackSupported(audioFormat, attributes) + }.getOrDefault(false) + + if (isSupported) candidate.label else null + } + } + + private fun getDeviceStorageStats(): Pair { + return runCatching { + val statFs = StatFs(Environment.getExternalStorageDirectory().path) + statFs.availableBytes to statFs.totalBytes + }.getOrElse { 0L to 0L } + } + + private fun resolveLocalFileSize(row: DeviceCapabilitySongRow): Long { + val pathSize = row.filePath + .takeIf { it.isNotBlank() } + ?.let { path -> + runCatching { + val file = File(path) + if (file.exists() && file.isFile) file.length() else 0L + }.getOrDefault(0L) + } + ?: 0L + + if (pathSize > 0L) return pathSize + + val contentSize = resolveContentUriSize(row.contentUriString) + if (contentSize > 0L) return contentSize + + return estimateFileSizeFromMetadata(row) + } + + private fun resolveContentUriSize(contentUriString: String): Long { + val uri = runCatching { Uri.parse(contentUriString) }.getOrNull() ?: return 0L + if (uri.scheme != "content") return 0L + + return runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else 0L + } else { + 0L + } + } ?: 0L + }.getOrDefault(0L) + } + + private fun estimateFileSizeFromMetadata(row: DeviceCapabilitySongRow): Long { + val bitrate = row.bitrate?.takeIf { it > 0 }?.toLong() ?: return 0L + val durationMs = row.duration.takeIf { it > 0 } ?: return 0L + return (bitrate * durationMs / 8_000L).coerceAtLeast(0L) + } + + @OptIn(UnstableApi::class) private fun getExoPlayerInfo(): ExoPlayerInfo { val player = engine.masterPlayer - - // This is a basic info string, expanding it would require deeper reflection or ExoPlayer specific listeners - // For now, we return version and renderer count. - val version = androidx.media3.common.MediaLibraryInfo.VERSION val exoPlayer = player as? androidx.media3.exoplayer.ExoPlayer - val renderers = "${exoPlayer?.rendererCount ?: 0} Active Renderers" - - // We can't easily get internal decoder counters without a listener, - // but we can show what we know. - + val renderers = "${exoPlayer?.rendererCount ?: 0}" + return ExoPlayerInfo( version = version, renderers = renderers, - decoderCounters = "N/A (Requires Debug Listener)" + decoderCounters = "N/A" ) } } + +private fun Int.toAudioOutputCategory(): AudioOutputCategory { + return when (this) { + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> AudioOutputCategory.BuiltIn + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_BLE_SPEAKER, + AudioDeviceInfo.TYPE_BLE_BROADCAST -> AudioOutputCategory.Bluetooth + AudioDeviceInfo.TYPE_USB_ACCESSORY, + AudioDeviceInfo.TYPE_USB_DEVICE, + AudioDeviceInfo.TYPE_USB_HEADSET -> AudioOutputCategory.Usb + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_LINE_ANALOG, + AudioDeviceInfo.TYPE_LINE_DIGITAL -> AudioOutputCategory.Wired + AudioDeviceInfo.TYPE_HDMI, + AudioDeviceInfo.TYPE_HDMI_ARC, + AudioDeviceInfo.TYPE_HDMI_EARC -> AudioOutputCategory.Cast + else -> AudioOutputCategory.Other + } +} + +private fun audioFormatCandidates(): List { + return buildList { + add(AudioFormatCandidate("MP3", "audio/mpeg", AudioFormat.ENCODING_MP3)) + add(AudioFormatCandidate("AAC", "audio/mp4a-latm", AudioFormat.ENCODING_AAC_LC)) + add(AudioFormatCandidate("FLAC", "audio/flac", null)) + add(AudioFormatCandidate("Vorbis", "audio/vorbis", null)) + add(AudioFormatCandidate("WAV", "audio/wav", AudioFormat.ENCODING_PCM_16BIT)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + add(AudioFormatCandidate("Opus", "audio/opus", AudioFormat.ENCODING_OPUS)) + } else { + add(AudioFormatCandidate("Opus", "audio/opus", null)) + } + add(AudioFormatCandidate("ALAC", "audio/alac", null)) + } +} + +private fun normalizeMimeType(mimeType: String): String { + return when (mimeType.lowercase(Locale.US).substringBefore(";").trim()) { + "audio/mp3", "audio/x-mp3" -> "audio/mpeg" + "audio/x-wav", "audio/wave" -> "audio/wav" + "audio/aac", "audio/x-aac", "audio/m4a", "audio/mp4" -> "audio/mp4a-latm" + "audio/x-flac" -> "audio/flac" + "audio/ogg" -> "audio/vorbis" + "audio/x-ms-wma" -> "audio/wma" + else -> mimeType.lowercase(Locale.US).substringBefore(";").trim() + } +} + +private fun compatibleMimeTypes(mimeType: String): Set { + val normalized = normalizeMimeType(mimeType) + return when (normalized) { + "audio/mpeg" -> setOf("audio/mpeg", "audio/mp3", "audio/x-mp3") + "audio/mp4a-latm" -> setOf("audio/mp4a-latm", "audio/aac", "audio/x-aac", "audio/m4a", "audio/mp4") + "audio/flac" -> setOf("audio/flac", "audio/x-flac") + "audio/wav" -> setOf("audio/wav", "audio/x-wav", "audio/wave") + "audio/vorbis" -> setOf("audio/vorbis", "audio/ogg") + else -> setOf(normalized) + } +} + +private fun isMimeTypeSupported( + mimeType: String, + supportedTypes: Set +): Boolean { + return compatibleMimeTypes(mimeType).any { normalizeMimeType(it) in supportedTypes } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt index 17cb10b09..f73d42ec0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt @@ -42,11 +42,12 @@ class PlaybackStateHolder @Inject constructor( companion object { private const val TAG = "PlaybackStateHolder" private const val DURATION_MISMATCH_TOLERANCE_MS = 1500L - // 500 ms keeps the progress bar/time display smooth to the eye (it still updates twice - // per second) while halving Compose recomposition pressure vs. the old 250 ms tick. - // The visual slider uses frame-clock interpolation on top of this, so the animation - // stays fluid even at a slower underlying cadence. - private const val FOREGROUND_PROGRESS_TICK_MS = 500L + // 250 ms keeps the slider/time display visibly smooth. We tried 500 ms to lower + // Compose recomposition pressure, but the smooth-progress sampler does not actually + // interpolate between source samples — it polls — so a 500 ms source cadence made the + // slider stutter in half-second jumps. Background tick is throttled to 1 s since the + // screen is off and no slider is visible. + private const val FOREGROUND_PROGRESS_TICK_MS = 250L private const val BACKGROUND_PROGRESS_TICK_MS = 1000L /** * Threshold above which we skip per-item moveMediaItem calls and use diff --git a/app/src/main/res/values-es/strings_presentation_batch_g.xml b/app/src/main/res/values-es/strings_presentation_batch_g.xml index a87dafdb8..6ffbf3a3e 100644 --- a/app/src/main/res/values-es/strings_presentation_batch_g.xml +++ b/app/src/main/res/values-es/strings_presentation_batch_g.xml @@ -88,28 +88,81 @@ Unique tracks Top 3 share ? - Device Info - Supported Audio Codecs - Audio Output - ExoPlayer Engine - Sample Rate - Frames Per Buffer - Low Latency Support - Pro Audio Support - Version - Active Renderers - Decoder Counters + Información del dispositivo + Códecs de audio compatibles + Salida de audio + Motor ExoPlayer + Frecuencia de muestreo + Frames por búfer + Baja latencia + Audio Pro + Versión + Renderizadores activos + Contadores del decodificador %1$d Hz - Yes + No - Hardware accelerated - Manufacturer - Model - Brand - Device - Android Version - SDK Version + Acelerado por hardware + Fabricante + Modelo + Marca + Dispositivo + Versión de Android + Versión SDK Hardware + Este dispositivo + -- + Listo para reproducir música + La reproducción necesita revisión + Formatos + Decod. HW + Canciones locales + Almacenamiento de música local + Tamaño de música + %1$d canciones locales + Disponible + %1$s total + Huella de música + Dispositivo usado + %1$d%% + <1% + %1$d canciones en la nube + %1$d archivos no legibles + Ruta de reproducción + %1$d frames por búfer + Hi-Fi PCM Float + Salida float de 32 bits + Memoria + disponibles de %1$s + Formatos con offload + Android no informó offload por hardware para formatos comprimidos. + Salidas detectadas + Android no informó rutas de salida. + %1$s renderizadores + Compatibilidad de formatos + %1$d pistas compatibles + %1$d formato desconocido + Sin decodificador informado + Decodificador por hardware + Decodificador por software + Offload + %1$d en biblioteca + Hallazgos de compatibilidad + Sin incompatibilidades importantes + Las pistas indexadas coinciden con los decodificadores que Android informa en este dispositivo. + %1$d pistas podrían no decodificar nativamente + Formatos a revisar: %1$s. + %1$d pistas locales podrían remuestrearse + La biblioteca llega a %1$d Hz, por encima de la frecuencia actual de salida. + %1$d pistas tienen metadatos desconocidos + Un reescaneo completo puede completar MIME, bitrate y frecuencia de muestreo. + +%1$d más + Salida integrada + Audio Bluetooth + Audio USB + Audio cableado + Salida digital + Otra salida Input Output Thought diff --git a/app/src/main/res/values/strings_presentation_batch_g.xml b/app/src/main/res/values/strings_presentation_batch_g.xml index d98a066c2..c32a50899 100644 --- a/app/src/main/res/values/strings_presentation_batch_g.xml +++ b/app/src/main/res/values/strings_presentation_batch_g.xml @@ -110,6 +110,59 @@ Android Version SDK Version Hardware + This device + -- + Ready for playback + Playback needs review + Formats + HW decoders + Local songs + Local Music Storage + Music size + %1$d local songs + Available + %1$s total + Music footprint + Device used + %1$d%% + <1% + %1$d cloud songs + %1$d files not readable + Playback Path + %1$d frames per buffer + Hi-Fi PCM Float + 32-bit float output path + Memory + available of %1$s + Offload-ready formats + No compressed format reported hardware offload support. + Detected outputs + No output routes were reported by Android. + %1$s renderers + Format Compatibility + %1$d supported tracks + %1$d unknown format + No decoder reported + Hardware decoder + Software decoder + Offload + %1$d in library + Compatibility Findings + No major incompatibilities + Your indexed tracks match the decoders Android reports on this device. + %1$d tracks may not decode natively + Formats to review: %1$s. + %1$d local tracks may be resampled + The library reaches %1$d Hz, above the current output sample rate. + %1$d tracks have unknown metadata + A full library rescan can fill missing MIME, bitrate, and sample-rate data. + +%1$d more + Built-in output + Bluetooth audio + USB audio + Wired audio + Digital output + Other output Input Output Thought