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/donottranslate-strings.xml b/app/src/main/res/values/donottranslate-strings.xml index bb222918c7c..bf1566cdb98 100644 --- a/app/src/main/res/values/donottranslate-strings.xml +++ b/app/src/main/res/values/donottranslate-strings.xml @@ -28,6 +28,7 @@ use_system_brightness_key swipe_enabled_key playback_speed_enabled_key + compressor_enabled_key player_resize_enabled_key player_source_priority_key pip_enabled_key @@ -142,16 +143,16 @@ Any legal issues regarding the content on this application should be taken up with the actual file hosts and providers themselves as we are not affiliated with them. - In case of copyright infringement, please directly contact the responsible parties or the streaming websites. + \n\nIn case of copyright infringement, please directly contact the responsible parties or the streaming websites. - The app is purely for educational and personal use. + \n\nThe app is purely for educational and personal use. - CloudStream does not host any content on the app, and has no control over what media is put up or taken down. + \n\nCloudStream does not host any content on the app, and has no control over what media is put up or taken down. CloudStream functions like any other search engine, such as Google. CloudStream does not host, upload or - manage any videos, films or content. It simply crawls, aggregates and displayes links in a convenient, + manage any videos, films or content. It simply crawls, aggregates and displays links in a convenient, user-friendly interface. - It merely scrapes 3rd-party websites that are publicly accessible via any regular web browser. It is the + \n\nIt merely scrapes 3rd-party websites that are publicly accessible via any regular web browser. It is the responsibility of user to avoid any actions that might violate the laws governing his/her locality. Use CloudStream at your own risk. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d530b59f6e..93d3fc16bc1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -532,6 +532,24 @@ 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) + 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 +