diff --git a/src/constants.js b/src/constants.js index e6c8aff4fde4e..dcdc98d2162e2 100644 --- a/src/constants.js +++ b/src/constants.js @@ -222,7 +222,9 @@ const PlayerIcons = { PLAY_CIRCLE_FILLED: 'm426-330 195-125q14-9 14-25t-14-25L426-630q-15-10-30.5-1.5T380-605v250q0 18 15.5 26.5T426-330Zm54 250q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z', RECORD_VOICE_OVER_FILLED: 'M920-600q0 69-24.5 131.5T829-355q-12 14-30 15t-32-13q-13-13-12-31t12-33q30-38 46.5-85t16.5-98q0-51-16.5-97T767-781q-12-15-12.5-33t12.5-32q13-14 31.5-13.5T829-845q42 51 66.5 113.5T920-600Zm-182 0q0 32-10 61.5T700-484q-11 15-29.5 15.5T638-482q-13-13-13.5-31.5T633-549q6-11 9.5-24t3.5-27q0-14-3.5-27t-9.5-25q-9-17-8.5-35t13.5-31q14-14 32.5-13.5T700-716q18 25 28 54.5t10 61.5ZM360-440q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-200v-32q0-33 17-62t47-44q51-26 115-44t141-18q77 0 141 18t115 44q30 15 47 44t17 62v32q0 33-23.5 56.5T600-120H120q-33 0-56.5-23.5T40-200Z', 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' + 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', + 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', } // Utils @@ -246,6 +248,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, @@ -253,6 +261,7 @@ export { DefaultFolderKind, KeyboardShortcuts, PlayerIcons, + 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 e4b52d37b4097..09c9e0d03bc5b 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' @@ -11,6 +11,7 @@ import { ScreenshotButton } from './player-components/ScreenshotButton' import { StatsButton } from './player-components/StatsButton' import { TheatreModeButton } from './player-components/TheatreModeButton' import { AutoplayToggle } from './player-components/AutoplayToggle' +import { SkipSilenceButton } from './player-components/SkipSilenceButton' import { deduplicateAudioTracks, findMostSimilarAudioBandwidth, @@ -146,6 +147,10 @@ export default defineComponent({ type: Number, default: 1 }, + skipSilenceEnabled: { + type: Boolean, + default: false + } }, emits: [ 'error', @@ -157,6 +162,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() @@ -193,6 +199,9 @@ export default defineComponent({ let startInFullscreen = props.startInFullscreen let startInPip = props.startInPip + const isSilenceSkipEnabled = ref(false) + const trickPlayNormalSpeed = ref(props.currentPlaybackRate) + /** * @type {{ * url: string, @@ -817,6 +826,7 @@ export default defineComponent({ 'captions', 'ft_audio_tracks', 'loop', + 'ft_skip_silence_toggle', 'ft_screenshot', 'picture_in_picture', 'ft_full_window', @@ -844,6 +854,7 @@ export default defineComponent({ 'playback_rate', props.format === 'legacy' ? 'ft_legacy_quality' : 'quality', 'loop', + 'ft_skip_silence_toggle', 'recenter_vr', 'toggle_stereoscopic', ) @@ -1207,6 +1218,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 @@ -1712,6 +1836,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') @@ -1863,6 +2006,9 @@ export default defineComponent({ shakaControls.registerElement('ft_screenshot', null) shakaOverflowMenu.registerElement('ft_screenshot', null) + + shakaControls.registerElement('ft_skip_silence_toggle', null) + shakaOverflowMenu.registerElement('ft_skip_silence_toggle', null) } // #endregion custom player controls @@ -2165,6 +2311,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 @@ -2176,12 +2324,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: @@ -2240,7 +2388,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: @@ -2251,7 +2399,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: @@ -2577,6 +2725,7 @@ export default defineComponent({ registerFullWindowButton() registerLegacyQualitySelection() registerStatsButton() + registerSkipSilenceToggle() if (ui.isMobile()) { onlyUseOverFlowMenu.value = true @@ -2631,6 +2780,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() { @@ -3030,7 +3186,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 4339079b7962b..1851d0eeaf1ad 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 bbac535bb25f8..3f67165b9599c 100644 --- a/src/renderer/views/Watch/Watch.vue +++ b/src/renderer/views/Watch/Watch.vue @@ -40,6 +40,7 @@ :start-in-fullwindow="startNextVideoInFullwindow" :start-in-pip="startNextVideoInPip" :current-playback-rate="currentPlaybackRate" + :skip-silence-enabled="startNextVideoWithSkipSilenceEnabled" class="videoPlayer" @error="handlePlayerError" @loaded="handleVideoLoaded" @@ -50,6 +51,7 @@ @playback-rate-updated="updatePlaybackRate" @skip-to-next="handleSkipToNext" @skip-to-prev="handleSkipToPrev" + @skip-silence-updated="updateSkipSilence" />