diff --git a/src/constants.js b/src/constants.js index 2869cb2a65567..2a0749d909a8b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -227,7 +227,9 @@ const PlayerIcons = { TUNE_FILLED: 'M480-120q-17 0-28.5-11.5T440-160v-160q0-17 11.5-28.5T480-360q17 0 28.5 11.5T520-320v40h280q17 0 28.5 11.5T840-240q0 17-11.5 28.5T800-200H520v40q0 17-11.5 28.5T480-120Zm-320-80q-17 0-28.5-11.5T120-240q0-17 11.5-28.5T160-280h160q17 0 28.5 11.5T360-240q0 17-11.5 28.5T320-200H160Zm160-160q-17 0-28.5-11.5T280-400v-40H160q-17 0-28.5-11.5T120-480q0-17 11.5-28.5T160-520h120v-40q0-17 11.5-28.5T320-600q17 0 28.5 11.5T360-560v160q0 17-11.5 28.5T320-360Zm160-80q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520h320q17 0 28.5 11.5T840-480q0 17-11.5 28.5T800-440H480Zm160-160q-17 0-28.5-11.5T600-640v-160q0-17 11.5-28.5T640-840q17 0 28.5 11.5T680-800v40h120q17 0 28.5 11.5T840-720q0 17-11.5 28.5T800-680H680v40q0 17-11.5 28.5T640-600Zm-480-80q-17 0-28.5-11.5T120-720q0-17 11.5-28.5T160-760h320q17 0 28.5 11.5T520-720q0 17-11.5 28.5T480-680H160Z', RECTANGLE_DEFAULT: 'M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-480H160v480Zm0 0v-480 480Z', SKIP_NEXT_FILLED: 'M660-280v-400q0-17 11.5-28.5T700-720q17 0 28.5 11.5T740-680v400q0 17-11.5 28.5T700-240q-17 0-28.5-11.5T660-280Zm-440-35v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T548-480q0 10-4.5 18.5T530-447L282-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Z', - SKIP_PREVIOUS_FILLED: 'M220-280v-400q0-17 11.5-28.5T260-720q17 0 28.5 11.5T300-680v400q0 17-11.5 28.5T260-240q-17 0-28.5-11.5T220-280Zm458-1L430-447q-9-6-13.5-14.5T412-480q0-10 4.5-18.5T430-513l248-166q5-4 11-5t11-1q16 0 28 11t12 29v330q0 18-12 29t-28 11q-5 0-11-1t-11-5Z' + SKIP_PREVIOUS_FILLED: 'M220-280v-400q0-17 11.5-28.5T260-720q17 0 28.5 11.5T300-680v400q0 17-11.5 28.5T260-240q-17 0-28.5-11.5T220-280Zm458-1L430-447q-9-6-13.5-14.5T412-480q0-10 4.5-18.5T430-513l248-166q5-4 11-5t11-1q16 0 28 11t12 29v330q0 18-12 29t-28 11q-5 0-11-1t-11-5Z', + TIMER_DEFAULT: 'M360-840v-80h240v80H360Zm80 440h80v-240h-80v240Zm40 320q-74 0-139.5-28.5T226-186q-49-49-77.5-114.5T120-440q0-74 28.5-139.5T226-694q49-49 114.5-77.5T480-800q62 0 119 20t107 58l56-56 56 56-56 56q38 50 58 107t20 119q0 74-28.5 139.5T734-186q-49 49-114.5 77.5T480-80Zm0-80q116 0 198-82t82-198q0-116-82-198t-198-82q-116 0-198 82t-82 198q0 116 82 198t198 82Zm0-280Z', + SHUTTER_SPEED_DEFAULT: 'M360-840v-80h240v80H360ZM480-80q-75 0-140.5-28.5T225-186q-49-49-77-114.5T120-440q0-74 28.5-139.5T226-694q49-49 114.5-77.5T480-800q63 0 120 21t104 59l58-58 56 56-56 58q36 47 57 104t21 120q0 74-28 139.5T735-186q-49 49-114.5 77.5T480-80Zm0-360Zm0-80h268q-18-62-61.5-109T584-700L480-520Zm-70 40 134-232q-59-15-121.5-2.5T306-660l104 180Zm-206 80h206L276-632q-42 47-62.5 106.5T204-400Zm172 220 104-180H212q18 62 61.5 109T376-180Zm40 12q66 17 128 1.5T654-220L550-400 416-168Zm268-80q44-48 63.5-107.5T756-480H550l134 232Z', } const UnsupportedPlayerActions = /** @type {const} */({ @@ -265,6 +267,12 @@ const MIXED_SEARCH_HISTORY_ENTRIES_DISPLAY_LIMIT = 4 // Displayed on the about page and used in the main.js file to only allow bitcoin URLs with this wallet address to be opened const ABOUT_BITCOIN_ADDRESS = '1Lih7Ho5gnxb1CwPD4o59ss78pwo2T91eS' +const SilenceSkip = { + SILENCE_DETECTION_MULTIPLIER: 4, // Multiplier for silence detection. Higher = more sensitive + MIN_SILENCE_DURATION_MS: 150, // Min silence duration in ms. Higher for longer silence before skipping. Lower for faster reaction. + MIN_SOUND_DURATION_MS: 5, // Min sound duration in ms. Higher to avoid false positives for short sounds. +} + export { IpcChannels, DBActions, @@ -273,6 +281,7 @@ export { KeyboardShortcuts, PlayerIcons, UnsupportedPlayerActions, + SilenceSkip, MAIN_PROFILE_ID, MOBILE_WIDTH_THRESHOLD, PLAYLIST_HEIGHT_FORCE_LIST_THRESHOLD, diff --git a/src/renderer/components/PlayerSettings/PlayerSettings.vue b/src/renderer/components/PlayerSettings/PlayerSettings.vue index cbf48e59e26c1..e86267cfef65e 100644 --- a/src/renderer/components/PlayerSettings/PlayerSettings.vue +++ b/src/renderer/components/PlayerSettings/PlayerSettings.vue @@ -40,6 +40,12 @@ :tooltip="t('Tooltips.Player Settings.Skip by Scrolling Over Video Player')" @change="updateVideoSkipMouseScroll" /> +
} */ +const skipSilenceEnabled = computed(() => store.getters.getSkipSilenceEnabled) + +/** + * @param {boolean} value + */ +function updateSkipSilenceEnabled(value) { + store.dispatch('updateSkipSilenceEnabled', value) +} + /** @type {import('vue').ComputedRef} */ const externalPlayer = computed(() => store.getters.getExternalPlayer) diff --git a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js index ad96c3abf1eae..756e44cf2251c 100644 --- a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js +++ b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js @@ -3,7 +3,7 @@ import shaka from 'shaka-player' import { useI18n } from '../../composables/use-i18n-polyfill' import store from '../../store/index' -import { DefaultFolderKind, KeyboardShortcuts } from '../../../constants' +import { DefaultFolderKind, KeyboardShortcuts, SilenceSkip } from '../../../constants' import { AudioTrackSelection } from './player-components/AudioTrackSelection' import { FullWindowButton } from './player-components/FullWindowButton' import { LegacyQualitySelection } from './player-components/LegacyQualitySelection' @@ -12,6 +12,7 @@ import { StatsButton } from './player-components/StatsButton' import { TheatreModeButton } from './player-components/TheatreModeButton' import { AutoplayToggle } from './player-components/AutoplayToggle' import { SkipButton } from './player-components/SkipButton' +import { SkipSilenceButton } from './player-components/SkipSilenceButton' import { deduplicateAudioTracks, findMostSimilarAudioBandwidth, @@ -151,6 +152,10 @@ export default defineComponent({ type: Number, default: 1 }, + skipSilenceEnabled: { + type: Boolean, + default: false + } }, emits: [ 'error', @@ -162,6 +167,7 @@ export default defineComponent({ 'playback-rate-updated', 'skip-to-next', 'skip-to-prev', + 'skip-silence-updated', ], setup: function (props, { emit, expose }) { const { locale, t } = useI18n() @@ -198,6 +204,9 @@ export default defineComponent({ let startInFullscreen = props.startInFullscreen let startInPip = props.startInPip + const isSilenceSkipEnabled = ref(false) + const trickPlayNormalSpeed = ref(props.currentPlaybackRate) + /** * @type {{ * url: string, @@ -830,6 +839,7 @@ export default defineComponent({ 'captions', 'ft_audio_tracks', 'loop', + 'ft_skip_silence_toggle', 'ft_screenshot', 'picture_in_picture', 'ft_full_window', @@ -857,6 +867,7 @@ export default defineComponent({ 'playback_rate', props.format === 'legacy' ? 'ft_legacy_quality' : 'quality', 'loop', + 'ft_skip_silence_toggle', 'recenter_vr', 'toggle_stereoscopic', ) @@ -1221,6 +1232,119 @@ export default defineComponent({ } } + /** + * Toggles and manages the silence skip functionality for the video player. + * + * When enabled, the function uses the Web Audio API to analyze the audio stream of the video element + * and detect silent segments. Silence detection is performed via an AnalyserNode connected in parallel + * to the audio output, allowing for volume analysis even when the output is muted during fast-forward. + * + * The detection logic calculates the maximum and average amplitude of the audio signal. If a silent segment + * is detected and persists for a defined minimum duration, the video is fast-forwarded and the output is smoothly + * muted using a GainNode to avoid click artifacts. When non-silent audio is detected and persists for a minimum duration, + * the output is smoothly unmuted and playback speed returns to normal, with a additional delay to further reduce audio clicks. + * + * The function continuously analyzes the audio stream using requestAnimationFrame, adapting playback and muting in real time. + * All transitions for muting and unmuting use smooth ramping via setTargetAtTime for click-free audio.. + */ + function skipSilence() { + isSilenceSkipEnabled.value = !isSilenceSkipEnabled.value + + const video_ = video.value + + if (video_ && player) { + const audioContext = video_.audioContext ?? new AudioContext() + let source = video_.audioSource + if (!source) { + source = audioContext.createMediaElementSource(video_) + video_.audioSource = source + } + if (!video_.audioContext) { + video_.audioContext = audioContext + } + const gain = audioContext.createGain() + const analyser = audioContext.createAnalyser() + source.disconnect() + source.connect(gain) + source.connect(analyser) + gain.connect(audioContext.destination) + + analyser.fftSize = 2048 + const bufferLength = analyser.frequencyBinCount + const amplitudeArray = new Uint8Array(bufferLength) + + let loopId = 0 + let silenceStart = null + let soundStart = null + let isSkipping = false + + trickPlayNormalSpeed.value = player.getPlaybackRate() + const trickPlayFastForwardSpeed = maxVideoPlaybackRate.value + + function resetSkip() { + gain.gain.setTargetAtTime(1, audioContext.currentTime, 0.015) + player.trickPlay(trickPlayNormalSpeed.value) + isSkipping = false + silenceStart = null + soundStart = null + } + + const loop = () => { + if (!player) { + cancelAnimationFrame(loopId) + return + } + + const currentPlaybackRate = player.getPlaybackRate() + // Update the trick play speed, if the user changes the playback rate + if (trickPlayNormalSpeed.value !== currentPlaybackRate && trickPlayFastForwardSpeed !== currentPlaybackRate) { + trickPlayNormalSpeed.value = player.getPlaybackRate() + } + + if (isSilenceSkipEnabled.value) { + analyser.getByteTimeDomainData(amplitudeArray) + const volumeValues = Array.from(amplitudeArray) + const filteredVolumes = volumeValues.map(v => v - 128).filter(v => v !== 0).map(Math.abs) + const maxVolume = filteredVolumes.length ? Math.max(...filteredVolumes) : 0 + const averageVolume = filteredVolumes.length ? filteredVolumes.reduce((a, b) => a + b, 0) / filteredVolumes.length : 0 + const silencePercentage = !isNaN(maxVolume) && !isNaN(averageVolume) ? (averageVolume / maxVolume) * SilenceSkip.SILENCE_DETECTION_MULTIPLIER : 0 + const isSilent = (maxVolume <= averageVolume || maxVolume <= silencePercentage) + + const now = performance.now() + + if (isSilent && !isSkipping && !video_.paused && !video_.ended && !video_.muted) { + if (!silenceStart) silenceStart = now + if (now - silenceStart > SilenceSkip.MIN_SILENCE_DURATION_MS) { + gain.gain.setTargetAtTime(0, audioContext.currentTime, 0.025) + player.trickPlay(trickPlayFastForwardSpeed) + isSkipping = true + soundStart = null + } + } else if (!isSilent && isSkipping) { + if (!soundStart) soundStart = now + if (now - soundStart > SilenceSkip.MIN_SOUND_DURATION_MS) { + gain.gain.setTargetAtTime(1, audioContext.currentTime, 0.015) + setTimeout(() => { + resetSkip() + }, 25) + } + } else if (!isSilent && !isSkipping) { + resetSkip() + } else if (isSkipping && (video_.paused || video_.ended || video_.muted)) { + resetSkip() + } + } else { + resetSkip() + return + } + + loopId = requestAnimationFrame(loop) + } + + loop() + } + } + // #endregion video event handlers // #region request/response filters @@ -1726,6 +1850,25 @@ export default defineComponent({ shakaOverflowMenu.registerElement('ft_autoplay_toggle', new AutoplayToggleFactory()) } + function registerSkipSilenceToggle() { + events.addEventListener('toggleSkipSilence', () => { + emit('skip-silence-updated', !isSilenceSkipEnabled.value) + skipSilence() + }) + + /** + * @implements {shaka.extern.IUIElement.Factory} + */ + class SkipSilenceToggleFactory { + create(rootElement, controls) { + return new SkipSilenceButton(isSilenceSkipEnabled.value, events, rootElement, controls) + } + } + + shakaControls.registerElement('ft_skip_silence_toggle', new SkipSilenceToggleFactory()) + shakaOverflowMenu.registerElement('ft_skip_silence_toggle', new SkipSilenceToggleFactory()) + } + function registerTheatreModeButton() { events.addEventListener('toggleTheatreMode', () => { emit('toggle-theatre-mode') @@ -1913,6 +2056,9 @@ export default defineComponent({ shakaControls.registerElement('ft_skip_previous', null) shakaOverflowMenu.registerElement('ft_skip_previous', null) + + shakaControls.registerElement('ft_skip_silence_toggle', null) + shakaOverflowMenu.registerElement('ft_skip_silence_toggle', null) } // #endregion custom player controls @@ -2215,6 +2361,8 @@ export default defineComponent({ return } + const playbackRate = isSilenceSkipEnabled.value ? trickPlayNormalSpeed.value : player.getPlaybackRate() + switch (event.key.toLowerCase()) { case ' ': case 'spacebar': // older browsers might return spacebar instead of a space character @@ -2226,12 +2374,12 @@ export default defineComponent({ case KeyboardShortcuts.VIDEO_PLAYER.PLAYBACK.LARGE_REWIND: // Rewind by 2x the time-skip interval (in seconds) event.preventDefault() - seekBySeconds(-defaultSkipInterval.value * player.getPlaybackRate() * 2, false, true) + seekBySeconds(-defaultSkipInterval.value * playbackRate * 2, false, true) break case KeyboardShortcuts.VIDEO_PLAYER.PLAYBACK.LARGE_FAST_FORWARD: // Fast-Forward by 2x the time-skip interval (in seconds) event.preventDefault() - seekBySeconds(defaultSkipInterval.value * player.getPlaybackRate() * 2, false, true) + seekBySeconds(defaultSkipInterval.value * playbackRate * 2, false, true) break case KeyboardShortcuts.VIDEO_PLAYER.PLAYBACK.DECREASE_VIDEO_SPEED: case KeyboardShortcuts.VIDEO_PLAYER.PLAYBACK.DECREASE_VIDEO_SPEED_ALT: @@ -2290,7 +2438,7 @@ export default defineComponent({ showOverlayControls() } else { // Rewind by the time-skip interval (in seconds) - seekBySeconds(-defaultSkipInterval.value * player.getPlaybackRate(), false, true) + seekBySeconds(-defaultSkipInterval.value * playbackRate, false, true) } break case KeyboardShortcuts.VIDEO_PLAYER.PLAYBACK.SMALL_FAST_FORWARD: @@ -2301,7 +2449,7 @@ export default defineComponent({ showOverlayControls() } else { // Fast-Forward by the time-skip interval (in seconds) - seekBySeconds(defaultSkipInterval.value * player.getPlaybackRate(), false, true) + seekBySeconds(defaultSkipInterval.value * playbackRate, false, true) } break case KeyboardShortcuts.VIDEO_PLAYER.GENERAL.PICTURE_IN_PICTURE: @@ -2628,6 +2776,7 @@ export default defineComponent({ registerLegacyQualitySelection() registerStatsButton() registerSkipButtons() + registerSkipSilenceToggle() if (ui.isMobile()) { onlyUseOverFlowMenu.value = true @@ -2682,6 +2831,13 @@ export default defineComponent({ player.addEventListener('ratechange', () => { emit('playback-rate-updated', player.getPlaybackRate()) }) + + if (props.skipSilenceEnabled) { + skipSilence() + events.dispatchEvent(new CustomEvent('setSkipSilence', { + detail: isSilenceSkipEnabled.value + })) + } }) async function performFirstLoad() { @@ -3081,7 +3237,7 @@ export default defineComponent({ pause, getCurrentTime, setCurrentTime, - destroyPlayer + destroyPlayer, }) // #endregion functions used by the watch page diff --git a/src/renderer/components/ft-shaka-video-player/player-components/SkipSilenceButton.js b/src/renderer/components/ft-shaka-video-player/player-components/SkipSilenceButton.js new file mode 100644 index 0000000000000..a521af2559ab0 --- /dev/null +++ b/src/renderer/components/ft-shaka-video-player/player-components/SkipSilenceButton.js @@ -0,0 +1,72 @@ +import shaka from 'shaka-player' + +import i18n from '../../../i18n/index' +import { PlayerIcons } from '../../../../constants' + +export class SkipSilenceButton extends shaka.ui.Element { + /** + * @param {boolean} skipSilenceEnabled + * @param {EventTarget} events + * @param {HTMLElement} parent + * @param {shaka.ui.Controls} controls + */ + constructor(skipSilenceEnabled, events, parent, controls) { + super(parent, controls) + + /** @private */ + this.button_ = document.createElement('button') + this.button_.classList.add('skip-silence-button', 'shaka-tooltip') + + /** @private */ + this.icon_ = new shaka.ui.MaterialSVGIcon(this.button_, PlayerIcons.TIMER_DEFAULT) + + const label = document.createElement('label') + label.classList.add( + 'shaka-overflow-button-label', + 'shaka-overflow-menu-only', + 'shaka-simple-overflow-button-label-inline' + ) + + /** @private */ + this.nameSpan_ = document.createElement('span') + label.appendChild(this.nameSpan_) + + /** @private */ + this.currentState_ = document.createElement('span') + this.currentState_.classList.add('shaka-current-selection-span') + label.appendChild(this.currentState_) + + this.button_.appendChild(label) + + this.parent.appendChild(this.button_) + + /** @private */ + this.skipSilenceEnabled_ = skipSilenceEnabled + + // listeners + + this.eventManager.listen(this.button_, 'click', () => { + events.dispatchEvent(new CustomEvent('toggleSkipSilence')) + this.skipSilenceEnabled_ = !this.skipSilenceEnabled_ + this.updateLocalisedStrings_() + }) + + this.eventManager.listen(events, 'setSkipSilence', (event) => { + this.skipSilenceEnabled_ = event.detail + this.updateLocalisedStrings_() + }) + + this.eventManager.listen(events, 'localeChanged', () => { + this.updateLocalisedStrings_() + }) + + this.updateLocalisedStrings_() + } + + /** @private */ + updateLocalisedStrings_() { + this.nameSpan_.textContent = this.button_.ariaLabel = i18n.global.t('Video.Player.Skip Silence') + this.icon_.use(this.skipSilenceEnabled_ ? PlayerIcons.SHUTTER_SPEED_DEFAULT : PlayerIcons.TIMER_DEFAULT) + this.currentState_.textContent = this.localization.resolve(this.skipSilenceEnabled_ ? 'ON' : 'OFF') + } +} diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js index a637b74ff705d..0f546258845cc 100644 --- a/src/renderer/store/modules/settings.js +++ b/src/renderer/store/modules/settings.js @@ -306,6 +306,7 @@ const state = { quickBookmarkTargetPlaylistId: 'favorites', generalAutoLoadMorePaginatedItemsEnabled: false, hideToTrayOnMinimize: false, + skipSilenceEnabled: false, // The settings below have side effects currentLocale: 'system', diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js index 42ee8d687a0a2..b39efa9009ef5 100644 --- a/src/renderer/views/Watch/Watch.js +++ b/src/renderer/views/Watch/Watch.js @@ -143,6 +143,7 @@ export default defineComponent({ /** @type {Date|null} */ streamingDataExpiryDate: null, currentPlaybackRate: null, + startNextVideoWithSkipSilenceEnabled: false } }, computed: { @@ -339,6 +340,7 @@ export default defineComponent({ this.checkIfTimestamp() this.currentPlaybackRate = this.$store.getters.getDefaultPlayback + this.startNextVideoWithSkipSilenceEnabled = this.$store.getters.getSkipSilenceEnabled }, mounted: function () { this.onMountedDependOnLocalStateLoading() @@ -1780,6 +1782,9 @@ export default defineComponent({ updatePlaybackRate(newRate) { this.currentPlaybackRate = newRate }, + updateSkipSilence(newState) { + this.startNextVideoWithSkipSilenceEnabled = newState + }, destroyPlayer: async function() { const uiState = await this.$refs.player.destroyPlayer() diff --git a/src/renderer/views/Watch/Watch.vue b/src/renderer/views/Watch/Watch.vue index 338f96c261225..177295baf19f8 100644 --- a/src/renderer/views/Watch/Watch.vue +++ b/src/renderer/views/Watch/Watch.vue @@ -41,6 +41,7 @@ :start-in-fullwindow="startNextVideoInFullwindow" :start-in-pip="startNextVideoInPip" :current-playback-rate="currentPlaybackRate" + :skip-silence-enabled="startNextVideoWithSkipSilenceEnabled" class="videoPlayer" @error="handlePlayerError" @loaded="handleVideoLoaded" @@ -51,6 +52,7 @@ @playback-rate-updated="updatePlaybackRate" @skip-to-next="handleSkipToNext" @skip-to-prev="handleSkipToPrev" + @skip-silence-updated="updateSkipSilence" />