diff --git a/web-ui/src/components/player/player-controls.tsx b/web-ui/src/components/player/player-controls.tsx index 187a10e3..f4f12f28 100644 --- a/web-ui/src/components/player/player-controls.tsx +++ b/web-ui/src/components/player/player-controls.tsx @@ -52,6 +52,9 @@ interface PlayerControlsProps { onPlayPause: () => void; volume: number; onVolumeChange: (volume: number) => void; + /** Extra gain above unity, 1 = 100% (up to 3). */ + volumeBoost: number; + onVolumeBoostChange: (boost: number) => void; /** False where the platform makes volume read-only (iOS): the slider is hidden, mute stays. */ canControlVolume: boolean; isMuted: boolean; @@ -405,6 +408,8 @@ function PlayerControlsComponent({ isPlaying, onPlayPause, volume, + volumeBoost, + onVolumeBoostChange, onVolumeChange, canControlVolume, isMuted, @@ -422,6 +427,39 @@ function PlayerControlsComponent({ }: PlayerControlsProps) { const t = usePlayerTranslation(locale); const isEffectivelyMuted = isMuted || volume <= 0; + + // One slider covers both ranges: the lower half is 0..100% element volume, the + // upper half is gain above unity up to 6x (+15.6dB) through the PCM gain stage. + const VOLUME_BOOST_MAX = 6; + const sliderValue = isMuted + ? 0 + : volume < 1 + ? volume * 0.5 + : 0.5 + ((Math.max(1, volumeBoost) - 1) / (VOLUME_BOOST_MAX - 1)) * 0.5; + // Readout while dragging: element volume below unity, the gain multiplier + // above it, so "150%" means 1.5x instead of a level the element cannot hold. + const [volumeHint, setVolumeHint] = useState(null); + const volumeHintTimerRef = useRef | null>(null); + const showVolumeHint = (percent: number) => { + setVolumeHint(percent); + if (volumeHintTimerRef.current) clearTimeout(volumeHintTimerRef.current); + volumeHintTimerRef.current = setTimeout(() => setVolumeHint(null), 1200); + }; + const handleSliderChange = (pos: number) => { + if (isMuted && pos > 0) { + onMuteToggle(); + } + if (pos <= 0.5) { + if (volumeBoost !== 1) onVolumeBoostChange(1); + onVolumeChange(Math.min(1, pos / 0.5)); + showVolumeHint(Math.round((pos / 0.5) * 100)); + } else { + if (volume < 1) onVolumeChange(1); + const boost = 1 + ((pos - 0.5) / 0.5) * (VOLUME_BOOST_MAX - 1); + onVolumeBoostChange(Math.round(boost * 10) / 10); + showVolumeHint(Math.round(boost * 100)); + } + }; const isCatchupSupported = channel.sources.some((s) => s.catchup && s.catchupSource); const hasEpgTimeline = epgPrograms.length > 0; const hasTimeline = isCatchupSupported || Boolean(currentProgram) || hasEpgTimeline; @@ -486,6 +524,11 @@ function PlayerControlsComponent({ {/* Volume */}
+ {volumeHint !== null && ( +
+ {volumeHint}% +
+ )}
diff --git a/web-ui/src/components/player/player-gesture-overlay.tsx b/web-ui/src/components/player/player-gesture-overlay.tsx index 8d0ea664..3a800a65 100644 --- a/web-ui/src/components/player/player-gesture-overlay.tsx +++ b/web-ui/src/components/player/player-gesture-overlay.tsx @@ -22,13 +22,17 @@ function formatSeekDelta(deltaSeconds: number): string { return `${sign}${minutes}:${String(seconds).padStart(2, "0")}`; } -function VolumeIndicator({ volume }: { volume: number }) { - const percent = Math.round(volume * 100); +const VOLUME_LEVEL_MAX = 6; + +function VolumeIndicator({ level }: { level: number }) { + const percent = Math.round(level * 100); + // Half the bar is 0..100%, the other half the gain range above it. + const fill = level <= 1 ? level * 50 : 50 + ((level - 1) / (VOLUME_LEVEL_MAX - 1)) * 50; return ( <> - {volume <= 0 ? ( + {level <= 0 ? ( - ) : volume < 0.5 ? ( + ) : level < 0.5 ? ( ) : ( @@ -36,7 +40,7 @@ function VolumeIndicator({ volume }: { volume: number }) {
@@ -109,7 +113,7 @@ export function PlayerGestureIndicatorOverlay({ >
- {shown?.kind === "volume" && } + {shown?.kind === "volume" && } {shown?.kind === "channel" && ( (null); const [volume, setVolume] = useState(() => getVolume()); + const [volumeBoost, setVolumeBoost] = useState(() => getVolumeBoost()); const [isMuted, setIsMuted] = useState(() => getMuted()); const [isPlaying, setIsPlaying] = useState(false); const [liveSessionAnchor, setLiveSessionAnchor] = useState(null); @@ -842,6 +850,7 @@ function VideoPlayerComponent({ pictureEnhancement, }); p.setVolume(volume); + p.setVolumeBoost?.(volumeBoost); p.setMuted(isMuted); p.on("error", (e) => { if (slotPlayerRef(slotId).current === p) { @@ -1508,6 +1517,18 @@ function VideoPlayerComponent({ }; }, [isDocumentPiP]); + const handleVolumeBoostChange = useEffectEvent((level: number) => { + setVolumeBoost(level); + saveVolumeBoost(level); + getActivePlayer()?.setVolumeBoost?.(level); + // The hidden slot carries the same programme and becomes audible on switch, + // so it has to follow the same level. + for (const sid of ["a", "b"] as SlotId[]) { + const player = slotPlayerRef(sid).current; + if (player) player.setVolumeBoost?.(level); + } + }); + const handleVolumeChange = useEffectEvent((newVolume: number) => { const player = getActivePlayer(); if (player) { @@ -1527,10 +1548,12 @@ function VideoPlayerComponent({ enableSeekGesture: isCatchupSupported, enableVolumeGesture: canControlVolume, volume, + volumeBoost, isMuted, prevChannel, nextChannel, onVolumeChange: handleVolumeChange, + onVolumeBoostChange: handleVolumeBoostChange, onChannelNavigate, onRelativeSeek: handleRelativeSeek, onTogglePlayPause: togglePlayPause, @@ -2015,6 +2038,8 @@ function VideoPlayerComponent({ onPlayPause={togglePlayPause} volume={volume} onVolumeChange={handleVolumeChange} + volumeBoost={volumeBoost} + onVolumeBoostChange={handleVolumeBoostChange} canControlVolume={canControlVolume} isMuted={isMuted} onMuteToggle={handleMuteToggle} diff --git a/web-ui/src/hooks/use-player-touch-gestures.ts b/web-ui/src/hooks/use-player-touch-gestures.ts index a4afc144..41d98644 100644 --- a/web-ui/src/hooks/use-player-touch-gestures.ts +++ b/web-ui/src/hooks/use-player-touch-gestures.ts @@ -14,8 +14,15 @@ const ACTIVATION_THRESHOLD_PX = 12; const CHANNEL_COMMIT_RATIO = 0.15; const CHANNEL_COMMIT_MIN_PX = 48; const CHANNEL_COMMIT_MAX_PX = 120; -/** Vertical travel that spans the whole 0..1 volume range, as a ratio of the surface height. */ +/** Vertical travel that spans the whole volume gesture range, as a ratio of the surface height. */ const VOLUME_FULL_SWING_RATIO = 0.6; +/** Highest level the gesture reaches: 0..1 is element volume, above it gain. */ +const VOLUME_LEVEL_MAX = 6; +/** Travel mapping shared with the control-bar slider: lower half 0..100%, upper half gain. */ +const levelToPos = (level: number): number => + level <= 1 ? level * 0.5 : 0.5 + ((level - 1) / (VOLUME_LEVEL_MAX - 1)) * 0.5; +const posToLevel = (pos: number): number => + pos <= 0.5 ? pos * 2 : 1 + ((pos - 0.5) / 0.5) * (VOLUME_LEVEL_MAX - 1); /** Seconds seeked when dragging across the full surface width. */ const SEEK_FULL_SWING_SECONDS = 120; /** Ignore sub-second seeks so a sloppy tap-drag does not nudge playback. */ @@ -26,7 +33,7 @@ const DOUBLE_TAP_SLOP_PX = 40; const INDICATOR_LINGER_MS = 700; export type PlayerGestureIndicator = - | { kind: "volume"; volume: number } + | { kind: "volume"; level: number } | { kind: "channel"; direction: "prev" | "next"; target: Channel | null } | { kind: "seek"; deltaSeconds: number }; @@ -43,7 +50,8 @@ interface GestureState { startedOnLeftHalf: boolean; width: number; height: number; - startVolume: number; + /** Slider position the drag started from (see levelToPos). */ + startVolumePos: number; /** Direction armed by the channel gesture, or null while below the commit threshold. */ channelDirection: "prev" | "next" | null; seekDeltaSeconds: number; @@ -57,10 +65,13 @@ interface UsePlayerTouchGesturesOptions { /** Right-half volume gesture; off where the platform makes volume read-only (iOS). */ enableVolumeGesture: boolean; volume: number; + /** Gain above unity, 1 = 100%. The volume gesture continues into this range. */ + volumeBoost: number; isMuted: boolean; prevChannel: Channel | null; nextChannel: Channel | null; onVolumeChange: (volume: number) => void; + onVolumeBoostChange: (boost: number) => void; onChannelNavigate: ((target: "prev" | "next") => void) | undefined; onRelativeSeek: (deltaSeconds: number) => void; onTogglePlayPause: () => void; @@ -85,10 +96,12 @@ export function usePlayerTouchGestures({ enableSeekGesture, enableVolumeGesture, volume, + volumeBoost, isMuted, prevChannel, nextChannel, onVolumeChange, + onVolumeBoostChange, onChannelNavigate, onRelativeSeek, onTogglePlayPause, @@ -180,7 +193,7 @@ export function usePlayerTouchGestures({ startedOnLeftHalf: event.clientX - rect.left < rect.width / 2, width: rect.width, height: rect.height, - startVolume: isMuted ? 0 : volume, + startVolumePos: levelToPos(isMuted ? 0 : volume < 1 ? volume : volumeBoost), channelDirection: null, seekDeltaSeconds: 0, }; @@ -209,10 +222,18 @@ export function usePlayerTouchGestures({ } if (gesture.mode === "volume") { - // Up is louder, hence the negated dy. - const nextVolume = clamp01(gesture.startVolume - dy / (gesture.height * VOLUME_FULL_SWING_RATIO)); - onVolumeChange(nextVolume); - showIndicator({ kind: "volume", volume: nextVolume }); + // Up is louder, hence the negated dy. Past the midpoint the same drag drives + // the gain stage, since element volume cannot exceed 1. + const nextPos = clamp01(gesture.startVolumePos - dy / (gesture.height * VOLUME_FULL_SWING_RATIO)); + const level = posToLevel(nextPos); + if (level <= 1) { + if (volumeBoost !== 1) onVolumeBoostChange(1); + onVolumeChange(level); + } else { + if (volume < 1) onVolumeChange(1); + onVolumeBoostChange(Math.round(level * 10) / 10); + } + showIndicator({ kind: "volume", level }); return; } diff --git a/web-ui/src/lib/player-storage.ts b/web-ui/src/lib/player-storage.ts index 0483bd7e..f2545fef 100644 --- a/web-ui/src/lib/player-storage.ts +++ b/web-ui/src/lib/player-storage.ts @@ -56,6 +56,7 @@ export const [getPictureEnhancement, savePictureEnhancement] = createStore("rtp2 ); export const [getVolume, saveVolume] = createStore("rtp2httpd-player-volume", 1); export const [getMuted, saveMuted] = createStore("rtp2httpd-player-muted", false); +export const [getVolumeBoost, saveVolumeBoost] = createStore("rtp2httpd-player-volume-boost", 1); // Per-channel source index uses a JSON object map, so it needs custom logic const [getSourceIndexMap, saveSourceIndexMap] = createStore>( diff --git a/web-ui/src/playback-engine/audio/pcm-audio-player.ts b/web-ui/src/playback-engine/audio/pcm-audio-player.ts index e9e72d03..dee5a5da 100644 --- a/web-ui/src/playback-engine/audio/pcm-audio-player.ts +++ b/web-ui/src/playback-engine/audio/pcm-audio-player.ts @@ -129,6 +129,9 @@ export class PCMAudioPlayer { private config: PlayerConfig; private context: AudioContext | null = null; private gainNode: GainNode | null = null; + private limiterNode: DynamicsCompressorNode | null = null; + /** Extra gain above unity, 1 = 100%. */ + private boost = 1; private volume: number = 1.0; private muted: boolean = false; @@ -222,10 +225,20 @@ export class PCMAudioPlayer { this.context = new AudioContext(); this.gainNode = this.context.createGain(); + // Limiter after the gain stage: anything above unity would clip, and clipping + // is audible as crackle on speech, so peaks are compressed instead of clipped. + this.limiterNode = this.context.createDynamicsCompressor(); + this.limiterNode.threshold.value = -6; + this.limiterNode.knee.value = 6; + this.limiterNode.ratio.value = 12; + this.limiterNode.attack.value = 0.003; + this.limiterNode.release.value = 0.25; + this.gainNode.connect(this.limiterNode); + if (isIOS()) { try { this.mediaStreamDestination = this.context.createMediaStreamDestination(); - this.gainNode.connect(this.mediaStreamDestination); + this.limiterNode.connect(this.mediaStreamDestination); this.audioElement = document.createElement("audio"); this.audioElement.srcObject = this.mediaStreamDestination.stream; @@ -236,10 +249,10 @@ export class PCMAudioPlayer { Log.v(TAG, "iOS detected: using MediaStream bypass for Silent Mode"); } catch (_e) { Log.w(TAG, "Failed to create MediaStream destination, falling back to default output"); - this.gainNode.connect(this.context.destination); + this.limiterNode.connect(this.context.destination); } } else { - this.gainNode.connect(this.context.destination); + this.limiterNode.connect(this.context.destination); } this.updateGain(); @@ -1019,7 +1032,7 @@ export class PCMAudioPlayer { if (smooth && ctx && this.gainNode && this.scheduledSpans.length > 0 && ctx.state === "running") { // Quick gain dip so already-playing audio doesn't click when stopped const now = ctx.currentTime; - const target = this.muted ? 0 : this.volume; + const target = this.currentGain(); const g = this.gainNode.gain; g.cancelScheduledValues(now); g.setValueAtTime(target, now); @@ -1412,14 +1425,28 @@ export class PCMAudioPlayer { this.updateGain(); } + /** Extra gain above unity: 1 = 100%, up to 6 = 600%. Composes with `volume`. */ + setBoost(boost: number): void { + this.boost = Math.max(1, Math.min(6, boost)); + this.updateGain(); + } + + /** Gain actually applied: volume composed with the boost, muted to zero. */ + private currentGain(): number { + return this.muted ? 0 : Math.min(this.volume * this.boost, 6); + } + private updateGain(): void { if (this.gainNode && this.context) { const g = this.gainNode.gain; + const level = this.currentGain(); g.cancelScheduledValues(this.context.currentTime); - g.value = this.muted ? 0 : this.volume; + g.value = level; } if (this.audioElement) { - this.audioElement.volume = this.muted ? 0 : this.volume; + // The element's own volume caps at 1; whatever exceeds it is already applied + // upstream by the gain stage. + this.audioElement.volume = this.muted ? 0 : Math.min(1, this.volume * this.boost); } } @@ -1443,6 +1470,10 @@ export class PCMAudioPlayer { this.mediaStreamDestination = null; } + if (this.limiterNode) { + this.limiterNode.disconnect(); + this.limiterNode = null; + } if (this.gainNode) { this.gainNode.disconnect(); this.gainNode = null; diff --git a/web-ui/src/playback-engine/backends/mse-playback-backend.ts b/web-ui/src/playback-engine/backends/mse-playback-backend.ts index dcd5a517..e24cfb85 100644 --- a/web-ui/src/playback-engine/backends/mse-playback-backend.ts +++ b/web-ui/src/playback-engine/backends/mse-playback-backend.ts @@ -91,6 +91,9 @@ export function createMSEPlaybackBackend(video: HTMLVideoElement, config?: Parti setMuted: (muted) => { video.muted = muted; }, + setVolumeBoost: (level) => { + controller?.setVolumeBoost(level); + }, getState: () => ({ currentTime: video.currentTime, diff --git a/web-ui/src/playback-engine/mse/playback-controller.ts b/web-ui/src/playback-engine/mse/playback-controller.ts index 8ee1dbf0..d9adecf8 100644 --- a/web-ui/src/playback-engine/mse/playback-controller.ts +++ b/web-ui/src/playback-engine/mse/playback-controller.ts @@ -52,6 +52,9 @@ export function createMSEPlaybackController( // PCM audio player for software-decoded audio (MP2) let pcmPlayer: PCMAudioPlayer | null = null; let pcmPlayerInitPromise: Promise | null = null; + // Kept outside the PCM player: it is created lazily, long after the UI + // applies the level, and is rebuilt on every audio recovery. + let volumeBoostLevel = 1; let startupRateControlActive = false; function ensurePCMPlayer(): PCMAudioPlayer { @@ -81,6 +84,7 @@ export function createMSEPlaybackController( }; pcmPlayerInitPromise = pcmPlayer.init(); pcmPlayer.attachVideo(video); + pcmPlayer.setBoost(volumeBoostLevel); } return pcmPlayer; } @@ -500,6 +504,11 @@ export function createMSEPlaybackController( goLiveTo(targetMseSeconds); }, + setVolumeBoost(level: number) { + volumeBoostLevel = level; + pcmPlayer?.setBoost(level); + }, + setLiveSessionAnchor(anchor: LiveSessionAnchor) { liveSessionAnchor = anchor; updateLiveState(); diff --git a/web-ui/src/playback-engine/types.ts b/web-ui/src/playback-engine/types.ts index eaea7431..63d487cc 100644 --- a/web-ui/src/playback-engine/types.ts +++ b/web-ui/src/playback-engine/types.ts @@ -83,6 +83,8 @@ export interface PlaybackBackend { play(): Promise; pause(): void; setVolume(volume: number): void; + /** Extra gain above unity, 1 = 100% (up to 6). No-op on backends without a gain stage. */ + setVolumeBoost?(level: number): void; setMuted(muted: boolean): void; getState(): PlaybackBackendState; seek(seconds: number): void; @@ -111,6 +113,7 @@ export interface MSEPlaybackController { loadSegments(segments: PlayerSegment[]): void; seek(seconds: number): void; goLive(targetMseSeconds: number): void; + setVolumeBoost(level: number): void; setLiveSessionAnchor(anchor: LiveSessionAnchor): void; setLiveSync(enabled: boolean): void; /** Release the video element (stop feeding, detach source) but keep reusable resources (worker) alive. */