/* * Copyright (c) Double Symmetry GmbH * Commercial use requires a license. See https://rntp.dev/pricing */ package com.doublesymmetry.trackplayer import androidx.media3.common.Player class SleepTimerController(private val player: Player) { var onTriggered: ((type: String) -> Unit)? = null var onStateChanged: (() -> Unit)? = null var sleepTimerType: String? = null private set var sleepTimerRemainingSeconds: Double = 0.0 private set var sleepTimerFadeOutSeconds: Double = 0.0 private set var sleepTimerTargetIndex: Int? = null private set var sleepTimerPreviousIndex: Int? = null private set private var sleepTimerPreFadeVolume: Float? = null fun sleepAfterTime(seconds: Double, fadeOutSeconds: Double) { cancelInternal(restoreVolume = true) sleepTimerType = "time" sleepTimerRemainingSeconds = seconds sleepTimerFadeOutSeconds = fadeOutSeconds.coerceAtMost(seconds) onStateChanged?.invoke() if (seconds <= 0) { player.pause() onTriggered?.invoke("time") cancelInternal(restoreVolume = true) return } } fun sleepAfterMediaItemAtIndex(index: Int) { cancelInternal(restoreVolume = true) sleepTimerType = "mediaItem" sleepTimerTargetIndex = index sleepTimerPreviousIndex = player.currentMediaItemIndex onStateChanged?.invoke() } fun cancel() { cancelInternal(restoreVolume = true) } fun getState(): Map? { val type = sleepTimerType ?: return null return if (type == "time") { mapOf( "type" to "time", "remainingSeconds" to sleepTimerRemainingSeconds, "fadeOutSeconds" to sleepTimerFadeOutSeconds ) } else { mapOf( "type" to "mediaItem", "index" to (sleepTimerTargetIndex ?: 0) ) } } /** * Call when a media item transition occurs. * Returns true if the timer fired. */ fun handleItemTransition(newIndex: Int): Boolean { if (sleepTimerType == "mediaItem") { val targetIndex = sleepTimerTargetIndex if (targetIndex != null && sleepTimerPreviousIndex == targetIndex && newIndex != targetIndex) { onTriggered?.invoke("mediaItem") cancelInternal(restoreVolume = false) sleepTimerPreviousIndex = newIndex return true } } sleepTimerPreviousIndex = newIndex return false } /** * Simulate one wall-clock second passing. * In production, called by a Timer on the main thread. * Exposed for testing. */ fun tick() { if (sleepTimerType != "time") return sleepTimerRemainingSeconds -= 1.0 onStateChanged?.invoke() if (sleepTimerFadeOutSeconds > 0 && sleepTimerRemainingSeconds < sleepTimerFadeOutSeconds) { if (sleepTimerPreFadeVolume == null) { sleepTimerPreFadeVolume = player.volume } val progress = maxOf(0.0, sleepTimerRemainingSeconds / sleepTimerFadeOutSeconds).toFloat() player.volume = (sleepTimerPreFadeVolume ?: 1f) * progress } if (sleepTimerRemainingSeconds <= 0) { sleepTimerRemainingSeconds = 0.0 player.pause() onTriggered?.invoke("time") cancelInternal(restoreVolume = true) return } } fun cancelInternal(restoreVolume: Boolean) { if (restoreVolume) { sleepTimerPreFadeVolume?.let { player.volume = it } } sleepTimerPreFadeVolume = null sleepTimerType = null sleepTimerRemainingSeconds = 0.0 sleepTimerFadeOutSeconds = 0.0 sleepTimerTargetIndex = null sleepTimerPreviousIndex = null onStateChanged?.invoke() } }