Skip to content
Open
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
49 changes: 46 additions & 3 deletions web-ui/src/components/player/player-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -405,6 +408,8 @@ function PlayerControlsComponent({
isPlaying,
onPlayPause,
volume,
volumeBoost,
onVolumeBoostChange,
onVolumeChange,
canControlVolume,
isMuted,
Expand All @@ -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<number | null>(null);
const volumeHintTimerRef = useRef<ReturnType<typeof setTimeout> | 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;
Expand Down Expand Up @@ -486,6 +524,11 @@ function PlayerControlsComponent({

{/* Volume */}
<div className="group/volume relative flex items-center">
{volumeHint !== null && (
<div className="pointer-events-none absolute -top-6 left-1/2 z-20 -translate-x-1/2 rounded-md bg-black/75 px-1.5 py-0.5 text-[11px] font-semibold leading-none text-white shadow md:-top-7 md:text-xs">
{volumeHint}%
</div>
)}
<button
type="button"
onClick={onMuteToggle}
Expand Down Expand Up @@ -516,11 +559,11 @@ function PlayerControlsComponent({
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
value={sliderValue}
onChange={(e) => handleSliderChange(parseFloat(e.target.value))}
className="relative z-10 m-0 block h-16 w-1 cursor-pointer appearance-none bg-transparent [writing-mode:vertical-lr] [direction:rtl] md:h-20"
style={{
background: `linear-gradient(to top, #3b82f6 0%, #6366f1 ${(isMuted ? 0 : volume) * 100}%, rgba(219,234,254,0.18) ${(isMuted ? 0 : volume) * 100}%, rgba(219,234,254,0.18) 100%)`,
background: `linear-gradient(to top, #3b82f6 0%, #6366f1 ${sliderValue * 100}%, rgba(219,234,254,0.18) ${sliderValue * 100}%, rgba(219,234,254,0.18) 100%)`,
}}
/>
</div>
Expand Down
16 changes: 10 additions & 6 deletions web-ui/src/components/player/player-gesture-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,25 @@ 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 ? (
<VolumeX className={ICON_CLASS} />
) : volume < 0.5 ? (
) : level < 0.5 ? (
<Volume1 className={ICON_CLASS} />
) : (
<Volume2 className={ICON_CLASS} />
)}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-blue-50/15 shadow-[inset_0_1px_3px_rgba(0,0,0,0.45)] ring-1 ring-white/10 md:w-40">
<div
className="player-performance-progress-fill h-full rounded-full bg-[linear-gradient(90deg,#3b82f6_0%,#38bdf8_52%,#6366f1_100%)] shadow-[0_0_18px_rgba(59,130,246,0.4)]"
style={{ width: `${percent}%` }}
style={{ width: `${fill}%` }}
/>
</div>
<span className="w-10 shrink-0 text-right font-semibold text-blue-50 text-sm tabular-nums md:text-base">
Expand Down Expand Up @@ -109,7 +113,7 @@ export function PlayerGestureIndicatorOverlay({
>
<PlayerSelectedGlassLayers />
<div className="relative z-10 flex min-w-0 items-center gap-2 md:gap-3">
{shown?.kind === "volume" && <VolumeIndicator volume={shown.volume} />}
{shown?.kind === "volume" && <VolumeIndicator level={shown.level} />}
{shown?.kind === "channel" && (
<ChannelIndicator
indicator={shown}
Expand Down
27 changes: 26 additions & 1 deletion web-ui/src/components/player/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ import { isEditableKeyboardTarget } from "../../lib/keyboard";
import type { Locale } from "../../lib/locale";
import { buildCatchupSegments } from "../../lib/m3u-parser";
import { isVolumeControlSupported } from "../../lib/platform";
import { getMuted, getVolume, saveMuted, saveVolume } from "../../lib/player-storage";
import {
getMuted,
getVolume,
getVolumeBoost,
saveMuted,
saveVolume,
saveVolumeBoost,
} from "../../lib/player-storage";
import { createProgramTimeline, programPositionToWallClock } from "../../lib/program-timeline";
import {
createPlaybackBackend,
Expand Down Expand Up @@ -338,6 +345,7 @@ function VideoPlayerComponent({
);
const [warning, setWarning] = useState<PlaybackErrorDisplay | null>(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<LiveSessionAnchor | null>(null);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -1527,10 +1548,12 @@ function VideoPlayerComponent({
enableSeekGesture: isCatchupSupported,
enableVolumeGesture: canControlVolume,
volume,
volumeBoost,
isMuted,
prevChannel,
nextChannel,
onVolumeChange: handleVolumeChange,
onVolumeBoostChange: handleVolumeBoostChange,
onChannelNavigate,
onRelativeSeek: handleRelativeSeek,
onTogglePlayPause: togglePlayPause,
Expand Down Expand Up @@ -2015,6 +2038,8 @@ function VideoPlayerComponent({
onPlayPause={togglePlayPause}
volume={volume}
onVolumeChange={handleVolumeChange}
volumeBoost={volumeBoost}
onVolumeBoostChange={handleVolumeBoostChange}
canControlVolume={canControlVolume}
isMuted={isMuted}
onMuteToggle={handleMuteToggle}
Expand Down
37 changes: 29 additions & 8 deletions web-ui/src/hooks/use-player-touch-gestures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 };

Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -85,10 +96,12 @@ export function usePlayerTouchGestures({
enableSeekGesture,
enableVolumeGesture,
volume,
volumeBoost,
isMuted,
prevChannel,
nextChannel,
onVolumeChange,
onVolumeBoostChange,
onChannelNavigate,
onRelativeSeek,
onTogglePlayPause,
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions web-ui/src/lib/player-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, number>>(
Expand Down
43 changes: 37 additions & 6 deletions web-ui/src/playback-engine/audio/pcm-audio-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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;
Expand Down
Loading