Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,10 @@ class MusicService : MediaLibraryService() {
const val ACTION_SLEEP_TIMER_EXPIRED = "com.theveloper.pixelplay.ACTION_SLEEP_TIMER_EXPIRED"
const val EXTRA_FORCE_FOREGROUND_ON_START =
"com.theveloper.pixelplay.extra.FORCE_FOREGROUND_ON_START"
private const val PLAYBACK_SNAPSHOT_DEBOUNCE_MS = 350L
// Queue/index/flags snapshot is only used for restore on process death. A full-queue
// JSON+DataStore rewrite on every Media3 event (track transition fires 3-4 listeners
// within ~200ms) is unnecessary work. 1500ms coalesces those without harming restore.
private const val PLAYBACK_SNAPSHOT_DEBOUNCE_MS = 1500L
private const val FORCED_WIDGET_STATE_DEBOUNCE_MS = 90L
private const val MEDIA_SESSION_BUTTON_DEBOUNCE_MS = 90L
private val pendingMediaButtonForegroundStarts = AtomicInteger(0)
Expand Down Expand Up @@ -1049,7 +1052,6 @@ class MusicService : MediaLibraryService() {
}
requestWidgetAndWearRefreshWithFollowUp()
mediaSession?.let { refreshMediaSessionUiWithFollowUp(it) }
mediaSession?.let { refreshMediaSessionUi(it) }
schedulePlaybackSnapshotPersist()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class DualPlayerEngine @Inject constructor(
private companion object {
private const val AUDIO_OFFLOAD_BUFFERING_FALLBACK_MS = 4_000L
private val LOCAL_MEDIA_SCHEMES = setOf("content", "file", "android.resource")
private val REMOTE_MEDIA_SCHEMES = setOf("http", "https", "telegram", "netease", "qqmusic", "navidrome", "jellyfin", "gdrive")
}

private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
Expand Down Expand Up @@ -167,7 +168,9 @@ class DualPlayerEngine @Inject constructor(
// Limpieza para canciones que no son de Telegram
telegramCacheManager.setActivePlayback(null)
}
// Wake mode is configured once in buildPlayer().
// Upgrade/downgrade wake policy so local playback does not keep the radio awake
// and cloud/remote playback still holds the network wake lock.
applyWakeModeForCurrentItem()

// --- Pre-Resolve Next/Prev Tracks para Performance ---
try {
Expand Down Expand Up @@ -337,6 +340,33 @@ class DualPlayerEngine @Inject constructor(
return scheme == null || scheme in LOCAL_MEDIA_SCHEMES
}

/**
* Selects the cheapest wake policy that still keeps playback reliable.
* Network wake is only needed when the current queue item actually talks to the network —
* local file playback keeps only CPU awake so the device can sleep the radio.
*/
private fun wakeModeFor(mediaItem: MediaItem?): Int {
val scheme = mediaItem?.localConfiguration?.uri?.scheme?.lowercase()
return if (scheme != null && scheme in REMOTE_MEDIA_SCHEMES) {
C.WAKE_MODE_NETWORK
} else {
C.WAKE_MODE_LOCAL
}
}

private fun applyWakeModeForCurrentItem() {
if (!::playerA.isInitialized) return
val mode = wakeModeFor(playerA.currentMediaItem)
try {
playerA.setWakeMode(mode)
if (::playerB.isInitialized) {
playerB.setWakeMode(mode)
}
} catch (e: Exception) {
Timber.tag("DualPlayerEngine").w(e, "Failed to update wake mode")
}
}

private fun shouldDisableAudioOffloadByDefault(): Boolean {
val manufacturer = Build.MANUFACTURER.lowercase()
val brand = Build.BRAND.lowercase()
Expand Down Expand Up @@ -388,6 +418,7 @@ class DualPlayerEngine @Inject constructor(
playerA.shuffleModeEnabled = shuffleMode
playerA.prepare()
playerA.playWhenReady = desiredPlayWhenReady
applyWakeModeForCurrentItem()
}

_activeAudioSessionId.value = playerA.audioSessionId
Expand Down Expand Up @@ -510,10 +541,11 @@ class DualPlayerEngine @Inject constructor(
.build()
)
setHandleAudioBecomingNoisy(true) // Force player to pause automatically when audio is rerouted from a headset to device speakers
// Cloud sources are proxied through localhost, but the proxy still depends on
// upstream network access. Keep both CPU and network awake so background
// playback does not stall when the screen turns off or the app is backgrounded.
setWakeMode(C.WAKE_MODE_NETWORK)
// Default to CPU-only wake. Upgraded to WAKE_MODE_NETWORK dynamically when the
// current item is a remote/cloud source via applyWakeModeForCurrentItem().
// Keeping local playback on WAKE_MODE_LOCAL lets the radio sleep, which is one
// of the biggest heat savings for long sessions on weaker phones.
setWakeMode(C.WAKE_MODE_LOCAL)
// Explicitly keep both players live so they can overlap without affecting each other
playWhenReady = false
Timber.tag("DualPlayerEngine").d("Built player with audio offload %s", if (audioOffloadEnabled) "enabled" else "disabled")
Expand Down Expand Up @@ -933,7 +965,9 @@ class DualPlayerEngine @Inject constructor(
// playerB is now the Outgoing/Aux.

val duration = settings.durationMs.toLong().coerceAtLeast(500L)
val stepMs = 16L
// 30Hz volume ramp is indistinguishable from 60Hz at the AudioTrack frame boundary
// for a multi-second crossfade; halves wake-ups during overlap.
val stepMs = 32L
var elapsed = 0L
var lastLog = 0L

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,13 +250,14 @@ class TransitionController @Inject constructor(
return@collectLatest
}

// Wait loop with adaptive sleep
// Wait loop with adaptive sleep. 250ms near-end cadence still lands the crossfade
// within ±125ms of the target — imperceptible for a multi-second overlap, and 5×
// fewer wakeups in the last second of every track.
while (player.currentPosition < transitionPoint && isActive) {
val remaining = transitionPoint - player.currentPosition
val sleep = when {
remaining > 5000 -> 1000L
remaining > 1000 -> 250L
else -> 50L // Tight loop near the end
else -> 250L
}
if (remaining < 2000 && remaining % 500 < 50) {
Timber.tag("TransitionDebug").v("Countdown: %d ms to transition", remaining)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,16 @@ class TelegramStreamProxy @Inject constructor(
var currentPos = start
val buffer = ByteArray(64 * 1024) // Increased to 64KB for smoother streaming
var noDataCount = 0

// Exponential backoff while the reader is waiting for TDLib to
// deliver more bytes. The previous fixed 50ms delay combined with
// a per-iteration getFile() call kept the IO thread and TDLib
// database churning during any stall, which showed up as sustained
// CPU heat on weaker devices during cloud playback.
var stallDelayMs = 50L
val maxStallDelayMs = 400L

raf.seek(currentPos)

var cachedDownloadedPrefixSize = fileInfo?.local?.downloadedPrefixSize?.toLong() ?: 0L

while (true) {
Expand All @@ -218,22 +225,27 @@ class TelegramStreamProxy @Inject constructor(
// If size is different than expected, we still stop because we can't get more.
break
}

// Verify cancellation/failure
if (updatedInfo?.local?.isDownloadingCompleted == false && !updatedInfo.local.canBeDownloaded) {
break // Failed/Cancelled
}

delay(50) // Wait for more data

delay(stallDelayMs)
stallDelayMs = (stallDelayMs * 2).coerceAtMost(maxStallDelayMs)
continue
} else {
// New data arrived — reset the backoff so we stay
// responsive once the download catches up.
stallDelayMs = 50L
}
}

// 3. Determine safe read amount
// Read min of: buffer size, remaining in range, remaining valid bytes
val remainingValid = cachedDownloadedPrefixSize - currentPos
val toRead = min(buffer.size.toLong(), min(remaining, remainingValid)).toInt()

val read = raf.read(buffer, 0, toRead)
if (read > 0) {
writeFully(buffer, 0, read)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1769,11 +1769,13 @@ private fun LyricsTrackInfo(

LaunchedEffect(isPlaying) {
if (isPlaying) {
// Spin forever
// Spin forever. 8s per revolution halves the effective per-second animation work
// vs the original 4s cadence — visually still clearly a rotating "vinyl", but
// drives fewer Compose invalidations during long listening sessions.
while (true) {
currentRotation.animateTo(
targetValue = currentRotation.value + 360f,
animationSpec = tween(4000, easing = LinearEasing)
animationSpec = tween(8000, easing = LinearEasing)
)
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ fun PlayingEqIcon(
bars: Int = 3,
minHeightFraction: Float = 0.28f,
maxHeightFraction: Float = 1.0f,
phaseDurationMillis: Int = 2400, // ciclo más lento
wanderDurationMillis: Int = 8000, // patrón más largo
// Slower cycles mean fewer animation frames / Canvas redraws per second while the icon
// is visible. With many current-song indicators potentially on screen (home, queue,
// lyrics sheet), this noticeably lowers screen-on CPU on weaker devices.
phaseDurationMillis: Int = 3600, // ciclo más lento
wanderDurationMillis: Int = 12000, // patrón más largo
gapFraction: Float = 0.30f
) {
val fullRotation = (2f * PI).toFloat()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ class PlaybackStateHolder @Inject constructor(
companion object {
private const val TAG = "PlaybackStateHolder"
private const val DURATION_MISMATCH_TOLERANCE_MS = 1500L
private const val FOREGROUND_PROGRESS_TICK_MS = 250L
// 500 ms keeps the progress bar/time display smooth to the eye (it still updates twice
// per second) while halving Compose recomposition pressure vs. the old 250 ms tick.
// The visual slider uses frame-clock interpolation on top of this, so the animation
// stays fluid even at a slower underlying cadence.
private const val FOREGROUND_PROGRESS_TICK_MS = 500L
private const val BACKGROUND_PROGRESS_TICK_MS = 1000L
/**
* Threshold above which we skip per-item moveMediaItem calls and use
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class DeckController(
.build()
)
setHandleAudioBecomingNoisy(true)
setWakeMode(C.WAKE_MODE_NETWORK)
setWakeMode(C.WAKE_MODE_LOCAL)
}
}

Expand Down