diff --git a/build/filters.js b/build/filters.js index 872ed47f9ea..6f746545e9b 100644 --- a/build/filters.js +++ b/build/filters.js @@ -32,7 +32,7 @@ module.exports.unicodeFilter = [ '!LICENSES.chromium.html', '!**/LICENSE', - '!**/*.{dll,exe,png,bmp,jpg,scpt,cur,ttf,woff,eot,template,ico,icns}', + '!**/*.{dll,exe,png,bmp,jpg,scpt,cur,ttf,woff,eot,template,ico,icns,webm}', '!**/test/**', '!**/*.test.ts', '!**/*.{d.ts,json,md}', @@ -108,7 +108,7 @@ module.exports.indentationFilter = [ '!src/vs/*/**/*.d.ts', '!src/typings/**/*.d.ts', '!extensions/**/*.d.ts', - '!**/*.{svg,exe,png,bmp,jpg,scpt,bat,cmd,cur,ttf,woff,eot,md,ps1,template,yaml,yml,d.ts.recipe,ico,icns,plist}', + '!**/*.{svg,exe,png,bmp,jpg,scpt,bat,cmd,cur,ttf,woff,eot,md,ps1,template,yaml,yml,d.ts.recipe,ico,icns,plist,webm}', '!build/{lib,download,linux,darwin}/**/*.js', '!build/**/*.sh', '!build/azure-pipelines/**/*.js', @@ -133,6 +133,7 @@ module.exports.copyrightFilter = [ '!**/*.bat', '!**/*.cmd', '!**/*.ico', + '!**/*.webm', '!**/*.icns', '!**/*.xml', '!**/*.sh', diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 2c01da47058..e5056d42933 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -262,6 +262,10 @@ "name": "vs/workbench/contrib/languageDetection", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/audioCues", + "project": "vscode-workbench" + }, { "name": "vs/workbench/services/actions", "project": "vscode-workbench" diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index ecbac6597a7..8160ace0dce 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -371,6 +371,23 @@ export namespace Event { handler(undefined); return event(e => handler(e)); } + + export function runAndSubscribeWithStore(event: Event, handler: (e: T | undefined, disposableStore: DisposableStore) => any): IDisposable { + let store: DisposableStore | null = null; + + function run(e: T | undefined) { + store?.dispose(); + store = new DisposableStore(); + handler(e, store); + } + + run(undefined); + const disposable = event(e => run(e)); + return toDisposable(() => { + disposable.dispose(); + store?.dispose(); + }); + } } export type Listener = [(e: T) => void, any] | ((e: T) => void); diff --git a/src/vs/base/test/common/event.test.ts b/src/vs/base/test/common/event.test.ts index 9fa0a7fb49c..3dbfe4292c3 100644 --- a/src/vs/base/test/common/event.test.ts +++ b/src/vs/base/test/common/event.test.ts @@ -7,7 +7,7 @@ import { timeout } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; import { errorHandler, setUnexpectedErrorHandler } from 'vs/base/common/errors'; import { AsyncEmitter, DebounceEmitter, Emitter, Event, EventBufferer, EventMultiplexer, IWaitUntil, MicrotaskEmitter, PauseableEmitter, Relay } from 'vs/base/common/event'; -import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; namespace Samples { @@ -956,4 +956,34 @@ suite('Event utils', () => { assert.deepStrictEqual(result, [2, 4]); }); }); + + test('runAndSubscribeWithStore', () => { + const eventEmitter = new Emitter(); + const event = eventEmitter.event; + + let i = 0; + let log = new Array(); + const disposable = Event.runAndSubscribeWithStore(event, (e, disposables) => { + const idx = i++; + log.push({ label: 'handleEvent', data: e || null, idx }); + disposables.add(toDisposable(() => { + log.push({ label: 'dispose', idx }); + })); + }); + + log.push({ label: 'fire' }); + eventEmitter.fire('someEventData'); + + log.push({ label: 'disposeAll' }); + disposable.dispose(); + + assert.deepStrictEqual(log, [ + { label: 'handleEvent', data: null, idx: 0 }, + { label: 'fire' }, + { label: 'dispose', idx: 0 }, + { label: 'handleEvent', data: 'someEventData', idx: 1 }, + { label: 'disposeAll' }, + { label: 'dispose', idx: 1 }, + ]); + }); }); diff --git a/src/vs/code/electron-browser/workbench/workbench.html b/src/vs/code/electron-browser/workbench/workbench.html index 0efd6539960..47066f520be 100644 --- a/src/vs/code/electron-browser/workbench/workbench.html +++ b/src/vs/code/electron-browser/workbench/workbench.html @@ -3,7 +3,7 @@ - + diff --git a/src/vs/code/electron-sandbox/workbench/workbench.html b/src/vs/code/electron-sandbox/workbench/workbench.html index 0efd6539960..47066f520be 100644 --- a/src/vs/code/electron-sandbox/workbench/workbench.html +++ b/src/vs/code/electron-sandbox/workbench/workbench.html @@ -3,7 +3,7 @@ - + diff --git a/src/vs/server/webClientServer.ts b/src/vs/server/webClientServer.ts index d2d03c7cc67..3774eed0336 100644 --- a/src/vs/server/webClientServer.ts +++ b/src/vs/server/webClientServer.ts @@ -209,7 +209,7 @@ export class WebClientServer { const cspDirectives = [ 'default-src \'self\';', 'img-src \'self\' https: data: blob:;', - 'media-src \'none\';', + 'media-src \'self\';', `script-src 'self' 'unsafe-eval' ${this._getScriptCspHashes(data).join(' ')} 'sha256-cb2sg39EJV8ABaSNFfWu/ou8o1xVXYK7jp90oZ9vpcg=' http://${remoteAuthority};`, // the sha is the same as in src/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html 'child-src \'self\';', `frame-src 'self' https://*.vscode-webview.net ${this._productService.webEndpointUrl || ''} data:;`, diff --git a/src/vs/workbench/contrib/audioCues/browser/audioCueContribution.ts b/src/vs/workbench/contrib/audioCues/browser/audioCueContribution.ts new file mode 100644 index 00000000000..0e4d35cf9d2 --- /dev/null +++ b/src/vs/workbench/contrib/audioCues/browser/audioCueContribution.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { IDebugService } from 'vs/workbench/contrib/debug/common/debug'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { Event } from 'vs/base/common/event'; +import { isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { raceTimeout } from 'vs/base/common/async'; +import { FileAccess } from 'vs/base/common/network'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; + +export class AudioCueContribution extends DisposableStore implements IWorkbenchContribution { + constructor( + @IDebugService readonly debugService: IDebugService, + @IEditorService readonly editorService: IEditorService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + ) { + super(); + + this.add(Event.runAndSubscribeWithStore(editorService.onDidActiveEditorChange, (_, store) => { + let lastLineNumber = -1; + + const activeTextEditorControl = editorService.activeTextEditorControl; + if (isCodeEditor(activeTextEditorControl) || isDiffEditor(activeTextEditorControl)) { + const editor = isDiffEditor(activeTextEditorControl) ? activeTextEditorControl.getOriginalEditor() : activeTextEditorControl; + + store.add( + editor.onDidChangeCursorPosition(() => { + const model = editor.getModel(); + if (!model) { + return; + } + const position = editor.getPosition(); + if (!position) { + return; + } + const lineNumber = position.lineNumber; + if (lineNumber === lastLineNumber) { + return; + } + lastLineNumber = lineNumber; + + const uri = model.uri; + + const breakpoints = debugService.getModel().getBreakpoints({ uri, lineNumber }); + const hasBreakpoints = breakpoints.length > 0; + + if (hasBreakpoints) { + this.handleBreakpointOnLine(); + } + }) + ); + } + })); + } + + private get audioCuesEnabled(): boolean { + const value = this._configurationService.getValue<'auto' | 'on' | 'off'>('audioCues.enabled'); + if (value === 'on') { + return true; + } else if (value === 'auto') { + return this.accessibilityService.isScreenReaderOptimized(); + } else { + return false; + } + } + + public handleBreakpointOnLine(): void { + this.playSound('breakpointHit'); + } + + private async playSound(fileName: string) { + if (!this.audioCuesEnabled) { + return; + } + + const url = FileAccess.asBrowserUri(`vs/workbench/contrib/audioCues/browser/media/${fileName}.webm`, require).toString(); + const audio = new Audio(url); + + try { + // Don't play when loading takes more than 1s, due to loading, decoding or playing issues. + // Delayed sounds are very confusing. + await raceTimeout(audio.play(), 1000); + } catch (e) { + audio.remove(); + } + } +} diff --git a/src/vs/workbench/contrib/audioCues/browser/audioCues.contribution.ts b/src/vs/workbench/contrib/audioCues/browser/audioCues.contribution.ts new file mode 100644 index 00000000000..a3b2104bc5c --- /dev/null +++ b/src/vs/workbench/contrib/audioCues/browser/audioCues.contribution.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; +import { AudioCueContribution } from 'vs/workbench/contrib/audioCues/browser/audioCueContribution'; +import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; + +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(AudioCueContribution, LifecyclePhase.Restored); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + 'properties': { + 'audioCues.enabled': { + 'type': 'string', + 'description': localize('audioCues.enabled', "Controls whether audio cues are enabled."), + 'enum': ['auto', 'on', 'off'], + 'default': 'auto', + 'enumDescriptions': [ + localize('audioCues.enabled.auto', "Enable audio cues when a screen reader is attached."), + localize('audioCues.enabled.on', "Enable audio cues."), + localize('audioCues.enabled.off', "Disable audio cues.") + ], + } + } +}); diff --git a/src/vs/workbench/contrib/audioCues/browser/media/breakpointHit.webm b/src/vs/workbench/contrib/audioCues/browser/media/breakpointHit.webm new file mode 100644 index 00000000000..030f938a009 Binary files /dev/null and b/src/vs/workbench/contrib/audioCues/browser/media/breakpointHit.webm differ diff --git a/src/vs/workbench/contrib/audioCues/browser/media/error.webm b/src/vs/workbench/contrib/audioCues/browser/media/error.webm new file mode 100644 index 00000000000..fbaccf5b439 Binary files /dev/null and b/src/vs/workbench/contrib/audioCues/browser/media/error.webm differ diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index ee1856d0b20..fc61f7f4702 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -326,4 +326,7 @@ import 'vs/workbench/contrib/workspaces/browser/workspaces.contribution'; // List import 'vs/workbench/contrib/list/browser/list.contribution'; +// Audio Cues +import 'vs/workbench/contrib/audioCues/browser/audioCues.contribution'; + //#endregion