-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Feature: Silence skip #7797
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature: Silence skip #7797
Changes from 32 commits
2a49a85
52213b8
e42485d
00516a9
61ab31f
156ca59
2571bb0
b9639cb
de85d69
1535bb0
6e2ce03
af1ec54
660ccf7
e7750fa
33bdf7d
02fdb49
2fa16b1
75ed668
651693a
aa1dbdf
766bb9a
54cd5ec
88d7593
729e84e
efcdfcf
7c513cf
22cdec1
c65c858
6843abe
c19d74c
9d63f44
441fdd3
b1ea3c3
759a61d
ecbc530
338cd09
c291226
c32e4a7
9bdcaf2
a9ee002
6be694e
b20cb98
3fa2a80
3546fbb
2d6626f
8081994
cd2b1c6
4f32185
5737f36
160b21d
d399a2a
1a5a789
37b82c4
de639cf
4bebb13
fba6d84
9caa888
8abaed8
0945179
2bf9145
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,14 +3,15 @@ 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' | ||||||||||||||
| 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,8 @@ export default defineComponent({ | |||||||||||||
| let startInFullscreen = props.startInFullscreen | ||||||||||||||
| let startInPip = props.startInPip | ||||||||||||||
|
|
||||||||||||||
| const isSilenceSkipEnabled = ref(false) | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * @type {{ | ||||||||||||||
| * url: string, | ||||||||||||||
|
|
@@ -811,13 +819,15 @@ export default defineComponent({ | |||||||||||||
|
|
||||||||||||||
| if (onlyUseOverFlowMenu.value) { | ||||||||||||||
| uiConfig.overflowMenuButtons = [ | ||||||||||||||
| 'ft_screenshot', | ||||||||||||||
|
efb4f5ff-1298-471a-8973-3d47447115dc marked this conversation as resolved.
Outdated
|
||||||||||||||
| 'ft_skip_silence_toggle', | ||||||||||||||
| 'ft_autoplay_toggle', | ||||||||||||||
| props.format === 'legacy' ? 'ft_legacy_quality' : 'quality', | ||||||||||||||
| 'playback_rate', | ||||||||||||||
| 'captions', | ||||||||||||||
| 'ft_audio_tracks', | ||||||||||||||
| 'loop', | ||||||||||||||
|
efb4f5ff-1298-471a-8973-3d47447115dc marked this conversation as resolved.
|
||||||||||||||
| 'ft_screenshot', | ||||||||||||||
| 'ft_skip_silence_toggle', | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is
This comment was marked as resolved.
Sorry, something went wrong.
efb4f5ff-1298-471a-8973-3d47447115dc marked this conversation as resolved.
|
||||||||||||||
| 'picture_in_picture', | ||||||||||||||
| 'ft_full_window', | ||||||||||||||
| 'recenter_vr', | ||||||||||||||
|
|
@@ -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', | ||||||||||||||
| ) | ||||||||||||||
|
|
@@ -1710,6 +1721,24 @@ export default defineComponent({ | |||||||||||||
| shakaOverflowMenu.registerElement('ft_autoplay_toggle', new AutoplayToggleFactory()) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function registerSkipSilenceToggle() { | ||||||||||||||
| events.addEventListener('toggleSkipSilence', () => { | ||||||||||||||
| 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') | ||||||||||||||
|
|
@@ -1861,6 +1890,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 | ||||||||||||||
|
|
@@ -2162,6 +2194,8 @@ export default defineComponent({ | |||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const previousIsSilenceSkipEnabledState = isSilenceSkipEnabled.value | ||||||||||||||
|
|
||||||||||||||
| switch (event.key.toLowerCase()) { | ||||||||||||||
| case ' ': | ||||||||||||||
| case 'spacebar': // older browsers might return spacebar instead of a space character | ||||||||||||||
|
|
@@ -2231,6 +2265,9 @@ export default defineComponent({ | |||||||||||||
| break | ||||||||||||||
| case KeyboardShortcuts.VIDEO_PLAYER.PLAYBACK.SMALL_REWIND: | ||||||||||||||
| event.preventDefault() | ||||||||||||||
| if (previousIsSilenceSkipEnabledState) { | ||||||||||||||
| skipSilence() | ||||||||||||||
| } | ||||||||||||||
| if (canChapterJump(event, 'previous')) { | ||||||||||||||
| // Jump to the previous chapter | ||||||||||||||
| video_.currentTime = props.chapters[props.currentChapterIndex - 1].startSeconds | ||||||||||||||
|
|
@@ -2239,9 +2276,15 @@ export default defineComponent({ | |||||||||||||
| // Rewind by the time-skip interval (in seconds) | ||||||||||||||
| seekBySeconds(-defaultSkipInterval.value * player.getPlaybackRate(), false, true) | ||||||||||||||
| } | ||||||||||||||
| if (previousIsSilenceSkipEnabledState) { | ||||||||||||||
| skipSilence() | ||||||||||||||
| } | ||||||||||||||
| break | ||||||||||||||
| case KeyboardShortcuts.VIDEO_PLAYER.PLAYBACK.SMALL_FAST_FORWARD: | ||||||||||||||
| event.preventDefault() | ||||||||||||||
| if (previousIsSilenceSkipEnabledState) { | ||||||||||||||
| skipSilence() | ||||||||||||||
| } | ||||||||||||||
| if (canChapterJump(event, 'next')) { | ||||||||||||||
| // Jump to the next chapter | ||||||||||||||
| video_.currentTime = (props.chapters[props.currentChapterIndex + 1].startSeconds) | ||||||||||||||
|
|
@@ -2250,6 +2293,9 @@ export default defineComponent({ | |||||||||||||
| // Fast-Forward by the time-skip interval (in seconds) | ||||||||||||||
| seekBySeconds(defaultSkipInterval.value * player.getPlaybackRate(), false, true) | ||||||||||||||
| } | ||||||||||||||
| if (previousIsSilenceSkipEnabledState) { | ||||||||||||||
| skipSilence() | ||||||||||||||
| } | ||||||||||||||
| break | ||||||||||||||
| case KeyboardShortcuts.VIDEO_PLAYER.GENERAL.PICTURE_IN_PICTURE: | ||||||||||||||
| // Toggle picture in picture | ||||||||||||||
|
|
@@ -2580,6 +2626,7 @@ export default defineComponent({ | |||||||||||||
| registerFullWindowButton() | ||||||||||||||
| registerLegacyQualitySelection() | ||||||||||||||
| registerStatsButton() | ||||||||||||||
| registerSkipSilenceToggle() | ||||||||||||||
|
|
||||||||||||||
| if (ui.isMobile()) { | ||||||||||||||
| onlyUseOverFlowMenu.value = true | ||||||||||||||
|
|
@@ -2634,6 +2681,13 @@ export default defineComponent({ | |||||||||||||
| player.addEventListener('ratechange', () => { | ||||||||||||||
| emit('playback-rate-updated', player.getPlaybackRate()) | ||||||||||||||
| }) | ||||||||||||||
|
|
||||||||||||||
| if (store.getters.getSkipSilenceEnabled || props.skipSilenceEnabled) { | ||||||||||||||
| skipSilence() | ||||||||||||||
| events.dispatchEvent(new CustomEvent('setSkipSilence', { | ||||||||||||||
| detail: isSilenceSkipEnabled.value | ||||||||||||||
| })) | ||||||||||||||
| } | ||||||||||||||
| }) | ||||||||||||||
|
|
||||||||||||||
| async function performFirstLoad() { | ||||||||||||||
|
|
@@ -3026,14 +3080,118 @@ export default defineComponent({ | |||||||||||||
| return uiState | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * 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() { | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function is in the wrong place too, it's called by the button in the player or by keyboard shortcuts not by the watch page component, so it doesn't belong in the |
||||||||||||||
| if (!isSilenceSkipEnabled.value) { | ||||||||||||||
| isSilenceSkipEnabled.value = true | ||||||||||||||
| } else { | ||||||||||||||
| isSilenceSkipEnabled.value = false | ||||||||||||||
| } | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
| emit('skip-silence-updated', 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 | ||||||||||||||
|
|
||||||||||||||
| const loop = () => { | ||||||||||||||
| if (!player) { | ||||||||||||||
| cancelAnimationFrame(loopId) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| 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) { | ||||||||||||||
| gain.gain.setTargetAtTime(0, audioContext.currentTime, 0.025) | ||||||||||||||
| player.trickPlay(SilenceSkip.TRICKPLAY_FAST_FORWARD_SPEED) | ||||||||||||||
| isSkipping = true | ||||||||||||||
| soundStart = null | ||||||||||||||
| } | ||||||||||||||
| } else if (!isSilent && isSkipping) { | ||||||||||||||
| if (!soundStart) soundStart = now | ||||||||||||||
| if (now - soundStart > SilenceSkip.MIN_SOUND_DURATION) { | ||||||||||||||
| gain.gain.setTargetAtTime(1, audioContext.currentTime, 0.015) | ||||||||||||||
| setTimeout(() => { | ||||||||||||||
| player.trickPlay(SilenceSkip.TRICKPLAY_DEFAULT_SPEED) | ||||||||||||||
| isSkipping = false | ||||||||||||||
| silenceStart = null | ||||||||||||||
| soundStart = null | ||||||||||||||
| }, 25) | ||||||||||||||
| } | ||||||||||||||
| } else if (!isSilent && !isSkipping) { | ||||||||||||||
| gain.gain.setTargetAtTime(1, audioContext.currentTime, 0.015) | ||||||||||||||
| silenceStart = null | ||||||||||||||
| soundStart = null | ||||||||||||||
| } | ||||||||||||||
| } else { | ||||||||||||||
| player.trickPlay(SilenceSkip.TRICKPLAY_DEFAULT_SPEED) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| loopId = requestAnimationFrame(loop) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| loop() | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| expose({ | ||||||||||||||
| hasLoaded, | ||||||||||||||
|
|
||||||||||||||
| isPaused, | ||||||||||||||
| pause, | ||||||||||||||
| getCurrentTime, | ||||||||||||||
| setCurrentTime, | ||||||||||||||
| destroyPlayer | ||||||||||||||
| destroyPlayer, | ||||||||||||||
| }) | ||||||||||||||
|
|
||||||||||||||
| // #endregion functions used by the watch page | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The keys should be the name of the Material Symbol with a suffix saying whether it was the default or filled variant, as searching for skip silence on the Material Symbols page doesn't produce any results, it looks like you've invented your own name here.