diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index d316f28cd19..4cb45a01b15 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -46,7 +46,9 @@ import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.common.audio.AudioProcessor import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer.STATE_ENABLED import androidx.media3.exoplayer.Renderer.STATE_STARTED @@ -161,6 +163,14 @@ class CS3IPlayer : IPlayer { private var ignoreSSL: Boolean = true private var playBackSpeed: Float = 1.0f + /** + * Shared compressor instance — only created when the setting is enabled. + * When disabled, this stays null and the audio pipeline is completely + * unmodified (no custom AudioProcessor, no custom AudioSink at all). + */ + var compressor: DynamicRangeCompressor? = null + private set + private var lastMuteVolume: Float = 1.0f private var currentLink: ExtractorLink? = null @@ -1109,8 +1119,17 @@ class CS3IPlayer : IPlayer { else -> isLayout(PHONE or EMULATOR) to false } + // Only create the compressor when the setting is actually enabled. + // When it's off, `compressor` stays null and nothing about the audio + // pipeline is touched — same behaviour as before this feature existed. + val isCompressorEnabled = settingsManager.getBoolean( + context.getString(R.string.compressor_enabled_key), + false + ) + compressor = if (isCompressorEnabled) DynamicRangeCompressor() else null + val factory = if (isSoftwareDecodingEnabled) { - FixedNextRenderersFactory(context).apply { + FixedNextRenderersFactory(context, compressor).apply { setEnableDecoderFallback(true) setExtensionRendererMode( if (isSoftwareDecodingPreferred) @@ -1120,8 +1139,24 @@ class CS3IPlayer : IPlayer { ) } } else { - // no nextlib = EXTENSION_RENDERER_MODE_OFF - DefaultRenderersFactory(context) + val activeCompressor = compressor + if (activeCompressor == null) { + // no nextlib = EXTENSION_RENDERER_MODE_OFF, no compressor = fully default sink + DefaultRenderersFactory(context) + } else { + object : DefaultRenderersFactory(context) { + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + override fun buildAudioSink( + ctx: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean + ) = DefaultAudioSink.Builder(ctx) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioOutputPlaybackParameters(enableAudioTrackPlaybackParams) + .setAudioProcessors(arrayOf(activeCompressor)) + .build() + } + } } val style = CustomDecoder.style diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DynamicRangeCompressor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DynamicRangeCompressor.kt new file mode 100644 index 00000000000..394535d8c47 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DynamicRangeCompressor.kt @@ -0,0 +1,249 @@ +package com.lagradost.cloudstream3.ui.player + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.audio.AudioProcessor +import androidx.media3.common.audio.AudioProcessor.AudioFormat +import androidx.media3.common.util.UnstableApi +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.pow + +/** + * A dynamic range compressor for the audio player. + * + * ## What it does + * A dynamic range compressor automatically turns down loud sounds and turns up + * quiet ones, narrowing the gap between the loudest and quietest moments. + * This is useful for watching movies or TV shows where action/explosion scenes + * are very loud but dialogue scenes are very quiet — the compressor brings + * everything to a more comfortable, even volume. + * + * ## Algorithm + * Implements a standard single-band feed-forward peak compressor using a + * 1-pole IIR envelope follower. This is the same algorithm used in most + * audio software (Audacity, VLC, etc.) and is well-established DSP. It is + * NOT a port or derivative of any specific implementation — the math is + * standard and covered extensively in audio engineering literature + * (e.g. Zölzer "Digital Audio Signal Processing"). + * + * Uses a single shared envelope across all channels so left and right always + * receive identical gain — independent per-channel envelopes would destroy + * the stereo image. + * + * Handles both PCM_16BIT and PCM_FLOAT (both are used by ExoPlayer depending + * on the source and device). Always stays "active" in the pipeline so that + * enable/disable works instantly without reloading the player — when disabled, + * samples are copied unchanged (passthrough). + */ +@OptIn(UnstableApi::class) +class DynamicRangeCompressor : AudioProcessor { + + @Volatile var enabled: Boolean = false + + /** + * The level (in dB) above which compression kicks in. Signals quieter than + * this pass through unchanged; louder signals get compressed. + * Lower = compresses more content (including quieter sounds like dialogue). + * -24 dB is a good starting point for movies: it catches action peaks + * while leaving quiet dialogue mostly untouched before makeup gain. + */ + @Volatile var threshold: Float = -24f // dB, range -30..0 + + /** + * How aggressively to compress sounds that exceed the threshold. + * A ratio of 8:1 means an 8 dB increase above the threshold becomes only + * 1 dB in the output. Higher = more "squashed" dynamic range. + * Below ~2 is barely noticeable; above ~10 sounds very processed/radio-like. + */ + @Volatile var ratio: Float = 8f // n:1, range 1..20 + + /** + * How quickly (in ms) the compressor clamps down when a loud sound starts. + * Too low (< 1 ms): no transient punch, sounds dull. + * Too high (> 50 ms): loud transients slip through before gain reduces. + * 5 ms preserves punch while still catching most action scene peaks. + */ + @Volatile var attackMs: Float = 5f // ms, range 1..400 + + /** + * How quickly (in ms) the compressor lets go after a loud sound ends. + * Too low (< 50 ms): audible "pumping" — volume visibly breathes up/down. + * Too high (> 800 ms): gain stays low too long, quieter sounds after + * an action scene stay suppressed for a noticeable time. + * 400 ms is slow enough to be transparent on most movie content. + */ + @Volatile var releaseMs: Float = 400f // ms, range 2..800 + + /** + * Output gain (in dB) applied after compression. + * Compression reduces overall loudness so makeup gain brings it back up. + * +12 dB compensates for the typical reduction at a 8:1 ratio with a + * -24 dB threshold, and also lifts quiet dialogue to a more audible level. + * Too high risks clipping on uncompressed peaks below the threshold. + */ + @Volatile var makeupGain: Float = 12f // dB, range 0..24 + + private var format = AudioFormat.NOT_SET + private var isFloat = false + private var sampleRate = 44100 + private var channelCount = 2 + + // Single shared envelope across all channels — keeps L/R gain identical + // so stereo image is preserved and no crackling from gain mismatch. + private var envelope = 0f + + private var attackCoeff = 0f + private var releaseCoeff = 0f + private var lastAttackMs = -1f + private var lastReleaseMs = -1f + private var lastSampleRate = -1 + + private var outputBuffer: ByteBuffer = AudioProcessor.EMPTY_BUFFER + private var inputEnded = false + + // ── AudioProcessor ──────────────────────────────────────────────────────── + + override fun configure(inputAudioFormat: AudioFormat): AudioFormat { + if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT && + inputAudioFormat.encoding != C.ENCODING_PCM_FLOAT) { + return inputAudioFormat // pass unsupported formats through unchanged + } + format = inputAudioFormat + isFloat = inputAudioFormat.encoding == C.ENCODING_PCM_FLOAT + sampleRate = inputAudioFormat.sampleRate + channelCount = inputAudioFormat.channelCount + envelope = 0f + return inputAudioFormat + } + + // Always active when format is set — toggling isActive() mid-stream has no + // effect in media3 (only checked at configure() time). We do passthrough + // in queueInput() instead so enable/disable works immediately. + override fun isActive(): Boolean = + format != AudioFormat.NOT_SET && + (format.encoding == C.ENCODING_PCM_16BIT || format.encoding == C.ENCODING_PCM_FLOAT) + + override fun queueInput(inputBuffer: ByteBuffer) { + if (!inputBuffer.hasRemaining()) return + val remaining = inputBuffer.remaining() + val out = replaceOutputBuffer(remaining) + if (!enabled) { out.put(inputBuffer); out.flip(); return } + updateCoefficients() + val makeupLinear = dbToLinear(makeupGain) + val threshLinear = dbToLinear(threshold) + val bytesPerSample = if (isFloat) 4 else 2 + val frameCount = remaining / (channelCount * bytesPerSample) + if (isFloat) processFloat(inputBuffer, out, frameCount, threshLinear, makeupLinear) + else processShort(inputBuffer, out, frameCount, threshLinear, makeupLinear) + out.flip() + } + + /** Processes PCM_FLOAT frames. Two-pass: peak detection then gain application. */ + private fun processFloat( + input: ByteBuffer, out: ByteBuffer, + frameCount: Int, threshLinear: Float, makeupLinear: Float + ) { + repeat(frameCount) { + val frameStart = input.position() + var peak = 0f + repeat(channelCount) { val s = input.getFloat(); val a = if (s < 0f) -s else s; if (a > peak) peak = a } + advanceEnvelope(peak) + val gain = computeGain(threshLinear) * makeupLinear + input.position(frameStart) + repeat(channelCount) { out.putFloat(input.getFloat() * gain) } + } + } + + /** Processes PCM_16BIT frames. Two-pass: peak detection then gain application. */ + private fun processShort( + input: ByteBuffer, out: ByteBuffer, + frameCount: Int, threshLinear: Float, makeupLinear: Float + ) { + repeat(frameCount) { + val frameStart = input.position() + var peak = 0f + repeat(channelCount) { val s = input.getShort() / 32768f; val a = if (s < 0f) -s else s; if (a > peak) peak = a } + advanceEnvelope(peak) + val gain = computeGain(threshLinear) * makeupLinear + input.position(frameStart) + repeat(channelCount) { + val s = input.getShort() / 32768f + out.putShort((s * gain).coerceIn(-1f, 1f).let { (it * 32768f).toInt().toShort() }) + } + } + } + + private fun advanceEnvelope(peak: Float) { + val coeff = if (peak > envelope) attackCoeff else releaseCoeff + envelope = peak + coeff * (envelope - peak) + } + + override fun queueEndOfStream() { inputEnded = true } + + override fun getOutput(): ByteBuffer { + val out = outputBuffer + outputBuffer = AudioProcessor.EMPTY_BUFFER + return out + } + + override fun isEnded(): Boolean = + inputEnded && outputBuffer === AudioProcessor.EMPTY_BUFFER + + // AudioProcessor.flush() (no-arg) is deprecated upstream in favor of a + // seek-aware overload, but this interface implementation still only exposes + // the no-arg version to override, so this suppresses that specific warning + // without adding @Deprecated (which would cascade to our own reset() below, + // since it calls flush() internally). + @Suppress("OVERRIDE_DEPRECATION") + override fun flush() { + // Called on every seek. Reset envelope so there's no gain burst + // from stale state — which is what caused crackling after skipping. + envelope = 0f + outputBuffer = AudioProcessor.EMPTY_BUFFER + inputEnded = false + } + + override fun reset() { + flush() + format = AudioFormat.NOT_SET + isFloat = false + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun computeGain(threshLinear: Float): Float { + return if (envelope <= threshLinear) { + 1f + } else { + val overDb = linearToDb(envelope) - threshold + dbToLinear(-(overDb * (1f - 1f / ratio))) + } + } + + private fun replaceOutputBuffer(size: Int): ByteBuffer { + if (outputBuffer.capacity() < size) { + outputBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder()) + } else { + outputBuffer.clear() + } + return outputBuffer + } + + private fun updateCoefficients() { + if (attackMs == lastAttackMs && + releaseMs == lastReleaseMs && + sampleRate == lastSampleRate) return + lastAttackMs = attackMs + lastReleaseMs = releaseMs + lastSampleRate = sampleRate + attackCoeff = exp(-1.0 / (sampleRate * attackMs / 1000.0)).toFloat() + releaseCoeff = exp(-1.0 / (sampleRate * releaseMs / 1000.0)).toFloat() + } + + private fun dbToLinear(db: Float): Float = 10f.pow(db / 20f) + private fun linearToDb(linear: Float): Float = + if (linear <= 0f) -120f else (20.0 * ln(linear.toDouble()) / ln(10.0)).toFloat() +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt index 025267cc9ed..3fdd7081b4f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt @@ -2,14 +2,21 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.audio.AudioProcessor import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory @UnstableApi -class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) { +class FixedNextRenderersFactory( + context: Context, + private val compressor: DynamicRangeCompressor? = null, +) : NextRenderersFactory(context) { /** Somehow the nextlib authors decided that we need a text renderer that causes * "ERROR_CODE_FAILED_RUNTIME_CHECK". * @@ -25,4 +32,26 @@ class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context ) { out.add(TextRenderer(output, outputLooper)) } -} \ No newline at end of file + + /** + * Only builds a custom [DefaultAudioSink] when a compressor is actually supplied. + * When [compressor] is null (the setting is off, or unsupported on this device) we + * fall through to the completely unmodified default sink from the parent factory, + * so playback behaves exactly as it did before this feature existed. + */ + @OptIn(UnstableApi::class) + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean + ): AudioSink? { + val activeCompressor = compressor + ?: return super.buildAudioSink(context, enableFloatOutput, enableAudioTrackPlaybackParams) + + return DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioOutputPlaybackParameters(enableAudioTrackPlaybackParams) + .setAudioProcessors(arrayOf(activeCompressor)) + .build() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index d90b6043f28..91277b0cd57 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -4,6 +4,8 @@ import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import android.content.Context import android.content.DialogInterface import android.content.pm.ActivityInfo @@ -67,6 +69,14 @@ import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import kotlin.math.roundToInt +private const val COMPRESSOR_ENABLED_KEY = "player_compressor_enabled" +private const val COMPRESSOR_THRESHOLD_KEY = "player_compressor_threshold" +private const val COMPRESSOR_RATIO_KEY = "player_compressor_ratio" +private const val COMPRESSOR_ATTACK_KEY = "player_compressor_attack" +private const val COMPRESSOR_RELEASE_KEY = "player_compressor_release" +private const val COMPRESSOR_MAKEUP_KEY = "player_compressor_makeup" + + private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" // All the UI Logic for the player @@ -157,6 +167,15 @@ open class FullScreenPlayer : AbstractPlayerFragment( autoHide() } } + protected var selectCompressorDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var playBackCompressorEnabled = false /** Checks if any top level dialog is open and showing */ fun isDialogOpen() = @@ -164,6 +183,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( || selectTrackDialog?.isShowing == true || selectSpeedDialog?.isShowing == true || selectSubtitlesDialog?.isShowing == true + || selectCompressorDialog?.isShowing == true || isShowingEpisodeOverlay private fun scheduleMetadataVisibility() { @@ -697,6 +717,273 @@ open class FullScreenPlayer : AbstractPlayerFragment( //} } + // getColorStateList(index) on a TypedArray built from a raw intArrayOf(...) can't be + // statically verified by lint as a styleable resource index (it isn't tied to an + // ), so it always flags a false-positive ResourceType error here — + // this is the standard, expected suppression for this obtainStyledAttributes pattern. + @SuppressLint("ResourceType") + private fun showCompressorDialog() { + val act = activity ?: return + val compressor = (player as? CS3IPlayer)?.compressor ?: return + + // Always restore — CS3IPlayer is recreated between videos + restoreCompressorSettings() + + // Snapshot for Cancel revert + data class Snap(val enabled: Boolean, val threshold: Float, val makeupGain: Float) + val snap = Snap(compressor.enabled, compressor.threshold, compressor.makeupGain) + + val binding = com.lagradost.cloudstream3.databinding.CompressorDialogBinding + .inflate(android.view.LayoutInflater.from(act)) + + // ── Visual update helpers ────────────────────────────────────────── + fun updateCurrentLabel() { + binding.compressorCurrentLabel.text = if (compressor.enabled) { + act.getString( + R.string.compressor_on_format, + compressor.threshold.toInt(), + compressor.makeupGain.toInt() + ) + } else { + act.getString(R.string.compressor_off) + } + } + + fun updateThresholdLabel() { + binding.compressorThresholdLabel.text = + act.getString(R.string.compressor_threshold_label, compressor.threshold.toInt()) + } + + fun updateMakeupLabel() { + binding.compressorMakeupLabel.text = + act.getString(R.string.compressor_makeup_label, compressor.makeupGain.toInt()) + } + + // Short, plain-language explanation of what each slider does, updated live — + // same idea as the "Use this if the subtitles are shown X ms too late" hint. + fun updateThresholdHint() { + binding.compressorThresholdHint.text = + act.getString(R.string.compressor_threshold_hint, compressor.threshold.toInt()) + } + + fun updateMakeupHint() { + binding.compressorMakeupHint.text = + act.getString(R.string.compressor_makeup_hint, compressor.makeupGain.toInt()) + } + + // WhiteButton = selected/active, BlackButton = unselected — same as speed presets + fun syncEnableButtons() { + val ctx = context ?: return + listOf( + binding.compressorEnableBtt to compressor.enabled, + binding.compressorDisableBtt to !compressor.enabled, + ).forEach { (btn, active) -> + // Apply the full WhiteButton or BlackButton style — backgroundTint only. + // Also update setTextColor so we don't get white text on white background. + val styleAttr = if (active) R.style.WhiteButton else R.style.BlackButton + val ta = ctx.obtainStyledAttributes( + styleAttr, + intArrayOf( + com.google.android.material.R.attr.backgroundTint, + android.R.attr.textColor, + ) + ) + btn.backgroundTintList = ta.getColorStateList(0) + ta.getColorStateList(1)?.let { btn.setTextColor(it) } + ta.recycle() + } + updateCurrentLabel() + } + + // ── Presets — declared before the sliders so their listeners can call + // syncPresetButtons(matchingPresetButton()) to keep the highlight accurate ── + val allPresets = listOf( + binding.compressorPresetLight, + binding.compressorPresetDialog, + binding.compressorPresetHeavy, + ) + + fun syncPresetButtons(active: com.google.android.material.button.MaterialButton?) { + val ctx = context ?: return + allPresets.forEach { btn -> + val isActive = btn == active + val ta = ctx.obtainStyledAttributes( + if (isActive) R.style.WhiteButton else R.style.BlackButton, + intArrayOf( + com.google.android.material.R.attr.backgroundTint, + android.R.attr.textColor, + ) + ) + btn.backgroundTintList = ta.getColorStateList(0) + ta.getColorStateList(1)?.let { btn.setTextColor(it) } + ta.recycle() + } + } + + // Figures out which preset (if any) matches the current values, so the + // correct button stays highlighted when the dialog is reopened, instead + // of always resetting to "none selected". + fun matchingPresetButton(): com.google.android.material.button.MaterialButton? = when { + compressor.threshold == -18f && compressor.makeupGain == 4f -> binding.compressorPresetLight + compressor.threshold == -24f && compressor.makeupGain == 12f -> binding.compressorPresetDialog + compressor.threshold == -30f && compressor.makeupGain == 16f -> binding.compressorPresetHeavy + else -> null + } + + fun applyPreset( + threshold: Float, + makeup: Float, + activeBtn: com.google.android.material.button.MaterialButton + ) { + compressor.threshold = threshold + compressor.makeupGain = makeup + binding.compressorThresholdBar.value = threshold.coerceIn(-30f, 0f) + binding.compressorRatioBar.value = makeup.coerceIn(0f, 24f) + updateThresholdLabel() + updateMakeupLabel() + updateThresholdHint() + updateMakeupHint() + updateCurrentLabel() + syncPresetButtons(activeBtn) + } + + // ── Restore UI to current compressor state ───────────────────────── + binding.compressorThresholdBar.value = compressor.threshold.coerceIn(-30f, 0f) + binding.compressorRatioBar.value = compressor.makeupGain.coerceIn(0f, 24f) + updateThresholdLabel() + updateMakeupLabel() + updateThresholdHint() + updateMakeupHint() + syncEnableButtons() + syncPresetButtons(matchingPresetButton()) + + // ── On / Off ─────────────────────────────────────────────────────── + binding.compressorEnableBtt.setOnClickListener { + compressor.enabled = true + syncEnableButtons() + } + binding.compressorDisableBtt.setOnClickListener { + compressor.enabled = false + syncEnableButtons() + } + + // ── Threshold slider + FABs ──────────────────────────────────────── + binding.compressorThresholdBar.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + compressor.threshold = value + updateThresholdLabel() + updateThresholdHint() + updateCurrentLabel() + syncPresetButtons(matchingPresetButton()) + } + } + binding.thresholdMinus.setOnClickListener { + val v = (compressor.threshold - 1f).coerceIn(-30f, 0f) + compressor.threshold = v + binding.compressorThresholdBar.value = v + updateThresholdLabel() + updateThresholdHint() + updateCurrentLabel() + syncPresetButtons(matchingPresetButton()) + } + binding.thresholdPlus.setOnClickListener { + val v = (compressor.threshold + 1f).coerceIn(-30f, 0f) + compressor.threshold = v + binding.compressorThresholdBar.value = v + updateThresholdLabel() + updateThresholdHint() + updateCurrentLabel() + syncPresetButtons(matchingPresetButton()) + } + + // ── Makeup gain slider + FABs (reusing ratio_minus/plus ids) ────── + binding.compressorRatioBar.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + compressor.makeupGain = value + updateMakeupLabel() + updateMakeupHint() + updateCurrentLabel() + syncPresetButtons(matchingPresetButton()) + } + } + binding.ratioMinus.setOnClickListener { + val v = (compressor.makeupGain - 1f).coerceIn(0f, 24f) + compressor.makeupGain = v + binding.compressorRatioBar.value = v + updateMakeupLabel() + updateMakeupHint() + updateCurrentLabel() + syncPresetButtons(matchingPresetButton()) + } + binding.ratioPlus.setOnClickListener { + val v = (compressor.makeupGain + 1f).coerceIn(0f, 24f) + compressor.makeupGain = v + binding.compressorRatioBar.value = v + updateMakeupLabel() + updateMakeupHint() + updateCurrentLabel() + syncPresetButtons(matchingPresetButton()) + } + + // ── Preset buttons ───────────────────────────────────────────────── + binding.compressorPresetLight.setOnClickListener { + applyPreset(-18f, 4f, binding.compressorPresetLight) + } + binding.compressorPresetDialog.setOnClickListener { + applyPreset(-24f, 12f, binding.compressorPresetDialog) + } + binding.compressorPresetHeavy.setOnClickListener { + applyPreset(-30f, 16f, binding.compressorPresetHeavy) + } + + // ── Dialog lifecycle ─────────────────────────────────────────────── + val dialog = AlertDialog.Builder(act, R.style.AlertDialogCustom) + .setView(binding.root) + .setOnDismissListener { + activity?.hideSystemUI() + selectCompressorDialog = null + } + .create() + selectCompressorDialog = dialog + + binding.applyBtt.setOnClickListener { + saveCompressorSettings(compressor) + dialog.dismiss() + } + binding.resetBtt.setOnClickListener { + applyPreset(-24f, 12f, binding.compressorPresetDialog) + compressor.enabled = true + syncEnableButtons() + } + binding.cancelBtt.setOnClickListener { + compressor.enabled = snap.enabled + compressor.threshold = snap.threshold + compressor.makeupGain = snap.makeupGain + dialog.dismiss() + } + + dialog.show() + } + + private fun saveCompressorSettings(c: DynamicRangeCompressor) { + setKey(COMPRESSOR_ENABLED_KEY, c.enabled) + setKey(COMPRESSOR_THRESHOLD_KEY, c.threshold) + setKey(COMPRESSOR_RATIO_KEY, c.ratio) + setKey(COMPRESSOR_ATTACK_KEY, c.attackMs) + setKey(COMPRESSOR_RELEASE_KEY, c.releaseMs) + setKey(COMPRESSOR_MAKEUP_KEY, c.makeupGain) + } + + protected fun restoreCompressorSettings() { + val c = (player as? CS3IPlayer)?.compressor ?: return + c.enabled = getKey(COMPRESSOR_ENABLED_KEY) ?: false + c.threshold = getKey(COMPRESSOR_THRESHOLD_KEY) ?: -24f + c.ratio = getKey(COMPRESSOR_RATIO_KEY) ?: 8f + c.attackMs = getKey(COMPRESSOR_ATTACK_KEY) ?: 5f + c.releaseMs = getKey(COMPRESSOR_RELEASE_KEY) ?: 400f + c.makeupGain = getKey(COMPRESSOR_MAKEUP_KEY) ?: 12f + } + private fun onClickChange() { isShowing = !isShowing if (isShowing) autoHide() @@ -1139,6 +1426,10 @@ open class FullScreenPlayer : AbstractPlayerFragment( ctx.getString(R.string.playback_speed_enabled_key), false ) + playBackCompressorEnabled = settingsManager.getBoolean( + ctx.getString(R.string.compressor_enabled_key), + false + ) playerRotateEnabled = settingsManager.getBoolean( ctx.getString(R.string.rotate_video_key), false @@ -1165,6 +1456,10 @@ open class FullScreenPlayer : AbstractPlayerFragment( } playerBinding?.apply { playerSpeedBtt.isVisible = playBackSpeedEnabled + playerCompressorBtt.isVisible = playBackCompressorEnabled + if (playBackCompressorEnabled) { + restoreCompressorSettings() + } playerResizeBtt.isVisible = playerResizeEnabled playerRotateBtt.isVisible = if (isLayout(TV or EMULATOR)) false else playerRotateEnabled @@ -1226,6 +1521,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( showSpeedDialog() } + playerCompressorBtt.setOnClickListener { + autoHide() + showCompressorDialog() + } + playerSkipOp.setOnClickListener { autoHide() skipOp() diff --git a/app/src/main/res/drawable/ic_compressor_24.xml b/app/src/main/res/drawable/ic_compressor_24.xml new file mode 100644 index 00000000000..aa512eab713 --- /dev/null +++ b/app/src/main/res/drawable/ic_compressor_24.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/compressor_dialog.xml b/app/src/main/res/layout/compressor_dialog.xml new file mode 100644 index 00000000000..32dd9968ea2 --- /dev/null +++ b/app/src/main/res/layout/compressor_dialog.xml @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index 5ccc3ff09ac..9a79e6590fc 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -927,10 +927,19 @@ android:layout_height="40dp" android:nextFocusLeft="@id/player_sources_btt" - android:nextFocusRight="@id/player_skip_op" + android:nextFocusRight="@id/player_compressor_btt" android:text="@string/tracks" app:icon="@drawable/ic_baseline_equalizer_24" /> + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/player_custom_layout_tv.xml b/app/src/main/res/layout/player_custom_layout_tv.xml index c6445d80fa5..06ec055c828 100644 --- a/app/src/main/res/layout/player_custom_layout_tv.xml +++ b/app/src/main/res/layout/player_custom_layout_tv.xml @@ -1120,10 +1120,21 @@ android:id="@+id/player_tracks_btt" style="@style/VideoButtonTV" android:nextFocusLeft="@id/player_sources_btt" - android:nextFocusRight="@id/player_skip_op" + android:nextFocusRight="@id/player_compressor_btt" android:nextFocusUp="@id/player_pause_play" + android:nextFocusDown="@id/player_compressor_btt" android:text="@string/tracks" app:icon="@drawable/ic_baseline_equalizer_24" /> + + @@ -1190,4 +1201,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d530b59f6e..1fef2d9c913 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -532,6 +532,25 @@ Warning: CloudStream does not take any responsibility for using third-party extensions and does not provide any support for them! %s (Disabled) Tracks + Compressor + Dynamic Range Compressor + Show compressor controls in the player (helps balance loud/quiet audio) + compressor_button_enabled + Enabled + On + On · %1$d dB / +%2$d dB + Off + Presets + Light + Dialogue Boost + Heavy + Threshold: %d dB + Ratio: %d:1 + Attack: %d ms + Release: %d ms + Makeup Gain: +%d dB + Sounds louder than %d dB get turned down + Turns up the overall volume by +%d dB Audio tracks Video tracks Restart the app to see changes. diff --git a/app/src/main/res/xml/settings_player.xml b/app/src/main/res/xml/settings_player.xml index 6e136747448..f370fbcc9c2 100644 --- a/app/src/main/res/xml/settings_player.xml +++ b/app/src/main/res/xml/settings_player.xml @@ -80,6 +80,12 @@ android:title="@string/eigengraumode_settings" app:defaultValue="false" app:key="@string/playback_speed_enabled_key" /> + - \ No newline at end of file +