diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 60e725321..1afb6f404 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,9 @@ android:maxSdkVersion="28" /> + diff --git a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt index f74d9ab4b..74d681a2e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt @@ -79,6 +79,9 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.foundation.gestures.detectTapGestures +import com.theveloper.pixelplay.presentation.viewmodel.PlayerSheetState import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -779,7 +782,6 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), bottomBar = { if (shouldRenderNavigationBar) { - val playerContentExpansionFraction = playerViewModel.playerContentExpansionFraction.value val currentSongId by remember { playerViewModel.stablePlayerState .map { it.currentSong?.id } @@ -788,58 +790,31 @@ class MainActivity : ComponentActivity() { val showPlayerContentArea = currentSongId != null val navBarElevation = 3.dp - val playerContentActualBottomRadiusTargetValue by remember( - navBarStyle, - showPlayerContentArea, - playerContentExpansionFraction, - navBarCornerRadius - ) { - derivedStateOf { - if (navBarStyle == NavBarStyle.FULL_WIDTH) { - return@derivedStateOf lerp(navBarCornerRadius.dp, 26.dp, playerContentExpansionFraction) - } - - if (showPlayerContentArea) { - if (playerContentExpansionFraction < 0.2f) { - lerp(navBarCornerRadius.dp, 26.dp, (playerContentExpansionFraction / 0.2f).coerceIn(0f, 1f)) - } else { - 26.dp - } - } else { - navBarCornerRadius.dp - } - } - } - - val playerContentActualBottomRadius by animateDpAsState( - targetValue = playerContentActualBottomRadiusTargetValue, - animationSpec = androidx.compose.animation.core.spring( - dampingRatio = androidx.compose.animation.core.Spring.DampingRatioNoBouncy, - stiffness = androidx.compose.animation.core.Spring.StiffnessMedium - ), - label = "PlayerContentBottomRadius" - ) - - val navBarHideFraction = if (showPlayerContentArea) playerContentExpansionFraction else 0f - val navBarHideFractionClamped = navBarHideFraction.coerceIn(0f, 1f) - val animatedNavBarCornerRadius by animateDpAsState( targetValue = navBarCornerRadius.dp, animationSpec = tween(400), label = "NavBarCornerRadius" ) - val actualShape = remember(playerContentActualBottomRadius, showPlayerContentArea, navBarStyle, animatedNavBarCornerRadius) { - val bottomRadius = if (navBarStyle == NavBarStyle.FULL_WIDTH) 0.dp else animatedNavBarCornerRadius - AbsoluteSmoothCornerShape( - cornerRadiusTL = playerContentActualBottomRadius, - smoothnessAsPercentBR = 60, - cornerRadiusTR = playerContentActualBottomRadius, - smoothnessAsPercentTL = 60, - cornerRadiusBL = bottomRadius, - smoothnessAsPercentTR = 60, - cornerRadiusBR = bottomRadius, - smoothnessAsPercentBL = 60 + val actualShape = remember(navBarStyle, showPlayerContentArea, navBarCornerRadius, animatedNavBarCornerRadius) { + DynamicSmoothCornerShape( + topRadiusProvider = { + val fraction = playerViewModel.playerContentExpansionFraction.value + if (navBarStyle == NavBarStyle.FULL_WIDTH) { + lerp(navBarCornerRadius.dp, 26.dp, fraction) + } else if (showPlayerContentArea) { + if (fraction < 0.2f) { + lerp(navBarCornerRadius.dp, 26.dp, (fraction / 0.2f).coerceIn(0f, 1f)) + } else { + 26.dp + } + } else { + navBarCornerRadius.dp + } + }, + bottomRadiusProvider = { + if (navBarStyle == NavBarStyle.FULL_WIDTH) 0.dp else animatedNavBarCornerRadius + } ) } @@ -851,16 +826,6 @@ class MainActivity : ComponentActivity() { val bottomBarPaddingPx = remember(bottomBarPadding, density) { with(density) { bottomBarPadding.toPx() } } - val animatedTranslationY by remember( - navBarHideFractionClamped, - componentHeightPx, - shadowOverflowPx, - bottomBarPaddingPx, - ) { - derivedStateOf { - (componentHeightPx + shadowOverflowPx + bottomBarPaddingPx) * navBarHideFractionClamped - } - } Box( modifier = Modifier @@ -879,7 +844,12 @@ class MainActivity : ComponentActivity() { .padding(bottom = bottomBarPadding) .onSizeChanged { componentHeightPx = it.height } .graphicsLayer { - translationY = animatedTranslationY + val hideFraction = if (showPlayerContentArea) { + playerViewModel.playerContentExpansionFraction.value.coerceIn(0f, 1f) + } else { + 0f + } + translationY = (componentHeightPx + shadowOverflowPx + bottomBarPaddingPx) * hideFraction alpha = 1f } .height(navBarHeight) @@ -943,6 +913,29 @@ class MainActivity : ComponentActivity() { onOpenSidebar = { scope.launch { drawerState.open() } } ) + val isExpandedOrExpanding by remember { + derivedStateOf { + playerViewModel.playerContentExpansionFraction.value > 0.01f + } + } + AnimatedVisibility( + visible = isExpandedOrExpanding, + enter = fadeIn(animationSpec = tween(durationMillis = 350)), + exit = fadeOut(animationSpec = tween(durationMillis = 350)), + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLowest.copy(alpha = 0.6f)) + .pointerInput(Unit) { + detectTapGestures { + playerViewModel.collapsePlayerSheet() + } + } + ) + } + UnifiedPlayerSheetV2( playerViewModel = playerViewModel, sheetCollapsedTargetY = sheetCollapsedTargetY, @@ -1088,3 +1081,28 @@ class MainActivity : ComponentActivity() { } + +private class DynamicSmoothCornerShape( + private val topRadiusProvider: () -> androidx.compose.ui.unit.Dp, + private val bottomRadiusProvider: () -> androidx.compose.ui.unit.Dp +) : androidx.compose.ui.graphics.Shape { + override fun createOutline( + size: androidx.compose.ui.geometry.Size, + layoutDirection: androidx.compose.ui.unit.LayoutDirection, + density: androidx.compose.ui.unit.Density + ): androidx.compose.ui.graphics.Outline { + val topRadius = topRadiusProvider() + val bottomRadius = bottomRadiusProvider() + val delegate = AbsoluteSmoothCornerShape( + cornerRadiusTL = topRadius, + smoothnessAsPercentTL = 60, + cornerRadiusTR = topRadius, + smoothnessAsPercentTR = 60, + cornerRadiusBL = bottomRadius, + smoothnessAsPercentBL = 60, + cornerRadiusBR = bottomRadius, + smoothnessAsPercentBR = 60 + ) + return delegate.createOutline(size, layoutDirection, density) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index 06ebec089..929b5fafc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -56,6 +56,7 @@ import com.theveloper.pixelplay.data.preferences.EqualizerPreferencesRepository import com.theveloper.pixelplay.data.preferences.ThemePreferencesRepository import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.data.service.cast.CastRemotePlaybackState import com.theveloper.pixelplay.data.service.player.DualPlayerEngine import com.theveloper.pixelplay.data.service.player.TransitionController import com.theveloper.pixelplay.ui.glancewidget.ControlWidget4x2 @@ -182,6 +183,7 @@ class MusicService : MediaLibraryService() { private var castRemoteClientCallback: RemoteMediaClient.Callback? = null private var observedCastSession: CastSession? = null private var activeCastStatsOccurrenceId: String? = null + private var activeCastPlaybackIntent: Boolean = false private var playbackSnapshotPersistJob: Job? = null private var playbackSnapshotUnloadWriteJob: Job? = null private var isRestoringPlaybackSnapshot = false @@ -207,6 +209,8 @@ class MusicService : MediaLibraryService() { private const val PLAYBACK_SNAPSHOT_DEBOUNCE_MS = 1500L private const val FORCED_WIDGET_STATE_DEBOUNCE_MS = 250L private const val MEDIA_SESSION_BUTTON_DEBOUNCE_MS = 250L + private const val DEFERRED_SERVICE_STARTUP_WORK_DELAY_MS = 1_000L + private const val PAUSED_RESTORE_PREPARE_QUEUE_LIMIT = 50 private val pendingMediaButtonForegroundStarts = AtomicInteger(0) private const val APP_PACKAGE_PREFIX = "com.theveloper.pixelplay" @@ -404,7 +408,12 @@ class MusicService : MediaLibraryService() { engine.addTransitionFinishedListener(transitionFinishedListener) controller.initialize() - initializeCastWearSync() + serviceScope.launch { + delay(DEFERRED_SERVICE_STARTUP_WORK_DELAY_MS) + if (!isPlaybackUnloadInProgress && mediaSession != null) { + initializeCastWearSync() + } + } registerHeadsetReconnectMonitor() serviceScope.launch { @@ -1612,6 +1621,7 @@ class MusicService : MediaLibraryService() { syncCastListeningStatsFromRemote() } ?: run { activeCastStatsOccurrenceId = null + activeCastPlaybackIntent = false listeningStatsTracker.onPlaybackStopped() } requestWidgetFullUpdate(force = true) @@ -1944,9 +1954,9 @@ class MusicService : MediaLibraryService() { resolvedIndex, snapshot.currentPositionMs.coerceAtLeast(0L) ) - // Even paused restores must prepare the timeline so duration/seek state is - // available immediately when the UI opens after a cold start. - player.prepare() + if (shouldRestorePlaying || preparedItems.size <= PAUSED_RESTORE_PREPARE_QUEUE_LIMIT) { + player.prepare() + } player.repeatMode = safeRepeatMode player.shuffleModeEnabled = false isManualShuffleEnabled = snapshot.shuffleEnabled @@ -2061,6 +2071,7 @@ class MusicService : MediaLibraryService() { val artist: String, val artworkUri: Uri?, val isPlaying: Boolean, + val isActuallyPlaying: Boolean, val currentPositionMs: Long, val totalDurationMs: Long, val repeatMode: Int, @@ -2082,7 +2093,7 @@ class MusicService : MediaLibraryService() { songId = songId, positionMs = snapshot.currentPositionMs, durationMs = snapshot.totalDurationMs, - isPlaying = snapshot.isPlaying + isPlaying = snapshot.isActuallyPlaying ) return } @@ -2091,7 +2102,7 @@ class MusicService : MediaLibraryService() { songId = songId, positionMs = snapshot.currentPositionMs, durationMs = snapshot.totalDurationMs, - isPlaying = snapshot.isPlaying + isPlaying = snapshot.isActuallyPlaying ) } @@ -2144,6 +2155,11 @@ class MusicService : MediaLibraryService() { MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE -> Player.REPEAT_MODE_ALL else -> Player.REPEAT_MODE_OFF } + val remotePlayback = CastRemotePlaybackState.project( + mediaStatus = mediaStatus, + previousPlayIntent = activeCastPlaybackIntent + ) + activeCastPlaybackIntent = remotePlayback.playWhenReady return RemotePlaybackSnapshot( occurrenceId = occurrenceId, @@ -2151,7 +2167,8 @@ class MusicService : MediaLibraryService() { title = metadata?.getString(CastMediaMetadata.KEY_TITLE).orEmpty(), artist = metadata?.getString(CastMediaMetadata.KEY_ARTIST).orEmpty(), artworkUri = imageUri, - isPlaying = mediaStatus.playerState == MediaStatus.PLAYER_STATE_PLAYING, + isPlaying = remotePlayback.isPlaying, + isActuallyPlaying = mediaStatus.playerState == MediaStatus.PLAYER_STATE_PLAYING, currentPositionMs = remoteClient.approximateStreamPosition.coerceAtLeast(0L), totalDurationMs = effectiveDurationMs, repeatMode = mappedRepeatMode, diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastAudioMimeUtils.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastAudioMimeUtils.kt new file mode 100644 index 000000000..85af5720e --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastAudioMimeUtils.kt @@ -0,0 +1,163 @@ +package com.theveloper.pixelplay.data.service.cast + +import java.util.Locale + +internal object CastAudioMimeUtils { + const val AUDIO_OGG = "audio/ogg" + const val AUDIO_OGG_OPUS = "audio/ogg; codecs=\"opus\"" + const val AUDIO_OGG_VORBIS = "audio/ogg; codecs=\"vorbis\"" + + fun baseMimeType(mimeType: String?): String? { + return mimeType + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.substringBefore(';') + ?.trim() + ?.lowercase(Locale.ROOT) + } + + fun toCastSupportedMimeTypeOrNull(rawMimeType: String): String? { + val raw = rawMimeType.trim().takeIf { it.isNotEmpty() } ?: return null + val normalizedRaw = raw.lowercase(Locale.ROOT) + val normalizedBase = baseMimeType(normalizedRaw) ?: return null + + if (normalizedBase == AUDIO_OGG || + normalizedBase == "audio/oga" || + normalizedBase == "application/ogg" + ) { + detectOggCodecFromMimeParameter(normalizedRaw)?.let { return it } + } + + return when (normalizedBase) { + "audio/mpeg", + "audio/mp3", + "audio/mpeg3", + "audio/x-mpeg" -> "audio/mpeg" + + "audio/flac", + "audio/x-flac" -> "audio/flac" + + "audio/aac", + "audio/aacp", + "audio/adts", + "audio/vnd.dlna.adts", + "audio/mp4a-latm", + "audio/aac-latm", + "audio/x-aac", + "audio/x-hx-aac-adts", + "audio/alac" -> "audio/aac" + + "audio/mp4", + "audio/x-m4a", + "audio/m4a", + "audio/3gpp", + "audio/3gp" -> "audio/mp4" + + "audio/wav", + "audio/x-wav", + "audio/wave" -> "audio/wav" + + AUDIO_OGG, + "audio/oga", + "application/ogg" -> AUDIO_OGG + + "audio/opus" -> AUDIO_OGG_OPUS + "audio/vorbis" -> AUDIO_OGG_VORBIS + + "audio/webm" -> "audio/webm" + + "audio/amr", + "audio/amr-wb", + "audio/l16", + "audio/l24" -> normalizedBase + + "audio/aiff", + "audio/x-aiff", + "audio/aif" -> "audio/mpeg" + + else -> null + } + } + + fun resolveOggContentType( + rawMimeCandidates: List, + extension: String?, + headerBytes: ByteArray? + ): String? { + rawMimeCandidates.asSequence() + .filterNotNull() + .mapNotNull { toCastSupportedMimeTypeOrNull(it) } + .firstOrNull { isExactOggContentType(it) } + ?.let { return it } + + if (extension.equals("opus", ignoreCase = true)) { + return AUDIO_OGG_OPUS + } + + detectOggCodecContentType(headerBytes)?.let { return it } + + val hasOggCandidate = rawMimeCandidates.any { raw -> + val base = baseMimeType(raw) + base == AUDIO_OGG || base == "audio/oga" || base == "application/ogg" || base == "audio/opus" + } || extension.equals("ogg", ignoreCase = true) || + extension.equals("oga", ignoreCase = true) || + extension.equals("opus", ignoreCase = true) + + return if (hasOggCandidate) AUDIO_OGG else null + } + + fun isExactOggContentType(contentType: String): Boolean { + return contentType == AUDIO_OGG_OPUS || contentType == AUDIO_OGG_VORBIS + } + + fun isCastSeekUnstableContentType(contentType: String?): Boolean { + return when (baseMimeType(contentType)) { + AUDIO_OGG, + "audio/oga", + "audio/opus", + "audio/vorbis", + "application/ogg" -> true + else -> false + } + } + + private fun detectOggCodecFromMimeParameter(normalizedRawMimeType: String): String? { + val codecsParameter = normalizedRawMimeType + .substringAfter("codecs=", missingDelimiterValue = "") + .trim() + .trim('"', '\'') + + return when { + codecsParameter.contains("opus") -> AUDIO_OGG_OPUS + codecsParameter.contains("vorbis") -> AUDIO_OGG_VORBIS + else -> null + } + } + + private fun detectOggCodecContentType(headerBytes: ByteArray?): String? { + val bytes = headerBytes ?: return null + if (!containsAscii(bytes, "OggS")) return null + return when { + containsAscii(bytes, "OpusHead") -> AUDIO_OGG_OPUS + containsAscii(bytes, "vorbis") -> AUDIO_OGG_VORBIS + else -> null + } + } + + private fun containsAscii(bytes: ByteArray, token: String): Boolean { + if (token.isEmpty() || bytes.size < token.length) return false + val tokenBytes = token.encodeToByteArray() + val lastStart = bytes.size - tokenBytes.size + for (start in 0..lastStart) { + var matched = true + for (offset in tokenBytes.indices) { + if (bytes[start + offset] != tokenBytes[offset]) { + matched = false + break + } + } + if (matched) return true + } + return false + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastRemotePlaybackState.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastRemotePlaybackState.kt new file mode 100644 index 000000000..2386dd36c --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastRemotePlaybackState.kt @@ -0,0 +1,53 @@ +package com.theveloper.pixelplay.data.service.cast + +import com.google.android.gms.cast.MediaStatus + +internal data class CastRemotePlaybackProjection( + val isPlaying: Boolean, + val playWhenReady: Boolean, + val isBuffering: Boolean +) + +internal object CastRemotePlaybackState { + fun project( + mediaStatus: MediaStatus, + previousPlayIntent: Boolean + ): CastRemotePlaybackProjection { + return project( + playerState = mediaStatus.playerState, + idleReason = mediaStatus.idleReason, + previousPlayIntent = previousPlayIntent + ) + } + + fun project( + playerState: Int, + idleReason: Int, + previousPlayIntent: Boolean + ): CastRemotePlaybackProjection { + val isRecoverableError = playerState == MediaStatus.PLAYER_STATE_IDLE && + idleReason == MediaStatus.IDLE_REASON_ERROR && + previousPlayIntent + + val playWhenReady = when (playerState) { + MediaStatus.PLAYER_STATE_PLAYING, + MediaStatus.PLAYER_STATE_BUFFERING -> true + MediaStatus.PLAYER_STATE_PAUSED -> false + MediaStatus.PLAYER_STATE_IDLE -> isRecoverableError + else -> previousPlayIntent + } + + val isPlaying = when (playerState) { + MediaStatus.PLAYER_STATE_PLAYING, + MediaStatus.PLAYER_STATE_BUFFERING -> true + MediaStatus.PLAYER_STATE_IDLE -> isRecoverableError + else -> previousPlayIntent && playerState == MediaStatus.PLAYER_STATE_UNKNOWN + } + + return CastRemotePlaybackProjection( + isPlaying = isPlaying, + playWhenReady = playWhenReady, + isBuffering = playerState == MediaStatus.PLAYER_STATE_BUFFERING || isRecoverableError + ) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetector.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetector.kt new file mode 100644 index 000000000..c12453a7f --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetector.kt @@ -0,0 +1,172 @@ +package com.theveloper.pixelplay.data.service.cast + +import java.nio.charset.StandardCharsets +import java.util.Locale + +internal object IsoBmffAudioCodecDetector { + private const val BOX_HEADER_SIZE = 8 + private const val EXTENDED_BOX_HEADER_SIZE = 16 + private val containerBoxes = setOf("moov", "trak", "mdia", "minf", "stbl") + + fun detectAudioCodec(bytes: ByteArray): String? { + if (bytes.size < BOX_HEADER_SIZE) return null + + forEachBox(bytes, 0, bytes.size) { box -> + if (box.type == "moov") { + detectInMoov(bytes, box.contentStart, box.end)?.let { return it } + } + true + } + return null + } + + private fun detectInMoov(bytes: ByteArray, start: Int, end: Int): String? { + forEachBox(bytes, start, end) { box -> + if (box.type == "trak") { + detectInTrak(bytes, box.contentStart, box.end)?.let { return it } + } + true + } + return null + } + + private fun detectInTrak(bytes: ByteArray, start: Int, end: Int): String? { + forEachBox(bytes, start, end) { box -> + if (box.type == "mdia") { + val handler = findHandlerType(bytes, box.contentStart, box.end) + if (handler == "soun") { + findSampleDescriptionCodec(bytes, box.contentStart, box.end)?.let { return it } + } + } + true + } + return null + } + + private fun findHandlerType(bytes: ByteArray, start: Int, end: Int): String? { + forEachBox(bytes, start, end) { box -> + if (box.type == "hdlr") { + val handlerOffset = box.contentStart + 8 + if (handlerOffset + 4 <= box.end) { + return ascii(bytes, handlerOffset, 4) + } + } + true + } + return null + } + + private fun findSampleDescriptionCodec(bytes: ByteArray, start: Int, end: Int): String? { + forEachBox(bytes, start, end) { box -> + when { + box.type == "stsd" -> parseStsd(bytes, box.contentStart, box.end)?.let { return it } + box.type in containerBoxes -> { + findSampleDescriptionCodec(bytes, box.contentStart, box.end)?.let { return it } + } + } + true + } + return null + } + + private fun parseStsd(bytes: ByteArray, start: Int, end: Int): String? { + if (start + 8 > end) return null + val entryCount = readUInt32(bytes, start + 4)?.coerceAtMost(32) ?: return null + var offset = start + 8 + repeat(entryCount.toInt()) { + val box = readBox(bytes, offset, end) ?: return null + sampleEntryTypeToMime(box.type)?.let { return it } + offset = box.end + } + return null + } + + private fun sampleEntryTypeToMime(type: String): String? { + return when (type.lowercase(Locale.ROOT)) { + "alac" -> "audio/alac" + "mp4a" -> "audio/mp4a-latm" + "ac-3" -> "audio/ac3" + "ec-3" -> "audio/eac3" + "flac" -> "audio/flac" + "opus" -> "audio/opus" + else -> null + } + } + + private inline fun forEachBox( + bytes: ByteArray, + start: Int, + end: Int, + block: (Box) -> Boolean + ) { + var offset = start.coerceAtLeast(0) + val safeEnd = end.coerceAtMost(bytes.size) + while (offset + BOX_HEADER_SIZE <= safeEnd) { + val box = readBox(bytes, offset, safeEnd) ?: break + if (!block(box)) return + if (box.end <= offset) break + offset = box.end + } + } + + private fun readBox(bytes: ByteArray, offset: Int, parentEnd: Int): Box? { + if (offset + BOX_HEADER_SIZE > parentEnd || offset + BOX_HEADER_SIZE > bytes.size) return null + val size32 = readUInt32(bytes, offset) ?: return null + val type = ascii(bytes, offset + 4, 4) ?: return null + val headerSize: Int + val rawSize: Long + + when (size32) { + 0L -> { + headerSize = BOX_HEADER_SIZE + rawSize = (parentEnd - offset).toLong() + } + 1L -> { + if (offset + EXTENDED_BOX_HEADER_SIZE > parentEnd || offset + EXTENDED_BOX_HEADER_SIZE > bytes.size) { + return null + } + headerSize = EXTENDED_BOX_HEADER_SIZE + rawSize = readUInt64(bytes, offset + 8) ?: return null + } + else -> { + headerSize = BOX_HEADER_SIZE + rawSize = size32 + } + } + + if (rawSize < headerSize) return null + val declaredEnd = offset.toLong() + rawSize + val safeEnd = declaredEnd.coerceAtMost(parentEnd.toLong()).coerceAtMost(bytes.size.toLong()).toInt() + val contentStart = offset + headerSize + if (contentStart > safeEnd) return null + return Box(type = type, contentStart = contentStart, end = safeEnd) + } + + private fun readUInt32(bytes: ByteArray, offset: Int): Long? { + if (offset < 0 || offset + 4 > bytes.size) return null + return ((bytes[offset].toLong() and 0xFF) shl 24) or + ((bytes[offset + 1].toLong() and 0xFF) shl 16) or + ((bytes[offset + 2].toLong() and 0xFF) shl 8) or + (bytes[offset + 3].toLong() and 0xFF) + } + + private fun readUInt64(bytes: ByteArray, offset: Int): Long? { + if (offset < 0 || offset + 8 > bytes.size) return null + var value = 0L + for (i in 0 until 8) { + value = (value shl 8) or (bytes[offset + i].toLong() and 0xFF) + } + return if (value >= 0L) value else null + } + + private fun ascii(bytes: ByteArray, offset: Int, length: Int): String? { + if (offset < 0 || length <= 0 || offset + length > bytes.size) return null + return String(bytes, offset, length, StandardCharsets.US_ASCII) + } + + private data class Box( + val type: String, + val contentStart: Int, + val end: Int + ) +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/http/MediaFileHttpServerService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/http/MediaFileHttpServerService.kt index 2f425d59b..f089d5cf2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/http/MediaFileHttpServerService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/http/MediaFileHttpServerService.kt @@ -31,6 +31,8 @@ import androidx.media3.decoder.ffmpeg.FfmpegLibrary import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.data.service.cast.CastAudioMimeUtils +import com.theveloper.pixelplay.data.service.cast.IsoBmffAudioCodecDetector import com.theveloper.pixelplay.utils.AlbumArtUtils import dagger.hilt.android.AndroidEntryPoint import io.ktor.http.ContentType @@ -55,6 +57,7 @@ import io.ktor.server.routing.routing import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -166,6 +169,9 @@ class MediaFileHttpServerService : Service() { @Volatile private var castAccessPolicy: CastAccessPolicy = CastAccessPolicy.EMPTY private const val SERVER_START_PORT_RETRY_LIMIT = 3 + private const val ISO_BMFF_CODEC_PROBE_BYTES = 1024 * 1024 + private const val TRANSCODE_RANGE_WAIT_TIMEOUT_MINUTES = 10L + private const val TRANSCODE_STREAM_IDLE_TIMEOUT_MS = 45_000L internal fun configureCastSessionAccess( allowedSongIds: Collection, @@ -989,39 +995,39 @@ class MediaFileHttpServerService : Service() { private suspend fun ApplicationCall.ensureAuthorizedCastMediaRequest(songId: String): Boolean { val remoteAddress = request.origin.remoteHost val policy = currentCastAccessPolicy() - - if (!CastSessionSecurity.isAuthorizedClientAddress(remoteAddress, policy)) { - Timber.tag(castHttpLogTag).w( - "Rejected Cast media request from unauthorized client=%s songId=%s", - remoteAddress, - songId - ) - respond(HttpStatusCode.Forbidden, "Forbidden") - return false - } - val providedToken = request.queryParameters[CastSessionSecurity.AUTH_QUERY_PARAMETER] - if (!CastSessionSecurity.isAuthorizedSongRequest(providedToken, songId, policy)) { - val hasValidToken = !policy.authToken.isNullOrBlank() && providedToken == policy.authToken - if (!hasValidToken) { - Timber.tag(castHttpLogTag).w( - "Rejected Cast media request with invalid token client=%s songId=%s", - remoteAddress, - songId - ) - respond(HttpStatusCode.Unauthorized, "Unauthorized") - } else { - Timber.tag(castHttpLogTag).w( - "Rejected Cast media request for non-whitelisted song client=%s songId=%s", + + // Cast receivers can fetch byte ranges through alternate LAN endpoints during seek. + // The per-session token and song allowlist are the stable authorization boundary. + if (CastSessionSecurity.isAuthorizedSongRequest(providedToken, songId, policy)) { + if (!CastSessionSecurity.isAuthorizedClientAddress(remoteAddress, policy)) { + Timber.tag(castHttpLogTag).i( + "Accepted Cast media request from non-hinted client with valid token client=%s songId=%s", remoteAddress, songId ) - respond(HttpStatusCode.NotFound, "Song not found") } - return false + return true } - return true + val hasValidToken = !policy.authToken.isNullOrBlank() && providedToken == policy.authToken + if (!hasValidToken) { + Timber.tag(castHttpLogTag).w( + "Rejected Cast media request with invalid token client=%s songId=%s", + remoteAddress, + songId + ) + respond(HttpStatusCode.Unauthorized, "Unauthorized") + } else { + Timber.tag(castHttpLogTag).w( + "Rejected Cast media request for non-whitelisted song client=%s songId=%s", + remoteAddress, + songId + ) + respond(HttpStatusCode.NotFound, "Song not found") + } + + return false } private fun resolveAudioStreamSource(song: Song, uri: Uri): AudioStreamSource? { @@ -1379,6 +1385,27 @@ class MediaFileHttpServerService : Service() { // with signature detection, which can produce false positives (e.g. 0xFF sync words inside // an MP4 moov atom are misread as AAC/MPEG framing). Only use signature to upgrade truly // ambiguous metadata types (audio/mpeg, audio/aac) or when metadata is absent. + // Ogg is a container; Cast behaves better when Opus/Vorbis is declared explicitly. + if (CastAudioMimeUtils.baseMimeType(normalizedFallback) == CastAudioMimeUtils.AUDIO_OGG) { + val extension = song.path.substringAfterLast('.', "") + val rawCandidates = listOf(song.mimeType, providerMimeType, resolveAudioMimeTypeFromPath(song.path)) + val metadataOggContentType = CastAudioMimeUtils.resolveOggContentType( + rawMimeCandidates = rawCandidates, + extension = extension, + headerBytes = null + ) + if (metadataOggContentType != null && + CastAudioMimeUtils.isExactOggContentType(metadataOggContentType) + ) { + return metadataOggContentType + } + return CastAudioMimeUtils.resolveOggContentType( + rawMimeCandidates = rawCandidates, + extension = extension, + headerBytes = readAudioSignature(song = song, uri = uri) + ) ?: metadataOggContentType ?: normalizedFallback + } + val isContainerFormat = normalizedFallback != null && normalizedFallback != "audio/mpeg" && normalizedFallback != "audio/aac" @@ -1409,59 +1436,7 @@ class MediaFileHttpServerService : Service() { } private fun normalizeCastAudioMimeType(rawMimeType: String): String? { - val normalized = rawMimeType - .trim() - .substringBefore(';') - .lowercase(Locale.ROOT) - return when (normalized) { - "audio/mpeg", - "audio/mp3", - "audio/mpeg3", - "audio/x-mpeg" -> "audio/mpeg" - - "audio/flac", - "audio/x-flac" -> "audio/flac" - - "audio/aac", - "audio/aacp", - "audio/adts", - "audio/vnd.dlna.adts", - "audio/mp4a-latm", - "audio/aac-latm", - "audio/x-aac", - "audio/x-hx-aac-adts", - // ALAC codec in M4A: server transcodes to AAC ADTS, normalize as audio/aac - "audio/alac" -> "audio/aac" - - "audio/mp4", - "audio/x-m4a", - "audio/m4a", - "audio/3gpp", - "audio/3gp" -> "audio/mp4" - - "audio/wav", - "audio/x-wav", - "audio/wave" -> "audio/wav" - - "audio/ogg", - "audio/oga", - "audio/opus", - "application/ogg" -> "audio/ogg" - - "audio/webm" -> "audio/webm" - - "audio/amr", - "audio/amr-wb", - "audio/l16", - "audio/l24" -> normalized - - // AIFF is not natively supported by Cast. Map to audio/mpeg as best-effort fallback. - "audio/aiff", - "audio/x-aiff", - "audio/aif" -> "audio/mpeg" - - else -> null - } + return CastAudioMimeUtils.toCastSupportedMimeTypeOrNull(rawMimeType) } private fun detectAudioMimeTypeBySignature(song: Song, uri: Uri): String? { @@ -1625,7 +1600,8 @@ class MediaFileHttpServerService : Service() { "m4a", "m4b", "m4p", "mp4", "3gp", "3gpp", "3ga" -> "audio/mp4" "wav" -> "audio/wav" "aif", "aiff", "aifc" -> "audio/aiff" - "ogg", "oga", "opus" -> "audio/ogg" + "ogg", "oga" -> CastAudioMimeUtils.AUDIO_OGG + "opus" -> CastAudioMimeUtils.AUDIO_OGG_OPUS "weba" -> "audio/webm" "wma" -> "audio/x-ms-wma" else -> null @@ -1829,25 +1805,26 @@ class MediaFileHttpServerService : Service() { if (!mime.startsWith("audio/")) continue // Fix ALAC mislabeled by missing OEM box definitions. - // EAC3 (audio/eac3) and AC3 (audio/ac3) are common misidentifications for ALAC boxes on some Samsung devices, - // alongside audio/mp4a-latm. + // EAC3/AC3 can be Samsung extractor misidentifications for ALAC boxes. + // Do not use bitrate as a proxy: high-bitrate AAC in an M4A is still AAC, + // and routing it through ALAC transcode causes avoidable CPU/buffer pressure. if (mime == "audio/mp4a-latm" || mime == "audio/eac3" || mime == "audio/ac3") { val isM4a = song.path.endsWith(".m4a", true) val isExplicitAlacMetadata = song.mimeType?.contains("alac", true) == true - // High-bitrate heuristic only applies to audio/mp4a-latm misidentifications. - // AC3/EAC3 files can legitimately have high bitrates, so excluding them prevents - // genuine Dolby files from being mis-reclassified as ALAC. - val hasHighBitrate = mime == "audio/mp4a-latm" && - (runCatching { fmt.getInteger(MediaFormat.KEY_BIT_RATE) }.getOrNull() ?: 0) > 600_000 + val isoBmffCodec = if (isM4a) detectIsoBmffAudioCodec(song, uri) else null // EAC3/AC3 inside an .m4a is mislabeled ALAC ONLY when CSD-0 is present. // The Samsung OEM bug shows audio/ac3 but the MediaFormat still carries the // ALACSpecificConfig as csd-0. Genuine AC3/EAC3 content has no csd-0 at all. val hasCsd0 = (mime == "audio/eac3" || mime == "audio/ac3") && runCatching { (fmt.getByteBuffer("csd-0")?.remaining() ?: 0) > 0 }.getOrDefault(false) - val isImpossibleCodecInM4a = isM4a && (mime == "audio/eac3" || mime == "audio/ac3") && hasCsd0 + val isImpossibleCodecInM4a = isM4a && + (mime == "audio/eac3" || mime == "audio/ac3") && + hasCsd0 && + isoBmffCodec != "audio/ac3" && + isoBmffCodec != "audio/eac3" - if (isExplicitAlacMetadata || isImpossibleCodecInM4a || (isM4a && hasHighBitrate)) { + if (isExplicitAlacMetadata || isoBmffCodec == "audio/alac" || isImpossibleCodecInM4a) { mime = "audio/alac" } else if (isM4a) { val mmr = android.media.MediaMetadataRetriever() @@ -1886,6 +1863,14 @@ class MediaFileHttpServerService : Service() { return result } + private fun detectIsoBmffAudioCodec(song: Song, uri: Uri): String? { + return readAudioSignature( + song = song, + uri = uri, + maxBytes = ISO_BMFF_CODEC_PROBE_BYTES + )?.let(IsoBmffAudioCodecDetector::detectAudioCodec) + } + // ------------------------------------------------------------------------- // Transcode cache helpers // ------------------------------------------------------------------------- @@ -1894,15 +1879,13 @@ class MediaFileHttpServerService : Service() { * Responds to a GET request for a transcoded song (ALAC→AAC or FLAC→AAC). * * Strategy: - * - If a completed temp file exists in [transcodeCache], serve it immediately - * with full Range/206/Content-Length/Accept-Ranges support (seek-safe). - * - If a transcode is already in-progress, wait for it then serve the file - * (or fall back to streaming if it failed). - * - If no entry exists yet, create one and start a background transcode: - * • First request: transcode while simultaneously piping data to the - * HTTP response ("tee" pattern so playback starts immediately). - * • Data is written to the temp file; once finished the entry is marked done. - * • Future seek requests (Range: bytes=X-) read directly from the temp file. + * - Cache hit (done): serve immediately with Range/206/Content-Length support. + * - In-progress: stream bytes from the growing temp file as the encoder produces them + * (progressive read). Non-zero Range requests wait for the completed temp file so + * Cast never receives bytes from the wrong offset. + * - Miss: start a background transcode coroutine writing to a temp file, then immediately + * begin streaming progressively via [streamFromGrowingFile]. The encoder and the response + * stream are fully decoupled — a Cast connection reset cannot fail the transcode. */ private suspend fun ApplicationCall.respondTranscodedWithCache( song: Song, @@ -1928,32 +1911,30 @@ class MediaFileHttpServerService : Service() { return } - // If a transcode is already running, wait for it. - if (existing != null && !existing.done) { + // If a transcode is already running, stream progressively from the growing temp file. + // Responding immediately (no latch wait) prevents Cast's loading timeout from firing + // while we're still transcoding. Non-zero Range requests wait for the completed file. + if (existing != null && !existing.done && !existing.failed) { Timber.tag(castHttpLogTag).d( - "transcode-cache WAIT songId=%s range=%s", songId, rangeHeader + "transcode-cache WAIT songId=%s range=%s, streaming progressively", songId, rangeHeader ) - // Wait with a generous timeout (10 min for very long songs). - withContext(Dispatchers.IO) { - existing.latch.await(10, TimeUnit.MINUTES) - } - if (existing.done && !existing.failed && existing.tempFile.exists()) { - respondWithAudioStream( + if (isNonInitialRangeRequest(rangeHeader)) { + respondWhenTranscodeCompletes( + entry = existing, contentType = aacContentType, - fileSize = existing.tempFile.length(), rangeHeader = rangeHeader - ) { FileInputStream(existing.tempFile) } + ) return } - // Transcode failed: fall back to raw on-the-fly streaming (no seek support). - Timber.tag(castHttpLogTag).w( - "transcode-cache FAILED-WAIT songId=%s, falling back to on-the-fly", songId - ) respondOutputStream(aacContentType) { - transcodeToAacAdts(codecInfo, song, uri, this) + streamFromGrowingFile(existing, this) } return } + // Previous transcode failed — remove the stale entry so the miss-path below can retry. + if (existing != null && existing.failed) { + transcodeCache.remove(songId) + } // No entry yet — we are the first request. Create the entry and start transcoding. val tempFile = File(cacheDir, "cast_transcode_${songId}.aac") @@ -1963,70 +1944,112 @@ class MediaFileHttpServerService : Service() { val entry = TranscodeEntry(tempFile = tempFile) transcodeCache[songId] = entry - Timber.tag(castHttpLogTag).d( - "transcode-cache MISS songId=%s, starting tee transcode", songId - ) + Timber.tag(castHttpLogTag).d("transcode-cache MISS songId=%s, starting progressive stream", songId) Timber.tag("PX_CAST_HTTP").i("transcode_cache_start songId=$songId codec=${codecInfo.codecMime}") - // If a Range header was sent on the very first request (unlikely but defensive), - // we can't tee and serve from offset 0. Wait is the cleanest option — start - // transcode in a background job and serve the whole file once done. - if (rangeHeader != null && !rangeHeader.startsWith("bytes=0-")) { - // Launch background transcode, wait until done then serve. - serviceScope.launch { - runCatching { - FileOutputStream(tempFile).use { fos -> - transcodeToAacAdts(codecInfo, song, uri, fos) - } - entry.done = true - }.onFailure { t -> - entry.failed = true + // Transcode to the temp file in the background while we stream progressively to Cast. + // This decouples the encoder from the response stream: a Cast connection reset no longer + // marks the entry as failed and no longer deletes the temp file mid-transcode. + serviceScope.launch { + runCatching { + FileOutputStream(tempFile).use { fos -> + transcodeToAacAdts(codecInfo, song, uri, fos) + } + entry.done = true + Timber.tag("PX_CAST_HTTP").i("transcode_cache_done songId=$songId size=${tempFile.length()}") + }.onFailure { t -> + entry.failed = true + runCatching { tempFile.delete() } + if (!t.isClientAbortDuringResponse()) { Timber.tag(castHttpLogTag).e(t, "transcode-cache bg transcode failed songId=%s", songId) - runCatching { tempFile.delete() } - }.also { - entry.latch.countDown() + Timber.tag("PX_CAST_HTTP").e(t, "transcode_cache_error songId=$songId") } + }.also { + entry.latch.countDown() } - // Wait for completion. - withContext(Dispatchers.IO) { - entry.latch.await(10, TimeUnit.MINUTES) - } - if (entry.done && tempFile.exists()) { - respondWithAudioStream( - contentType = aacContentType, - fileSize = tempFile.length(), - rangeHeader = rangeHeader - ) { FileInputStream(tempFile) } - } else { - respond(HttpStatusCode.InternalServerError, "Transcode failed") - } + } + + if (isNonInitialRangeRequest(rangeHeader)) { + respondWhenTranscodeCompletes( + entry = entry, + contentType = aacContentType, + rangeHeader = rangeHeader + ) return } - // Normal first request (no Range / Range:bytes=0-): tee transcode output to both - // the HTTP response stream and the temp file simultaneously. - // respondOutputStream uses an extension-function receiver: `this` IS the OutputStream. respondOutputStream(aacContentType) { - val responseStream: OutputStream = this - val transcodeError = runCatching { - FileOutputStream(tempFile).use { fileOut -> - val tee = TeeOutputStream(responseStream, fileOut) - transcodeToAacAdts(codecInfo, song, uri, tee) - } - entry.done = true - Timber.tag("PX_CAST_HTTP").i("transcode_cache_done songId=$songId size=${tempFile.length()}") - }.exceptionOrNull() + streamFromGrowingFile(entry, this) + } + } - if (transcodeError != null) { - entry.failed = true - runCatching { tempFile.delete() } - if (!transcodeError.isClientAbortDuringResponse()) { - Timber.tag(castHttpLogTag).e(transcodeError, "transcode-cache tee failed songId=%s", songId) - Timber.tag("PX_CAST_HTTP") - .e(transcodeError, "transcode_cache_error songId=$songId") + private suspend fun ApplicationCall.respondWhenTranscodeCompletes( + entry: TranscodeEntry, + contentType: ContentType, + rangeHeader: String? + ) { + val completed = withContext(Dispatchers.IO) { + entry.latch.await(TRANSCODE_RANGE_WAIT_TIMEOUT_MINUTES, TimeUnit.MINUTES) + } + if (completed && entry.done && !entry.failed && entry.tempFile.exists()) { + respondWithAudioStream( + contentType = contentType, + fileSize = entry.tempFile.length(), + rangeHeader = rangeHeader + ) { FileInputStream(entry.tempFile) } + } else { + respond(HttpStatusCode.ServiceUnavailable, "Transcode not ready") + } + } + + /** + * Reads bytes from [entry.tempFile] as the background transcode writes them, forwarding + * each chunk to [out] until the transcode finishes, fails, or the writer stops producing + * bytes for [TRANSCODE_STREAM_IDLE_TIMEOUT_MS]. Once the encoder marks [TranscodeEntry.done], + * any remaining bytes are flushed and the function returns normally so Ktor can close the + * response. + */ + private suspend fun streamFromGrowingFile(entry: TranscodeEntry, out: OutputStream) { + val buf = ByteArray(16384) + var lastProgressAtMs = System.currentTimeMillis() + + // The background coroutine creates the file when it opens FileOutputStream. + // Poll until it exists (usually within one scheduling quantum). + while ( + !entry.tempFile.exists() && + !entry.failed && + System.currentTimeMillis() - lastProgressAtMs < TRANSCODE_STREAM_IDLE_TIMEOUT_MS + ) { + delay(50) + } + if (!entry.tempFile.exists() || entry.failed) return + + FileInputStream(entry.tempFile).use { fis -> + while (true) { + val n = fis.read(buf) + when { + n > 0 -> { + out.write(buf, 0, n) + lastProgressAtMs = System.currentTimeMillis() + } + entry.done -> break // Reached true EOF — transcode complete + entry.failed -> break + System.currentTimeMillis() - lastProgressAtMs >= TRANSCODE_STREAM_IDLE_TIMEOUT_MS -> break + else -> delay(50) // Writer hasn't produced more bytes yet; wait } } - entry.latch.countDown() + } + } + + private fun isNonInitialRangeRequest(rangeHeader: String?): Boolean { + val ranges = rangeHeader + ?.let { header -> runCatching { io.ktor.http.parseRangesSpecifier(header)?.ranges }.getOrNull() } + ?: return false + val range = ranges.firstOrNull() ?: return false + return when (range) { + is io.ktor.http.ContentRange.Bounded -> range.from > 0L + is io.ktor.http.ContentRange.TailFrom -> range.from > 0L + is io.ktor.http.ContentRange.Suffix -> true } } @@ -2050,28 +2073,6 @@ class MediaFileHttpServerService : Service() { } } - // ------------------------------------------------------------------------- - // TeeOutputStream: writes every byte to two delegates simultaneously. - // ------------------------------------------------------------------------- - private class TeeOutputStream( - private val primary: OutputStream, - private val secondary: OutputStream - ) : OutputStream() { - override fun write(b: Int) { - primary.write(b) - secondary.write(b) - } - override fun write(b: ByteArray, off: Int, len: Int) { - primary.write(b, off, len) - secondary.write(b, off, len) - } - override fun flush() { - primary.flush() - secondary.flush() - } - // Do NOT close delegates here — caller controls their lifecycles. - } - /** * Transcodes audio (primarily ALAC/FLAC) to raw ADTS-framed AAC-LC using Android's MediaCodec. * The output stream receives a continuous sequence of 7-byte ADTS headers + AAC frames, @@ -2275,16 +2276,19 @@ class MediaFileHttpServerService : Service() { while (remaining > 0 || isEosEmpty) { val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_US) if (encInIdx < 0) { - if (drainEncoderToAdts(encoder, encoderInfo, codecInfo.sampleRate, encChannels, outputStream)) { + // Encoder input full — wait for it to produce output before retrying. + // Using TIMEOUT_US here (instead of 0) lets the encoder finish processing + // queued PCM and release output buffers, preventing bufferpool saturation. + if (drainEncoderToAdts(encoder, encoderInfo, codecInfo.sampleRate, encChannels, outputStream, TIMEOUT_US)) { encDone = true break } continue } - + val encBuf = encoder.getInputBuffer(encInIdx) ?: break encBuf.clear() - + val frameBytesIn = bytesPerSampleFrame.takeIf { it > 0 } ?: (actualDecoderChannels * 2) val frameBytesOut = encChannels * 2 val rawCapacityFrames = if (frameBytesOut > 0) encBuf.capacity() / frameBytesOut else 1024 @@ -2293,6 +2297,12 @@ class MediaFileHttpServerService : Service() { val rawToWrite = if (isEosEmpty) 0 else minOf(remaining, maxInputBytes) val toWrite = if (frameBytesIn > 0) (rawToWrite / frameBytesIn) * frameBytesIn else rawToWrite + // Guard: if frame alignment rounds toWrite down to 0 on a non-empty remainder, + // releasing the encoder slot and breaking prevents an infinite loop. + if (toWrite == 0 && !isEosEmpty) { + encoder.queueInputBuffer(encInIdx, 0, 0, pts, 0) + break + } var encoderBytesAssigned = toWrite if (toWrite > 0 && pcm != null) { if (pcmBuffer.size < toWrite) { @@ -2378,8 +2388,9 @@ class MediaFileHttpServerService : Service() { } catch (e: Exception) { if (!e.isClientAbortDuringResponse()) { Timber.tag(castHttpLogTag).e(e, "transcode %s→AAC error songId=%s", codecInfo.codecMime, song.id) - Timber.tag("PX_CAST_HTTP").e(e, "transcode_ffmpeg_error codec=${codecInfo.codecMime} songId=${song.id}") + Timber.tag("PX_CAST_HTTP").e(e, "transcode_mediacodec_error codec=${codecInfo.codecMime} songId=${song.id}") } + throw e } finally { // Always restore the thread priority so pooled IO threads aren't permanently // degraded — Ktor reuses threads and a background-priority thread causes @@ -2509,7 +2520,8 @@ class MediaFileHttpServerService : Service() { while (remaining > 0) { val encInIdx = encoder.dequeueInputBuffer(timeoutUs) if (encInIdx < 0) { - if (drainEncoderToAdts(encoder, encoderInfo, codecInfo.sampleRate, encChannels, outputStream)) { + // Encoder input full — wait for output before retrying (same fix as MediaCodec path). + if (drainEncoderToAdts(encoder, encoderInfo, codecInfo.sampleRate, encChannels, outputStream, timeoutUs)) { encDone = true break } @@ -2770,7 +2782,8 @@ class MediaFileHttpServerService : Service() { encoderInfo, codecInfo.sampleRate, codecInfo.channelCount, - outputStream + outputStream, + firstTimeoutUs = 20_000L ) } error("Failed to queue AAC encoder EOS") @@ -2802,16 +2815,20 @@ class MediaFileHttpServerService : Service() { return bytes } - /** Drains all available encoder output, wrapping each AAC frame with an ADTS header. Returns true when EOS. */ + /** Drains all available encoder output, wrapping each AAC frame with an ADTS header. Returns true when EOS. + * [firstTimeoutUs] is used for the initial dequeue only; subsequent frames drain with 0ms to flush all + * immediately-available output without blocking. Pass TIMEOUT_US (20ms) when calling from a "no input + * buffer available" branch so the encoder has time to finish processing queued PCM before we retry. */ private fun drainEncoderToAdts( encoder: MediaCodec, info: MediaCodec.BufferInfo, sampleRate: Int, channels: Int, - out: OutputStream + out: OutputStream, + firstTimeoutUs: Long = 0L ): Boolean { var eos = false - var idx = encoder.dequeueOutputBuffer(info, 0) + var idx = encoder.dequeueOutputBuffer(info, firstTimeoutUs) while (idx >= 0 || idx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { if (idx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { idx = encoder.dequeueOutputBuffer(info, 0) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt index 5bb7c7a72..d387df3d6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt @@ -22,6 +22,8 @@ import com.google.android.gms.cast.framework.media.RemoteMediaClient import com.google.android.gms.common.api.PendingResult import com.google.android.gms.common.images.WebImage import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.service.cast.CastAudioMimeUtils +import com.theveloper.pixelplay.data.service.cast.IsoBmffAudioCodecDetector import com.theveloper.pixelplay.data.service.http.CastSessionSecurity import org.json.JSONObject import timber.log.Timber @@ -37,6 +39,7 @@ class CastPlayer( companion object { private const val MIME_NONE = "" + private const val ISO_BMFF_CODEC_PROBE_BYTES = 1024 * 1024 private val extractorMimeCache = java.util.concurrent.ConcurrentHashMap() private val retrieverMimeCache = java.util.concurrent.ConcurrentHashMap() private val signatureMimeCache = java.util.concurrent.ConcurrentHashMap() @@ -304,6 +307,19 @@ class CastPlayer( null } + // Ogg containers are more reliable on Cast when the codec is explicit. + if (rawExtractorMime == "audio/opus" || rawExtractorMime == "audio/vorbis") { + val forcedMime = CastAudioMimeUtils.toCastSupportedMimeTypeOrNull(rawExtractorMime) + if (forcedMime != null) { + forcedMimeBySongId[song.id] = forcedMime + Log.i( + "PX_CAST_QLOAD", + "ogg_codec_probe songId=${song.id} rawCodec=$rawExtractorMime forcedMime=$forcedMime nonce=$queueLoadNonce" + ) + } + continue + } + // Only override the MIME when the server will transcode (ALAC or FLAC → AAC ADTS). if (rawExtractorMime == "audio/alac") { val alacDecoderAvailable = isAlacTranscodeSupported() @@ -467,9 +483,7 @@ class CastPlayer( ): MediaQueueItem { val contentType = forcedMimeType ?: resolveCastContentType() val durationHintMs = this.duration.coerceAtLeast(0L) - // Some library entries report inaccurate duration metadata. Let Cast infer duration - // from the stream to avoid false "track ended" auto-skips on problematic files. - val streamDuration = MediaInfo.UNKNOWN_DURATION + val streamDuration = durationHintMs.takeIf { it > 0L } ?: MediaInfo.UNKNOWN_DURATION val streamRevision = buildCastStreamRevision(contentType, queueLoadNonce) val mediaMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK) @@ -544,17 +558,37 @@ class CastPlayer( "aac" -> "audio/aac" "m4a", "m4b", "m4p", "mp4", "3gp", "3gpp", "3ga" -> "audio/mp4" "wav" -> "audio/wav" - "ogg", "oga" -> "audio/ogg" - "opus" -> "audio/ogg" + "ogg", "oga" -> CastAudioMimeUtils.AUDIO_OGG + "opus" -> CastAudioMimeUtils.AUDIO_OGG_OPUS "weba", "webm" -> "audio/webm" "amr" -> "audio/amr" else -> null } - val normalizedCandidate = listOfNotNull(metadataMimeType, resolverMimeType, extensionMimeType) + val rawMimeCandidates = listOf(metadataMimeType, resolverMimeType, extensionMimeType) + val normalizedCandidate = rawMimeCandidates + .filterNotNull() .firstNotNullOfOrNull { candidate -> candidate.toCastSupportedMimeTypeOrNull() } ?: "audio/mpeg" + if (CastAudioMimeUtils.baseMimeType(normalizedCandidate) == CastAudioMimeUtils.AUDIO_OGG) { + val metadataOggContentType = CastAudioMimeUtils.resolveOggContentType( + rawMimeCandidates = rawMimeCandidates, + extension = extension, + headerBytes = null + ) + if (metadataOggContentType != null && + CastAudioMimeUtils.isExactOggContentType(metadataOggContentType) + ) { + return metadataOggContentType + } + return CastAudioMimeUtils.resolveOggContentType( + rawMimeCandidates = rawMimeCandidates, + extension = extension, + headerBytes = readAudioSignature(this) + ) ?: metadataOggContentType ?: normalizedCandidate + } + // Container formats from metadata are reliable. Signature detection (framed sync-word scan) // can produce false positives on container binary data (e.g. 0xFF bytes inside MP4 moov atom). // Only use signature to disambiguate truly ambiguous metadata (audio/mpeg or audio/aac). @@ -582,59 +616,40 @@ class CastPlayer( } private fun String.toCastSupportedMimeTypeOrNull(): String? { - val normalized = this.trim().substringBefore(';').lowercase(Locale.ROOT) - return when (normalized) { - "audio/mpeg", - "audio/mp3", - "audio/mpeg3", - "audio/x-mpeg" -> "audio/mpeg" - - "audio/flac", - "audio/x-flac" -> "audio/flac" - - "audio/aac", - "audio/aacp", - "audio/adts", - "audio/vnd.dlna.adts", - "audio/mp4a-latm", - "audio/aac-latm", - "audio/x-aac", - "audio/x-hx-aac-adts", - // ALAC codec in M4A container: server transcodes to AAC ADTS, so announce as audio/aac - "audio/alac" -> "audio/aac" - - "audio/mp4", - "audio/x-m4a", - "audio/m4a", - "audio/3gpp", - "audio/3gp" -> "audio/mp4" - - "audio/wav", - "audio/x-wav", - "audio/wave" -> "audio/wav" - - "audio/ogg", - "audio/oga", - "audio/opus", - "application/ogg" -> "audio/ogg" - - "audio/webm" -> "audio/webm" - - "audio/amr", - "audio/amr-wb", - "audio/l16", - "audio/l24" -> normalized - - // AIFF is not natively supported by Cast. Map to audio/mpeg as best-effort fallback. - "audio/aiff", - "audio/x-aiff", - "audio/aif" -> "audio/mpeg" + return CastAudioMimeUtils.toCastSupportedMimeTypeOrNull(this) + } - else -> null - } + fun canSeekCurrentItem(): Boolean { + val status = remoteMediaClient?.mediaStatus ?: return true + val currentItemId = status.currentItemId + val currentItem = status.getQueueItemById(currentItemId) + val mediaContentType = currentItem?.media?.contentType + val customMimeType = currentItem + ?.customData + ?.optString("mimeType") + ?.takeIf { it.isNotBlank() } + + return !CastAudioMimeUtils.isCastSeekUnstableContentType(mediaContentType) && + !CastAudioMimeUtils.isCastSeekUnstableContentType(customMimeType) } - fun seek(position: Long) { + fun seek(position: Long): Boolean { + if (!canSeekCurrentItem()) { + pendingSeekPositionMs = null + seekDispatchRunnable?.let { commandHandler.removeCallbacks(it) } + seekDispatchRunnable = null + val status = remoteMediaClient?.mediaStatus + val currentItem = status?.getQueueItemById(status.currentItemId) + Timber.tag(castLogTag).w( + "Blocked Cast seek for unstable Ogg stream. itemId=%s contentType=%s customMime=%s", + status?.currentItemId, + currentItem?.media?.contentType, + currentItem?.customData?.optString("mimeType") + ) + remoteMediaClient?.requestStatus() + return false + } + val targetPosition = position.coerceAtLeast(0L) pendingSeekPositionMs = targetPosition seekDispatchRunnable?.let { commandHandler.removeCallbacks(it) } @@ -654,6 +669,7 @@ class CastPlayer( } seekDispatchRunnable = runnable commandHandler.postDelayed(runnable, seekDebounceMs) + return true } fun play() { @@ -898,11 +914,18 @@ class CastPlayer( if (trackMime == "audio/mp4a-latm" || trackMime == "audio/eac3" || trackMime == "audio/ac3") { val isM4a = song.path.endsWith(".m4a", true) val isExplicitAlacMetadata = song.mimeType?.contains("alac", true) == true - val hasHighBitrate = (runCatching { trackFormat.getInteger(MediaFormat.KEY_BIT_RATE) }.getOrNull() ?: 0) > 600_000 - - val isImpossibleCodecInM4a = isM4a && (trackMime == "audio/eac3" || trackMime == "audio/ac3") - - if (isExplicitAlacMetadata || isImpossibleCodecInM4a || (isM4a && hasHighBitrate)) { + val isoBmffCodec = if (isM4a) detectIsoBmffAudioCodec(song) else null + val hasCsd0 = (trackMime == "audio/eac3" || trackMime == "audio/ac3") && + runCatching { (trackFormat.getByteBuffer("csd-0")?.remaining() ?: 0) > 0 } + .getOrDefault(false) + + val isImpossibleCodecInM4a = isM4a && + (trackMime == "audio/eac3" || trackMime == "audio/ac3") && + hasCsd0 && + isoBmffCodec != "audio/ac3" && + isoBmffCodec != "audio/eac3" + + if (isExplicitAlacMetadata || isoBmffCodec == "audio/alac" || isImpossibleCodecInM4a) { trackMime = "audio/alac" } else if (isM4a) { val mmr = MediaMetadataRetriever() @@ -938,6 +961,13 @@ class CastPlayer( return result } + private fun detectIsoBmffAudioCodec(song: Song): String? { + return readAudioSignature( + song = song, + maxBytes = ISO_BMFF_CODEC_PROBE_BYTES + )?.let(IsoBmffAudioCodecDetector::detectAudioCodec) + } + private fun isMimeTypeDecoderSupported(mimeType: String): Boolean { return runCatching { val list = MediaCodecList(MediaCodecList.REGULAR_CODECS) @@ -1049,7 +1079,7 @@ class CastPlayer( // the container-level resolution only for diagnostic display. val sentMime = forcedMimeBySongId[song.id] ?: song.resolveCastContentType() val streamRevision = song.buildCastStreamRevision(sentMime, queueLoadNonce) - val likelySupported = sentMime in setOf( + val likelySupported = CastAudioMimeUtils.baseMimeType(sentMime) in setOf( "audio/mpeg", "audio/aac", "audio/mp4", @@ -1059,6 +1089,9 @@ class CastPlayer( "audio/webm", "audio/amr" ) + val streamDurationSent = song.duration.coerceAtLeast(0L) + .takeIf { it > 0L } + ?: MediaInfo.UNKNOWN_DURATION val mediaUrl = CastSessionSecurity.redactAuthToken( CastSessionSecurity.buildSongUrl( serverAddress = serverAddress, @@ -1084,13 +1117,13 @@ class CastPlayer( forcedMimeBySongId.containsKey(song.id), likelySupported, song.duration, - MediaInfo.UNKNOWN_DURATION, + streamDurationSent, mediaUrl, artUrl ) Log.i( "PX_CAST_QLOAD", - "item index=$index songId=${song.id} mimeRaw=${song.mimeType} mimeSent=$sentMime mimeForced=${forcedMimeBySongId.containsKey(song.id)} durationHintMs=${song.duration.coerceAtLeast(0L)} streamDurationSentMs=${MediaInfo.UNKNOWN_DURATION}" + "item index=$index songId=${song.id} mimeRaw=${song.mimeType} mimeSent=$sentMime mimeForced=${forcedMimeBySongId.containsKey(song.id)} durationHintMs=${song.duration.coerceAtLeast(0L)} streamDurationSentMs=$streamDurationSent" ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt index aa282d2ac..bd29b65fc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt @@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import timber.log.Timber @@ -63,6 +62,18 @@ data class ActiveDecoderInfo( val isHardware: Boolean ) +internal fun shouldResumeAfterTransientAudioFocusLoss( + masterPlayWhenReady: Boolean, + masterIsPlaying: Boolean, + transitionRunning: Boolean, + auxiliaryPlayWhenReady: Boolean, + auxiliaryIsPlaying: Boolean +): Boolean { + return masterPlayWhenReady || + masterIsPlaying || + (transitionRunning && (auxiliaryPlayWhenReady || auxiliaryIsPlaying)) +} + /** * Manages two ExoPlayer instances (A and B) to enable seamless transitions. * @@ -98,7 +109,7 @@ class DualPlayerEngine @Inject constructor( val queueSize: Int ) - private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) var hiFiModeEnabled: Boolean = false private set private var audioOffloadEnabled = !shouldDisableAudioOffloadByDefault() @@ -113,7 +124,7 @@ class DualPlayerEngine @Inject constructor( private var preparedPlayerUsesWindowedQueue = false private lateinit var playerA: ExoPlayer - private lateinit var playerB: ExoPlayer + private var playerB: ExoPlayer? = null private val onPlayerSwappedListeners = mutableListOf<(Player) -> Unit>() private val onTransitionDisplayPlayerListeners = mutableListOf<(Player) -> Unit>() @@ -153,21 +164,28 @@ class DualPlayerEngine @Inject constructor( Timber.tag("TransitionDebug").d("AudioFocus LOSS. Pausing.") isFocusLossPause = false playerA.playWhenReady = false - playerB.playWhenReady = false + playerB?.playWhenReady = false abandonAudioFocus() } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { Timber.tag("TransitionDebug").d("AudioFocus LOSS_TRANSIENT. Pausing.") - isFocusLossPause = true + val auxiliaryPlayer = playerB + isFocusLossPause = shouldResumeAfterTransientAudioFocusLoss( + masterPlayWhenReady = playerA.playWhenReady, + masterIsPlaying = playerA.isPlaying, + transitionRunning = transitionRunning, + auxiliaryPlayWhenReady = auxiliaryPlayer?.playWhenReady == true, + auxiliaryIsPlaying = auxiliaryPlayer?.isPlaying == true + ) playerA.playWhenReady = false - playerB.playWhenReady = false + auxiliaryPlayer?.playWhenReady = false } AudioManager.AUDIOFOCUS_GAIN -> { Timber.tag("TransitionDebug").d("AudioFocus GAIN. Resuming if paused by loss.") if (isFocusLossPause) { isFocusLossPause = false playerA.playWhenReady = true - if (transitionRunning) playerB.playWhenReady = true + if (transitionRunning) playerB?.playWhenReady = true } } } @@ -349,31 +367,31 @@ class DualPlayerEngine @Inject constructor( } val masterPlayer: Player - get() = playerA + get() { + initialize() + return playerA + } fun isTransitionRunning(): Boolean = transitionRunning - fun getAudioSessionId(): Int = playerA.audioSessionId + fun getAudioSessionId(): Int = if (::playerA.isInitialized) playerA.audioSessionId else 0 private var isReleased = false private val resolvedUriCache = LruCache(100) - init { - initialize() - } - fun initialize() { if (!isReleased && ::playerA.isInitialized && playerA.applicationLooper.thread.isAlive) return + if (scope.coroutineContext[Job]?.isActive != true) { + scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + } if (::playerA.isInitialized) { try { playerA.release() } catch (e: Exception) { /* Ignore */ } } - if (::playerB.isInitialized) { - try { playerB.release() } catch (e: Exception) { /* Ignore */ } - } + playerB?.let { try { it.release() } catch (e: Exception) { /* Ignore */ } } + playerB = null playerA = buildPlayer() - playerB = buildPlayer() playerA.addListener(masterPlayerListener) playerA.addAnalyticsListener(masterPlayerListener) @@ -412,7 +430,7 @@ class DualPlayerEngine @Inject constructor( audioFocusRequest = request isFocusLossPause = true playerA.playWhenReady = false - if (transitionRunning) playerB.playWhenReady = false + if (transitionRunning) playerB?.playWhenReady = false } else -> { Timber.tag("TransitionDebug").w("AudioFocus Request Failed: $result") @@ -480,9 +498,7 @@ class DualPlayerEngine @Inject constructor( try { playerA.setWakeMode(mode) - if (::playerB.isInitialized) { - playerB.setWakeMode(mode) - } + playerB?.setWakeMode(mode) currentWakeMode = mode Timber.tag("DualPlayerEngine").d("Wake mode updated to %d", mode) } catch (e: Exception) { @@ -531,10 +547,10 @@ class DualPlayerEngine @Inject constructor( playerA.removeListener(masterPlayerListener) playerA.removeAnalyticsListener(masterPlayerListener) playerA.release() - playerB.release() + playerB?.release() + playerB = null playerA = buildPlayer() - playerB = buildPlayer() playerA.addListener(masterPlayerListener) playerA.addAnalyticsListener(masterPlayerListener) @@ -674,8 +690,18 @@ class DualPlayerEngine @Inject constructor( } } + private fun getOrCreateAuxiliaryPlayer(): ExoPlayer { + playerB?.let { return it } + return buildPlayer().also { player -> + player.setWakeMode(currentWakeMode) + playerB = player + } + } + fun setPauseAtEndOfMediaItems(shouldPause: Boolean) { - playerA.pauseAtEndOfMediaItems = shouldPause + if (::playerA.isInitialized) { + playerA.pauseAtEndOfMediaItems = shouldPause + } } fun getNextTransitionTarget(currentMediaItem: MediaItem, repeatMode: Int): TransitionTarget? { @@ -815,9 +841,10 @@ class DualPlayerEngine @Inject constructor( else -> findMediaItemIndex(snapshot, mediaItem.mediaId, currentAbsoluteIndex) } val resolvedItem = resolveMediaItem(mediaItem) + val auxiliaryPlayer = getOrCreateAuxiliaryPlayer() - playerB.stop() - playerB.clearMediaItems() + auxiliaryPlayer.stop() + auxiliaryPlayer.clearMediaItems() if (targetIndex != C.INDEX_UNSET && snapshot.isNotEmpty()) { val count = snapshot.size @@ -829,17 +856,17 @@ class DualPlayerEngine @Inject constructor( } preparedWindowStartIndex = start preparedPlayerUsesWindowedQueue = count > MAX_AUXILIARY_TIMELINE_ITEMS - playerB.setMediaItems(windowItems, targetIndex - start, startPositionMs) + auxiliaryPlayer.setMediaItems(windowItems, targetIndex - start, startPositionMs) } else { // Fallback for single item if not found in current timeline resetPreparedWindowState() - playerB.setMediaItem(resolvedItem) - playerB.seekTo(startPositionMs) + auxiliaryPlayer.setMediaItem(resolvedItem) + auxiliaryPlayer.seekTo(startPositionMs) } - playerB.prepare() - playerB.volume = 0f - playerB.pause() + auxiliaryPlayer.prepare() + auxiliaryPlayer.volume = 0f + auxiliaryPlayer.pause() } catch (e: Exception) { resetPreparedWindowState() Timber.tag("TransitionDebug").e(e, "Failed to prepare next player") @@ -847,17 +874,21 @@ class DualPlayerEngine @Inject constructor( } fun cancelNext() { + val shouldPublishMasterPlayer = transitionRunning transitionJob?.cancel() transitionRunning = false resetPreparedWindowState() - if (::playerB.isInitialized && playerB.mediaItemCount > 0) { + playerB?.takeIf { it.mediaItemCount > 0 }?.let { auxiliaryPlayer -> try { - playerB.stop() - playerB.clearMediaItems() + auxiliaryPlayer.stop() + auxiliaryPlayer.clearMediaItems() } catch (e: Exception) { /* Ignore */ } } if (::playerA.isInitialized) { playerA.volume = 1f + if (shouldPublishMasterPlayer) { + onPlayerSwappedListeners.forEach { it(playerA) } + } } incomingTrackReplayGainVolume = null setPauseAtEndOfMediaItems(false) @@ -875,7 +906,7 @@ class DualPlayerEngine @Inject constructor( } playerA.volume = 1f setPauseAtEndOfMediaItems(false) - if (::playerB.isInitialized) playerB.stop() + playerB?.stop() } finally { transitionRunning = false onTransitionFinishedListeners.forEach { it() } @@ -884,15 +915,16 @@ class DualPlayerEngine @Inject constructor( } private suspend fun performOverlapTransition(settings: TransitionSettings) { - if (playerB.mediaItemCount == 0) { + val auxiliaryPlayer = playerB + if (auxiliaryPlayer == null || auxiliaryPlayer.mediaItemCount == 0) { playerA.volume = 1f setPauseAtEndOfMediaItems(false) return } - if (playerB.playbackState == Player.STATE_IDLE) playerB.prepare() - if (playerB.playbackState == Player.STATE_BUFFERING) { - if (!awaitPlayerReady(playerB, 3000L)) { + if (auxiliaryPlayer.playbackState == Player.STATE_IDLE) auxiliaryPlayer.prepare() + if (auxiliaryPlayer.playbackState == Player.STATE_BUFFERING) { + if (!awaitPlayerReady(auxiliaryPlayer, 3000L)) { playerA.volume = 1f setPauseAtEndOfMediaItems(false) return @@ -900,13 +932,13 @@ class DualPlayerEngine @Inject constructor( } val outgoingStartVolume = playerA.volume.coerceIn(0f, 1f) - playerB.volume = 0f + auxiliaryPlayer.volume = 0f if (!playerA.isPlaying && playerA.playbackState == Player.STATE_READY) playerA.play() - playerB.playWhenReady = true - playerB.play() + auxiliaryPlayer.playWhenReady = true + auxiliaryPlayer.play() val outgoingPlayer = playerA - val incomingPlayer = playerB + val incomingPlayer = auxiliaryPlayer incomingPlayer.repeatMode = outgoingPlayer.repeatMode incomingPlayer.shuffleModeEnabled = outgoingPlayer.shuffleModeEnabled @@ -945,7 +977,7 @@ class DualPlayerEngine @Inject constructor( resetPreparedWindowState() playerA.pauseAtEndOfMediaItems = false - playerB.pauseAtEndOfMediaItems = false + playerB?.pauseAtEndOfMediaItems = false playerA.addListener(masterPlayerListener) playerA.addAnalyticsListener(masterPlayerListener) if (playerA.playWhenReady) requestAudioFocus() @@ -953,9 +985,9 @@ class DualPlayerEngine @Inject constructor( onPlayerSwappedListeners.forEach { it(playerA) } _activeAudioSessionId.value = playerA.audioSessionId - playerB.pause() - playerB.stop() - playerB.clearMediaItems() + playerB?.pause() + playerB?.stop() + playerB?.clearMediaItems() setPauseAtEndOfMediaItems(false) } @@ -1088,7 +1120,8 @@ class DualPlayerEngine @Inject constructor( playerA.removeAnalyticsListener(masterPlayerListener) playerA.release() } - if (::playerB.isInitialized) playerB.release() + playerB?.release() + playerB = null isReleased = true } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt index 3bb9f2ab4..e12222673 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/stats/PlaybackStatsRepository.kt @@ -44,6 +44,7 @@ class PlaybackStatsRepository @Inject constructor( private val historyFile = File(context.filesDir, "playback_history.json") private val atomicHistoryFile = AtomicFile(historyFile) private val fileLock = Any() + private var cachedEvents: List? = null // guarded by fileLock private val eventsType = object : TypeToken>() {}.type private val _refreshVersion = MutableStateFlow(0L) val refreshFlow: StateFlow = _refreshVersion.asStateFlow() @@ -276,6 +277,7 @@ class PlaybackStatsRepository @Inject constructor( compareByDescending { it.totalDurationMs } .thenByDescending { it.playCount } ) + .take(MAX_SONG_STATS_COUNT) val topSongs = allSongs.take(5) val topGenres = segmentsBySong.entries @@ -419,13 +421,15 @@ class PlaybackStatsRepository @Inject constructor( } val peakDayLabel = peakDay?.key?.getDisplayName(TextStyle.FULL, Locale.US) val peakDayDuration = peakDay?.value?.sumOf { it.durationMs } ?: 0L - val dayListeningDistribution = computeDayListeningDistribution( - spans = overallSpans, - zoneId = zoneId, - range = range, - startBound = startBound, - endBound = endBound - ) + val dayListeningDistribution = if (range == StatsTimeRange.DAY || range == StatsTimeRange.WEEK) { + computeDayListeningDistribution( + spans = overallSpans, + zoneId = zoneId, + range = range, + startBound = startBound, + endBound = endBound + ) + } else null return PlaybackStatsSummary( range = range, @@ -503,8 +507,11 @@ class PlaybackStatsRepository @Inject constructor( } private fun readEvents(): List { + synchronized(fileLock) { cachedEvents }?.let { return it } val raw = synchronized(fileLock) { readRawHistoryLocked() } - return parseEvents(raw) + return parseEvents(raw).also { parsed -> + synchronized(fileLock) { if (cachedEvents == null) cachedEvents = parsed } + } } private fun readRawHistoryLocked(): String? { @@ -821,7 +828,9 @@ class PlaybackStatsRepository @Inject constructor( if (latestRaw != rawSnapshot) { return@synchronized false } - writePayloadLocked(payload) + val result = writePayloadLocked(payload) + if (result) cachedEvents = null + result } if (writeSucceeded) { return true @@ -831,7 +840,9 @@ class PlaybackStatsRepository @Inject constructor( val fallbackRawSnapshot = synchronized(fileLock) { readRawHistoryLocked() } val payload = serializeEvents(transform(parseEvents(fallbackRawSnapshot))) return synchronized(fileLock) { - writePayloadLocked(payload) + val result = writePayloadLocked(payload) + if (result) cachedEvents = null + result } } @@ -1088,6 +1099,7 @@ class PlaybackStatsRepository @Inject constructor( private const val UNKNOWN_ARTIST = "Unknown Artist" private val MAX_HISTORY_AGE_MS = TimeUnit.DAYS.toMillis(730) // Keep roughly two years of history private const val SEGMENT_JOIN_TOLERANCE_MS = 0L + private const val MAX_SONG_STATS_COUNT = 100 } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MarqueeText.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MarqueeText.kt index f3b8da239..af15b30a5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MarqueeText.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MarqueeText.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.MarqueeSpacing import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -36,10 +37,11 @@ fun AutoScrollingTextOnDemand( style: TextStyle, gradientEdgeColor: Color, expansionFractionProvider: () -> Float, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + canScroll: Boolean = true ) { - var overflow by remember { mutableStateOf(false) } - val canStart by remember { derivedStateOf { expansionFractionProvider() > 0.99f && overflow } } + var overflow by remember(text, style) { mutableStateOf(false) } + val canStart by remember(text, style) { derivedStateOf { expansionFractionProvider() > 0.99f && overflow } } // Usamos un Text "medidor" sólo la primera composición para detectar overflow. @@ -58,7 +60,8 @@ fun AutoScrollingTextOnDemand( style = style, textAlign = TextAlign.Start, gradientEdgeColor = gradientEdgeColor, - modifier = modifier + modifier = modifier, + canScroll = canScroll ) } } @@ -71,7 +74,8 @@ fun AutoScrollingText( style: TextStyle, textAlign: TextAlign? = null, gradientEdgeColor: Color, - gradientWidth: Dp = 24.dp + gradientWidth: Dp = 24.dp, + canScroll: Boolean = true ) { SubcomposeLayout(modifier = modifier.clipToBounds()) { constraints -> val textPlaceable = subcompose("text") { @@ -81,12 +85,12 @@ fun AutoScrollingText( val isOverflowing = textPlaceable.width > constraints.maxWidth val content = @Composable { - if (isOverflowing) { - val initialDelayMillis = 1500 + if (isOverflowing && canScroll) { + val initialDelayMillis = 2000 val fadeAnimationDuration = 500 - var isScrolling by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { + var isScrolling by remember(text, canScroll) { mutableStateOf(false) } + LaunchedEffect(text, canScroll) { isScrolling = false // Ensure initial state kotlinx.coroutines.delay(initialDelayMillis.toLong()) isScrolling = true @@ -138,12 +142,41 @@ fun AutoScrollingText( ) ) } + } else if (isOverflowing) { + Box( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val gradientWidthPx = gradientWidth.toPx() + // Right fade-out: Always visible for overflow + drawRect( + brush = Brush.horizontalGradient( + colors = listOf(gradientEdgeColor, Color.Transparent), + startX = size.width - gradientWidthPx, + endX = size.width + ), + blendMode = BlendMode.DstIn + ) + } + ) { + Text( + text = text, + style = style, + textAlign = textAlign, + maxLines = 1, + softWrap = false, + modifier = Modifier.fillMaxWidth() + ) + } } else { Text( text = text, style = style, textAlign = textAlign, maxLines = 1, + softWrap = false ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt index e3c1d0e38..8325463a5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/QueueBottomSheet.kt @@ -839,7 +839,7 @@ fun QueueBottomSheet( onClick = { onPlaySong(song) }, song = song, isCurrentSong = index == currentSongDisplayIndex, - isPlaying = isPlaying, + isPlaying = isPlaying && isVisible, isDragging = isDragging, onRemoveClick = { onRemoveSong(song.id) }, isReorderModeEnabled = false, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt index d37ef46df..e7e918f98 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt @@ -5,7 +5,10 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri +import android.provider.Settings import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateDpAsState @@ -63,6 +66,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import com.theveloper.pixelplay.R @@ -77,6 +81,7 @@ import com.theveloper.pixelplay.data.ai.SongMetadata import com.theveloper.pixelplay.data.media.CoverArtUpdate import com.theveloper.pixelplay.ui.theme.MontserratFamily import com.theveloper.pixelplay.presentation.viewmodel.SongInfoBottomSheetViewModel +import com.theveloper.pixelplay.presentation.viewmodel.SongInfoBottomSheetViewModel.ToneTarget import kotlinx.coroutines.launch import androidx.compose.ui.graphics.TransformOrigin @@ -127,6 +132,10 @@ fun SongInfoBottomSheet( val context = LocalContext.current var showEditSheet by remember { mutableStateOf(false) } var showArtistPicker by remember { mutableStateOf(false) } + var showTonePickerDialog by remember { mutableStateOf(false) } + var toneConfirmationTarget by remember { mutableStateOf(null) } + var pendingTonePermissionSong by remember { mutableStateOf(null) } + var pendingTonePermissionTarget by remember { mutableStateOf(null) } val audioMeta by songInfoViewModel.audioMeta.collectAsStateWithLifecycle() val resolvedArtists by songInfoViewModel.resolvedArtists.collectAsStateWithLifecycle() val isPixelPlayWatchAvailable by songInfoViewModel.isPixelPlayWatchAvailable.collectAsStateWithLifecycle() @@ -161,6 +170,82 @@ fun SongInfoBottomSheet( songInfoViewModel.refreshWatchAvailability() } + val ringtonePermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + val pendingSong = pendingTonePermissionSong + val pendingTarget = pendingTonePermissionTarget + pendingTonePermissionSong = null + pendingTonePermissionTarget = null + if (pendingSong == null || pendingTarget == null) { + return@rememberLauncherForActivityResult + } + if (songInfoViewModel.hasSystemWritePermission()) { + songInfoViewModel.setSongAsTone(pendingSong, pendingTarget) { result -> + val message = when (result) { + is SongInfoBottomSheetViewModel.ToneActionResult.Success -> result.message + is SongInfoBottomSheetViewModel.ToneActionResult.Error -> result.message + is SongInfoBottomSheetViewModel.ToneActionResult.NeedsSystemWritePermission -> result.message + } + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } else { + Toast.makeText( + context, + context.getString(R.string.song_info_ringtone_permission_missing), + Toast.LENGTH_LONG + ).show() + } + } + + fun requestToneSystemWritePermission(songToSet: Song, target: ToneTarget, message: String) { + pendingTonePermissionSong = songToSet + pendingTonePermissionTarget = target + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + try { + ringtonePermissionLauncher.launch(songInfoViewModel.createSystemWriteSettingsIntent()) + } catch (_: ActivityNotFoundException) { + try { + ringtonePermissionLauncher.launch(Intent(Settings.ACTION_SETTINGS)) + } catch (e: Exception) { + pendingTonePermissionSong = null + pendingTonePermissionTarget = null + Toast.makeText( + context, + context.getString( + R.string.song_info_ringtone_failed, + e.localizedMessage ?: "" + ), + Toast.LENGTH_LONG + ).show() + } + } + } + + fun handleToneResult( + songToSet: Song, + target: ToneTarget, + result: SongInfoBottomSheetViewModel.ToneActionResult + ) { + when (result) { + is SongInfoBottomSheetViewModel.ToneActionResult.Success -> { + Toast.makeText(context, result.message, Toast.LENGTH_LONG).show() + } + is SongInfoBottomSheetViewModel.ToneActionResult.Error -> { + Toast.makeText(context, result.message, Toast.LENGTH_LONG).show() + } + is SongInfoBottomSheetViewModel.ToneActionResult.NeedsSystemWritePermission -> { + requestToneSystemWritePermission(songToSet, target, result.message) + } + } + } + + fun setCurrentSongAsTone(target: ToneTarget) { + songInfoViewModel.setSongAsTone(song, target) { result -> + handleToneResult(song, target, result) + } + } + var lastShownWatchTransferError by remember(song.id) { mutableStateOf(null) } LaunchedEffect( latestSongWatchTransfer?.requestId, @@ -585,77 +670,102 @@ fun SongInfoBottomSheet( currentSongTransfer != null || shouldOfferWatchTransfer || shouldShowWatchTransferLoading - if (shouldRenderWatchTransferRow) { - item { - FilledTonalButton( + item { + if (shouldRenderWatchTransferRow) { + Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = 66.dp), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = if (isPixelPlayWatchAvailable) { - sendToWatchContainerColor - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - }, - contentColor = if (isPixelPlayWatchAvailable) { - sendToWatchContentColor - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - shape = CircleShape, - enabled = shouldOfferWatchTransfer && !isSendingToWatch, - onClick = { - songInfoViewModel.sendSongToWatch(song) { message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - if (shouldShowWatchTransferLoading) { - LoadingIndicator(modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(10.dp)) - Text(stringResource(R.string.song_info_checking_watch)) - } else if (isSendingToWatch) { - LoadingIndicator(modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(10.dp)) - Text( - when { - currentSongTransfer != null && currentSongTransfer.totalBytes > 0L -> - stringResource( - R.string.song_info_transferring_percent, - currentSongTransferPercent - ) - currentSongTransfer != null -> - stringResource(R.string.song_info_transferring_to_watch) - else -> - stringResource(R.string.song_info_transfer_in_progress) + RingtoneActionButton( + modifier = Modifier + .weight(0.38f) + .fillMaxHeight(), + showText = true, + compactText = true, + onClick = { showTonePickerDialog = true }, + ) + + FilledTonalButton( + modifier = Modifier + .weight(0.62f) + .fillMaxHeight(), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = if (isPixelPlayWatchAvailable) { + sendToWatchContainerColor + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + contentColor = if (isPixelPlayWatchAvailable) { + sendToWatchContentColor + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + shape = CircleShape, + enabled = shouldOfferWatchTransfer && !isSendingToWatch, + onClick = { + songInfoViewModel.sendSongToWatch(song) { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } - ) - } else { - Icon( - painter = painterResource(R.drawable.rounded_watch_arrow_down_24), - contentDescription = stringResource( - if (isPixelPlayWatchAvailable) { - R.string.cd_send_song_to_watch - } else { - R.string.cd_watch_unavailable + } + ) { + if (shouldShowWatchTransferLoading) { + LoadingIndicator(modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(10.dp)) + Text(stringResource(R.string.song_info_checking_watch)) + } else if (isSendingToWatch) { + LoadingIndicator(modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(10.dp)) + Text( + when { + currentSongTransfer != null && currentSongTransfer.totalBytes > 0L -> + stringResource( + R.string.song_info_transferring_percent, + currentSongTransferPercent + ) + currentSongTransfer != null -> + stringResource(R.string.song_info_transferring_to_watch) + else -> + stringResource(R.string.song_info_transfer_in_progress) } ) - ) - Spacer(Modifier.width(8.dp)) - Text( - stringResource( - if (isPixelPlayWatchAvailable) { - R.string.song_info_send_to_watch - } else { - R.string.song_info_watch_unavailable - } + } else { + Icon( + painter = painterResource(R.drawable.rounded_watch_arrow_down_24), + contentDescription = stringResource( + if (isPixelPlayWatchAvailable) { + R.string.cd_send_song_to_watch + } else { + R.string.cd_watch_unavailable + } + ) ) - ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource( + if (isPixelPlayWatchAvailable) { + R.string.song_info_send_to_watch + } else { + R.string.song_info_watch_unavailable + } + ) + ) + } } } + } else { + RingtoneActionButton( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 66.dp), + showText = true, + onClick = { showTonePickerDialog = true }, + ) } } @@ -863,8 +973,302 @@ fun SongInfoBottomSheet( } ) } + + if (showTonePickerDialog) { + ToneTargetPickerDialog( + onDismiss = { showTonePickerDialog = false }, + onTargetSelected = { target -> + showTonePickerDialog = false + toneConfirmationTarget = target + } + ) + } + + toneConfirmationTarget?.let { target -> + ToneConfirmationDialog( + song = song, + target = target, + onDismiss = { toneConfirmationTarget = null }, + onConfirm = { + toneConfirmationTarget = null + setCurrentSongAsTone(target) + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ToneTargetPickerDialog( + onDismiss: () -> Unit, + onTargetSelected: (ToneTarget) -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + shape = AbsoluteSmoothCornerShape( + cornerRadiusTR = 32.dp, + smoothnessAsPercentBR = 60, + cornerRadiusBR = 32.dp, + smoothnessAsPercentTL = 60, + cornerRadiusTL = 32.dp, + smoothnessAsPercentBL = 60, + cornerRadiusBL = 32.dp, + smoothnessAsPercentTR = 60, + ), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 6.dp, + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + ToneDialogIcon(target = null) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(R.string.song_info_tone_picker_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.song_info_tone_picker_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Column( + modifier = Modifier.clip(RoundedCornerShape(22.dp)), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + ToneTarget.values().forEach { target -> + ToneTargetOption( + target = target, + onClick = { onTargetSelected(target) }, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + } + } + } } +@Composable +private fun ToneTargetOption( + target: ToneTarget, + onClick: () -> Unit, +) { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + leadingContent = { + ToneDialogIcon( + target = target, + modifier = Modifier.size(42.dp), + iconModifier = Modifier.size(22.dp), + ) + }, + headlineContent = { + Text( + text = stringResource(target.titleResId), + fontWeight = FontWeight.SemiBold, + ) + }, + supportingContent = { + Text(stringResource(target.subtitleResId)) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ToneConfirmationDialog( + song: Song, + target: ToneTarget, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + shape = AbsoluteSmoothCornerShape( + cornerRadiusTR = 32.dp, + smoothnessAsPercentBR = 60, + cornerRadiusBR = 32.dp, + smoothnessAsPercentTL = 60, + cornerRadiusTL = 32.dp, + smoothnessAsPercentBL = 60, + cornerRadiusBL = 32.dp, + smoothnessAsPercentTR = 60, + ), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 6.dp, + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + ToneDialogIcon(target = target) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.song_info_tone_confirm_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource( + R.string.song_info_tone_confirm_body, + song.title, + stringResource(target.confirmLabelResId), + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + FilledTonalButton(onClick = onConfirm) { + Text(stringResource(R.string.song_info_tone_confirm_action)) + } + } + } + } + } +} + +@Composable +private fun ToneDialogIcon( + target: ToneTarget?, + modifier: Modifier = Modifier.size(56.dp), + iconModifier: Modifier = Modifier.size(28.dp), +) { + Box( + modifier = modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + when (target) { + ToneTarget.Ringtone -> Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + modifier = iconModifier, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + ToneTarget.Notification -> Icon( + painter = painterResource(R.drawable.rounded_notifications_active_24), + contentDescription = null, + modifier = iconModifier, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + ToneTarget.Alarm -> Icon( + painter = painterResource(R.drawable.rounded_alarm_24), + contentDescription = null, + modifier = iconModifier, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + null -> Icon( + painter = painterResource(R.drawable.rounded_notifications_active_24), + contentDescription = null, + modifier = iconModifier, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } +} + +@Composable +private fun RingtoneActionButton( + modifier: Modifier, + showText: Boolean, + compactText: Boolean = false, + onClick: () -> Unit, +) { + val colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + if (showText) { + FilledTonalButton( + modifier = modifier, + colors = colors, + contentPadding = PaddingValues(horizontal = if (compactText) 12.dp else 18.dp), + shape = CircleShape, + onClick = onClick, + ) { + Icon( + modifier = Modifier.size(if (compactText) 20.dp else 24.dp), + painter = painterResource(R.drawable.rounded_notifications_active_24), + contentDescription = stringResource(R.string.cd_choose_song_tone), + ) + Spacer(Modifier.width(if (compactText) 6.dp else 8.dp)) + Text( + text = stringResource( + if (compactText) R.string.song_info_set_as_short else R.string.song_info_choose_tone + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + FilledTonalIconButton( + modifier = modifier, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + shape = CircleShape, + onClick = onClick, + ) { + Icon( + modifier = Modifier.size(FloatingActionButtonDefaults.LargeIconSize), + painter = painterResource(R.drawable.rounded_notifications_active_24), + contentDescription = stringResource(R.string.cd_choose_song_tone), + ) + } + } +} + +private val ToneTarget.titleResId: Int + get() = when (this) { + ToneTarget.Ringtone -> R.string.song_info_tone_ringtone_title + ToneTarget.Notification -> R.string.song_info_tone_notification_title + ToneTarget.Alarm -> R.string.song_info_tone_alarm_title + } + +private val ToneTarget.subtitleResId: Int + get() = when (this) { + ToneTarget.Ringtone -> R.string.song_info_tone_ringtone_subtitle + ToneTarget.Notification -> R.string.song_info_tone_notification_subtitle + ToneTarget.Alarm -> R.string.song_info_tone_alarm_subtitle + } + +private val ToneTarget.confirmLabelResId: Int + get() = when (this) { + ToneTarget.Ringtone -> R.string.song_info_tone_ringtone_label + ToneTarget.Notification -> R.string.song_info_tone_notification_label + ToneTarget.Alarm -> R.string.song_info_tone_alarm_label + } + @Composable private fun SongInfoSegmentedListItem( headline: String, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt index 4425a950e..f5fcfd550 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt @@ -81,6 +81,8 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( Box( modifier = Modifier .align(Alignment.TopCenter) + .fillMaxWidth() + .height(MiniPlayerHeight) .graphicsLayer { // Compute miniAlpha in the draw phase from the Animatable, // avoiding per-frame recomposition during gestures. @@ -89,20 +91,18 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( } .zIndex(miniPlayerZIndex) ) { - val miniAlbumCornerRadius by remember(overallSheetTopCornerRadiusProvider) { - derivedStateOf { - (overallSheetTopCornerRadiusProvider().value * 0.5f).dp - } + val isMiniPlayerVisible by remember { + derivedStateOf { playerContentExpansionFraction.value < 0.01f } } MiniPlayerContentInternal( song = currentSongNonNull, - cornerRadiusAlb = miniAlbumCornerRadius, isPlaying = infrequentPlayerState.isPlaying, isCastConnecting = isCastConnecting, isPreparingPlayback = isPreparingPlayback, onPlayPause = { playerViewModel.playPause() }, onPrevious = { playerViewModel.previousSong() }, onNext = { playerViewModel.nextSong() }, + canScroll = isMiniPlayerVisible && infrequentPlayerState.isPlaying, modifier = Modifier.fillMaxSize() ) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt index b4d092180..3ab6c43d7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetShared.kt @@ -67,9 +67,9 @@ internal fun MiniPlayerContentInternal( isPreparingPlayback: Boolean, onPlayPause: () -> Unit, onPrevious: () -> Unit, - cornerRadiusAlb: Dp, onNext: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + canScroll: Boolean = true ) { val hapticFeedback = LocalHapticFeedback.current val controlsEnabled = !isCastConnecting && !isPreparingPlayback @@ -136,12 +136,14 @@ internal fun MiniPlayerContentInternal( else -> song.title }, style = titleStyle, - gradientEdgeColor = LocalMaterialTheme.current.primaryContainer + gradientEdgeColor = LocalMaterialTheme.current.primaryContainer, + canScroll = canScroll ) AutoScrollingText( text = if (isPreparingPlayback) "Loading audio…" else song.displayArtist, style = artistStyle, - gradientEdgeColor = LocalMaterialTheme.current.primaryContainer + gradientEdgeColor = LocalMaterialTheme.current.primaryContainer, + canScroll = canScroll ) } Spacer(modifier = Modifier.width(8.dp)) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt index 3e650662a..8db9d8728 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color @@ -372,6 +373,7 @@ fun UnifiedPlayerSheetV2( swipeDismissProgress = swipeDismissProgress ) val currentBottomPadding = sheetVisualState.currentBottomPadding + val baseBottomPadding = sheetVisualState.baseBottomPadding val playerContentAreaHeightPxProvider = sheetVisualState.playerContentAreaHeightPxProvider val visualSheetTranslationYProvider = sheetVisualState.visualSheetTranslationYProvider val overallSheetTopCornerRadiusProvider = sheetVisualState.overallSheetTopCornerRadiusProvider @@ -517,10 +519,12 @@ fun UnifiedPlayerSheetV2( val playerAreaBackground = sheetThemeState.playerAreaBackground // Elevation is only visible in the mini/collapsed state (expansion < 0.18). // miniReadyAlpha fades the shadow in during the initial song-appear animation. - val visualCardShadowElevation by remember(showQueueSheet, miniReadyAlpha) { + val isDragging = sheetBackAndDragState.isDragging + val visualCardShadowElevation by remember(showQueueSheet, miniReadyAlpha, isDragging) { derivedStateOf { if ( showQueueSheet || + isDragging || playerContentExpansionFraction.isRunning || playerContentExpansionFraction.value > 0.18f ) { @@ -592,9 +596,17 @@ fun UnifiedPlayerSheetV2( Box( modifier = Modifier .fillMaxWidth() - // Modifier.layout reads from pixel lambdas during the layout phase — - // this avoids recomposition per drag frame (unlike derivedStateOf). - // Layout still runs per-frame, but composition is skipped entirely. + .graphicsLayer { + translationX = offsetAnimatable.value + scaleX = miniAppearScale + scaleY = visualOvershootScaleY.value * miniAppearScale + alpha = miniReadyAlpha + transformOrigin = TransformOrigin(0.5f, 1f) + } + // outerLayout: + // Measures downstream chain with innerWidth and targetHeightPx. + // Places child at startPaddingPx to center it horizontally. + // Reports full screen width to parent to satisfy fillMaxWidth() constraints. .layout { measurable, constraints -> val targetHeightPx = playerContentAreaHeightPxProvider() .toInt().coerceAtLeast(0) @@ -604,6 +616,7 @@ fun UnifiedPlayerSheetV2( .toInt().coerceAtLeast(0) val innerWidth = (constraints.maxWidth - startPaddingPx - endPaddingPx) .coerceAtLeast(0) + val placeable = measurable.measure( constraints.copy( minWidth = innerWidth, @@ -616,17 +629,6 @@ fun UnifiedPlayerSheetV2( placeable.placeRelative(startPaddingPx, 0) } } - .miniPlayerDismissHorizontalGesture( - enabled = currentSheetContentState == PlayerSheetState.COLLAPSED, - handler = miniDismissGestureHandler - ) - .graphicsLayer { - translationX = offsetAnimatable.value - scaleX = miniAppearScale - scaleY = visualOvershootScaleY.value * miniAppearScale - alpha = miniReadyAlpha - transformOrigin = TransformOrigin(0.5f, 1f) - } // Always apply Modifier.shadow with the dynamic elevation // (0.dp renders nothing). Keeping the modifier chain // structurally stable avoids the costly relayout/redraw @@ -641,10 +643,27 @@ fun UnifiedPlayerSheetV2( color = playerAreaBackground, shape = sheetInteractionState.playerShadowShape ) - .clipToBounds() - .semantics { - contentDescription = playerSheetSemanticsDescription + .clip(sheetInteractionState.playerShadowShape) + // innerLayout: + // Measures the actual player content with full screen height targetContentHeightPx + // so that it can render correctly, while reporting targetHeightPx to the outer + // clip/background/shadow so that they are perfectly constrained to the miniplayer card bounds. + .layout { measurable, constraints -> + val targetContentHeightPx = containerHeight.roundToPx() + val placeable = measurable.measure( + constraints.copy( + minHeight = targetContentHeightPx, + maxHeight = targetContentHeightPx + ) + ) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.placeRelative(0, 0) + } } + .miniPlayerDismissHorizontalGesture( + enabled = currentSheetContentState == PlayerSheetState.COLLAPSED, + handler = miniDismissGestureHandler + ) .playerSheetVerticalDragGesture( enabled = sheetInteractionState.canDragSheet, handler = sheetInteractionState.sheetVerticalDragGestureHandler @@ -656,6 +675,9 @@ fun UnifiedPlayerSheetV2( ) { playerViewModel.togglePlayerSheetState() } + .semantics { + contentDescription = playerSheetSemanticsDescription + } ) { UnifiedPlayerMiniAndFullLayers( currentSong = infrequentPlayerState.currentSong, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/WavySliderExpressive.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/WavySliderExpressive.kt index 37749b3b7..993a6ffc6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/WavySliderExpressive.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/WavySliderExpressive.kt @@ -27,6 +27,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius @@ -45,6 +47,7 @@ import androidx.compose.ui.semantics.setProgress import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max +import androidx.compose.ui.unit.times import kotlinx.coroutines.isActive import kotlin.math.abs import kotlin.math.roundToInt @@ -52,7 +55,7 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun WavySliderExpressive( - value: Float, + value: () -> Float, onValueChange: (Float) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -86,15 +89,25 @@ fun WavySliderExpressive( Stroke(width = strokeWidthPx, cap = StrokeCap.Round) } - val normalizedValue = if (valueRange.endInclusive == valueRange.start) 0f - else ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)).coerceIn(0f, 1f) + val normalizedValueState = remember(valueRange) { + derivedStateOf { + val v = value() + if (valueRange.endInclusive == valueRange.start) 0f + else ((v - valueRange.start) / (valueRange.endInclusive - valueRange.start)).coerceIn(0f, 1f) + } + } val safeSemanticsStep = semanticsProgressStep.coerceIn(0.005f, 0.25f) - val semanticNormalizedValue = remember(normalizedValue, safeSemanticsStep) { - ((normalizedValue / safeSemanticsStep).roundToInt() * safeSemanticsStep).coerceIn(0f, 1f) + val semanticNormalizedValueState = remember(safeSemanticsStep) { + derivedStateOf { + val norm = normalizedValueState.value + ((norm / safeSemanticsStep).roundToInt() * safeSemanticsStep).coerceIn(0f, 1f) + } } - val semanticSliderValue = remember(semanticNormalizedValue, valueRange) { - valueRange.start + semanticNormalizedValue * (valueRange.endInclusive - valueRange.start) + val semanticSliderValueState = remember(valueRange) { + derivedStateOf { + valueRange.start + semanticNormalizedValueState.value * (valueRange.endInclusive - valueRange.start) + } } val latestOnValueChange by rememberUpdatedState(onValueChange) val latestOnValueChangeFinished by rememberUpdatedState(onValueChangeFinished) @@ -113,44 +126,78 @@ fun WavySliderExpressive( label = "amplitude" ) + val currentHalfWidth = remember(thumbRadius, strokeWidth) { + derivedStateOf { + val fraction = thumbInteractionFraction + val radius = thumbRadius + val halfStroke = strokeWidth * 0.6f + radius * (1f - fraction) + halfStroke * fraction + } + } + + val dynamicGapSize = remember { + derivedStateOf { + currentHalfWidth.value + 4.dp + } + } + // Keep visual progress interpolation out of composition: // update this state on frame clock, then consume it only inside draw lambdas. // This preserves smooth visuals while avoiding high-frequency recompositions. - val renderedNormalizedProgress = remember { mutableFloatStateOf(normalizedValue) } + val renderedNormalizedProgress = remember { + val initialVal = value() + val initialNorm = if (valueRange.endInclusive == valueRange.start) 0f + else ((initialVal - valueRange.start) / (valueRange.endInclusive - valueRange.start)).coerceIn(0f, 1f) + mutableFloatStateOf(initialNorm) + } var lastProgressUpdateNanos by remember { mutableLongStateOf(0L) } - LaunchedEffect(normalizedValue, isInteracting, enabled) { - val target = normalizedValue.coerceIn(0f, 1f) - if (!enabled || isInteracting) { - renderedNormalizedProgress.floatValue = target - lastProgressUpdateNanos = System.nanoTime() - return@LaunchedEffect - } + LaunchedEffect(isInteracting, enabled) { + snapshotFlow { normalizedValueState.value }.collect { target -> + if (!enabled || isInteracting) { + renderedNormalizedProgress.floatValue = target + lastProgressUpdateNanos = System.nanoTime() + return@collect + } - val nowNanos = System.nanoTime() - val intervalMs = if (lastProgressUpdateNanos == 0L) { - 180L - } else { - ((nowNanos - lastProgressUpdateNanos) / 1_000_000L).coerceAtLeast(1L) - } - lastProgressUpdateNanos = nowNanos + val start = renderedNormalizedProgress.floatValue + // Snap on discontinuities (song change, big catch-up after a seek, resume after + // backgrounding). Per-tick natural progress is well under 10% even for short + // clips, so a bigger jump can't be normal playback — tweening it produces the + // "slowly slides to 0" effect on track switch. + if (abs(start - target) > 0.1f) { + renderedNormalizedProgress.floatValue = target + lastProgressUpdateNanos = System.nanoTime() + return@collect + } - val start = renderedNormalizedProgress.floatValue - if (abs(start - target) <= 0.0001f) { - renderedNormalizedProgress.floatValue = target - return@LaunchedEffect - } + val nowNanos = System.nanoTime() + // Cap the perceived interval so a long pause (paused playback, sheet hidden, + // backgrounded app) can't translate into a multi-second tween once progress + // resumes with a tiny delta. + val intervalMs = if (lastProgressUpdateNanos == 0L) { + 180L + } else { + ((nowNanos - lastProgressUpdateNanos) / 1_000_000L).coerceIn(1L, 250L) + } + lastProgressUpdateNanos = nowNanos + + if (abs(start - target) <= 0.0001f) { + renderedNormalizedProgress.floatValue = target + return@collect + } - val durationNanos = (intervalMs * 900_000L).coerceAtLeast(1_000_000L) - var startFrameNanos = 0L - while (isActive) { - val frameNanos = withFrameNanos { it } - if (startFrameNanos == 0L) startFrameNanos = frameNanos - val elapsedNanos = (frameNanos - startFrameNanos).coerceAtLeast(0L) - val fraction = (elapsedNanos.toDouble() / durationNanos.toDouble()).toFloat().coerceIn(0f, 1f) - renderedNormalizedProgress.floatValue = start + (target - start) * fraction - if (fraction >= 1f) break + val durationNanos = (intervalMs * 900_000L).coerceAtLeast(1_000_000L) + var startFrameNanos = 0L + while (isActive) { + val frameNanos = withFrameNanos { it } + if (startFrameNanos == 0L) startFrameNanos = frameNanos + val elapsedNanos = (frameNanos - startFrameNanos).coerceAtLeast(0L) + val fraction = (elapsedNanos.toDouble() / durationNanos.toDouble()).toFloat().coerceIn(0f, 1f) + renderedNormalizedProgress.floatValue = start + (target - start) * fraction + if (fraction >= 1f) break + } + renderedNormalizedProgress.floatValue = target } - renderedNormalizedProgress.floatValue = target } val containerHeight = max(WavyProgressIndicatorDefaults.LinearContainerHeight, max(thumbRadius * 2, thumbLineHeightWhenInteracting)) @@ -164,7 +211,7 @@ fun WavySliderExpressive( contentDescription = semanticsLabel } progressBarRangeInfo = ProgressBarRangeInfo( - current = semanticSliderValue, + current = semanticSliderValueState.value, range = valueRange.start..valueRange.endInclusive, steps = 0 ) @@ -191,7 +238,7 @@ fun WavySliderExpressive( trackColor = inactiveTrackColor, stroke = stroke, trackStroke = stroke, - gapSize = thumbRadius + 4.dp, + gapSize = dynamicGapSize.value * (1.0f + 0.1573f * animatedAmplitude * animatedAmplitude), stopSize = 3.dp, amplitude = { progress -> if (progress > 0f) animatedAmplitude else 0f }, wavelength = wavelength, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/external/ExternalPlayerOverlay.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/external/ExternalPlayerOverlay.kt index b3c6f2d74..f2cfb3b3b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/external/ExternalPlayerOverlay.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/external/ExternalPlayerOverlay.kt @@ -256,7 +256,7 @@ fun ExternalPlayerOverlay( Spacer(modifier = Modifier.height(24.dp)) WavySliderExpressive( - value = sliderPosition, + value = { sliderPosition }, enabled = totalDuration > 0, onValueChange = { newValue -> isUserScrubbing = true diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt index 1ce2f58a9..9bf506183 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt @@ -67,6 +67,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.material3.rememberModalBottomSheetState import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -580,7 +582,8 @@ fun FullPlayerContent( chipColor = playerOnAccentColor.copy(alpha = 0.8f), chipContentColor = playerAccentColor, onQueueClick = onSongMetadataQueueClick, - onArtistClick = onSongMetadataArtistClick + onArtistClick = onSongMetadataArtistClick, + isPlayingProvider = isPlayingProvider ) } @@ -602,7 +605,8 @@ fun FullPlayerContent( chipColor = playerOnAccentColor.copy(alpha = 0.8f), chipContentColor = playerAccentColor, onQueueClick = onSongMetadataQueueClick, - onArtistClick = onSongMetadataArtistClick + onArtistClick = onSongMetadataArtistClick, + isPlayingProvider = isPlayingProvider ) } @@ -1309,7 +1313,8 @@ private fun FullPlayerSongMetadataSection( chipColor: Color, chipContentColor: Color, onQueueClick: () -> Unit, - onArtistClick: () -> Unit + onArtistClick: () -> Unit, + isPlayingProvider: () -> Boolean = { true } ) { val shouldDelay = loadingTweaks.delayAll || loadingTweaks.delaySongMetadata @@ -1353,7 +1358,8 @@ private fun FullPlayerSongMetadataSection( chipContentColor = chipContentColor, showQueueButton = isLandscape, onClickQueue = onQueueClick, - onClickArtist = onArtistClick + onClickArtist = onArtistClick, + isPlayingProvider = isPlayingProvider ) } } @@ -1452,7 +1458,8 @@ private fun SongMetadataDisplaySection( showQueueButton: Boolean, onClickQueue: () -> Unit, onClickArtist: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isPlayingProvider: () -> Boolean = { true } ) { Row( modifier @@ -1475,7 +1482,8 @@ private fun SongMetadataDisplaySection( onClickArtist = onClickArtist, modifier = Modifier .weight(1f) - .align(Alignment.CenterVertically) + .align(Alignment.CenterVertically), + isPlayingProvider = isPlayingProvider ) } @@ -1693,33 +1701,30 @@ private fun PlayerProgressBarSection( ) var sliderDragValue by remember { mutableStateOf(null) } - // Optimistic Seek: Holds the target position immediately after seek to prevent snap-back - var optimisticPosition by remember { mutableStateOf(null) } + // Held seek target (fraction) — mirrors PlayerSeekBar so the slider stays where the user + // dropped it until real playback catches up. Fraction-based so it survives duration drift. + var targetSeekFraction by remember { mutableFloatStateOf(-1f) } + var lastSeekFinishedTime by remember { mutableLongStateOf(0L) } - // Reset seek state on song change to avoid stale position from previous song + // Reset seek state on song change to avoid stale position from previous song. LaunchedEffect(songId) { sliderDragValue = null - optimisticPosition = null - } - - // Clear optimistic position ONLY when the SMOOTH (visual) progress catches up - // using raw position causes a jump because smooth progress might lag behind raw. - LaunchedEffect(optimisticPosition) { - val target = optimisticPosition - if (target != null) { - val start = System.currentTimeMillis() - - while (optimisticPosition != null) { - // Check if the current VISUAL progress (smoothState) corresponds to the target - // We use the derived state value which falls back to smoothProgressState - val currentVisual = smoothProgressState.value - val currentVisualMs = (currentVisual * durationForCalc).toLong() - - // If visual is close enough (within 500ms visual distance) - if (kotlin.math.abs(currentVisualMs - target) < 500 || (System.currentTimeMillis() - start) > 2000) { - optimisticPosition = null - } - kotlinx.coroutines.delay(50) + targetSeekFraction = -1f + lastSeekFinishedTime = 0L + } + + // Release the held target once smooth progress catches up (within 4%) or after a 5 s + // safety net — same thresholds as the LyricsSheet PlayerSeekBar. Re-keying on songId + // restarts the snapshotFlow so the new song's progress drives the catch-up cleanly. + LaunchedEffect(songId) { + snapshotFlow { smoothProgressState.value }.collect { progress -> + if (sliderDragValue != null) return@collect + val target = targetSeekFraction + if (target < 0f) return@collect + val timeSinceSeek = System.currentTimeMillis() - lastSeekFinishedTime + val diff = kotlin.math.abs(progress - target) + if (timeSinceSeek > 5000L || diff < 0.04f) { + targetSeekFraction = -1f } } } @@ -1730,20 +1735,13 @@ private fun PlayerProgressBarSection( } // Always drive the thumb from smoothed progress to avoid visual jumps from 500ms raw ticks. - val animatedProgressState = remember( - sliderDragValue, - optimisticPosition, - smoothProgressState, - durationForCalc - ) { + val animatedProgressState = remember(smoothProgressState) { derivedStateOf { - if (sliderDragValue != null) { - sliderDragValue!! - } else if (optimisticPosition != null) { - (optimisticPosition!!.toFloat() / durationForCalc.toFloat()).coerceIn(0f, 1f) - } else { - smoothProgressState.value - } + when { + sliderDragValue != null -> sliderDragValue!! + targetSeekFraction >= 0f -> targetSeekFraction + else -> smoothProgressState.value + } } } @@ -1808,7 +1806,8 @@ private fun PlayerProgressBarSection( onValueChange = { sliderDragValue = it }, onValueCommit = { finalValue -> val targetMs = (finalValue * durationForCalc).roundToLong() - optimisticPosition = targetMs + targetSeekFraction = finalValue + lastSeekFinishedTime = System.currentTimeMillis() onSeek(targetMs) sliderDragValue = null }, @@ -1862,7 +1861,7 @@ private fun EfficientSlider( } WavySliderExpressive( - value = valueState.value, + value = { valueState.value }, onValueChange = onValueChangeWithHaptics, onValueCommit = onValueCommit, interactionSource = interactionSource, @@ -2124,7 +2123,8 @@ private fun PlayerSongInfo( gradientEdgeColor: Color, playerViewModel: PlayerViewModel, onClickArtist: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isPlayingProvider: () -> Boolean = { true } ) { val coroutineScope = rememberCoroutineScope() var isNavigatingToArtist by remember { mutableStateOf(false) } @@ -2160,11 +2160,12 @@ private fun PlayerSongInfo( // If we want to avoid recomposition, we might need to pass the provider or just 1f if scrolling logic handles itself. // For now, let's pass the current value from provider for logic correctness, but ideally this component should be optimized too. AutoScrollingTextOnDemand( - title, - titleStyle, - gradientEdgeColor, - expansionFractionProvider, - modifier = Modifier.fillMaxWidth() + text = title, + style = titleStyle, + gradientEdgeColor = gradientEdgeColor, + expansionFractionProvider = expansionFractionProvider, + modifier = Modifier.fillMaxWidth(), + canScroll = isPlayingProvider() ) Spacer(modifier = Modifier.height(2.dp)) @@ -2203,7 +2204,8 @@ private fun PlayerSongInfo( } } } - ) + ), + canScroll = isPlayingProvider() ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/ComposeLoader.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/ComposeLoader.kt index 2f03fc4c2..68555769a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/ComposeLoader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/ComposeLoader.kt @@ -13,6 +13,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.first +import androidx.compose.runtime.snapshotFlow // ------------------------------------------------------------ // 1) Phase loader: compose a subtree only after a threshold, then keep it alive @@ -86,12 +88,22 @@ fun rememberSmoothProgress( sampleNow() while (isActive) { - if (!latestIsVisible) { - delay(200L) - continue - } + val isVisible = latestIsVisible val isPlaying = latestIsPlayingProvider() - val delayMillis = if (isPlaying) latestSampleWhilePlayingMs else latestSampleWhilePausedMs + + if (!isVisible || !isPlaying) { + val initialPos = latestPositionProvider() + snapshotFlow { + latestIsVisible && latestIsPlayingProvider() || latestPositionProvider() != initialPos + }.first { it } + + sampleNow() + if (!latestIsVisible || !latestIsPlayingProvider()) { + continue + } + } + + val delayMillis = latestSampleWhilePlayingMs delay(delayMillis.coerceAtLeast(1L)) sampleNow() } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetInteractionState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetInteractionState.kt index 827c9ef39..7988543de 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetInteractionState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetInteractionState.kt @@ -133,7 +133,7 @@ private class PlayerSheetDynamicShape( ): Outline { val topRadius = topRadiusProvider().nonNegative() val bottomRadius = bottomRadiusProvider().nonNegative() - if (!useSmoothShapeProvider()) { + if (topRadius <= 1.dp || bottomRadius <= 1.dp || !useSmoothShapeProvider()) { val topRadiusPx = with(density) { topRadius.toPx() } val bottomRadiusPx = with(density) { bottomRadius.toPx() } return Outline.Rounded( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetVisualState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetVisualState.kt index 29a82d310..06045d9b3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetVisualState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/scoped/SheetVisualState.kt @@ -19,6 +19,7 @@ private const val PREDICTIVE_BACK_SWIPE_EDGE_RIGHT = 1 internal data class SheetVisualState( val currentBottomPadding: Dp, + val baseBottomPadding: Dp, /** Draw-phase provider: read this inside graphicsLayer to avoid layout relayout per frame. */ val playerContentAreaHeightPxProvider: () -> Float, /** Layout-phase provider: read inside .offset { } to avoid recomposition per drag frame. */ @@ -48,37 +49,30 @@ internal fun rememberSheetVisualState( hasCurrentSong: Boolean, swipeDismissProgress: Float ): SheetVisualState { - val currentBottomPadding by remember( - showPlayerContentArea, - collapsedStateHorizontalPadding, - predictiveBackCollapseProgress, - currentSheetContentState - ) { - derivedStateOf { - if (predictiveBackCollapseProgress > 0f && - showPlayerContentArea && - currentSheetContentState == PlayerSheetState.EXPANDED - ) { - lerp(0.dp, collapsedStateHorizontalPadding, predictiveBackCollapseProgress) - } else { - 0.dp - } - } - } - // Compute in px to be read inside graphicsLayer (draw phase) — zero relayout per drag frame. val density = LocalDensity.current + val baseBottomPadding = remember(containerHeight, sheetCollapsedTargetY, density) { + val targetYDp = with(density) { sheetCollapsedTargetY.toDp() } + (containerHeight - com.theveloper.pixelplay.presentation.components.MiniPlayerHeight - targetYDp) + .coerceAtLeast(0.dp) + } + + val currentBottomPadding = 0.dp + val miniHeightPx = remember(density) { with(density) { com.theveloper.pixelplay.presentation.components.MiniPlayerHeight.toPx() } } val containerHeightPx = remember(containerHeight, density) { with(density) { containerHeight.toPx() } } + val baseBottomPaddingPx = remember(baseBottomPadding, density) { with(density) { baseBottomPadding.toPx() } } val playerContentAreaHeightPxProvider: () -> Float = remember( showPlayerContentArea, playerContentExpansionFraction, + predictiveBackCollapseProgress, miniHeightPx, containerHeightPx ) { { if (showPlayerContentArea) { - androidx.compose.ui.util.lerp(miniHeightPx, containerHeightPx, playerContentExpansionFraction.value) + val effectiveFraction = playerContentExpansionFraction.value * (1f - predictiveBackCollapseProgress) + androidx.compose.ui.util.lerp(miniHeightPx, containerHeightPx, effectiveFraction) } else { 0f } @@ -104,7 +98,6 @@ internal fun rememberSheetVisualState( showPlayerContentArea, playerContentExpansionFraction, predictiveBackCollapseProgress, - currentSheetContentState, navBarStyle, navBarCornerRadiusDp, isNavBarHidden @@ -119,16 +112,9 @@ internal fun rememberSheetVisualState( navBarCornerRadiusDp } - if (predictiveBackCollapseProgress > 0f && - currentSheetContentState == PlayerSheetState.EXPANDED - ) { - val expandedCorner = 0.dp - lerp(expandedCorner, collapsedCornerTarget, predictiveBackCollapseProgress) - } else { - val fraction = playerContentExpansionFraction.value - val expandedTarget = 0.dp - lerp(collapsedCornerTarget, expandedTarget, fraction) - } + val effectiveFraction = playerContentExpansionFraction.value * (1f - predictiveBackCollapseProgress) + val expandedTarget = 0.dp + lerp(collapsedCornerTarget, expandedTarget, effectiveFraction) } else { if (navBarStyle == NavBarStyle.FULL_WIDTH) { 0.dp @@ -153,52 +139,43 @@ internal fun rememberSheetVisualState( showPlayerContentArea, playerContentExpansionFraction, predictiveBackCollapseProgress, - currentSheetContentState, swipeDismissProgress, isNavBarHidden, navBarCornerRadiusDp ) { { - if (navBarStyle == NavBarStyle.FULL_WIDTH) { - val fraction = playerContentExpansionFraction.value - lerp(32.dp, 0.dp, fraction) + val collapsedRadius = if (navBarStyle == NavBarStyle.FULL_WIDTH) { + 32.dp + } else if (isNavBarHidden) { + 60.dp } else { - val calculatedNormally = - if (predictiveBackCollapseProgress > 0f && - showPlayerContentArea && - currentSheetContentState == PlayerSheetState.EXPANDED - ) { - val expandedRadius = 0.dp - val collapsedRadiusTarget = if (isNavBarHidden) 60.dp else 12.dp - lerp(expandedRadius, collapsedRadiusTarget, predictiveBackCollapseProgress) - } else { - if (showPlayerContentArea) { - val fraction = playerContentExpansionFraction.value - val collapsedRadius = if (isNavBarHidden) 60.dp else 12.dp - if (fraction < 0.2f) { - lerp(collapsedRadius, 26.dp, (fraction / 0.2f).coerceIn(0f, 1f)) - } else { - lerp(26.dp, 0.dp, ((fraction - 0.2f) / 0.8f).coerceIn(0f, 1f)) - } - } else { - if (!isPlayingState.value || !hasCurrentSongState.value) { - if (isNavBarHidden) 32.dp else navBarCornerRadiusDp - } else { - if (isNavBarHidden) 32.dp else 12.dp - } - } - } + navBarCornerRadiusDp + } - if (currentSheetContentState == PlayerSheetState.COLLAPSED && - swipeDismissProgress > 0f && - showPlayerContentArea && - playerContentExpansionFraction.value < 0.01f - ) { - val baseCollapsedRadius = if (isNavBarHidden) 32.dp else 12.dp - lerp(baseCollapsedRadius, navBarCornerRadiusDp, swipeDismissProgress) + val effectiveFraction = playerContentExpansionFraction.value * (1f - predictiveBackCollapseProgress) + val calculatedNormally = + if (showPlayerContentArea) { + val expandedTarget = 0.dp + lerp(collapsedRadius, expandedTarget, effectiveFraction) } else { - calculatedNormally + if (!isPlayingState.value || !hasCurrentSongState.value) { + if (isNavBarHidden) 32.dp else navBarCornerRadiusDp + } else { + collapsedRadius + } } + + if (navBarStyle == NavBarStyle.FULL_WIDTH) { + calculatedNormally + } else if (currentSheetContentState == PlayerSheetState.COLLAPSED && + swipeDismissProgress > 0f && + showPlayerContentArea && + playerContentExpansionFraction.value < 0.01f + ) { + val baseCollapsedRadius = if (isNavBarHidden) 32.dp else navBarCornerRadiusDp + lerp(baseCollapsedRadius, navBarCornerRadiusDp, swipeDismissProgress) + } else { + calculatedNormally } } } @@ -213,66 +190,39 @@ internal fun rememberSheetVisualState( // per-frame relayout. The lambda captures Animatable/Float refs and reads them at draw time. val currentHorizontalPaddingStartPxProvider: () -> Float = remember( showPlayerContentArea, - currentSheetContentState, - predictiveBackCollapseProgress, - predictiveBackSwipeEdge, collapsedStateHorizontalPaddingPx, - playerContentExpansionFraction + playerContentExpansionFraction, + predictiveBackCollapseProgress ) { { - val currentPadding = if (showPlayerContentArea) { - androidx.compose.ui.util.lerp(collapsedStateHorizontalPaddingPx, 0f, playerContentExpansionFraction.value) + if (showPlayerContentArea) { + val effectiveFraction = playerContentExpansionFraction.value * (1f - predictiveBackCollapseProgress) + androidx.compose.ui.util.lerp(collapsedStateHorizontalPaddingPx, 0f, effectiveFraction) } else { collapsedStateHorizontalPaddingPx } - if (predictiveBackCollapseProgress > 0f && - showPlayerContentArea && - currentSheetContentState == PlayerSheetState.EXPANDED - ) { - val gestureSidePaddingPx = androidx.compose.ui.util.lerp(0f, collapsedStateHorizontalPaddingPx, predictiveBackCollapseProgress) - when (predictiveBackSwipeEdge) { - PREDICTIVE_BACK_SWIPE_EDGE_LEFT -> gestureSidePaddingPx - PREDICTIVE_BACK_SWIPE_EDGE_RIGHT -> 0f - else -> currentPadding - } - } else { - currentPadding - } } } val currentHorizontalPaddingEndPxProvider: () -> Float = remember( showPlayerContentArea, - currentSheetContentState, - predictiveBackCollapseProgress, - predictiveBackSwipeEdge, collapsedStateHorizontalPaddingPx, - playerContentExpansionFraction + playerContentExpansionFraction, + predictiveBackCollapseProgress ) { { - val currentPadding = if (showPlayerContentArea) { - androidx.compose.ui.util.lerp(collapsedStateHorizontalPaddingPx, 0f, playerContentExpansionFraction.value) + if (showPlayerContentArea) { + val effectiveFraction = playerContentExpansionFraction.value * (1f - predictiveBackCollapseProgress) + androidx.compose.ui.util.lerp(collapsedStateHorizontalPaddingPx, 0f, effectiveFraction) } else { collapsedStateHorizontalPaddingPx } - if (predictiveBackCollapseProgress > 0f && - showPlayerContentArea && - currentSheetContentState == PlayerSheetState.EXPANDED - ) { - val gestureSidePaddingPx = androidx.compose.ui.util.lerp(0f, collapsedStateHorizontalPaddingPx, predictiveBackCollapseProgress) - when (predictiveBackSwipeEdge) { - PREDICTIVE_BACK_SWIPE_EDGE_LEFT -> 0f - PREDICTIVE_BACK_SWIPE_EDGE_RIGHT -> gestureSidePaddingPx - else -> currentPadding - } - } else { - currentPadding - } } } return SheetVisualState( currentBottomPadding = currentBottomPadding, + baseBottomPadding = baseBottomPadding, playerContentAreaHeightPxProvider = playerContentAreaHeightPxProvider, visualSheetTranslationYProvider = visualSheetTranslationYProvider, overallSheetTopCornerRadiusProvider = overallSheetTopCornerRadiusProvider, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayerSeekBar.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayerSeekBar.kt index c3cbd115b..18a8006fd 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayerSeekBar.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayerSeekBar.kt @@ -59,12 +59,20 @@ fun PlayerSeekBar( } var isUserSeeking by remember { mutableStateOf(false) } + var lastSeekFinishedTime by remember { mutableStateOf(0L) } + var targetSeekFraction by remember { mutableFloatStateOf(-1f) } var seekFraction by remember { mutableFloatStateOf(progressFraction) } val lastHapticStep = remember { intArrayOf(-1) } - LaunchedEffect(progressFraction) { + LaunchedEffect(progressFraction, isUserSeeking) { if (!isUserSeeking) { - seekFraction = progressFraction + val now = System.currentTimeMillis() + val timeSinceSeek = now - lastSeekFinishedTime + val diffFraction = kotlin.math.abs(progressFraction - targetSeekFraction) + if (targetSeekFraction < 0f || timeSinceSeek > 5000L || diffFraction < 0.04f) { + seekFraction = progressFraction + targetSeekFraction = -1f + } } } @@ -95,7 +103,7 @@ fun PlayerSeekBar( .fillMaxWidth() .padding(horizontal = 0.dp), //.weight(0.8f), - value = seekFraction, + value = { seekFraction }, onValueChange = { newFraction -> isUserSeeking = true seekFraction = newFraction @@ -107,8 +115,11 @@ fun PlayerSeekBar( } }, onValueCommit = { finalFraction -> + seekFraction = finalFraction onSeek((finalFraction * totalDuration).roundToLong()) onSeekPreview?.invoke(null) + targetSeekFraction = finalFraction + lastSeekFinishedTime = System.currentTimeMillis() isUserSeeking = false }, strokeWidth = 5.dp, // Was trackHeight diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIcon.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIcon.kt index 47a32afe9..8904f11c8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIcon.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIcon.kt @@ -62,9 +62,6 @@ fun PlayingEqIcon( } } - val phase = phaseAnim.value - val wander = wanderAnim.value - // Factor de actividad: 1 = barras, 0 = puntitos (morph suave) val activity by animateFloatAsState( targetValue = if (isPlaying) 1f else 0f, @@ -77,6 +74,8 @@ fun PlayingEqIcon( val shifts = remember(bars) { List(bars) { i -> i * 0.9f } } Canvas(modifier = modifier) { + val phase = phaseAnim.value + val wander = wanderAnim.value val w = size.width val h = size.height diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/model/RecentlyPlayedSongUi.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/model/RecentlyPlayedSongUi.kt index b039f43d8..122996162 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/model/RecentlyPlayedSongUi.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/model/RecentlyPlayedSongUi.kt @@ -3,13 +3,16 @@ package com.theveloper.pixelplay.presentation.model import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.stats.PlaybackStatsRepository import com.theveloper.pixelplay.data.stats.StatsTimeRange +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import java.time.ZoneId import java.time.temporal.TemporalAdjusters +@Parcelize data class RecentlyPlayedSongUiModel( val song: Song, val lastPlayedTimestamp: Long -) +) : Parcelable fun mapRecentlyPlayedSongs( playbackHistory: List, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt index e3e8e945a..7fba291fa 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt @@ -198,7 +198,7 @@ fun HomeScreen( ) } // Keep the visible Home snapshot stable and only refresh it once the screen is off-screen. - var recentlyPlayedSongs by remember { mutableStateOf(latestRecentlyPlayedSongs) } + var recentlyPlayedSongs by rememberSaveable { mutableStateOf(latestRecentlyPlayedSongs) } val latestRecentlyPlayedSongsState = rememberUpdatedState(latestRecentlyPlayedSongs) LaunchedEffect(latestRecentlyPlayedSongs, lifecycleOwner) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/utils/GenreIconProvider.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/utils/GenreIconProvider.kt index e48f26bc8..c3ac6ef24 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/utils/GenreIconProvider.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/utils/GenreIconProvider.kt @@ -3,65 +3,973 @@ package com.theveloper.pixelplay.presentation.utils import com.theveloper.pixelplay.R object GenreIconProvider { - - // Provide a list of default common genres + val DEFAULT_GENRES = listOf( "Rock", "Pop", "Jazz", "Classical", "Electronic", "Hip Hop", "Country", "Blues", "Reggae", "Metal", "Folk", "R&B", "Punk", "Indie", "Alternative", "Latino", "Reggaeton", "Salsa", "Bachata", "Merengue", "Cumbia", - "Oldies", "Soundtrack", "Gaming", "Sleep", "Workout", "Party", "Focus" + "Oldies", "Soundtrack", "Gaming", "Sleep", "Workout", "Party", "Focus", + "Gospel", "Children's", "World", "Dance", "New Age", "Easy Listening", + "Afrobeats", "Synthwave", "Drum and Bass", "Lo-fi", "Phonk", "Anime", + "Balada", "Sertanejo", "Forró", "Tango", "Norteño", "Música Tropical", + "Schlager", "Chanson", "Enka", "Trot" ) - // Map of name/keyword to resource ID for picking - // We expose a list of "Selectable" icons. val SELECTABLE_ICONS = listOf( R.drawable.rock, R.drawable.pop_mic, R.drawable.sax, R.drawable.clasic_piano, R.drawable.electronic_sound, R.drawable.rapper, R.drawable.banjo, R.drawable.harmonica, - R.drawable.maracas, R.drawable.metal_guitar, R.drawable.accordion, R.drawable.synth_piano, - R.drawable.punk, R.drawable.idk_indie_ig, R.drawable.acoustic_guitar, R.drawable.alt_video, - R.drawable.star_angle, R.drawable.conga, R.drawable.bongos, R.drawable.drum, - R.drawable.rounded_schedule_24, R.drawable.rounded_tv_24, R.drawable.rounded_touch_app_24, - R.drawable.rounded_alarm_24, R.drawable.rounded_celebration_24, R.drawable.rounded_edit_24, - R.drawable.rounded_library_music_24, - // Add some generic ones if available - R.drawable.rounded_music_note_24, R.drawable.rounded_headphones_24, R.drawable.rounded_speaker_24 + R.drawable.maracas, R.drawable.metal_guitar, R.drawable.metal_guitar_2, R.drawable.accordion, + R.drawable.synth_piano, R.drawable.punk, R.drawable.idk_indie_ig, R.drawable.acoustic_guitar, + R.drawable.alt_video, R.drawable.star_angle, R.drawable.conga, R.drawable.bongos, + R.drawable.drum, R.drawable.rattle, R.drawable.rounded_schedule_24, R.drawable.rounded_tv_24, + R.drawable.rounded_touch_app_24, R.drawable.rounded_alarm_24, R.drawable.rounded_celebration_24, + R.drawable.rounded_edit_24, R.drawable.rounded_favorite_24, R.drawable.rounded_lyrics_24, + R.drawable.rounded_library_music_24, R.drawable.rounded_music_note_24, + R.drawable.rounded_headphones_24, R.drawable.rounded_speaker_24 ) - - // Helper to get logic (accepts optional custom mapping) + + @Suppress("CyclomaticComplexMethod") fun getGenreImageResource(genreId: String, customIcons: Map = emptyMap()): Any { - // Check custom first customIcons[genreId]?.let { return it } - - return when (genreId.lowercase()) { - "rock", "hard rock", "alternative rock", "classic rock" -> R.drawable.rock - "pop", "pop rock", "k-pop", "dance pop" -> R.drawable.pop_mic - "jazz", "smooth jazz", "bebop" -> R.drawable.sax - "classical", "orchestra", "symphony", "piano" -> R.drawable.clasic_piano - "electronic", "edm", "techno", "house", "trance", "dubstep", "electro" -> R.drawable.electronic_sound - "hip hop", "hip-hop", "rap", "trap", "gangsta rap" -> R.drawable.rapper - "country", "bluegrass", "americana" -> R.drawable.banjo - "blues", "rhythm & blues" -> R.drawable.harmonica - "reggae", "ska", "dancehall" -> R.drawable.maracas - "metal", "heavy metal", "death metal", "black metal", "thrash metal" -> R.drawable.metal_guitar - "folk", "acoustic", "singer-songwriter" -> R.drawable.accordion - "r&b / soul", "rnb", "soul", "funk", "motown" -> R.drawable.synth_piano - "punk", "punk rock", "pop punk", "grunge" -> R.drawable.punk - "indie", "indie rock", "indie pop", "lo-fi" -> R.drawable.idk_indie_ig - "folk & acoustic" -> R.drawable.acoustic_guitar - "alternative", "alt-rock" -> R.drawable.alt_video - "latino", "latin", "latin pop", "urbano latino" -> R.drawable.star_angle - "reggaeton" -> R.drawable.rapper - "salsa" -> R.drawable.conga - "bachata" -> R.drawable.bongos - "merengue" -> R.drawable.drum - "cumbia" -> R.drawable.maracas - "oldies", "retro", "80s", "90s" -> R.drawable.rounded_schedule_24 - "soundtrack", "score", "movie tunes" -> R.drawable.rounded_tv_24 - "gaming", "video game music" -> R.drawable.rounded_touch_app_24 - "sleep", "relax", "meditation", "ambient" -> R.drawable.rounded_alarm_24 - "workout", "gym", "fitness" -> R.drawable.electronic_sound - "party", "club" -> R.drawable.rounded_celebration_24 - "focus", "study" -> R.drawable.rounded_edit_24 + + return when (genreId.lowercase().trim()) { + + // ── ROCK ───────────────────────────────────────────────────────────── + "rock", "hard rock", "classic rock", "southern rock", "progressive rock", + "prog rock", "progressive", "math rock", "post-rock", "post rock", + "soft rock", "j-rock", "j rock", "art rock", "symphonic rock", "space rock", + "psychedelic rock", "glam rock", "garage rock", "country rock", "slow rock", + "post-grunge", "folk rock", "folk-rock", "swamp rock", "power pop rock", + "rock and roll", "rock & roll", + // slash/conjunction variants + "rock/pop", "rock/metal", "rock/punk", "rock music", + // ES + "rock clásico", "rock clasico", "rock duro", "rock progresivo", + "rock suave", "rock alternativo", "rock en español", "rock en espanol", + // PT + "rock brasileiro", "rock nacional", + // FR + "rock français", "rock alternatif", + // DE + "krautrock", + // IT + "rock italiano", + // JA + "ロック", + // KO + "록", + // ZH + "摇滚", "摇滚乐", "搖滾", "搖滾樂", "民谣摇滚" -> R.drawable.rock + + // ── ALTERNATIVE ────────────────────────────────────────────────────── + "alternative", "alt-rock", "alternative rock", "experimental", + "avant-garde", "avantgarde", "abstract", "psychedelic", "psychadelic", + "neo-psychedelia", "art rock alt", + // slash/conjunction variants + "alternative/indie", "indie/alternative", + // ES + "alternativo", "alternativa", "rock alternativo independiente", + // PT + "alternativo brasileiro", + // FR + "alternatif", + // DE + "alternativ", + // IT + "alternativa", + // ZH + "另类", "另類" -> R.drawable.alt_video + + // ── METAL ───────────────────────────────────────────────────────────── + "metal", "heavy metal", "death metal", "black metal", "thrash metal", + "speed metal", "power metal", "doom metal", "stoner rock", "stoner metal", + "sludge", "sludge metal", "gothic metal", "symphonic metal", "folk metal", + "pagan metal", "viking metal", "glam metal", + "metal music", + // ES + "metal pesado", "metal extremo", + // JA + "メタル", "ヘヴィメタル", "ヘビーメタル", + // KO + "메탈", "헤비메탈", + // ZH + "金属", "重金属", "死亡金属", "金屬", "重金屬" -> R.drawable.metal_guitar + + "nu metal", "nu-metal", "metalcore", "deathcore", "screamo", + "noise rock", "industrial", "noise", "grindcore", "djent", + "mathcore", "technical death metal", "brutal death metal", + "melodic death metal", "progressive death metal", + "blackgaze", "ebm", "industrial rock", "post-metal", + "terror", "frenchcore" -> R.drawable.metal_guitar_2 + + // ── PUNK ────────────────────────────────────────────────────────────── + "punk", "punk rock", "pop punk", "grunge", "emo", "post-punk", + "post punk", "hardcore punk", "street punk", "skate punk", + "melodic punk", "melodic hardcore", "acid punk", "folk punk", + "garage punk", "anarcho-punk", "crust punk", "emo pop", + "punk music", + // slash/conjunction + "punk/rock", "rock/punk", + // ES + "punk en español", + // JA + "パンク", "パンクロック", + // KO + "펑크", + // ZH + "朋克" -> R.drawable.punk + + // ── INDIE ───────────────────────────────────────────────────────────── + "indie", "indie rock", "indie pop", "lo-fi", "lo fi", + "shoegaze", "dream pop", "noise pop", "twee pop", "sadcore", "slowcore", + "britpop", "brit pop", + "indie music", + // ES + "indie en español", "indie latinoamericano", + // JA + "インディー", "インディ", + // KO + "인디", "인디 음악", + // ZH + "独立", "独立音乐", "獨立", "獨立音樂" -> R.drawable.idk_indie_ig + + // ── POP ─────────────────────────────────────────────────────────────── + "pop", "pop rock", "k-pop", "dance pop", "teen pop", "bubblegum pop", + "adult contemporary", "j-pop", "c-pop", "mandopop", "cantopop", + "dance-pop", "europop", "karaoke", "power pop", "art pop", + "vocal", "top 40", "eurodance", + "pop music", "pop/dance", "dance/pop", "pop/rock", + // slash/conjunction variants + "pop/latin", "latin/pop", + // ES + "pop latino", "pop en español", "pop en espanol", "musica pop", + "música pop", "pop español", "pop espanol", "contemporaneo", "contemporáneo", + // PT + "mpb", "música popular brasileira", "musica popular brasileira", + "brega", "arrocha", "tropicália", "tropicalia", "tropicalismo", + "opm", + // FR + "chanson", "chanson française", "chanson francaise", + "variété", "variete", "variété française", "variete francaise", + // DE + "schlager", + // IT + "canzone italiana", "musica italiana", "cantautori", "cantautore", + "musica napoletana", "napoletana", + // Asia + "t-pop", "v-pop", + // JA + "ポップス", "ポップ", "Jポップ", + // KO + "팝", "k-팝", "k팝", "트로트", + // ZH + "流行", "流行音乐", "流行音樂", "华语流行", "粤语", "粤語", "粤语流行", + "粤语歌", "台语", "国语", "國語" -> R.drawable.pop_mic + + // ── SYNTH-POP / NEW WAVE / DARKWAVE ────────────────────────────────── + "synth-pop", "synthpop", "new wave", "electropop", + "synthwave", "outrun", "retrowave", "vaporwave", + "darkwave", "coldwave", "electroclash", + // ES + "onda retro", "nueva ola", + // DE + "neue deutsche welle", "ndw", + // ZH + "合成器流行" -> R.drawable.synth_piano + + // ── HIP HOP / RAP ───────────────────────────────────────────────────── + "hip hop", "hip-hop", "rap", "trap", "gangsta rap", "reggaeton", + "lo-fi hip hop", "lofi hip hop", "lo fi hip hop", "chillhop", + "phonk", "drill", "cloud rap", "mumble rap", "trip-hop", "trip hop", + "g-funk", "gangsta", "freestyle", "christian rap", "christian gangsta rap", + "hip hop music", "hip-hop music", "rap music", + // slash/conjunction variants + "rap/hip-hop", "hip-hop/rap", "hip hop/rap", "trap/hip-hop", + "hip-hop/trap", + // ES + "hip hop en español", "rap en español", "rap en espanol", + "rap español", "rap espanol", "trap en español", "trap en espanol", + "corridos tumbados", "trap latino", "urbano", + // PT + "funk carioca", "funk brasileiro", "brega funk", + // FR + "rap français", "rap francais", + // JA + "ヒップホップ", "ラップ", + // KO + "힙합", "랩", + // ZH + "嘻哈", "说唱", "說唱" -> R.drawable.rapper + + // ── JAZZ ────────────────────────────────────────────────────────────── + "jazz", "smooth jazz", "bebop", "swing", "big band", "dixieland", + "jazz fusion", "fusion", "cool jazz", "free jazz", "latin jazz", + "acid jazz", "nu jazz", "spiritual jazz", "electro swing", "swing jazz", + "fast fusion", "jazz+funk", "jazz blues", + "jazz music", + // slash/conjunction variants + "jazz/blues", "blues/jazz", + // PT + "choro", "chorinho", + // JA + "ジャズ", + // KO + "재즈", + // ZH + "爵士", "爵士乐", "爵士樂", "爵士蓝调" -> R.drawable.sax + + // ── BLUES ───────────────────────────────────────────────────────────── + "blues", "rhythm & blues", "delta blues", "chicago blues", + "electric blues", "boogie", "boogie-woogie", + "blues music", + // JA + "ブルース", + // KO + "블루스", + // ZH + "蓝调", "藍調" -> R.drawable.harmonica + + // ── CLASSICAL ───────────────────────────────────────────────────────── + "classical", "orchestra", "symphony", "piano", "baroque", "opera", + "chamber", "chamber music", "choral", "contemporary classical", + "neo-classical", "neoclassical", "minimalism", "string quartet", + "piano classical", "romantic classical", "sonata", "chorus", + "showtunes", "musical", "musicals", "broadway", "theatre music", + "chamber pop", "baroque pop", + "classical music", + // ES + "clásica", "clasica", "música clásica", "musica clasica", + "clásico", "clasico", "orquesta", "sinfonía", "sinfonia", + "ópera", "barroco", "coro", "danzon", "danzón", + // PT + "clássico", "classico", "música clássica", "musica classica", "clássica", + // FR + "classique", "musique classique", + // DE + "klassik", "klassische musik", + // IT + "classica", "musica classica", + // JA + "クラシック", "クラシック音楽", "クラシカル", "演歌", + // KO + "클래식", "클래식 음악", + // ZH + "古典", "古典音乐", "古典音樂", "古典乐", "戏曲", "京剧", "昆曲" -> R.drawable.clasic_piano + + // ── ELECTRONIC / EDM ───────────────────────────────────────────────── + "electronic", "edm", "techno", "house", "trance", "dubstep", "electro", + "deep house", "progressive house", "tropical house", "future bass", + "ambient house", "garage", "uk garage", "disco", "euro-disco", + "idm", "psytrance", "goa", "goa trance", "big beat", "rave", + "euro-techno", "euro-house", "club-house", "techno-industrial", + "electronic music", "dance/electronic", "electronic/dance", + // slash/conjunction variants + "electronic/dance", "dance/electronic", + // ES + "electrónica", "electronica", "música electrónica", "musica electronica", + // PT + "eletrônica", "eletronica", "música eletrônica", "musica eletronica", + "baile funk", "tecnobrega", + // FR + "électronique", "electronique", "musique électronique", + "musique electronique", "électro", + // DE + "elektronisch", "elektronische musik", "elektro", + // IT + "elettronica", "musica elettronica", + // JA + "エレクトロニック", "電子音楽", "エレクトロ", + // KO + "일렉트로닉", "전자음악", + // ZH + "电子", "电子音乐", "電子", "電子音樂", "电音", + // Fitness (upbeat) + "workout", "gym", "fitness", "running", "cardio", "sports", + "workout music", + "ejercicio", "entrenamiento", "gimnasio", "deporte", "deportes", + "exercício", "exercicio", "academia", "treino", + "exercice", "sport", "workout musik", "musik für sport" -> R.drawable.electronic_sound + + // ── DRUM & BASS ─────────────────────────────────────────────────────── + "drum and bass", "d&b", "dnb", "jungle", "breakbeat", "breaks" -> R.drawable.drum + + // ── HARDSTYLE ───────────────────────────────────────────────────────── + "hardstyle", "hardcore", "gabber" -> R.drawable.metal_guitar_2 + + // ── CHILL / AMBIENT / NEW AGE ───────────────────────────────────────── + "sleep", "relax", "meditation", "ambient", "chillout", "chill out", + "chill", "downtempo", "new age", "spa", "nature sounds", + "dark ambient", "drone", "psybient", + "sleep music", "meditation music", "ambient music", "new age music", + // ES + "relajación", "relajacion", "meditación", "meditacion", + "dormir", "música ambiental", "musica ambiental", "nueva era", + "ambiente", "bienestar", + // PT + "relaxamento", "relaxação", "relaxacao", "meditação", "meditacao", + // FR + "méditation", "relaxation", "bien-être", "bien-etre", + // DE + "entspannung", "schlafmusik", "naturgeräusche", + // IT + "meditazione", "rilassamento", "musica rilassante", + // JA + "睡眠", "リラックス", "瞑想", + // KO + "명상", "힐링", + // ZH + "冥想", "放松", "睡眠", "新世纪", "新紀元", "禅", "佛教" -> R.drawable.rounded_alarm_24 + + // ── COUNTRY / REGIONAL ──────────────────────────────────────────────── + "country", "bluegrass", "americana", "ranchera", "corrido", "corridos", + "country music", + // slash/conjunction + "country/folk", "folk/country", + // ES + "regional mexicano", "regional mexicana", "música regional mexicana", + "musica regional mexicana", "corridos del norte", + // PT + "sertanejo", "sertanejo universitário", "sertanejo universitario", + "sertanejo raiz", + // JA + "カントリー", + // KO + "컨트리", + // ZH + "乡村", "乡村音乐" -> R.drawable.banjo + + // ── FOLK / ACOUSTIC / SINGER-SONGWRITER ─────────────────────────────── + "folk", "acoustic", "singer-songwriter", "folk & acoustic", + "nueva canción", "nueva cancion", "fado", + "indie folk", "folk pop", "dark folk", "gothic folk", "anti-folk", + "folk music", "acoustic music", + // slash/conjunction + "folk/acoustic", "acoustic/folk", "singer/songwriter", + // ES + "folclore", "folklore", "música folclórica", "musica folklorica", + "música folk", "musica folk", "trova", "nueva trova", + "bambuco", "tonada", + // PT + "mpb folk", + // FR + "musique folk", "chanson folk", + // DE + "volksmusik", "volkslied", "volkslieder", + // IT + "folk italiano", "musica tradizionale", "folkloristica", + // JA + "フォーク", "フォークソング", + // KO + "포크", + // ZH + "民谣", "民謠", "民间音乐", "民間音樂", "民间歌曲" -> R.drawable.acoustic_guitar + + // ── R&B / SOUL / FUNK ───────────────────────────────────────────────── + "r&b / soul", "rnb", "r&b", "soul", "funk", "motown", + "neo-soul", "neo soul", "quiet storm", "slow jam", "ballad", + "r&b music", "soul music", "funk music", + // slash/conjunction + "r&b/soul", "soul/r&b", "soul/funk", "funk/soul", + // ES + "rhythm and blues", "balada", "balada romántica", "balada romantica", + "romántica", "romantica", + // JA + "ソウル", "ファンク", + // KO + "소울", "발라드", + // ZH + "灵魂乐", "放克", "靈魂樂", "節奏藍調", "节奏布鲁斯" -> R.drawable.synth_piano + + // ── LATIN — CONGA ───────────────────────────────────────────────────── + "salsa", "samba", "mambo", "rumba", "cha-cha", "cha cha", "chacha", + "son cubano", "son", "flamenco", "champeta", "cumbia villera", + "guaracha", "timba", "landó", "lando", "festejo", + "boogaloo", "son montuno", "salsa romántica", "salsa romantica", + "salsa dura", "timba cubana", "mozambique", "rumba flamenca", + "flamenco pop", "nuevo flamenco", "latin soul", "afrolatino", + "duranguense", "cumbia sonidera", "mapalé", "mapale", + "currulao", "garifuna", + // Afro + "afrobeat", "afrobeats", "afropop", "afro", "highlife", + "soukous", "kizomba", "kuduro", "semba", "rebita", "kwaito", + "amapiano", "gqom", "afro house", "afrohouse", "bongo flava", + "juju", "makossa", + "axé", "axe", "axé music" -> R.drawable.conga + + // ── LATIN — BONGOS ──────────────────────────────────────────────────── + "bachata", "tango", "bolero", "zouk", + "tango nuevo", "new tango", "tango argentino", + // ES + "milonga", "zamba", "pasillo", "música criolla", "musica criolla", + "milonga argentina", "pasillo colombiano", "zamba argentina" -> R.drawable.bongos + + // ── LATIN — DRUM ────────────────────────────────────────────────────── + "merengue", "banda", "merengue urbano", + // PT + "pagode", "pagode baiano", "pagode baiana" -> R.drawable.drum + + // ── LATIN — MARACAS ─────────────────────────────────────────────────── + "cumbia", "mariachi", "marimba", "huapango", "porro", + "bossa nova", "bossanova", "bossa", "soca", "calypso", + // ES + "chacarera", "cueca", "son jarocho", "joropo", "gaita", + "música andina", "musica andina", "cumbia andina", "cumbia chilena", + "punta", "música tropical", "musica tropical", + "música llanera", "musica llanera", "quebradita", + "huayno", "saya", + // PT + "frevo", "carimbó", "carimbo", "lambada", "piseiro", "pisadinha", + "forró", "forro", "xote", "xaxado", "maracatu", + // IT + "tarantella", + // World + "samba-reggae", + // JA + "ボサノバ", "レゲエ", + // KO + "레게" -> R.drawable.maracas + + // ── LATIN — ACCORDION ───────────────────────────────────────────────── + "norteño", "norteno", "tejano", "grupero", + "polka", "klezmer", "musette", + // ES + "cuarteto", "vallenato", + // PT + "baião", "baiao", "música nordestina", "musica nordestina", + // DE / World + "cajun", "zydeco", "celtic", "irish", + "folk scandinavia", "nordic" -> R.drawable.accordion + + // ── LATIN GENERAL ───────────────────────────────────────────────────── + "latino", "latin", "latin pop", "urbano latino", "tropical", + "latin alternative", "latin rock", "tropipop", + // ES + "música latina", "musica latina", "pop latinoamericano", + "música latinoamericana", "musica latinoamericana" -> R.drawable.star_angle + + // ── REGGAE / SKA ────────────────────────────────────────────────────── + "reggae", "ska", "dancehall", "roots reggae", "dub", + "reggae music", + // slash/conjunction + "reggae/ska", "ska/reggae" -> R.drawable.maracas + + // ── WORLD / ETHNIC ──────────────────────────────────────────────────── + "world", "world music", "ethnic", "folk world & country", + "traditional", "indigenous", "tribal", "global", + "bollywood", "filmi", "bhangra", "carnatic", "hindustani", + "ghazal", "qawwali", "rai", "chaabi", "arabic pop", "arab pop", + "turkish pop", + // ES + "música del mundo", "musica del mundo", "música mundial", + "musica mundial", "étnica", "etnica", "tradicional", "indígena", "indigena", + "música tradicional", "musica tradicional", + // PT + "música do mundo", + // FR + "musique du monde", "musique africaine", "musique traditionnelle", + // DE + "weltmusik", + // IT + "musica del mondo", "musica tradizionale italiana", + // JA + "民謡", "日本民謡", + // KO + "국악", "민요", "한국 민요", + // ZH + "民族", "传统", "中国传统音乐", "國風", "国风", "中国风", "古风", + "傳統", "民族音乐" -> R.drawable.rattle + + // ── GOSPEL / CHRISTIAN ──────────────────────────────────────────────── + "gospel", "christian", "christian rock", "ccm", "contemporary christian", + "spiritual", "religious", "worship", "praise", + // slash/conjunction + "gospel/christian", "christian/gospel", + // ES + "música cristiana", "musica cristiana", "cristiana", "evangélica", + "evangelica", "música gospel", "musica gospel", "música espiritual", + "musica espiritual", "alabanza", "adoración", "adoracion", + // PT + "música cristã", "musica crista", "cristã", "crista", "louvores", + // FR + "évangile", "evangile", "musique chrétienne", "musique chretienne", + "louanges", "cantiques", + // DE + "kirchenmusik", + // IT + "gospel italiano", "musica cristiana italiana", + // ZH + "福音", "基督教音乐", "圣歌", "赞美诗", "讚美詩" -> R.drawable.rounded_favorite_24 + + // ── CHILDREN'S ──────────────────────────────────────────────────────── + "children's", "children", "kids", "nursery", "nursery rhymes", + "baby", "lullaby", "lullabies", + "kids music", "children music", "children's music", + // ES + "música infantil", "musica infantil", "infantil", "niños", "ninos", + "canciones infantiles", "rondas", "nanas", "para niños", "para ninos", + // PT + "canções infantis", "cancoes infantis", + // FR + "musique pour enfants", "enfants", "comptines", + // DE + "kindermusik", "kinder", "kinderlieder", "kinderlied", + // IT + "musica per bambini", "bambini", "filastrocche", "canzoni per bambini", + // JA + "子供", "こども", "童謡", "子供の歌", + // KO + "어린이", "동요", + // ZH + "儿歌", "童謠", "童谣", "兒歌", "儿童" -> R.drawable.rattle + + // ── SPOKEN WORD / PODCAST / POETRY ─────────────────────────────────── + "spoken word", "poetry", "audiobook", "spoken", + "podcast", "speech", "audio theatre", "audio theater", + // slash/conjunction + "comedy/spoken", + // ES + "palabra hablada", "poesía", "poesia", "audiolibro", + // JA + "朗読", "詩", + // ZH + "有声书", "朗诵" -> R.drawable.rounded_lyrics_24 + + // ── COMEDY / HUMOR ──────────────────────────────────────────────────── + "comedy", "humor", "humour", "satire", "pranks", + // ES + "comedia", "humor musical", + // FR + "comédie", "comedie", + // IT + "commedia" -> R.drawable.rounded_celebration_24 + + // ── CHRISTMAS / SEASONAL ───────────────────────────────────────────── + "christmas", "holiday", "festive", "seasonal", + "christmas music", "holiday music", + // ES + "navidad", "navideña", "navidena", "música navideña", + "musica navidena", "villancicos", "aguinaldos", "posadas", + // PT + "natal", "músicas natalinas", "musicas natalinas", "música de natal", + "musica de natal", + // FR + "noël", "noel", "musique de noël", "musique de noel", + // DE + "weihnachtsmusik", "weihnachten", "weihnachtslieder", + // IT + "natale", "musica natalizia", "canzoni di natale", + // JA + "クリスマス", "クリスマスソング", + // KO + "크리스마스", + // ZH + "圣诞", "聖誕", "圣诞节", "圣诞歌曲" -> R.drawable.rounded_celebration_24 + + // ── OLDIES / RETRO ──────────────────────────────────────────────────── + "oldies", "retro", "80s", "90s", "70s", "60s", "50s", + "classic hits", "throwback", "revival", + // ES + "viejitos", "clásicos", "clasicos", "viejos éxitos", + "viejos exitos", "nostalgia", + // PT + "clássicos", "classicos", + // FR + "rétro", "années 80", "années 90", + // DE + "oldies deutsch", + // JA + "懐かしの曲", "昭和", + // KO + "옛날노래", + // ZH + "怀旧", "懷舊" -> R.drawable.rounded_schedule_24 + + // ── SOUNDTRACK / OST / ANIME ───────────────────────────────────────── + "soundtrack", "score", "film score", "movie tunes", "ost", + "anime soundtrack", "anime", "trailer", "trailer music", + "k-drama ost", "k-drama", + // ES + "banda sonora", "música de película", "musica de pelicula", + "música de cine", "musica de cine", "música original", "musica original", + // PT + "trilha sonora", "trilha", + // FR + "bande originale", "bande-son", "bande son", "musique de film", + // DE + "filmmusik", + // IT + "colonna sonora", + // JA + "サウンドトラック", "映画音楽", "アニメ", "アニメソング", "アニソン", + // KO + "사운드트랙", "영화음악", "애니메이션", + // ZH + "原声", "电影原声", "影视原声", "动漫", "動漫" -> R.drawable.rounded_tv_24 + + // ── GAMING ──────────────────────────────────────────────────────────── + "gaming", "video game music", "chiptune", "8-bit", "game music", + // Generic "video game" strings a tagger might use + "video game", "video games", "vgm", "game", + "game soundtrack", "game ost", "video game ost", + "video game soundtrack", "retro gaming", "arcade", "console music", + "8-bit music", "chiptune music", "gaming music", + // ES + "música de videojuegos", "musica de videojuegos", "videojuegos", + // PT + "música de jogos", "musica de jogos", + // JA + "ゲーム音楽", "ゲームミュージック", "BGM", + // KO + "게임 음악", "게임음악", + // ZH + "游戏音乐", "遊戲音樂" -> R.drawable.rounded_touch_app_24 + + // ── PARTY / DANCE ───────────────────────────────────────────────────── + "party", "club", "dance", "dance music", + // ES + "fiesta", "baile", "música de baile", "musica de baile", "discoteca", + // PT + "festa", "dança", "danca", + // FR + "fête", "fete", "danse", "soirée", "soiree", + // DE + "tanz", "tanzmusik", + // IT + "discoteca", "ballo", "musica dance", + // JA + "ダンス", "ダンスミュージック", + // KO + "댄스", "댄스 음악", + // ZH + "舞曲", "舞蹈", "派对" -> R.drawable.rounded_celebration_24 + + // ── FOCUS / STUDY ───────────────────────────────────────────────────── + "focus", "study", "concentration", "study music", "focus music", + // ES + "concentración", "concentracion", "estudio", "música de estudio", + "musica de estudio", + // PT + "concentração", "concentracao", + // FR + "concentration", "étude", "etude", + // DE + "konzentration", "lernen", + // IT + "concentrazione", "studio", + // JA + "集中", "勉強", + // KO + "공부", "집중", + // ZH + "专注", "學習", "学习", "專注" -> R.drawable.rounded_edit_24 + + // ── EASY LISTENING / LOUNGE ─────────────────────────────────────────── + "easy listening", "lounge", "background music", "smooth", + "easy", "lounge music", + // ES + "música suave", "musica suave", "música de fondo", "musica de fondo", + // PT + "música de ambiente", + // FR + "musique d'ambiance", + // DE + "hintergrundmusik", + // IT + "musica di sottofondo", + // JA + "イージーリスニング", + // KO + "이지 리스닝" -> R.drawable.rounded_headphones_24 + + // ── INSTRUMENTAL / A CAPPELLA ───────────────────────────────────────── + "instrumental", "acapella", "a cappella", "a capella", + "instrumental music", + // FR + "musique instrumentale", + // DE + "instrumentalmusik", + // IT + "musica strumentale", + // JA + "インストゥルメンタル", "インスト", + // ZH + "器乐", "純音樂", "纯音乐", "轻音乐" -> R.drawable.rounded_music_note_24 + + // ── RINGTONE / NOTIFICATION / SYSTEM SOUNDS ────────────────────────── + "ringtone", "ringtones", "notification", "notification sound", + "notification tone", "alert", "alert tone", "phone tone", + "message tone", "alarm tone", "tone", "tones", + // ES + "tono", "tonos", "tono de llamada", "tonos de llamada", + // PT + "toque", "toque de celular", "toque de chamada" -> R.drawable.rounded_music_note_24 + + // ── FAVORITES / HITS / CHARTS ───────────────────────────────────────── + "favorites", "favourites", "favorite", "favourite", + "greatest hits", "best of", "hits", "best hits", "top hits", + "popular", "trending", "chart", "charts", + // ES + "favoritos", "favoritas", "éxitos", "exitos", + "lo mejor de", "los mejores éxitos", "los mejores exitos", + "más popular", "mas popular", + // PT + "sucessos", "melhores músicas", "melhores musicas", + // FR + "favoris", "meilleures chansons", "tubes", + // DE + "favoriten", "beste lieder", + // IT + "preferiti", "successi", + // ZH + "最爱", "精選", "精选", "热门" -> R.drawable.rounded_favorite_24 + + // ── REMIX / DJ / MASHUP / PRODUCTION ───────────────────────────────── + "remix", "remixes", "remix ep", "dj mix", "dj set", "dj", + "continuous mix", "mashup", "mash-up", "mash up", + "rework", "reworks", "edits", "bootleg remix", + "extended mix", "radio edit", "club mix", + "flip", "refix", "re-edit", "vip" -> R.drawable.electronic_sound + + // ── LIVE / CONCERT ──────────────────────────────────────────────────── + "live", "live music", "concert", "live concert", "live session", + "live at", "mtv unplugged", "live recording", "live performance", + "live album", + // ES + "en vivo", "concierto", "en directo", "directo", + // PT + "ao vivo", "concerto", + // FR + "en direct", "concert live", + // DE + "live konzert", "konzert", + // IT + "dal vivo", "concerto live", + // JA + "ライブ", "コンサート", + // KO + "라이브" -> R.drawable.rounded_music_note_24 + + // ── UNPLUGGED / COVER / TRIBUTE / DEMO ─────────────────────────────── + "unplugged", "cover", "covers", "cover song", "cover songs", + "tribute", "acoustic cover", "acoustic session", + "b-sides", "b sides", "rarities", "demo", "demos", + "bootleg", "outtake", "outtakes", + // ES + "versión acústica", "version acustica", "versiones", + // PT + "acústico", "acustico", "versões" -> R.drawable.acoustic_guitar + + // ── MOOD — HAPPY / UPBEAT / ENERGETIC ──────────────────────────────── + "happy", "upbeat", "energetic", "feel good", "feelgood", + "fun", "euphoric", "cheerful", "joyful", "positive", "hype", + // ES + "alegre", "animado", "animada", "feliz", "divertido", "divertida", + // PT + "animado", "animada", + // FR + "joyeux", "joyeuse", "gai", + // DE + "fröhlich", + // IT + "allegro", "gioioso" -> R.drawable.rounded_celebration_24 + + // ── MOOD — ROMANTIC / LOVE ──────────────────────────────────────────── + "romantic", "romance", "love", "love songs", "love music", + "sensual", "passionate", "intimate", + // ES + "amor", "canciones de amor", "romantico", "romántico", + // PT + "amor", "romântico", "romântica", + // FR + "romantique", "amour", + // DE + "romantisch", "liebeslieder", + // IT + "romantico", "romantica", "amore", + // ZH + "情歌", "爱情", "愛情" -> R.drawable.rounded_favorite_24 + + // ── MOOD — SAD / EMOTIONAL / MELANCHOLIC ────────────────────────────── + "sad", "melancholic", "melancholy", "emotional", "heartbreak", + "breakup", "lonely", "tearjerker", "bittersweet", "somber", + // ES + "triste", "tristeza", "melancolico", "melancólico", "desamor", + // PT + "triste", "tristeza", "melancólico", + // FR + "triste", "mélancolique", + // DE + "traurig", "melancholisch", + // IT + "malinconico", + // ZH + "悲伤", "忧郁" -> R.drawable.synth_piano + + // ── MOOD — PEACEFUL / CALM / SOOTHING ──────────────────────────────── + "peaceful", "calm", "soothing", "tranquil", "serene", + "gentle", "quiet", "mellow", "soft", + // ES + "tranquilo", "tranquila", "calmado", "calmada", + // PT + "tranquilo", "calmo", + // FR + "calme", "paisible", "doux", + // DE + "ruhig", "sanft", + // IT + "tranquillo", "calmo", + // ZH + "轻柔", "安静" -> R.drawable.rounded_headphones_24 + + // ── ACTIVITY — DRIVING / TRAVEL / ROAD ─────────────────────────────── + "driving", "road trip", "travel", "commute", "car music", + "highway", "cruising", + // ES + "manejar", "conducir", "viaje", "carretera", "música para manejar", + // PT + "dirigindo", "viagem", + // FR + "conduite", "voyage", + // DE + "autofahren", "reise" -> R.drawable.electronic_sound + + // ── ACTIVITY — MORNING / WAKE UP ────────────────────────────────────── + "morning", "wake up", "wakeup", "good morning", "sunrise", + "breakfast", "morning routine", + // ES + "mañana", "despertar", "buenos días", "buenos dias", + // PT + "manhã", "manha", "acordar", + // FR + "matin", "réveil", + // DE + "morgen", "aufwachen", + // IT + "mattina", "risveglio", + // JA + "朝", "目覚め" -> R.drawable.rounded_alarm_24 + + // ── ACTIVITY — NIGHT / EVENING ──────────────────────────────────────── + "night", "late night", "nighttime", "midnight", "evening", + "after hours", "nocturnal", + // ES + "noche", "tarde", "medianoche", + // PT + "noite", + // FR + "nuit", + // DE + "nacht", + // IT + "notte", + // ZH + "夜晚", "夜曲" -> R.drawable.rounded_alarm_24 + + // ── ACTIVITY — WORK / OFFICE ────────────────────────────────────────── + "work", "office", "work music", "office music", + // ES + "trabajo", "oficina", + // PT + "trabalho", + // FR + "travail", "bureau", + // DE + "arbeit", "büro", + // IT + "lavoro", "ufficio" -> R.drawable.rounded_edit_24 + + // ── SPECIAL — WEDDING / GRADUATION / FORMAL ─────────────────────────── + "wedding", "wedding music", "marriage", "matrimony", "graduation", + "ceremony", "formal", + // ES + "boda", "matrimonio", "graduación", "graduacion", + "quinceañera", "quinceanera", "quince años", "quince anos", + // PT + "casamento", "formatura", + // FR + "mariage", + // DE + "hochzeit", "hochzeitsmusik", + // IT + "matrimonio", "nozze", + // ZH + "婚礼", "毕业" -> R.drawable.clasic_piano + + // ── SPECIAL — BIRTHDAY ──────────────────────────────────────────────── + "birthday", "birthday music", "happy birthday", + // ES + "cumpleaños", "feliz cumpleaños", "feliz cumpleanos", + // PT + "aniversário", "aniversario", "parabéns", "parabens", + // FR + "anniversaire", "joyeux anniversaire", + // DE + "geburtstag", "zum geburtstag", + // IT + "compleanno", "buon compleanno", + // ZH + "生日", "生日歌" -> R.drawable.rounded_celebration_24 + + // ── DECADES EXTENSION ───────────────────────────────────────────────── + "2000s", "00s", "2010s", "10s", "2020s", "20s", + "millennium", "vintage", "classic", + // ES + "años 2000", "los 2000", "años 2010", + // ZH + "复古" -> R.drawable.rounded_schedule_24 + + // ── AUDIO QUALITY / AUDIOPHILE ──────────────────────────────────────── + "hifi", "hi-fi", "hi fi", "lossless", "flac", "audiophile", + "high fidelity", "high quality", "hd audio", "hi-res", + "binaural", "asmr", "dolby", "surround", + // ES + "alta fidelidad", "alta calidad", + // PT + "alta fidelidade" -> R.drawable.rounded_headphones_24 + + // ── SOUND EFFECTS / NATURE / NOISE ──────────────────────────────────── + "sound effects", "sfx", "fx", "nature", "rain", "ocean", "waves", + "birds", "birdsong", "white noise", "pink noise", "brown noise", + "thunder", "storm", "wind", "waterfall", "forest", "fire", + "frequency", "binaural beats", "432hz", "528hz", "174hz", + // ES + "efectos de sonido", "sonidos de la naturaleza", "lluvia", + // PT + "efeitos sonoros", "sons da natureza", "chuva", + // DE + "regengeräusche", + // JA + "自然音", "雨音", + // ZH + "自然声音", "雨声" -> R.drawable.rounded_alarm_24 + + // ── GENERIC / VARIOUS / COMPILATION ────────────────────────────────── + // Catch-all for untagged or poorly-tagged libraries + "music", "audio", "sound", "track", "song", "songs", + "various", "various artists", "va", "compilation", "compil", + "mix", "mixtape", "playlist", "collection", "medley", + "new", "new music", "latest", "recent", "other", "misc", + "miscellaneous", "general", "uncategorized", + // ES + "música", "musica", "canción", "cancion", "canciones", + "varios", "variado", "varios artistas", "recopilatorio", + "colección", "coleccion", + // PT + "canção", "cancao", "canções", "cancoes", + "vários", "coletânea", "coletanea", + // FR + "musique", "compilation fr", + // DE + "musik", "sammlung", + // IT + "musica generica", "raccolta", + // JA + "音楽", "曲", "楽曲", + // KO + "음악", "노래", + // ZH + "音乐", "歌曲", "音樂" -> R.drawable.rounded_library_music_24 + "unknown" -> R.drawable.rounded_question_mark_24 else -> R.drawable.rounded_library_music_24 } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt index 95e5e3e33..124740938 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt @@ -18,6 +18,7 @@ import com.google.android.gms.cast.framework.SessionManager import com.google.android.gms.cast.framework.SessionManagerListener import com.google.android.gms.cast.framework.media.RemoteMediaClient import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.service.cast.CastRemotePlaybackState import com.theveloper.pixelplay.data.service.http.CastSessionSecurity import com.theveloper.pixelplay.data.service.http.MediaFileHttpServerService import com.theveloper.pixelplay.data.service.player.CastPlayer @@ -53,6 +54,11 @@ class CastTransferStateHolder @Inject constructor( private val dualPlayerEngine: DualPlayerEngine // For local player control during transfer ) { private val CAST_LOG_TAG = "PlayerCastTransfer" + private val remoteBufferingSoftRecoveryMs = 6_000L + private val remoteBufferingReloadMs = 14_000L + private val remoteBufferingTransferBackMs = 28_000L + private val remoteBufferingPositionToleranceMs = 750L + private val remoteBufferingLogIntervalMs = 5_000L private var scope: CoroutineScope? = null @@ -89,7 +95,6 @@ class CastTransferStateHolder @Inject constructor( // State tracking variables private var lastRemoteMediaStatus: MediaStatus? = null - private var consecutiveErrorSkipCount = 0 var lastRemoteQueue: List = emptyList() private set var lastRemoteSongId: String? = null @@ -97,6 +102,7 @@ class CastTransferStateHolder @Inject constructor( private var lastRemoteStreamPosition: Long = 0L private var lastRemoteRepeatMode: Int = MediaStatus.REPEAT_MODE_REPEAT_OFF private var lastKnownRemoteIsPlaying: Boolean = false + private var lastRemotePlaybackShouldResume: Boolean = false private var lastRemoteItemId: Int? = null private var pendingRemoteSongId: String? = null @@ -110,6 +116,12 @@ class CastTransferStateHolder @Inject constructor( private var lastRemoteIdleLogKey: String? = null private var lastRemoteIdleLoggedAt: Long = 0L private var skipTransferBackOnNextSessionEnd: Boolean = false + private var remoteBufferingStartedAtMs: Long = 0L + private var remoteBufferingLastProgressAtMs: Long = 0L + private var remoteBufferingLastProgressPositionMs: Long = 0L + private var remoteBufferingRecoveryAttempts: Int = 0 + private var remoteBufferingReloadAttempts: Int = 0 + private var lastRemoteBufferingLogAtMs: Long = 0L // Listeners private var remoteMediaClientCallback: RemoteMediaClient.Callback? = null @@ -119,6 +131,8 @@ class CastTransferStateHolder @Inject constructor( private var remoteStatusRefreshJob: Job? = null private var sessionSuspendedRecoveryJob: Job? = null private var alignToTargetJob: Job? = null + private var castErrorRecoveryJob: Job? = null + private var remoteBufferingRecoveryJob: Job? = null private val httpServerStartMutex = Mutex() fun initialize( @@ -199,7 +213,6 @@ class CastTransferStateHolder @Inject constructor( } override fun onSessionSuspended(session: CastSession, reason: Int) { Timber.tag(CAST_LOG_TAG).w("Cast session suspended (reason=%d). Waiting for recovery.", reason) - castStateHolder.setRemotePlaybackActive(false) castStateHolder.setCastConnecting(true) scheduleSessionSuspendedRecovery(session) } @@ -266,6 +279,11 @@ class CastTransferStateHolder @Inject constructor( val mediaStatus = remoteMediaClient.mediaStatus ?: return lastRemoteMediaStatus = mediaStatus + val remotePlayback = CastRemotePlaybackState.project( + mediaStatus = mediaStatus, + previousPlayIntent = lastRemotePlaybackShouldResume + ) + lastRemotePlaybackShouldResume = remotePlayback.playWhenReady val songMap = getSongsByIdMap?.invoke() ?: emptyMap() @@ -393,21 +411,8 @@ class CastTransferStateHolder @Inject constructor( ) } - // Implement auto-skip logic to jump to the next track if available - val queueItems = mediaStatus.queueItems - val currentIndex = queueItems.indexOfFirst { it.itemId == currentItemId } - val nextItem = if (currentIndex >= 0) queueItems.getOrNull(currentIndex + 1) else null - if (nextItem != null) { - if (consecutiveErrorSkipCount < 3) { - consecutiveErrorSkipCount++ - Timber.tag(CAST_LOG_TAG).w("Auto-skipping failed remote track. Consecutive failures: %d. Jumping to next item itemId=%d", consecutiveErrorSkipCount, nextItem.itemId) - Log.w("PX_CAST_AUTO_SKIP", "consecutiveFailures=$consecutiveErrorSkipCount jumpingToItem=${nextItem.itemId}") - castStateHolder.castPlayer?.jumpToItem(nextItem.itemId, 0L) - } else { - Timber.tag(CAST_LOG_TAG).e("Max consecutive Cast failures reached (%d). Stopping skip loop.", consecutiveErrorSkipCount) - Log.e("PX_CAST_AUTO_SKIP", "maxConsecutiveFailuresReached=$consecutiveErrorSkipCount stoppingSkipLoop") - } - } + scheduleCastErrorRecoveryIfNeeded(castSession, errorSongId) + return } val itemChanged = lastRemoteItemId != currentItemId @@ -464,10 +469,7 @@ class CastTransferStateHolder @Inject constructor( val songChanged = currentSongFallback?.id != playbackStateHolder.stablePlayerState.value.currentSong?.id - val isPlaying = mediaStatus.playerState == MediaStatus.PLAYER_STATE_PLAYING - if (isPlaying) { - consecutiveErrorSkipCount = 0 - } + val isPlaying = remotePlayback.isPlaying lastKnownRemoteIsPlaying = isPlaying lastRemoteStreamPosition = streamPosition lastRemoteRepeatMode = mediaStatus.queueRepeatMode @@ -476,6 +478,14 @@ class CastTransferStateHolder @Inject constructor( (currentSongFallback?.duration ?: 0L) > 0L -> currentSongFallback?.duration ?: 0L else -> playbackStateHolder.stablePlayerState.value.totalDuration.coerceAtLeast(0L) } + updateRemoteBufferingWatchdog( + session = castSession, + remoteMediaClient = remoteMediaClient, + mediaStatus = mediaStatus, + currentSong = currentSongFallback, + currentSongId = effectiveSongId, + streamPosition = streamPosition + ) if (!castStateHolder.isRemotelySeeking.value) { castStateHolder.setRemotePosition(streamPosition) @@ -487,6 +497,8 @@ class CastTransferStateHolder @Inject constructor( lyrics = if (songChanged) null else it.lyrics, isLoadingLyrics = if (songChanged && currentSongFallback != null) true else it.isLoadingLyrics, isPlaying = isPlaying, + playWhenReady = remotePlayback.playWhenReady, + isBuffering = remotePlayback.isBuffering, repeatMode = if (mediaStatus.queueRepeatMode == MediaStatus.REPEAT_MODE_REPEAT_SINGLE) Player.REPEAT_MODE_ONE else if (mediaStatus.queueRepeatMode == MediaStatus.REPEAT_MODE_REPEAT_ALL || mediaStatus.queueRepeatMode == MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF, @@ -513,6 +525,8 @@ class CastTransferStateHolder @Inject constructor( private fun transferPlayback(session: CastSession) { scope?.launch { + resetRemoteBufferingWatchdog() + remoteBufferingRecoveryJob?.cancel() castStateHolder.setPendingCastRouteId(null) castStateHolder.setCastConnecting(true) castStateHolder.setRemotelySeeking(false) @@ -538,7 +552,9 @@ class CastTransferStateHolder @Inject constructor( return@launch } - val wasPlaying = localPlayer.isPlaying + val wasPlaying = localPlayer.isPlaying || localPlayer.playWhenReady + lastKnownRemoteIsPlaying = wasPlaying + lastRemotePlaybackShouldResume = wasPlaying val currentSongIndex = localPlayer.currentMediaItemIndex val safeStartIndex = currentSongIndex.takeIf { it in currentQueue.indices } ?: 0 val currentPosition = localPlayer.currentPosition @@ -639,6 +655,8 @@ class CastTransferStateHolder @Inject constructor( lastRemoteSongId = currentQueue.getOrNull(safeStartIndex)?.id lastRemoteStreamPosition = currentPosition lastRemoteRepeatMode = castRepeatMode + lastKnownRemoteIsPlaying = wasPlaying + lastRemotePlaybackShouldResume = wasPlaying playbackStateHolder.startProgressUpdates() session.remoteMediaClient?.requestStatus() currentQueue.getOrNull(safeStartIndex)?.id?.let(::launchAlignToTarget) @@ -703,6 +721,226 @@ class CastTransferStateHolder @Inject constructor( } } + private fun scheduleCastErrorRecoveryIfNeeded(session: CastSession, errorSongId: String?) { + scheduleCastSessionTransferBack( + session = session, + songId = errorSongId, + reason = "item_error", + message = "Cast lost the stream. Resuming on this device." + ) + } + + private fun scheduleCastSessionTransferBack( + session: CastSession, + songId: String?, + reason: String, + message: String + ) { + if (!lastRemotePlaybackShouldResume || castErrorRecoveryJob?.isActive == true) return + + castStateHolder.setCastConnecting(true) + castErrorRecoveryJob = scope?.launch { + Timber.tag(CAST_LOG_TAG).w( + "Recovering from Cast %s by ending remote session. songId=%s", + reason, + songId + ) + Log.w("PX_CAST_RECOVERY", "ending_session reason=$reason songId=$songId") + emitCastError(message) + if (castStateHolder.castSession.value === session) { + sessionManager?.endCurrentSession(true) + } + } + } + + private fun updateRemoteBufferingWatchdog( + session: CastSession, + remoteMediaClient: RemoteMediaClient, + mediaStatus: MediaStatus, + currentSong: Song?, + currentSongId: String?, + streamPosition: Long + ) { + val now = SystemClock.elapsedRealtime() + if ( + mediaStatus.playerState != MediaStatus.PLAYER_STATE_BUFFERING || + !lastRemotePlaybackShouldResume + ) { + resetRemoteBufferingWatchdog() + return + } + + if (remoteBufferingStartedAtMs == 0L) { + remoteBufferingStartedAtMs = now + remoteBufferingLastProgressAtMs = now + remoteBufferingLastProgressPositionMs = streamPosition + remoteBufferingRecoveryAttempts = 0 + lastRemoteBufferingLogAtMs = 0L + } else if (abs(streamPosition - remoteBufferingLastProgressPositionMs) >= remoteBufferingPositionToleranceMs) { + remoteBufferingLastProgressAtMs = now + remoteBufferingLastProgressPositionMs = streamPosition + } + + val totalBufferingMs = now - remoteBufferingStartedAtMs + val stalledMs = now - remoteBufferingLastProgressAtMs + if (now - lastRemoteBufferingLogAtMs >= remoteBufferingLogIntervalMs) { + lastRemoteBufferingLogAtMs = now + Log.w( + "PX_CAST_BUFFERING", + "stalledMs=$stalledMs totalMs=$totalBufferingMs attempts=$remoteBufferingRecoveryAttempts songId=$currentSongId itemId=${mediaStatus.currentItemId} pos=$streamPosition duration=${remoteMediaClient.streamDuration}" + ) + } + + if (remoteBufferingRecoveryJob?.isActive == true) return + + when { + remoteBufferingRecoveryAttempts == 0 && stalledMs >= remoteBufferingSoftRecoveryMs -> { + remoteBufferingRecoveryAttempts = 1 + remoteBufferingRecoveryJob = scope?.launch { + Log.w("PX_CAST_BUFFERING", "soft_recovery songId=$currentSongId pos=$streamPosition") + remoteMediaClient.requestStatus() + castStateHolder.castPlayer?.play() + delay(750L) + remoteMediaClient.requestStatus() + } + } + remoteBufferingRecoveryAttempts == 1 && + remoteBufferingReloadAttempts < 1 && + stalledMs >= remoteBufferingReloadMs -> { + remoteBufferingRecoveryAttempts = 2 + remoteBufferingReloadAttempts += 1 + remoteBufferingRecoveryJob = scope?.launch { + reloadCurrentRemoteItemAfterBuffering( + session = session, + currentSong = currentSong, + currentSongId = currentSongId, + streamPosition = streamPosition + ) + } + } + stalledMs >= remoteBufferingTransferBackMs -> { + scheduleCastSessionTransferBack( + session = session, + songId = currentSongId, + reason = "buffering_timeout", + message = "Cast stayed stuck loading. Resuming on this device." + ) + } + } + } + + private suspend fun reloadCurrentRemoteItemAfterBuffering( + session: CastSession, + currentSong: Song?, + currentSongId: String?, + streamPosition: Long + ) { + val castPlayer = castStateHolder.castPlayer ?: run { + scheduleCastSessionTransferBack( + session = session, + songId = currentSongId, + reason = "buffering_no_cast_player", + message = "Cast stayed stuck loading. Resuming on this device." + ) + return + } + val queue = (getCurrentQueue?.invoke().orEmpty().takeIf { it.isNotEmpty() } ?: lastRemoteQueue) + val startSong = currentSong + ?: currentSongId?.let { songId -> queue.firstOrNull { it.id == songId } } + ?: lastRemoteSongId?.let { songId -> queue.firstOrNull { it.id == songId } } + + if (queue.isEmpty() || startSong == null) { + scheduleCastSessionTransferBack( + session = session, + songId = currentSongId, + reason = "buffering_missing_queue", + message = "Cast stayed stuck loading. Resuming on this device." + ) + return + } + + val castDeviceIpHint = resolveCastDeviceIp(session) + if (!ensureHttpServerRunning(castDeviceIpHint)) { + scheduleCastSessionTransferBack( + session = session, + songId = startSong.id, + reason = "buffering_server_unavailable", + message = "Cast stayed stuck loading. Resuming on this device." + ) + return + } + + val serverAddress = MediaFileHttpServerService.serverAddress + if (serverAddress == null) { + scheduleCastSessionTransferBack( + session = session, + songId = startSong.id, + reason = "buffering_missing_server_address", + message = "Cast stayed stuck loading. Resuming on this device." + ) + return + } + + val startIndex = queue.indexOfFirst { it.id == startSong.id }.coerceAtLeast(0) + val resumePosition = streamPosition + .takeIf { it > 0L } + ?: lastRemoteStreamPosition.takeIf { it > 0L } + ?: castStateHolder.remotePosition.value.coerceAtLeast(0L) + val accessPolicy = MediaFileHttpServerService.configureCastSessionAccess( + allowedSongIds = queue.map(Song::id), + castDeviceIpHint = castDeviceIpHint + ) + + Log.w( + "PX_CAST_BUFFERING", + "reload_current songId=${startSong.id} startIndex=$startIndex pos=$resumePosition queueSize=${queue.size}" + ) + resetRemoteBufferingWatchdog(clearReloadAttempts = false) + castPlayer.loadQueue( + songs = queue, + startIndex = startIndex, + startPosition = resumePosition, + repeatMode = lastRemoteRepeatMode, + serverAddress = serverAddress, + authToken = accessPolicy.authToken, + autoPlay = true, + onComplete = { success, detail -> + if (success) { + lastRemoteQueue = queue + lastRemoteSongId = startSong.id + lastRemoteStreamPosition = resumePosition + lastRemotePlaybackShouldResume = true + playbackStateHolder.startProgressUpdates() + session.remoteMediaClient?.requestStatus() + launchAlignToTarget(startSong.id) + } else { + Timber.tag(CAST_LOG_TAG).w( + "Failed to reload stuck Cast item. songId=%s detail=%s", + startSong.id, + detail + ) + scheduleCastSessionTransferBack( + session = session, + songId = startSong.id, + reason = "buffering_reload_failed", + message = "Cast stayed stuck loading. Resuming on this device." + ) + } + } + ) + } + + private fun resetRemoteBufferingWatchdog(clearReloadAttempts: Boolean = true) { + remoteBufferingStartedAtMs = 0L + remoteBufferingLastProgressAtMs = 0L + remoteBufferingLastProgressPositionMs = 0L + remoteBufferingRecoveryAttempts = 0 + if (clearReloadAttempts) { + remoteBufferingReloadAttempts = 0 + } + lastRemoteBufferingLogAtMs = 0L + } + private fun resolveCastDeviceIp(session: CastSession?): String? { val castDevice = session?.castDevice ?: return null return normalizeHostAddress(runCatching { castDevice.inetAddress }.getOrNull()) @@ -823,6 +1061,9 @@ class CastTransferStateHolder @Inject constructor( alignToTargetJob?.cancel() remoteProgressObserverJob?.cancel() remoteStatusRefreshJob?.cancel() + remoteBufferingRecoveryJob?.cancel() + resetRemoteBufferingWatchdog() + castErrorRecoveryJob?.cancel() castStateHolder.setRemotelySeeking(false) val shouldSkipTransferBack = skipTransferBackOnNextSessionEnd skipTransferBackOnNextSessionEnd = false @@ -953,6 +1194,8 @@ class CastTransferStateHolder @Inject constructor( lastRemoteQueue = emptyList() lastRemoteSongId = null lastRemoteStreamPosition = 0L + lastKnownRemoteIsPlaying = false + lastRemotePlaybackShouldResume = false onTransferBackComplete?.invoke() } @@ -1156,6 +1399,8 @@ class CastTransferStateHolder @Inject constructor( lastRemoteSongId = startSong.id lastRemoteStreamPosition = 0L lastRemoteRepeatMode = castRepeatMode + lastKnownRemoteIsPlaying = true + lastRemotePlaybackShouldResume = true castStateHolder.setRemotePlaybackActive(true) playbackStateHolder.startProgressUpdates() castStateHolder.castSession.value?.remoteMediaClient?.requestStatus() @@ -1178,6 +1423,7 @@ class CastTransferStateHolder @Inject constructor( lastPendingForceJumpAt = 0L lastRemoteSongId = song.id lastRemoteItemId = null + lastRemotePlaybackShouldResume = true Timber.tag(CAST_LOG_TAG).d("Marked pending remote song: %s", song.id) val songChanged = playbackStateHolder.stablePlayerState.value.currentSong?.id != song.id @@ -1292,6 +1538,9 @@ class CastTransferStateHolder @Inject constructor( remoteStatusRefreshJob?.cancel() sessionSuspendedRecoveryJob?.cancel() alignToTargetJob?.cancel() + castErrorRecoveryJob?.cancel() + remoteBufferingRecoveryJob?.cancel() + resetRemoteBufferingWatchdog() // Unregister Cast session manager listener castSessionManagerListener?.let { listener -> 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 ffc3fc3ee..35b4bb37f 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 @@ -24,6 +24,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.Job import kotlinx.coroutines.withContext import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.service.cast.CastRemotePlaybackState import com.google.android.gms.cast.MediaStatus import timber.log.Timber import com.theveloper.pixelplay.utils.QueueUtils @@ -61,9 +62,11 @@ class PlaybackStateHolder @Inject constructor( */ private const val BULK_REPLACE_THRESHOLD = 80 private const val SHUFFLE_TOGGLE_COOLDOWN_MS = 400L + private const val CAST_SEEK_BLOCKED_TOAST_COOLDOWN_MS = 2500L } private var scope: CoroutineScope? = null + private var onCastSeekBlocked: (() -> Unit)? = null // MediaController var mediaController: MediaController? = null @@ -91,6 +94,7 @@ class PlaybackStateHolder @Inject constructor( private var coldStartSnapshotPositionMs: Long? = null private var shuffleToggleJob: Job? = null private var lastShuffleToggleFinishedAtMs: Long = 0L + private var lastCastSeekBlockedToastAtMs: Long = 0L private val powerManager: PowerManager by lazy(LazyThreadSafetyMode.NONE) { appContext.getSystemService(Context.POWER_SERVICE) as PowerManager } @@ -130,8 +134,12 @@ class PlaybackStateHolder @Inject constructor( return false } - fun initialize(coroutineScope: CoroutineScope) { + fun initialize( + coroutineScope: CoroutineScope, + onCastSeekBlocked: (() -> Unit)? = null + ) { this.scope = coroutineScope + this.onCastSeekBlocked = onCastSeekBlocked scope?.launch { val snapshot = runCatching { userPreferencesRepository.getPlaybackQueueSnapshotOnce() @@ -179,6 +187,19 @@ class PlaybackStateHolder @Inject constructor( } } + private fun notifyCastSeekBlocked() { + val nowMs = SystemClock.elapsedRealtime() + if ( + lastCastSeekBlockedToastAtMs > 0L && + nowMs - lastCastSeekBlockedToastAtMs < CAST_SEEK_BLOCKED_TOAST_COOLDOWN_MS + ) { + return + } + + lastCastSeekBlockedToastAtMs = nowMs + onCastSeekBlocked?.invoke() + } + private fun activeLocalPlayer(): Player { val controller = mediaController return if (controller?.isConnected == true) { @@ -358,12 +379,19 @@ class PlaybackStateHolder @Inject constructor( val remoteMediaClient = castSession?.remoteMediaClient if (castSession != null && remoteMediaClient != null) { - if (remoteMediaClient.isPlaying) { + val remotePlayback = remoteMediaClient.mediaStatus?.let { mediaStatus -> + CastRemotePlaybackState.project( + mediaStatus = mediaStatus, + previousPlayIntent = _stablePlayerState.value.playWhenReady + ) + } + if (remoteMediaClient.isPlaying || remotePlayback?.playWhenReady == true) { castStateHolder.castPlayer?.pause() _stablePlayerState.update { it.copy( isPlaying = false, - playWhenReady = false + playWhenReady = false, + isBuffering = false ) } } else { @@ -396,10 +424,26 @@ class PlaybackStateHolder @Inject constructor( val castSession = castStateHolder.castSession.value if (castSession != null && castSession.remoteMediaClient != null) { val targetPosition = position.coerceAtLeast(0L) + val castPlayer = castStateHolder.castPlayer + if (castPlayer?.canSeekCurrentItem() == false) { + remoteSeekUnlockJob?.cancel() + castStateHolder.setRemotelySeeking(false) + castSession.remoteMediaClient?.requestStatus() + notifyCastSeekBlocked() + Timber.tag(TAG).w("Ignoring Cast seek for current item because receiver-side Ogg seeking is unstable.") + return + } castStateHolder.setRemotelySeeking(true) castStateHolder.setRemotePosition(targetPosition) setCurrentPosition(targetPosition) - castStateHolder.castPlayer?.seek(targetPosition) + if (castPlayer?.seek(targetPosition) != true) { + castStateHolder.setRemotelySeeking(false) + castSession.remoteMediaClient?.requestStatus() + if (castPlayer != null) { + notifyCastSeekBlocked() + } + return + } remoteSeekUnlockJob?.cancel() remoteSeekUnlockJob = scope?.launch { @@ -567,43 +611,52 @@ class PlaybackStateHolder @Inject constructor( while (true) { val tickMs = currentProgressTickMs() val castSession = castStateHolder.castSession.value - val isRemote = castSession?.remoteMediaClient != null + val remoteClient = castSession?.remoteMediaClient + val isRemote = remoteClient != null if (isRemote) { - val remoteClient = castSession?.remoteMediaClient - if (remoteClient != null) { - val isRemotePlaying = remoteClient.isPlaying - val currentPosition = remoteClient.approximateStreamPosition.coerceAtLeast(0L) - val songDurationHint = _stablePlayerState.value.currentSong?.duration ?: 0L - val duration = resolveEffectiveDuration( - reportedDurationMs = remoteClient.streamDuration, - songDurationHintMs = songDurationHint, - currentPositionMs = currentPosition + val activeRemoteClient = checkNotNull(remoteClient) + val previousPlayIntent = _stablePlayerState.value.playWhenReady + val remotePlayback = activeRemoteClient.mediaStatus?.let { mediaStatus -> + CastRemotePlaybackState.project( + mediaStatus = mediaStatus, + previousPlayIntent = previousPlayIntent ) - val isRemotelySeeking = castStateHolder.isRemotelySeeking.value - if (!isRemotelySeeking) { - castStateHolder.setRemotePosition(currentPosition) - } + } + val isRemotePlaying = remotePlayback?.isPlaying ?: activeRemoteClient.isPlaying + val remotePlayWhenReady = remotePlayback?.playWhenReady ?: activeRemoteClient.isPlaying + val currentPosition = activeRemoteClient.approximateStreamPosition.coerceAtLeast(0L) + val songDurationHint = _stablePlayerState.value.currentSong?.duration ?: 0L + val duration = resolveEffectiveDuration( + reportedDurationMs = activeRemoteClient.streamDuration, + songDurationHintMs = songDurationHint, + currentPositionMs = currentPosition + ) + val isRemotelySeeking = castStateHolder.isRemotelySeeking.value + if (!isRemotelySeeking) { + castStateHolder.setRemotePosition(currentPosition) + } - val nextPosition = if (isRemotelySeeking) _currentPosition.value else currentPosition - if (_currentPosition.value != nextPosition) { - _currentPosition.value = nextPosition - } + val nextPosition = if (isRemotelySeeking) _currentPosition.value else currentPosition + if (_currentPosition.value != nextPosition) { + _currentPosition.value = nextPosition + } - _stablePlayerState.update { state -> - if ( - state.totalDuration == duration && - state.isPlaying == isRemotePlaying && - state.playWhenReady == isRemotePlaying - ) { - state - } else { - state.copy( - totalDuration = duration, - isPlaying = isRemotePlaying, - playWhenReady = isRemotePlaying - ) - } + _stablePlayerState.update { state -> + if ( + state.totalDuration == duration && + state.isPlaying == isRemotePlaying && + state.playWhenReady == remotePlayWhenReady && + state.isBuffering == (remotePlayback?.isBuffering ?: false) + ) { + state + } else { + state.copy( + totalDuration = duration, + isPlaying = isRemotePlaying, + playWhenReady = remotePlayWhenReady, + isBuffering = remotePlayback?.isBuffering ?: false + ) } } } else { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 2a2a8f188..e5da21076 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -68,6 +68,7 @@ import com.theveloper.pixelplay.data.repository.LyricsSearchResult import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.data.service.MusicNotificationProvider import com.theveloper.pixelplay.data.service.MusicService +import com.theveloper.pixelplay.data.service.cast.CastRemotePlaybackState import com.theveloper.pixelplay.data.service.player.CastPlayer import com.theveloper.pixelplay.data.service.http.MediaFileHttpServerService import com.theveloper.pixelplay.data.service.player.DualPlayerEngine @@ -857,11 +858,15 @@ class PlayerViewModel @Inject constructor( } private fun cancelPendingDirectPlayback() { + cancelPendingDirectPlaybackBuild() + pendingQueueSegmentsJob?.cancel() + pendingQueueSegmentsJob = null + } + + private fun cancelPendingDirectPlaybackBuild() { directPlaybackToken += 1L directPlaybackJob?.cancel() directPlaybackJob = null - pendingQueueSegmentsJob?.cancel() - pendingQueueSegmentsJob = null } private fun throwIfDirectPlaybackRequestIsStale(requestToken: Long) { @@ -966,7 +971,12 @@ class PlayerViewModel @Inject constructor( listeningStatsTracker.initialize(viewModelScope) dailyMixStateHolder.initialize(viewModelScope) lyricsStateHolder.initialize(viewModelScope, lyricsLoadCallback, playbackStateHolder.stablePlayerState) - playbackStateHolder.initialize(viewModelScope) + playbackStateHolder.initialize( + coroutineScope = viewModelScope, + onCastSeekBlocked = { + sendToast(context.getString(R.string.cast_seek_unavailable_for_format)) + } + ) themeStateHolder.initialize(viewModelScope) // On cold start, the MediaController connects asynchronously, leaving stablePlayerState.currentSong @@ -2237,29 +2247,34 @@ class PlayerViewModel @Inject constructor( } return } // Local playback logic - mediaController?.let { controller -> - val currentQueue = _playerUiState.value.currentPlaybackQueue - val songIndexInQueue = currentQueue.indexOfFirst { it.id == song.id } - val queueMatchesContext = currentQueue.matchesSongOrder(playbackContext) - - if (songIndexInQueue != -1 && queueMatchesContext) { - cancelPendingDirectPlayback() - if (controller.currentMediaItemIndex == songIndexInQueue) { - if (!controller.isPlaying) controller.play() - } else { - controller.seekTo(songIndexInQueue, 0L) - controller.play() - } - if (isVoluntaryPlay) { - incrementSongScore(song) - if (playlistId != null && queueName != "None") { - appShortcutManager.updateLastPlaylistShortcut(playlistId, queueName) - } + val controller = mediaController + val currentQueue = _playerUiState.value.currentPlaybackQueue + val songIndexInQueue = currentQueue.indexOfFirst { it.id == song.id } + val queueMatchesContext = currentQueue.matchesSongOrder(playbackContext) + val reusableTargetIndex = if ( + controller != null && + controller.isConnected && + !dualPlayerEngine.isTransitionRunning() && + songIndexInQueue != -1 && + queueMatchesContext + ) { + controller.resolveReusablePlaybackTargetIndex(songIndexInQueue, song.id) + } else { + null + } + + if (controller != null && reusableTargetIndex != null) { + cancelPendingDirectPlaybackBuild() + playLoadedControllerItem(controller, reusableTargetIndex) + if (isVoluntaryPlay) { + incrementSongScore(song) + if (playlistId != null && queueName != "None") { + appShortcutManager.updateLastPlaylistShortcut(playlistId, queueName) } - } else { - if (isVoluntaryPlay) incrementSongScore(song) - playSongs(playbackContext, song, queueName, playlistId) } + } else { + if (isVoluntaryPlay) incrementSongScore(song) + playSongs(playbackContext, song, queueName, playlistId) } resetPredictiveBackState() } @@ -2282,6 +2297,34 @@ class PlayerViewModel @Inject constructor( return indices.all { this[it].id == contextSongs[it].id } } + private fun MediaController.resolveReusablePlaybackTargetIndex( + songIndexInQueue: Int, + songId: String + ): Int? { + currentMediaItem?.takeIf { it.mediaId == songId }?.let { + return currentMediaItemIndex.takeIf { index -> index != C.INDEX_UNSET } ?: 0 + } + + if (songIndexInQueue !in 0 until mediaItemCount) return null + + val mediaIdAtTarget = runCatching { getMediaItemAt(songIndexInQueue).mediaId }.getOrNull() + return songIndexInQueue.takeIf { mediaIdAtTarget == songId } + } + + private fun playLoadedControllerItem(controller: MediaController, targetIndex: Int) { + val shouldSeekToStart = + controller.currentMediaItemIndex != targetIndex || + controller.playbackState == Player.STATE_ENDED + + if (shouldSeekToStart) { + controller.seekTo(targetIndex, 0L) + } + if (controller.playbackState == Player.STATE_IDLE && controller.mediaItemCount > 0) { + controller.prepare() + } + controller.play() + } + private fun Song.requiresHydration(): Boolean { return contentUriString.isBlank() } @@ -3400,6 +3443,7 @@ class PlayerViewModel @Inject constructor( val playSongsAction = { // Use Direct Engine Access to avoid TransactionTooLargeException on Binder + dualPlayerEngine.cancelNext() val enginePlayer = dualPlayerEngine.masterPlayer enginePlayer.setMediaItem(startMediaItem, 0L) @@ -4214,12 +4258,19 @@ class PlayerViewModel @Inject constructor( val castSession = castStateHolder.castSession.value if (castSession != null && castSession.remoteMediaClient != null) { val remoteMediaClient = castSession.remoteMediaClient!! - if (remoteMediaClient.isPlaying) { + val remotePlayback = remoteMediaClient.mediaStatus?.let { mediaStatus -> + CastRemotePlaybackState.project( + mediaStatus = mediaStatus, + previousPlayIntent = playbackStateHolder.stablePlayerState.value.playWhenReady + ) + } + if (remoteMediaClient.isPlaying || remotePlayback?.playWhenReady == true) { castStateHolder.castPlayer?.pause() playbackStateHolder.updateStablePlayerState { it.copy( isPlaying = false, - playWhenReady = false + playWhenReady = false, + isBuffering = false ) } } else { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongInfoBottomSheetViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongInfoBottomSheetViewModel.kt index 15ba6a6b8..723a39e07 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongInfoBottomSheetViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongInfoBottomSheetViewModel.kt @@ -1,7 +1,18 @@ package com.theveloper.pixelplay.presentation.viewmodel +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.media.MediaScannerConnection +import android.media.RingtoneManager +import android.net.Uri +import android.provider.MediaStore +import android.provider.Settings +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.database.MusicDao import com.theveloper.pixelplay.data.database.toArtist import com.theveloper.pixelplay.data.model.Artist @@ -12,9 +23,11 @@ import com.theveloper.pixelplay.data.service.wear.WearPhoneTransferSender import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.utils.AudioMeta import com.theveloper.pixelplay.utils.AudioMetaUtils +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.lifecycle.HiltViewModel import java.io.File import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -25,12 +38,16 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume @HiltViewModel class SongInfoBottomSheetViewModel @Inject constructor( private val wearPhoneTransferSender: WearPhoneTransferSender, private val transferStateStore: PhoneWatchTransferStateStore, private val musicDao: MusicDao, + @ApplicationContext private val appContext: Context, ) : ViewModel() { data class SongLocationInfo( @@ -39,6 +56,18 @@ class SongInfoBottomSheetViewModel @Inject constructor( val isCloud: Boolean, ) + enum class ToneTarget { + Ringtone, + Notification, + Alarm, + } + + sealed interface ToneActionResult { + data class Success(val message: String) : ToneActionResult + data class NeedsSystemWritePermission(val message: String) : ToneActionResult + data class Error(val message: String) : ToneActionResult + } + private val _audioMeta = MutableStateFlow(null) private val _resolvedArtists = MutableStateFlow>(emptyList()) val resolvedArtists: StateFlow> = _resolvedArtists.asStateFlow() @@ -195,6 +224,25 @@ class SongInfoBottomSheetViewModel @Inject constructor( } } + fun hasSystemWritePermission(): Boolean { + return Settings.System.canWrite(appContext) + } + + fun createSystemWriteSettingsIntent(): Intent { + return Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { + data = Uri.parse("package:${appContext.packageName}") + } + } + + fun setSongAsTone(song: Song, target: ToneTarget, onComplete: (ToneActionResult) -> Unit) { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + setSongAsToneInternal(song, target) + } + onComplete(result) + } + } + fun cancelWatchTransfer(requestId: String) { if (requestId.isBlank()) return viewModelScope.launch { @@ -216,4 +264,152 @@ class SongInfoBottomSheetViewModel @Inject constructor( else -> null } } + + private suspend fun setSongAsToneInternal(song: Song, target: ToneTarget): ToneActionResult { + if (getCloudProviderLabel(song.contentUriString) != null) { + return ToneActionResult.Error( + appContext.getString(R.string.song_info_ringtone_local_only) + ) + } + + val ringtoneUri = runCatching { resolveMediaStoreAudioUri(song) }.getOrNull() + ?: return ToneActionResult.Error( + appContext.getString(R.string.song_info_ringtone_missing_file) + ) + + if (!Settings.System.canWrite(appContext)) { + return ToneActionResult.NeedsSystemWritePermission( + appContext.getString(R.string.song_info_ringtone_permission_prompt) + ) + } + + return runCatching { + markAsToneCandidate(ringtoneUri, target) + RingtoneManager.setActualDefaultRingtoneUri( + appContext, + target.ringtoneManagerType, + ringtoneUri, + ) + ToneActionResult.Success( + appContext.getString( + R.string.song_info_tone_success, + song.title, + appContext.getString(target.successLabelResId), + ) + ) + }.getOrElse { throwable -> + ToneActionResult.Error( + appContext.getString( + R.string.song_info_ringtone_failed, + throwable.localizedMessage ?: throwable.javaClass.simpleName + ) + ) + } + } + + private suspend fun resolveMediaStoreAudioUri(song: Song): Uri? { + song.id.toLongOrNull() + ?.takeIf { it > 0L } + ?.let { id -> + ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id) + } + ?.takeIf(::mediaStoreAudioExists) + ?.let { return it } + + song.contentUriString + .takeIf { it.startsWith("content://") } + ?.toUri() + ?.takeIf { it.authority == MediaStore.AUTHORITY } + ?.let { return it } + + findMediaStoreAudioUriByPath(song.path)?.let { return it } + + val file = File(song.path) + if (!file.exists()) return null + + return scanAudioFile(file, song.mimeType) + ?.takeIf { it.authority == MediaStore.AUTHORITY } + ?: findMediaStoreAudioUriByPath(song.path) + } + + private fun findMediaStoreAudioUriByPath(path: String): Uri? { + if (path.isBlank()) return null + val projection = arrayOf(MediaStore.Audio.Media._ID) + val selection = "${MediaStore.Audio.Media.DATA} = ?" + val selectionArgs = arrayOf(path) + + return runCatching { + appContext.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + null, + )?.use { cursor -> + if (!cursor.moveToFirst()) { + null + } else { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)) + ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id) + } + } + }.getOrNull() + } + + private suspend fun scanAudioFile(file: File, mimeType: String?): Uri? = + suspendCancellableCoroutine { continuation -> + val mimeTypes = mimeType + ?.takeIf { it.isNotBlank() } + ?.let { arrayOf(it) } + MediaScannerConnection.scanFile( + appContext, + arrayOf(file.absolutePath), + mimeTypes, + ) { _, uri -> + if (continuation.isActive) { + continuation.resume(uri) + } + } + } + + private fun mediaStoreAudioExists(uri: Uri): Boolean { + return runCatching { + appContext.contentResolver.query( + uri, + arrayOf(MediaStore.Audio.Media._ID), + null, + null, + null, + )?.use { cursor -> + cursor.moveToFirst() + } == true + }.getOrDefault(false) + } + + private fun markAsToneCandidate(uri: Uri, target: ToneTarget) { + runCatching { + val values = ContentValues().apply { + when (target) { + ToneTarget.Ringtone -> put(MediaStore.Audio.Media.IS_RINGTONE, true) + ToneTarget.Notification -> put(MediaStore.Audio.Media.IS_NOTIFICATION, true) + ToneTarget.Alarm -> put(MediaStore.Audio.Media.IS_ALARM, true) + } + } + appContext.contentResolver.update(uri, values, null, null) + } + } + + private val ToneTarget.ringtoneManagerType: Int + get() = when (this) { + ToneTarget.Ringtone -> RingtoneManager.TYPE_RINGTONE + ToneTarget.Notification -> RingtoneManager.TYPE_NOTIFICATION + ToneTarget.Alarm -> RingtoneManager.TYPE_ALARM + } + + private val ToneTarget.successLabelResId: Int + get() = when (this) { + ToneTarget.Ringtone -> R.string.song_info_tone_ringtone_label + ToneTarget.Notification -> R.string.song_info_tone_notification_label + ToneTarget.Alarm -> R.string.song_info_tone_alarm_label + } } diff --git a/app/src/main/res/values-de/strings_components.xml b/app/src/main/res/values-de/strings_components.xml index e109fa39e..33d69b150 100644 --- a/app/src/main/res/values-de/strings_components.xml +++ b/app/src/main/res/values-de/strings_components.xml @@ -103,6 +103,32 @@ Uhr nicht verfügbar Song an Uhr senden Uhr nicht verfügbar + Als + Als Sound festlegen + Auswählen, wie dieser Song als Systemsound verwendet wird + Als Klingelton festlegen + Song als Klingelton festlegen + Diesen Song verwenden als + Wähle, wo PixelPlayer diesen Sound installieren soll. + Telefonklingelton + Eingehende Anrufe + Benachrichtigungston + Nachrichten und App-Hinweise + Weckton + Wecker + Soundänderung bestätigen + \"%1$s\" als %2$s festlegen? + Sound festlegen + \"%1$s\" wurde als %2$s festgelegt + Klingelton + Benachrichtigungston + Weckton + Aktiviere Systemeinstellungen ändern und kehre zu PixelPlayer zurück, um automatisch fortzufahren. + Systemeinstellungen ändern wurde nicht aktiviert. + \"%1$s\" wurde als Klingelton festgelegt + Nur lokale Songs können als Systemsounds verwendet werden. + Diese Audiodatei konnte nicht vorbereitet werden. + Sound konnte nicht geändert werden: %1$s Dauer Song-Info Dauer diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 37444cb99..d1f14b7a0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -121,6 +121,7 @@ Controles completos con aleatorio y repetición Reproductor cuadrado minimalista Procesando acción de reproducción… + Por ahora no se puede mover la barra de progreso con este formato en Cast porque puede cerrar la sesión. %1$d minutos Fin de pista diff --git a/app/src/main/res/values-es/strings_components.xml b/app/src/main/res/values-es/strings_components.xml index 860e34c1c..b796550e7 100644 --- a/app/src/main/res/values-es/strings_components.xml +++ b/app/src/main/res/values-es/strings_components.xml @@ -101,6 +101,32 @@ Reloj no disponible Enviar canción al reloj Reloj no disponible + Usar como + Usar como sonido + Elegir cómo usar esta canción como sonido del sistema + Usar como tono + Usar canción como tono + Usar esta canción como + Elige dónde PixelPlayer debe instalar este sonido. + Tono de llamada + Llamadas entrantes + Sonido de notificación + Mensajes y alertas de apps + Sonido de alarma + Alarmas del reloj + Confirmar cambio de sonido + ¿Usar \"%1$s\" como %2$s? + Usar sonido + \"%1$s\" se estableció como %2$s + tono de llamada + sonido de notificación + sonido de alarma + Activa Modificar ajustes del sistema y vuelve a PixelPlayer para terminar automáticamente. + No se activó Modificar ajustes del sistema. + \"%1$s\" se estableció como tono de llamada + Solo las canciones locales se pueden usar como sonidos del sistema. + No se pudo preparar este archivo de audio. + No se pudo cambiar el sonido: %1$s Duración Información de la canción Duración diff --git a/app/src/main/res/values-fr/strings_components.xml b/app/src/main/res/values-fr/strings_components.xml index 4c600584c..a38820bc1 100644 --- a/app/src/main/res/values-fr/strings_components.xml +++ b/app/src/main/res/values-fr/strings_components.xml @@ -103,6 +103,32 @@ Montre non disponible Envoyer la chanson à la montre Montre non disponible + Définir + Définir comme son + Choisir comment utiliser cette chanson comme son système + Définir comme sonnerie + Définir la chanson comme sonnerie + Utiliser cette chanson comme + Choisissez où PixelPlayer doit installer ce son. + Sonnerie du téléphone + Appels entrants + Son de notification + Messages et alertes d\'apps + Son d\'alarme + Alarmes de l\'horloge + Confirmer le changement + Définir \"%1$s\" comme %2$s ? + Définir le son + \"%1$s\" a été défini comme %2$s + sonnerie + son de notification + son d\'alarme + Activez Modifier les paramètres système, puis revenez à PixelPlayer pour terminer automatiquement. + Modifier les paramètres système n\'a pas été activé. + \"%1$s\" a été défini comme sonnerie + Seules les chansons locales peuvent être utilisées comme sons système. + Impossible de préparer ce fichier audio. + Impossible de changer le son : %1$s Durée Infos chanson Durée diff --git a/app/src/main/res/values-in/strings_components.xml b/app/src/main/res/values-in/strings_components.xml index 70f0c06c4..72aa95dfe 100644 --- a/app/src/main/res/values-in/strings_components.xml +++ b/app/src/main/res/values-in/strings_components.xml @@ -103,6 +103,32 @@ Jam tangan tidak tersedia Kirim lagu ke jam tangan Jam tangan tidak tersedia + Jadikan + Jadikan suara + Pilih cara memakai lagu ini sebagai suara sistem + Jadikan nada dering + Jadikan lagu sebagai nada dering + Gunakan lagu ini sebagai + Pilih tempat PixelPlayer memasang suara ini. + Nada dering telepon + Panggilan masuk + Suara notifikasi + Pesan dan peringatan aplikasi + Suara alarm + Alarm jam + Konfirmasi perubahan suara + Jadikan \"%1$s\" sebagai %2$s? + Atur suara + \"%1$s\" ditetapkan sebagai %2$s + nada dering + suara notifikasi + suara alarm + Aktifkan Ubah setelan sistem, lalu kembali ke PixelPlayer untuk menyelesaikan otomatis. + Ubah setelan sistem belum diaktifkan. + \"%1$s\" ditetapkan sebagai nada dering + Hanya lagu lokal yang dapat digunakan sebagai suara sistem. + Tidak dapat menyiapkan file audio ini. + Tidak dapat mengubah suara: %1$s Durasi Info lagu Durasi diff --git a/app/src/main/res/values-it/strings_components.xml b/app/src/main/res/values-it/strings_components.xml index 41b2d1fdf..d01c5f1a6 100644 --- a/app/src/main/res/values-it/strings_components.xml +++ b/app/src/main/res/values-it/strings_components.xml @@ -103,6 +103,32 @@ Orologio non disponibile Invia brano all\'orologio Orologio non disponibile + Imposta + Imposta come suono + Scegli come usare questo brano come suono di sistema + Imposta come suoneria + Imposta brano come suoneria + Usa questo brano come + Scegli dove PixelPlayer deve installare questo suono. + Suoneria telefono + Chiamate in arrivo + Suono notifiche + Messaggi e avvisi app + Suono sveglia + Sveglie dell\'orologio + Conferma cambio suono + Impostare \"%1$s\" come %2$s? + Imposta suono + \"%1$s\" impostato come %2$s + suoneria + suono notifiche + suono sveglia + Attiva Modifica impostazioni di sistema, poi torna a PixelPlayer per completare automaticamente. + Modifica impostazioni di sistema non attivata. + \"%1$s\" impostato come suoneria + Solo i brani locali possono essere usati come suoni di sistema. + Impossibile preparare questo file audio. + Impossibile cambiare suono: %1$s Durata Info brano Durata @@ -158,4 +184,4 @@ Maniglia trascinamento Reimposta Fatto - \ No newline at end of file + diff --git a/app/src/main/res/values-ko/strings_components.xml b/app/src/main/res/values-ko/strings_components.xml index bbf76cfba..f43833753 100644 --- a/app/src/main/res/values-ko/strings_components.xml +++ b/app/src/main/res/values-ko/strings_components.xml @@ -103,6 +103,32 @@ 워치 사용 불가 워치로 곡 전송 워치 사용 불가 + 설정 + 사운드로 설정 + 이 곡을 시스템 사운드로 사용할 방식을 선택 + 벨소리로 설정 + 곡을 벨소리로 설정 + 이 곡 사용 + PixelPlayer가 이 사운드를 설치할 위치를 선택하세요. + 휴대전화 벨소리 + 수신 전화 + 알림음 + 메시지 및 앱 알림 + 알람음 + 시계 알람 + 사운드 변경 확인 + \"%1$s\"을(를) %2$s(으)로 설정할까요? + 사운드 설정 + \"%1$s\"이(가) %2$s(으)로 설정되었습니다 + 벨소리 + 알림음 + 알람음 + 시스템 설정 수정을 켠 다음 PixelPlayer로 돌아오면 자동으로 완료됩니다. + 시스템 설정 수정이 켜지지 않았습니다. + \"%1$s\"이(가) 벨소리로 설정되었습니다 + 로컬 곡만 시스템 사운드로 사용할 수 있습니다. + 이 오디오 파일을 준비할 수 없습니다. + 사운드를 변경할 수 없습니다: %1$s 지속 시간 곡 정보 지속 시간 diff --git a/app/src/main/res/values-nb/strings_components.xml b/app/src/main/res/values-nb/strings_components.xml index 3952f2e06..742101b01 100644 --- a/app/src/main/res/values-nb/strings_components.xml +++ b/app/src/main/res/values-nb/strings_components.xml @@ -103,6 +103,32 @@ Klokke utilgjengelig Send sang til klokke Klokke utilgjengelig + Bruk som + Bruk som lyd + Velg hvordan denne sangen skal brukes som systemlyd + Bruk som ringetone + Bruk sang som ringetone + Bruk denne sangen som + Velg hvor PixelPlayer skal installere denne lyden. + Ringetone + Innkommende anrop + Varsellyd + Meldinger og appvarsler + Alarmlyd + Klokkealarmer + Bekreft lydendring + Bruke \"%1$s\" som %2$s? + Bruk lyd + \"%1$s\" er brukt som %2$s + ringetone + varsellyd + alarmlyd + Aktiver Endre systeminnstillinger, og gå tilbake til PixelPlayer for å fullføre automatisk. + Endre systeminnstillinger ble ikke aktivert. + \"%1$s\" er brukt som ringetone + Bare lokale sanger kan brukes som systemlyder. + Kunne ikke klargjøre denne lydfilen. + Kunne ikke endre lyd: %1$s Varighet Sanginfo Varighet diff --git a/app/src/main/res/values-ru/strings_components.xml b/app/src/main/res/values-ru/strings_components.xml index 8f9d051ee..aa59d00e3 100644 --- a/app/src/main/res/values-ru/strings_components.xml +++ b/app/src/main/res/values-ru/strings_components.xml @@ -103,6 +103,32 @@ Часы недоступны Отправить на часы Часы недоступны + Задать + Задать как звук + Выберите, как использовать эту песню как системный звук + Задать как рингтон + Задать песню как рингтон + Использовать песню как + Выберите, куда PixelPlayer установит этот звук. + Рингтон телефона + Входящие вызовы + Звук уведомлений + Сообщения и уведомления приложений + Звук будильника + Будильники часов + Подтвердить смену звука + Задать \"%1$s\" как %2$s? + Задать звук + \"%1$s\" задан как %2$s + рингтон + звук уведомлений + звук будильника + Включите изменение системных настроек и вернитесь в PixelPlayer, чтобы завершить автоматически. + Изменение системных настроек не включено. + \"%1$s\" задан как рингтон + Только локальные песни можно использовать как системные звуки. + Не удалось подготовить этот аудиофайл. + Не удалось изменить звук: %1$s Длительность Инфо о песне Длительность diff --git a/app/src/main/res/values-zh-rCN/strings_components.xml b/app/src/main/res/values-zh-rCN/strings_components.xml index 150201e7d..946666f05 100644 --- a/app/src/main/res/values-zh-rCN/strings_components.xml +++ b/app/src/main/res/values-zh-rCN/strings_components.xml @@ -103,6 +103,32 @@ 手表不可用 将歌曲发送到手表 手表不可用 + 设为 + 设为声音 + 选择如何将这首歌用作系统声音 + 设为铃声 + 将歌曲设为铃声 + 将这首歌用作 + 选择 PixelPlayer 要安装此声音的位置。 + 手机铃声 + 来电 + 通知声音 + 消息和应用提醒 + 闹钟声音 + 时钟闹钟 + 确认声音更改 + 将“%1$s”设为%2$s? + 设置声音 + 已将“%1$s”设为%2$s + 铃声 + 通知声音 + 闹钟声音 + 开启修改系统设置,然后返回 PixelPlayer 自动完成。 + 未开启修改系统设置。 + 已将“%1$s”设为铃声 + 只有本地歌曲可用作系统声音。 + 无法准备此音频文件。 + 无法更改声音:%1$s 时长 歌曲信息 时长 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 835069cac..2b53febaf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -153,6 +153,7 @@ Casting to device Serving media to Cast device %1$s: %2$s + Seeking is temporarily unavailable for this audio format on Cast because it can crash the Cast session. Invalid backup: %1$s diff --git a/app/src/main/res/values/strings_components.xml b/app/src/main/res/values/strings_components.xml index 6105eff3b..1526891a9 100644 --- a/app/src/main/res/values/strings_components.xml +++ b/app/src/main/res/values/strings_components.xml @@ -105,6 +105,32 @@ Watch unavailable Send song to watch Watch unavailable + Set as + Set as sound + Choose how to use this song as a system sound + Set as ringtone + Set song as ringtone + Use this song as + Choose where PixelPlayer should install this sound. + Phone ringtone + Incoming calls + Notification sound + Messages and app alerts + Alarm sound + Clock alarms + Confirm sound change + Set \"%1$s\" as your %2$s? + Set sound + Set \"%1$s\" as your %2$s + ringtone + notification sound + alarm sound + Enable Modify system settings, then return to PixelPlayer to finish automatically. + Modify system settings was not enabled. + Set \"%1$s\" as your ringtone + Only local songs can be used as ringtones. + Couldn\'t prepare this audio file for ringtone. + Couldn\'t set ringtone: %1$s Duration Song info Duration diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/cast/CastRemotePlaybackStateTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/cast/CastRemotePlaybackStateTest.kt new file mode 100644 index 000000000..d46e8a2de --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/cast/CastRemotePlaybackStateTest.kt @@ -0,0 +1,60 @@ +package com.theveloper.pixelplay.data.service.cast + +import com.google.android.gms.cast.MediaStatus +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CastRemotePlaybackStateTest { + @Test + fun `buffering keeps playback active`() { + val projection = CastRemotePlaybackState.project( + playerState = MediaStatus.PLAYER_STATE_BUFFERING, + idleReason = MediaStatus.IDLE_REASON_NONE, + previousPlayIntent = false + ) + + assertTrue(projection.isPlaying) + assertTrue(projection.playWhenReady) + assertTrue(projection.isBuffering) + } + + @Test + fun `recoverable error preserves active playback intent`() { + val projection = CastRemotePlaybackState.project( + playerState = MediaStatus.PLAYER_STATE_IDLE, + idleReason = MediaStatus.IDLE_REASON_ERROR, + previousPlayIntent = true + ) + + assertTrue(projection.isPlaying) + assertTrue(projection.playWhenReady) + assertTrue(projection.isBuffering) + } + + @Test + fun `paused clears playback intent`() { + val projection = CastRemotePlaybackState.project( + playerState = MediaStatus.PLAYER_STATE_PAUSED, + idleReason = MediaStatus.IDLE_REASON_NONE, + previousPlayIntent = true + ) + + assertFalse(projection.isPlaying) + assertFalse(projection.playWhenReady) + assertFalse(projection.isBuffering) + } + + @Test + fun `finished idle clears playback intent`() { + val projection = CastRemotePlaybackState.project( + playerState = MediaStatus.PLAYER_STATE_IDLE, + idleReason = MediaStatus.IDLE_REASON_FINISHED, + previousPlayIntent = true + ) + + assertFalse(projection.isPlaying) + assertFalse(projection.playWhenReady) + assertFalse(projection.isBuffering) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetectorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetectorTest.kt new file mode 100644 index 000000000..85fad217d --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetectorTest.kt @@ -0,0 +1,106 @@ +package com.theveloper.pixelplay.data.service.cast + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class IsoBmffAudioCodecDetectorTest { + + @Test + fun detectAudioCodec_returnsAlacSampleEntry() { + val bytes = mp4WithTracks(audioTrack("alac")) + + val codec = IsoBmffAudioCodecDetector.detectAudioCodec(bytes) + + assertThat(codec).isEqualTo("audio/alac") + } + + @Test + fun detectAudioCodec_returnsAacSampleEntry() { + val bytes = mp4WithTracks(audioTrack("mp4a")) + + val codec = IsoBmffAudioCodecDetector.detectAudioCodec(bytes) + + assertThat(codec).isEqualTo("audio/mp4a-latm") + } + + @Test + fun detectAudioCodec_skipsVideoTracks() { + val bytes = mp4WithTracks( + track(handler = "vide", sampleEntry = "hvc1"), + audioTrack("mp4a") + ) + + val codec = IsoBmffAudioCodecDetector.detectAudioCodec(bytes) + + assertThat(codec).isEqualTo("audio/mp4a-latm") + } + + @Test + fun detectAudioCodec_returnsNullWithoutAudioTrack() { + val bytes = mp4WithTracks(track(handler = "vide", sampleEntry = "hvc1")) + + val codec = IsoBmffAudioCodecDetector.detectAudioCodec(bytes) + + assertThat(codec).isNull() + } + + private fun mp4WithTracks(vararg tracks: ByteArray): ByteArray { + return box("ftyp", "M4A ".encodeToByteArray()) + + box("moov", tracks.reduce(ByteArray::plus)) + } + + private fun audioTrack(sampleEntry: String): ByteArray = track( + handler = "soun", + sampleEntry = sampleEntry + ) + + private fun track(handler: String, sampleEntry: String): ByteArray { + return box( + "trak", + box( + "mdia", + hdlr(handler) + + box( + "minf", + box( + "stbl", + stsd(sampleEntry) + ) + ) + ) + ) + } + + private fun hdlr(handler: String): ByteArray { + return box( + "hdlr", + byteArrayOf(0, 0, 0, 0) + + byteArrayOf(0, 0, 0, 0) + + handler.encodeToByteArray() + ) + } + + private fun stsd(sampleEntry: String): ByteArray { + return box( + "stsd", + byteArrayOf(0, 0, 0, 0) + + intBytes(1) + + box(sampleEntry, ByteArray(8)) + ) + } + + private fun box(type: String, payload: ByteArray): ByteArray { + require(type.length == 4) + val size = 8 + payload.size + return intBytes(size) + type.encodeToByteArray() + payload + } + + private fun intBytes(value: Int): ByteArray { + return byteArrayOf( + (value ushr 24).toByte(), + (value ushr 16).toByte(), + (value ushr 8).toByte(), + value.toByte() + ) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/player/AudioFocusResumePolicyTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/player/AudioFocusResumePolicyTest.kt new file mode 100644 index 000000000..b9eebc0dc --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/player/AudioFocusResumePolicyTest.kt @@ -0,0 +1,59 @@ +package com.theveloper.pixelplay.data.service.player + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class AudioFocusResumePolicyTest { + + @Test + fun transientFocusLoss_doesNotResumeWhenPlaybackWasAlreadyPaused() { + val shouldResume = shouldResumeAfterTransientAudioFocusLoss( + masterPlayWhenReady = false, + masterIsPlaying = false, + transitionRunning = false, + auxiliaryPlayWhenReady = false, + auxiliaryIsPlaying = false + ) + + assertThat(shouldResume).isFalse() + } + + @Test + fun transientFocusLoss_resumesWhenMasterWasPlaying() { + val shouldResume = shouldResumeAfterTransientAudioFocusLoss( + masterPlayWhenReady = true, + masterIsPlaying = false, + transitionRunning = false, + auxiliaryPlayWhenReady = false, + auxiliaryIsPlaying = false + ) + + assertThat(shouldResume).isTrue() + } + + @Test + fun transientFocusLoss_resumesWhenAuxiliaryTransitionWasPlaying() { + val shouldResume = shouldResumeAfterTransientAudioFocusLoss( + masterPlayWhenReady = false, + masterIsPlaying = false, + transitionRunning = true, + auxiliaryPlayWhenReady = false, + auxiliaryIsPlaying = true + ) + + assertThat(shouldResume).isTrue() + } + + @Test + fun transientFocusLoss_ignoresPausedAuxiliaryOutsideTransition() { + val shouldResume = shouldResumeAfterTransientAudioFocusLoss( + masterPlayWhenReady = false, + masterIsPlaying = false, + transitionRunning = false, + auxiliaryPlayWhenReady = true, + auxiliaryIsPlaying = true + ) + + assertThat(shouldResume).isFalse() + } +}