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()
+ }
+}