From afb59a4d1547fdab76ff120fba1938d1129ce01c Mon Sep 17 00:00:00 2001 From: Dzianis Dashkevich Date: Mon, 30 Jan 2023 09:56:54 -0800 Subject: [PATCH 1/8] fix: Add exception guard for VTT parsing state if vtt.js is not loaded for any reasons. --- src/vtt-segment-loader.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/vtt-segment-loader.js b/src/vtt-segment-loader.js index 43be328a6..056732514 100644 --- a/src/vtt-segment-loader.js +++ b/src/vtt-segment-loader.js @@ -391,6 +391,8 @@ export default class VTTSegmentLoader extends SegmentLoader { /** * Uses the WebVTT parser to parse the segment response * + * @throws NoVttJsError + * * @param {Object} segmentInfo * a segment info object that describes the current segment * @private @@ -399,6 +401,11 @@ export default class VTTSegmentLoader extends SegmentLoader { let decoder; let decodeBytesToString = false; + if (typeof window.WebVTT !== 'function') { + // caller is responsible for exception handling. + throw new NoVttJsError(); + } + if (typeof window.TextDecoder === 'function') { decoder = new window.TextDecoder('utf8'); } else { @@ -494,3 +501,9 @@ export default class VTTSegmentLoader extends SegmentLoader { } } } + +class NoVttJsError extends Error { + constructor() { + super('Trying to parse received VTT cues, but there is no WebVTT. Make sure vtt.js is loaded.'); + } +} From ec440442cf0de94fdb1410189af3153f8d1ebbab Mon Sep 17 00:00:00 2001 From: Dzianis Dashkevich Date: Mon, 30 Jan 2023 10:10:57 -0800 Subject: [PATCH 2/8] fix: Do not override native for all iOS/iPadOS browsers --- src/videojs-http-streaming.js | 23 ++++++++++++++++------- test/videojs-http-streaming.test.js | 7 +++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/videojs-http-streaming.js b/src/videojs-http-streaming.js index 49df8d611..adab03393 100644 --- a/src/videojs-http-streaming.js +++ b/src/videojs-http-streaming.js @@ -1287,16 +1287,25 @@ const VhsSourceHandler = { return tech.vhs; }, canPlayType(type, options = {}) { - const { - vhs: { overrideNative = !videojs.browser.IS_ANY_SAFARI } = {}, - hls: { overrideNative: legacyOverrideNative = false } = {} - } = videojs.mergeOptions(videojs.options, options); + const simpleType = simpleTypeFromSourceType(type); - const supportedType = simpleTypeFromSourceType(type); - const canUseMsePlayback = supportedType && - (!Vhs.supportsTypeNatively(supportedType) || legacyOverrideNative || overrideNative); + if (!simpleType) { + return ''; + } + + const overrideNative = VhsSourceHandler.getOverrideNative(options); + const supportsTypeNatively = Vhs.supportsTypeNatively(simpleType); + const canUseMsePlayback = !supportsTypeNatively || overrideNative; return canUseMsePlayback ? 'maybe' : ''; + }, + getOverrideNative(options = {}) { + const { vhs = {}, hls = {} } = options; + const defaultOverrideNative = !(videojs.browser.IS_ANY_SAFARI || videojs.browser.IS_IOS); + const { overrideNative = defaultOverrideNative } = vhs; + const { overrideNative: legacyOverrideNative = false } = hls; + + return legacyOverrideNative || overrideNative; } }; diff --git a/test/videojs-http-streaming.test.js b/test/videojs-http-streaming.test.js index 4e6595e18..f1ee29f94 100644 --- a/test/videojs-http-streaming.test.js +++ b/test/videojs-http-streaming.test.js @@ -3312,12 +3312,14 @@ QUnit.test('has no effect if native HLS is available and browser is Safari', fun videojs.browser.IS_ANY_SAFARI = origIsAnySafari; }); -QUnit.test('loads if native HLS is available but browser is not Safari', function(assert) { +QUnit.test('has no effect if native HLS is available and browser is any non-safari browser on ios', function(assert) { const Html5 = videojs.getTech('Html5'); const oldHtml5CanPlaySource = Html5.canPlaySource; const origIsAnySafari = videojs.browser.IS_ANY_SAFARI; + const originalIsIos = videojs.browser.IS_IOS; videojs.browser.IS_ANY_SAFARI = false; + videojs.browser.IS_IOS = true; Html5.canPlaySource = () => true; Vhs.supportsNativeHls = true; const player = createPlayer(); @@ -3329,10 +3331,11 @@ QUnit.test('loads if native HLS is available but browser is not Safari', functio this.clock.tick(1); - assert.ok(player.tech_.vhs, 'loaded VHS tech'); + assert.ok(!player.tech_.vhs, 'did not load vhs tech'); player.dispose(); Html5.canPlaySource = oldHtml5CanPlaySource; videojs.browser.IS_ANY_SAFARI = origIsAnySafari; + videojs.browser.IS_IOS = originalIsIos; }); QUnit.test( From 6eaf8b6d78dc7254fd2c917c6e44cab75bd075e4 Mon Sep 17 00:00:00 2001 From: Dzianis Dashkevich Date: Tue, 17 Jan 2023 17:29:15 -0800 Subject: [PATCH 3/8] fix: Add guard for vtt-segment-loader to actually load vtt.js in case we do not have it loaded --- src/master-playlist-controller.js | 21 +++++++++- src/vtt-segment-loader.js | 33 +++++---------- test/vtt-segment-loader.test.js | 68 ++++++++++++++----------------- 3 files changed, 61 insertions(+), 61 deletions(-) diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js index 5dc4aba5c..37a92e195 100644 --- a/src/master-playlist-controller.js +++ b/src/master-playlist-controller.js @@ -284,7 +284,26 @@ export class MasterPlaylistController extends videojs.EventTarget { this.subtitleSegmentLoader_ = new VTTSegmentLoader(videojs.mergeOptions(segmentLoaderSettings, { loaderType: 'vtt', - featuresNativeTextTracks: this.tech_.featuresNativeTextTracks + featuresNativeTextTracks: this.tech_.featuresNativeTextTracks, + loadVttJs: () => new Promise((resolve, reject) => { + const tech = this.tech_; + + function onLoad () { + tech.off('vttjserror', onError); + resolve(); + } + + function onError () { + tech.off('vttjsloaded', onLoad); + reject(); + } + + tech.one('vttjsloaded', onLoad); + tech.one('vttjserror', onError); + + // safe to call multiple times, script will be loaded only once: + tech.addWebVttScript_(); + }) }), options); this.setupSegmentLoaderListeners_(); diff --git a/src/vtt-segment-loader.js b/src/vtt-segment-loader.js index 056732514..755e6ec9f 100644 --- a/src/vtt-segment-loader.js +++ b/src/vtt-segment-loader.js @@ -34,6 +34,8 @@ export default class VTTSegmentLoader extends SegmentLoader { this.featuresNativeTextTracks_ = settings.featuresNativeTextTracks; + this.loadVttJs_ = settings.loadVttJs; + // The VTT segment will have its own time mappings. Saving VTT segment timing info in // the sync controller leads to improper behavior. this.shouldSaveSegmentTimingInfo_ = false; @@ -297,29 +299,16 @@ export default class VTTSegmentLoader extends SegmentLoader { } segmentInfo.bytes = simpleSegment.bytes; - // Make sure that vttjs has loaded, otherwise, wait till it finished loading - if (typeof window.WebVTT !== 'function' && - this.subtitlesTrack_ && - this.subtitlesTrack_.tech_) { - - let loadHandler; - const errorHandler = () => { - this.subtitlesTrack_.tech_.off('vttjsloaded', loadHandler); - this.stopForError({ - message: 'Error loading vtt.js' - }); - return; - }; - - loadHandler = () => { - this.subtitlesTrack_.tech_.off('vttjserror', errorHandler); - this.segmentRequestFinished_(error, simpleSegment, result); - }; - + // Make sure that vttjs has loaded, otherwise, load it and wait till it finished loading + if (typeof window.WebVTT !== 'function' && typeof this.loadVttJs_ === 'function') { this.state = 'WAITING_ON_VTTJS'; - this.subtitlesTrack_.tech_.one('vttjsloaded', loadHandler); - this.subtitlesTrack_.tech_.one('vttjserror', errorHandler); - + // should be fine to call multiple times + // script will be loaded once but multiple listeners will be added to the queue, which is expected. + this.loadVttJs_() + .then( + () => this.segmentRequestFinished_(error, simpleSegment, result), + () => this.stopForError({ message: 'Error loading vtt.js' }) + ) return; } diff --git a/test/vtt-segment-loader.test.js b/test/vtt-segment-loader.test.js index 5bd69d5ff..3e8f0a885 100644 --- a/test/vtt-segment-loader.test.js +++ b/test/vtt-segment-loader.test.js @@ -308,6 +308,16 @@ QUnit.module('VTTSegmentLoader', function(hooks) { QUnit.test( 'waits for vtt.js to be loaded before attempting to parse cues', function(assert) { + let promiseLoadVttJs, resolveLoadVttJs + loader = new VTTSegmentLoader(LoaderCommonSettings.call(this, { + loaderType: 'vtt', + loadVttJs: () => { + promiseLoadVttJs = new Promise((resolve) => resolveLoadVttJs = resolve); + + return promiseLoadVttJs; + } + }), {}); + const vttjs = window.WebVTT; const playlist = playlistWithDuration(40); let parsedCues = false; @@ -319,22 +329,6 @@ QUnit.module('VTTSegmentLoader', function(hooks) { loader.state = 'READY'; }; - let vttjsCallback = () => {}; - - this.track.tech_ = { - one(event, callback) { - if (event === 'vttjsloaded') { - vttjsCallback = callback; - } - }, - trigger(event) { - if (event === 'vttjsloaded') { - vttjsCallback(); - } - }, - off() {} - }; - loader.playlist(playlist); loader.track(this.track); loader.load(); @@ -361,10 +355,12 @@ QUnit.module('VTTSegmentLoader', function(hooks) { window.WebVTT = vttjs; - loader.subtitlesTrack_.tech_.trigger('vttjsloaded'); + promiseLoadVttJs.then(() => { + assert.equal(loader.state, 'READY', 'loader is ready to load next segment'); + assert.ok(parsedCues, 'parsed cues'); + }); - assert.equal(loader.state, 'READY', 'loader is ready to load next segment'); - assert.ok(parsedCues, 'parsed cues'); + resolveLoadVttJs(); } ); @@ -748,25 +744,19 @@ QUnit.module('VTTSegmentLoader', function(hooks) { }); QUnit.test('loader triggers error event when vtt.js fails to load', function(assert) { + let promiseLoadVttJs, rejectLoadVttJs; + loader = new VTTSegmentLoader(LoaderCommonSettings.call(this, { + loaderType: 'vtt', + loadVttJs: () => { + promiseLoadVttJs = new Promise((resolve, reject) => rejectLoadVttJs = reject); + + return promiseLoadVttJs; + } + }), {}); const playlist = playlistWithDuration(40); let errors = 0; delete window.WebVTT; - let vttjsCallback = () => {}; - - this.track.tech_ = { - one(event, callback) { - if (event === 'vttjserror') { - vttjsCallback = callback; - } - }, - trigger(event) { - if (event === 'vttjserror') { - vttjsCallback(); - } - }, - off() {} - }; loader.on('error', () => errors++); @@ -794,11 +784,13 @@ QUnit.module('VTTSegmentLoader', function(hooks) { ); assert.equal(errors, 0, 'no errors yet'); - loader.subtitlesTrack_.tech_.trigger('vttjserror'); + promiseLoadVttJs.catch(() => { + assert.equal(loader.state, 'READY', 'loader is reset to ready'); + assert.ok(loader.paused(), 'loader is paused after error'); + assert.equal(errors, 1, 'loader triggered error when vtt.js load triggers error'); + }); - assert.equal(loader.state, 'READY', 'loader is reset to ready'); - assert.ok(loader.paused(), 'loader is paused after error'); - assert.equal(errors, 1, 'loader triggered error when vtt.js load triggers error'); + rejectLoadVttJs(); }); QUnit.test('does not save segment timing info', function(assert) { From bda667a560307820d529e05b433d8eac2a35d571 Mon Sep 17 00:00:00 2001 From: Dzianis Dashkevich Date: Tue, 17 Jan 2023 17:47:58 -0800 Subject: [PATCH 4/8] chore: fix eslit errors --- src/master-playlist-controller.js | 6 ++---- src/vtt-segment-loader.js | 14 +++++++------- test/vtt-segment-loader.test.js | 14 ++++++++++---- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js index 37a92e195..5c0ec48a1 100644 --- a/src/master-playlist-controller.js +++ b/src/master-playlist-controller.js @@ -286,14 +286,12 @@ export class MasterPlaylistController extends videojs.EventTarget { loaderType: 'vtt', featuresNativeTextTracks: this.tech_.featuresNativeTextTracks, loadVttJs: () => new Promise((resolve, reject) => { - const tech = this.tech_; - - function onLoad () { + function onLoad() { tech.off('vttjserror', onError); resolve(); } - function onError () { + function onError() { tech.off('vttjsloaded', onLoad); reject(); } diff --git a/src/vtt-segment-loader.js b/src/vtt-segment-loader.js index 755e6ec9f..c8bc9d042 100644 --- a/src/vtt-segment-loader.js +++ b/src/vtt-segment-loader.js @@ -13,6 +13,12 @@ import { ONE_SECOND_IN_TS } from 'mux.js/lib/utils/clock'; const VTT_LINE_TERMINATORS = new Uint8Array('\n\n'.split('').map(char => char.charCodeAt(0))); +class NoVttJsError extends Error { + constructor() { + super('Trying to parse received VTT cues, but there is no WebVTT. Make sure vtt.js is loaded.'); + } +} + /** * An object that manages segment loading and appending. * @@ -308,7 +314,7 @@ export default class VTTSegmentLoader extends SegmentLoader { .then( () => this.segmentRequestFinished_(error, simpleSegment, result), () => this.stopForError({ message: 'Error loading vtt.js' }) - ) + ); return; } @@ -490,9 +496,3 @@ export default class VTTSegmentLoader extends SegmentLoader { } } } - -class NoVttJsError extends Error { - constructor() { - super('Trying to parse received VTT cues, but there is no WebVTT. Make sure vtt.js is loaded.'); - } -} diff --git a/test/vtt-segment-loader.test.js b/test/vtt-segment-loader.test.js index 3e8f0a885..08709a1bf 100644 --- a/test/vtt-segment-loader.test.js +++ b/test/vtt-segment-loader.test.js @@ -308,11 +308,14 @@ QUnit.module('VTTSegmentLoader', function(hooks) { QUnit.test( 'waits for vtt.js to be loaded before attempting to parse cues', function(assert) { - let promiseLoadVttJs, resolveLoadVttJs + let promiseLoadVttJs; let resolveLoadVttJs; + loader = new VTTSegmentLoader(LoaderCommonSettings.call(this, { loaderType: 'vtt', loadVttJs: () => { - promiseLoadVttJs = new Promise((resolve) => resolveLoadVttJs = resolve); + promiseLoadVttJs = new Promise((resolve) => { + resolveLoadVttJs = resolve; + }); return promiseLoadVttJs; } @@ -744,11 +747,14 @@ QUnit.module('VTTSegmentLoader', function(hooks) { }); QUnit.test('loader triggers error event when vtt.js fails to load', function(assert) { - let promiseLoadVttJs, rejectLoadVttJs; + let promiseLoadVttJs; let rejectLoadVttJs; + loader = new VTTSegmentLoader(LoaderCommonSettings.call(this, { loaderType: 'vtt', loadVttJs: () => { - promiseLoadVttJs = new Promise((resolve, reject) => rejectLoadVttJs = reject); + promiseLoadVttJs = new Promise((resolve, reject) => { + rejectLoadVttJs = reject; + }); return promiseLoadVttJs; } From 63593a92627052a97e037f096e428f38de9fccd2 Mon Sep 17 00:00:00 2001 From: Dzianis Dashkevich Date: Tue, 17 Jan 2023 21:32:43 -0800 Subject: [PATCH 5/8] chore: Add loadVttJs test --- src/vtt-segment-loader.js | 6 +++--- test/master-playlist-controller.test.js | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/vtt-segment-loader.js b/src/vtt-segment-loader.js index c8bc9d042..d87c7ded4 100644 --- a/src/vtt-segment-loader.js +++ b/src/vtt-segment-loader.js @@ -40,7 +40,7 @@ export default class VTTSegmentLoader extends SegmentLoader { this.featuresNativeTextTracks_ = settings.featuresNativeTextTracks; - this.loadVttJs_ = settings.loadVttJs; + this.loadVttJs = settings.loadVttJs; // The VTT segment will have its own time mappings. Saving VTT segment timing info in // the sync controller leads to improper behavior. @@ -306,11 +306,11 @@ export default class VTTSegmentLoader extends SegmentLoader { segmentInfo.bytes = simpleSegment.bytes; // Make sure that vttjs has loaded, otherwise, load it and wait till it finished loading - if (typeof window.WebVTT !== 'function' && typeof this.loadVttJs_ === 'function') { + if (typeof window.WebVTT !== 'function' && typeof this.loadVttJs === 'function') { this.state = 'WAITING_ON_VTTJS'; // should be fine to call multiple times // script will be loaded once but multiple listeners will be added to the queue, which is expected. - this.loadVttJs_() + this.loadVttJs() .then( () => this.segmentRequestFinished_(error, simpleSegment, result), () => this.stopForError({ message: 'Error loading vtt.js' }) diff --git a/test/master-playlist-controller.test.js b/test/master-playlist-controller.test.js index b979133b6..2b03a8d0c 100644 --- a/test/master-playlist-controller.test.js +++ b/test/master-playlist-controller.test.js @@ -1,4 +1,5 @@ import QUnit from 'qunit'; +import sinon from 'sinon'; import videojs from 'video.js'; import window from 'global/window'; import { @@ -618,6 +619,24 @@ QUnit.test('resets everything for a fast quality change', function(assert) { assert.deepEqual(removeFuncArgs, {start: 0, end: 60}, 'remove() called with correct arguments if media is changed'); }); +QUnit.test('loadVttJs should be passed to the vttSegmentLoader and resolved on vttjsloaded', function(assert) { + const stub = sinon.stub(this.player.tech_, 'addWebVttScript_').callsFake(() => this.player.tech_.trigger('vttjsloaded')); + const controller = new PlaylistController({ src: 'test', tech: this.player.tech_}); + + controller.subtitleSegmentLoader_.loadVttJs().then(() => { + assert.equal(stub.callCount, 1, 'tech addWebVttScript called once'); + }); +}); + +QUnit.test('loadVttJs should be passed to the vttSegmentLoader and rejected on vttjserror', function(assert) { + const stub = sinon.stub(this.player.tech_, 'addWebVttScript_').callsFake(() => this.player.tech_.trigger('vttjserror')); + const controller = new PlaylistController({ src: 'test', tech: this.player.tech_}); + + controller.subtitleSegmentLoader_.loadVttJs().catch(() => { + assert.equal(stub.callCount, 1, 'tech addWebVttScript called once'); + }); +}); + QUnit.test('seeks in place for fast quality switch on non-IE/Edge browsers', function(assert) { let seeks = 0; From 7ae85abfb78ac875ab378bcc3ec346d58cda0d48 Mon Sep 17 00:00:00 2001 From: Dzianis Dashkevich Date: Mon, 30 Jan 2023 10:26:01 -0800 Subject: [PATCH 6/8] chore: Add test for parse exception if no vtt.js is loaded for any reason --- test/vtt-segment-loader.test.js | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/vtt-segment-loader.test.js b/test/vtt-segment-loader.test.js index 08709a1bf..c25c9b0b3 100644 --- a/test/vtt-segment-loader.test.js +++ b/test/vtt-segment-loader.test.js @@ -12,6 +12,7 @@ import { LoaderCommonFactory } from './loader-common.js'; import { encryptionKey, subtitlesEncrypted } from 'create-test-data!segments'; +import sinon from 'sinon'; const oldVTT = window.WebVTT; @@ -367,6 +368,52 @@ QUnit.module('VTTSegmentLoader', function(hooks) { } ); + QUnit.test( + 'parse should throw if no vtt.js is loaded for any reason', + function(assert) { + const vttjs = window.WebVTT; + const playlist = playlistWithDuration(40); + let errors = 0; + + const originalParse = loader.parseVTTCues_.bind(loader); + + loader.parseVTTCues_ = (...args) => { + delete window.WebVTT; + return originalParse(...args); + }; + + const spy = sinon.spy(loader, 'error'); + + loader.on('error', () => errors++); + + loader.playlist(playlist); + loader.track(this.track); + loader.load(); + + assert.equal(errors, 0, 'no error at loader start'); + + this.clock.tick(1); + + // state WAITING for segment response + this.requests[0].responseType = 'arraybuffer'; + this.requests.shift().respond(200, null, new Uint8Array(10).buffer); + + this.clock.tick(1); + + assert.equal(errors, 1, 'triggered error when parser emmitts fatal error'); + assert.ok(loader.paused(), 'loader paused when encountering fatal error'); + assert.equal(loader.state, 'READY', 'loader reset after error'); + assert.ok( + spy.withArgs(sinon.match({ + message: 'Trying to parse received VTT cues, but there is no WebVTT. Make sure vtt.js is loaded.' + })).calledOnce, + 'error method called once with instance of NoVttJsError' + ); + + window.WebVTT = vttjs; + } + ); + QUnit.test( 'uses timestampmap from vtt header to set cue and segment timing', function(assert) { From fc87f065f7692ab1ccdfbb8442e73695b034d346 Mon Sep 17 00:00:00 2001 From: Dzianis Dashkevich Date: Mon, 30 Jan 2023 10:50:09 -0800 Subject: [PATCH 7/8] chore: fix typo --- test/master-playlist-controller.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/master-playlist-controller.test.js b/test/master-playlist-controller.test.js index 2b03a8d0c..72367fc6d 100644 --- a/test/master-playlist-controller.test.js +++ b/test/master-playlist-controller.test.js @@ -621,7 +621,7 @@ QUnit.test('resets everything for a fast quality change', function(assert) { QUnit.test('loadVttJs should be passed to the vttSegmentLoader and resolved on vttjsloaded', function(assert) { const stub = sinon.stub(this.player.tech_, 'addWebVttScript_').callsFake(() => this.player.tech_.trigger('vttjsloaded')); - const controller = new PlaylistController({ src: 'test', tech: this.player.tech_}); + const controller = new MasterPlaylistController({ src: 'test', tech: this.player.tech_}); controller.subtitleSegmentLoader_.loadVttJs().then(() => { assert.equal(stub.callCount, 1, 'tech addWebVttScript called once'); @@ -630,7 +630,7 @@ QUnit.test('loadVttJs should be passed to the vttSegmentLoader and resolved on v QUnit.test('loadVttJs should be passed to the vttSegmentLoader and rejected on vttjserror', function(assert) { const stub = sinon.stub(this.player.tech_, 'addWebVttScript_').callsFake(() => this.player.tech_.trigger('vttjserror')); - const controller = new PlaylistController({ src: 'test', tech: this.player.tech_}); + const controller = new MasterPlaylistController({ src: 'test', tech: this.player.tech_}); controller.subtitleSegmentLoader_.loadVttJs().catch(() => { assert.equal(stub.callCount, 1, 'tech addWebVttScript called once'); From b11684fdc6cacbbbac5aba4b72730323318a898e Mon Sep 17 00:00:00 2001 From: Dzianis Dashkevich Date: Mon, 30 Jan 2023 11:10:09 -0800 Subject: [PATCH 8/8] chore: remove redundant default value --- src/videojs-http-streaming.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/videojs-http-streaming.js b/src/videojs-http-streaming.js index adab03393..c5233da1f 100644 --- a/src/videojs-http-streaming.js +++ b/src/videojs-http-streaming.js @@ -1286,7 +1286,7 @@ const VhsSourceHandler = { tech.vhs.src(source.src, source.type); return tech.vhs; }, - canPlayType(type, options = {}) { + canPlayType(type, options) { const simpleType = simpleTypeFromSourceType(type); if (!simpleType) {