- commit
- 80eafedd5d92d6200ce5348331addc453060abaf
- parent
- 2e92ea77423d87a3dd4bce406a0a288c5c4a9294
- author
- Dami <dami.etoile@gmail.com>
| M | portal/ | 157 | ++++-------------- |
| A | portal/ | 159 | ++++++++++++++++++ |
| A | portal/ | 258 | ++++++++++++++++++++++++++++++ |
@@ -25,12 +25,7 @@ import {
2525 chooseDirectionalIndex,2626 } from './controller-state.mjs';2727 import { createControllerRuntime } from './controller-runtime.mjs';28-import {29- constrainStreamWindowResize,30- fitInitialStreamWindow,31- fitStreamSurface,32- streamAspectKey,33-} from './window-geometry.mjs';28+import { createWindowRuntime } from './window-runtime.mjs';3429 import {3530 createVideoPipelineSession,3631 } from './video-pipeline.mjs';@@ -49,10 +44,6 @@ import { mapKey } from './keyboard-map.mjs';
4944 let enrollmentReady = false;5045 let pendingInvitationSummary = null;5146 let currentEnrollmentStatus = null;52-let fittedStreamAspect = null;53-let pendingStreamFit = null;54-let lastObservedWindowSize = null;55-let streamWindowResizeTimer = null;5647 let activePointerSession = null;5748 let controllerActivationInProgress = false;5849 let controlRuntime = null;@@ -469,7 +460,7 @@ async function showConnectedAttempt({ result, attempt }) {
469460 document.getElementById('frame-canvas').style.display = 'block';470461 document.getElementById('placeholder').classList.add('hidden');471462 document.getElementById('control-bar').classList.add('visible');472- void fitWindowToIncomingStream();463+ void windowRuntime.fitWindowToIncomingStream();473464 updateControlUI();474465 // Land controller users on the deliberate view-only/control boundary.475466 // The connect button is hidden at this point, so retaining its focus@@ -499,11 +490,7 @@ async function teardownConnectedResources() {
499490 }500491 501492 async function showDisconnectedState() {502- fittedStreamAspect = null;503- pendingStreamFit = null;504- lastObservedWindowSize = null;505- if (streamWindowResizeTimer !== null) clearTimeout(streamWindowResizeTimer);506- streamWindowResizeTimer = null;493+ windowRuntime.reset();507494 controlRuntime.resetDisconnected();508495 updatePanelVisibility();509496 setStatus('', 'idle');@@ -595,6 +582,36 @@ let hasWebCodecs = ('VideoDecoder' in window);
595582 const canvas = document.getElementById('frame-canvas');596583 const remotePointer = document.getElementById('remote-pointer');597584 const ctx = canvas.getContext('2d');585+const windowRuntime = createWindowRuntime({586+ isConnected: () => connectionState.connected,587+ getFormat: () => videoPipeline.format,588+ getGeneration: () => streamRuntime.generation,589+ getChromeHeight: () => {590+ const topbar = document.querySelector('.topbar');591+ const bottombar = document.querySelector('.bottombar');592+ return Math.max(0, (topbar?.offsetHeight ?? 0) + (bottombar?.offsetHeight ?? 0));593+ },594+ getScreenBounds: () => ({595+ width: window.screen.availWidth,596+ height: window.screen.availHeight,597+ }),598+ getWindowSize: () => ({ width: window.innerWidth, height: window.innerHeight }),599+ getSurfaceBounds: () => {600+ const main = canvas.parentElement;601+ if (!main) return null;602+ const bounds = main.getBoundingClientRect();603+ return { width: bounds.width, height: bounds.height };604+ },605+ setSurfaceSize: ({ width, height }) => {606+ canvas.style.width = `${width}px`;607+ canvas.style.height = `${height}px`;608+ },609+ applyNativeGeometry: (geometry, unmaximize) => invoke('set_client_window_size', {610+ logicalWidth: geometry.width,611+ logicalHeight: geometry.height,612+ unmaximize,613+ }),614+});598615 controlRuntime = createControlRuntime({599616 getConnection: () => ({600617 connected: connectionState.connected,@@ -630,8 +647,8 @@ videoPipeline = createVideoPipelineSession({
630647 resetAudioSync: audioPipeline.resetSyncTelemetry,631648 sampleAudioSkew: audioPipeline.sampleSkew,632649 onFormatChanged: () => {633- sizeCanvasToIncomingStream();634- if (connectionState.connected) void fitWindowToIncomingStream();650+ windowRuntime.sizeSurfaceToIncomingStream();651+ if (connectionState.connected) void windowRuntime.fitWindowToIncomingStream();635652 },636653 });637654 streamRuntime = createStreamRuntime({@@ -762,106 +779,6 @@ setInterval(() => {
762779 if (connectionState.connected) updateStreamStats();763780 }, 250);764781 765-function clientChromeHeight() {766- const topbar = document.querySelector('.topbar');767- const bottombar = document.querySelector('.bottombar');768- return Math.max(0, (topbar?.offsetHeight ?? 0) + (bottombar?.offsetHeight ?? 0));769-}770-771-function sizeCanvasToIncomingStream() {772- const { width: frameWidth, height: frameHeight } = videoPipeline.format;773- if (frameWidth < 1 || frameHeight < 1) return;774- const main = canvas.parentElement;775- if (!main) return;776- const bounds = main.getBoundingClientRect();777- if (bounds.width <= 0 || bounds.height <= 0) return;778- const surface = fitStreamSurface({779- frameWidth,780- frameHeight,781- availableWidth: bounds.width,782- availableHeight: bounds.height,783- });784- canvas.style.width = `${surface.width}px`;785- canvas.style.height = `${surface.height}px`;786-}787-788-async function applyClientWindowGeometry(geometry, unmaximize) {789- try {790- const applied = await invoke('set_client_window_size', {791- logicalWidth: geometry.width,792- logicalHeight: geometry.height,793- unmaximize,794- });795- if (applied) lastObservedWindowSize = { ...geometry };796- return applied;797- } catch (error) {798- console.warn('could not apply stream window geometry:', error);799- return false;800- }801-}802-803-async function fitWindowToIncomingStream() {804- const {805- width: frameWidth,806- height: frameHeight,807- epoch: activeVideoFormatEpoch,808- } = videoPipeline.format;809- if (!connectionState.connected || frameWidth < 1 || frameHeight < 1) return false;810- const aspect = streamAspectKey(frameWidth, frameHeight);811- if (fittedStreamAspect === aspect || pendingStreamFit?.aspect === aspect) return false;812- const request = { aspect, generation: streamRuntime.generation, epoch: activeVideoFormatEpoch };813- pendingStreamFit = request;814- const geometry = fitInitialStreamWindow({815- frameWidth,816- frameHeight,817- chromeHeight: clientChromeHeight(),818- availableWidth: window.screen.availWidth,819- availableHeight: window.screen.availHeight,820- });821- const applied = await applyClientWindowGeometry(geometry, true);822- const currentFormat = videoPipeline.format;823- const currentAspect = currentFormat.width > 0 && currentFormat.height > 0824- ? streamAspectKey(currentFormat.width, currentFormat.height)825- : null;826- const current = pendingStreamFit === request827- && connectionState.connected828- && streamRuntime.generation === request.generation829- && currentFormat.epoch === request.epoch830- && currentAspect === request.aspect;831- if (pendingStreamFit === request) pendingStreamFit = null;832- if (applied && current) fittedStreamAspect = request.aspect;833- return applied && current;834-}835-836-function scheduleStreamWindowAspectCorrection() {837- const observed = { width: window.innerWidth, height: window.innerHeight };838- const previous = lastObservedWindowSize ?? observed;839- lastObservedWindowSize = observed;840- if (streamWindowResizeTimer !== null) clearTimeout(streamWindowResizeTimer);841- streamWindowResizeTimer = setTimeout(() => {842- streamWindowResizeTimer = null;843- const { width: frameWidth, height: frameHeight } = videoPipeline.format;844- if (!connectionState.connected || frameWidth < 1 || frameHeight < 1) return;845- const current = { width: window.innerWidth, height: window.innerHeight };846- let geometry;847- try {848- geometry = constrainStreamWindowResize({849- frameWidth,850- frameHeight,851- chromeHeight: clientChromeHeight(),852- width: current.width,853- height: current.height,854- previousWidth: previous.width,855- previousHeight: previous.height,856- });857- } catch (error) {858- console.warn('could not constrain stream window geometry:', error);859- return;860- }861- if (geometry !== null) void applyClientWindowGeometry(geometry, false);862- }, 80);863-}864-865782 // The software decoder/JPEG compatibility path intentionally remains an event:866783 // it is only selected when WebCodecs is unavailable and is not latency-critical.867784 listen('frame', (event) => {@@ -1264,9 +1181,9 @@ document.addEventListener('pointerdown', () => {
12641181 }, { capture: true });12651182 12661183 window.addEventListener('resize', () => {1267- sizeCanvasToIncomingStream();1184+ windowRuntime.sizeSurfaceToIncomingStream();12681185 renderRemotePointer();1269- scheduleStreamWindowAspectCorrection();1186+ windowRuntime.scheduleAspectCorrection();12701187 });12711188 12721189 window.addEventListener('focus', () => {@@ -0,0 +1,159 @@
1+import {2+ constrainStreamWindowResize,3+ fitInitialStreamWindow,4+ fitStreamSurface,5+ streamAspectKey,6+} from './window-geometry.mjs';7+8+export const STREAM_WINDOW_CORRECTION_DELAY_MS = 80;9+10+export function createWindowRuntime({11+ isConnected,12+ getFormat,13+ getGeneration,14+ getChromeHeight,15+ getScreenBounds,16+ getWindowSize,17+ getSurfaceBounds,18+ setSurfaceSize,19+ applyNativeGeometry,20+ scheduleTimeout = globalThis.setTimeout,21+ cancelTimeout = globalThis.clearTimeout,22+ logger = console,23+} = {}) {24+ const requiredCallbacks = {25+ isConnected,26+ getFormat,27+ getGeneration,28+ getChromeHeight,29+ getScreenBounds,30+ getWindowSize,31+ getSurfaceBounds,32+ setSurfaceSize,33+ applyNativeGeometry,34+ };35+ for (const [name, callback] of Object.entries(requiredCallbacks)) {36+ if (typeof callback !== 'function') throw new TypeError(`${name} must be a function`);37+ }38+39+ let fittedStreamAspect = null;40+ let pendingStreamFit = null;41+ let lastObservedWindowSize = null;42+ let streamWindowResizeTimer = null;43+44+ function sizeSurfaceToIncomingStream() {45+ const { width: frameWidth, height: frameHeight } = getFormat();46+ if (frameWidth < 1 || frameHeight < 1) return false;47+ const bounds = getSurfaceBounds();48+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return false;49+ const surface = fitStreamSurface({50+ frameWidth,51+ frameHeight,52+ availableWidth: bounds.width,53+ availableHeight: bounds.height,54+ });55+ setSurfaceSize(surface);56+ return true;57+ }58+59+ async function applyWindowGeometry(geometry, unmaximize) {60+ try {61+ const applied = await applyNativeGeometry(geometry, unmaximize);62+ if (applied) lastObservedWindowSize = { ...geometry };63+ return applied;64+ } catch (error) {65+ logger.warn('could not apply stream window geometry:', error);66+ return false;67+ }68+ }69+70+ async function fitWindowToIncomingStream() {71+ const {72+ width: frameWidth,73+ height: frameHeight,74+ epoch: activeVideoFormatEpoch,75+ } = getFormat();76+ if (!isConnected() || frameWidth < 1 || frameHeight < 1) return false;77+ const aspect = streamAspectKey(frameWidth, frameHeight);78+ if (fittedStreamAspect === aspect || pendingStreamFit?.aspect === aspect) return false;79+ const request = { aspect, generation: getGeneration(), epoch: activeVideoFormatEpoch };80+ pendingStreamFit = request;81+ const screen = getScreenBounds();82+ const geometry = fitInitialStreamWindow({83+ frameWidth,84+ frameHeight,85+ chromeHeight: getChromeHeight(),86+ availableWidth: screen.width,87+ availableHeight: screen.height,88+ });89+ const applied = await applyWindowGeometry(geometry, true);90+ const currentFormat = getFormat();91+ const currentAspect = currentFormat.width > 0 && currentFormat.height > 092+ ? streamAspectKey(currentFormat.width, currentFormat.height)93+ : null;94+ const current = pendingStreamFit === request95+ && isConnected()96+ && getGeneration() === request.generation97+ && currentFormat.epoch === request.epoch98+ && currentAspect === request.aspect;99+ if (pendingStreamFit === request) pendingStreamFit = null;100+ if (applied && current) fittedStreamAspect = request.aspect;101+ return applied && current;102+ }103+104+ function scheduleAspectCorrection() {105+ const observed = getWindowSize();106+ const previous = lastObservedWindowSize ?? observed;107+ lastObservedWindowSize = observed;108+ if (streamWindowResizeTimer !== null) cancelTimeout(streamWindowResizeTimer);109+ streamWindowResizeTimer = scheduleTimeout(() => {110+ streamWindowResizeTimer = null;111+ const { width: frameWidth, height: frameHeight } = getFormat();112+ if (!isConnected() || frameWidth < 1 || frameHeight < 1) return;113+ const current = getWindowSize();114+ let geometry;115+ try {116+ geometry = constrainStreamWindowResize({117+ frameWidth,118+ frameHeight,119+ chromeHeight: getChromeHeight(),120+ width: current.width,121+ height: current.height,122+ previousWidth: previous.width,123+ previousHeight: previous.height,124+ });125+ } catch (error) {126+ logger.warn('could not constrain stream window geometry:', error);127+ return;128+ }129+ if (geometry !== null) void applyWindowGeometry(geometry, false);130+ }, STREAM_WINDOW_CORRECTION_DELAY_MS);131+ }132+133+ function reset() {134+ fittedStreamAspect = null;135+ pendingStreamFit = null;136+ lastObservedWindowSize = null;137+ if (streamWindowResizeTimer !== null) cancelTimeout(streamWindowResizeTimer);138+ streamWindowResizeTimer = null;139+ }140+141+ function snapshot() {142+ return Object.freeze({143+ fittedStreamAspect,144+ pendingStreamFit: pendingStreamFit === null ? null : Object.freeze({ ...pendingStreamFit }),145+ lastObservedWindowSize: lastObservedWindowSize === null146+ ? null : Object.freeze({ ...lastObservedWindowSize }),147+ correctionScheduled: streamWindowResizeTimer !== null,148+ });149+ }150+151+ return Object.freeze({152+ applyWindowGeometry,153+ fitWindowToIncomingStream,154+ reset,155+ scheduleAspectCorrection,156+ sizeSurfaceToIncomingStream,157+ snapshot,158+ });159+}@@ -0,0 +1,258 @@
1+import assert from 'node:assert/strict';2+import { readFile } from 'node:fs/promises';3+import test from 'node:test';4+5+import {6+ STREAM_WINDOW_CORRECTION_DELAY_MS,7+ createWindowRuntime,8+} from './window-runtime.mjs';9+10+const main = await readFile(new URL('./main.js', import.meta.url), 'utf8');11+12+function deferred() {13+ let resolve;14+ let reject;15+ const promise = new Promise((resolvePromise, rejectPromise) => {16+ resolve = resolvePromise;17+ reject = rejectPromise;18+ });19+ return { promise, resolve, reject };20+}21+22+function newHarness({ applyNativeGeometry } = {}) {23+ let connected = true;24+ let generation = 7;25+ let format = { width: 1280, height: 800, epoch: 3 };26+ let chromeHeight = 64;27+ let screenBounds = { width: 1440, height: 900 };28+ let windowSize = { width: 1000, height: 700 };29+ let surfaceBounds = { width: 1920, height: 1080 };30+ const surfaceSizes = [];31+ const nativeApplications = [];32+ const warnings = [];33+ const timers = new Map();34+ const cancelled = [];35+ let nextTimerId = 1;36+ const runtime = createWindowRuntime({37+ isConnected: () => connected,38+ getFormat: () => format,39+ getGeneration: () => generation,40+ getChromeHeight: () => chromeHeight,41+ getScreenBounds: () => screenBounds,42+ getWindowSize: () => windowSize,43+ getSurfaceBounds: () => surfaceBounds,44+ setSurfaceSize: (geometry) => surfaceSizes.push(geometry),45+ applyNativeGeometry: applyNativeGeometry ?? (async (geometry, unmaximize) => {46+ nativeApplications.push({ geometry, unmaximize });47+ return true;48+ }),49+ scheduleTimeout: (callback, delay) => {50+ const id = nextTimerId;51+ nextTimerId += 1;52+ timers.set(id, { callback, delay });53+ return id;54+ },55+ cancelTimeout: (id) => {56+ cancelled.push(id);57+ timers.delete(id);58+ },59+ logger: { warn: (...args) => warnings.push(args) },60+ });61+ return {62+ runtime,63+ surfaceSizes,64+ nativeApplications,65+ warnings,66+ timers,67+ cancelled,68+ set connected(value) { connected = value; },69+ set generation(value) { generation = value; },70+ set format(value) { format = value; },71+ set chromeHeight(value) { chromeHeight = value; },72+ set screenBounds(value) { screenBounds = value; },73+ set windowSize(value) { windowSize = value; },74+ set surfaceBounds(value) { surfaceBounds = value; },75+ };76+}77+78+async function flushMicrotasks() {79+ await Promise.resolve();80+ await Promise.resolve();81+}82+83+test('surface sizing scales to available bounds without owning DOM style', () => {84+ const harness = newHarness();85+86+ assert.equal(harness.runtime.sizeSurfaceToIncomingStream(), true);87+ assert.deepEqual(harness.surfaceSizes, [{ width: 1728, height: 1080 }]);88+89+ harness.surfaceBounds = null;90+ assert.equal(harness.runtime.sizeSurfaceToIncomingStream(), false);91+ harness.format = { width: 0, height: 800, epoch: 4 };92+ harness.surfaceBounds = { width: 800, height: 600 };93+ assert.equal(harness.runtime.sizeSurfaceToIncomingStream(), false);94+ assert.equal(harness.surfaceSizes.length, 1);95+});96+97+test('initial fit applies once per stable aspect and records the observed native result', async () => {98+ const harness = newHarness();99+100+ assert.equal(await harness.runtime.fitWindowToIncomingStream(), true);101+ assert.deepEqual(harness.nativeApplications, [{102+ geometry: { width: 1165, height: 792 },103+ unmaximize: true,104+ }]);105+ assert.deepEqual(harness.runtime.snapshot(), {106+ fittedStreamAspect: '8:5',107+ pendingStreamFit: null,108+ lastObservedWindowSize: { width: 1165, height: 792 },109+ correctionScheduled: false,110+ });111+ assert.equal(await harness.runtime.fitWindowToIncomingStream(), false);112+ assert.equal(harness.nativeApplications.length, 1);113+});114+115+test('same-aspect fits are single-flight while native sizing is pending', async () => {116+ const native = deferred();117+ let applications = 0;118+ const harness = newHarness({119+ applyNativeGeometry: async () => {120+ applications += 1;121+ return native.promise;122+ },123+ });124+125+ const first = harness.runtime.fitWindowToIncomingStream();126+ assert.equal(await harness.runtime.fitWindowToIncomingStream(), false);127+ assert.equal(applications, 1);128+ native.resolve(true);129+ assert.equal(await first, true);130+});131+132+test('generation and format epoch protect against stale async fit completion', async () => {133+ const native = deferred();134+ const harness = newHarness({ applyNativeGeometry: async () => native.promise });135+ const fitting = harness.runtime.fitWindowToIncomingStream();136+ harness.generation = 8;137+ harness.format = { width: 1280, height: 800, epoch: 4 };138+ native.resolve(true);139+140+ assert.equal(await fitting, false);141+ assert.deepEqual(harness.runtime.snapshot(), {142+ fittedStreamAspect: null,143+ pendingStreamFit: null,144+ lastObservedWindowSize: { width: 1165, height: 792 },145+ correctionScheduled: false,146+ });147+});148+149+test('an older completion cannot clear or commit a newer aspect request', async () => {150+ const firstNative = deferred();151+ const secondNative = deferred();152+ let applications = 0;153+ const harness = newHarness({154+ applyNativeGeometry: async () => {155+ applications += 1;156+ return applications === 1 ? firstNative.promise : secondNative.promise;157+ },158+ });159+ const first = harness.runtime.fitWindowToIncomingStream();160+ harness.format = { width: 1280, height: 720, epoch: 4 };161+ const second = harness.runtime.fitWindowToIncomingStream();162+163+ firstNative.resolve(true);164+ assert.equal(await first, false);165+ assert.deepEqual(harness.runtime.snapshot().pendingStreamFit, {166+ aspect: '16:9',167+ generation: 7,168+ epoch: 4,169+ });170+ secondNative.resolve(true);171+ assert.equal(await second, true);172+ assert.equal(harness.runtime.snapshot().fittedStreamAspect, '16:9');173+});174+175+test('native geometry errors fail closed and retain the exact warning boundary', async () => {176+ const harness = newHarness({177+ applyNativeGeometry: async () => { throw new Error('native rejected'); },178+ });179+180+ assert.equal(await harness.runtime.fitWindowToIncomingStream(), false);181+ assert.equal(harness.warnings[0][0], 'could not apply stream window geometry:');182+ assert.match(harness.warnings[0][1].message, /native rejected/);183+});184+185+test('resize correction debounces for 80ms and uses only the latest observed delta', async () => {186+ const harness = newHarness();187+ harness.runtime.scheduleAspectCorrection();188+ const firstTimer = [...harness.timers.keys()][0];189+ harness.windowSize = { width: 1100, height: 700 };190+ harness.runtime.scheduleAspectCorrection();191+192+ assert.deepEqual(harness.cancelled, [firstTimer]);193+ assert.equal(harness.timers.size, 1);194+ const [{ callback, delay }] = [...harness.timers.values()];195+ assert.equal(delay, STREAM_WINDOW_CORRECTION_DELAY_MS);196+ callback();197+ await flushMicrotasks();198+199+ assert.deepEqual(harness.nativeApplications, [{200+ geometry: { width: 1100, height: 752 },201+ unmaximize: false,202+ }]);203+ assert.equal(harness.runtime.snapshot().correctionScheduled, false);204+});205+206+test('already-correct and disconnected resize callbacks do not touch native geometry', async () => {207+ const harness = newHarness();208+ harness.windowSize = { width: 1280, height: 864 };209+ harness.runtime.scheduleAspectCorrection();210+ [...harness.timers.values()][0].callback();211+ await flushMicrotasks();212+ assert.equal(harness.nativeApplications.length, 0);213+214+ harness.windowSize = { width: 1000, height: 700 };215+ harness.runtime.scheduleAspectCorrection();216+ harness.connected = false;217+ [...harness.timers.values()][0].callback();218+ await flushMicrotasks();219+ assert.equal(harness.nativeApplications.length, 0);220+});221+222+test('invalid correction geometry is contained and reset cancels pending correction state', () => {223+ const harness = newHarness();224+ harness.chromeHeight = 900;225+ harness.runtime.scheduleAspectCorrection();226+ const timerId = [...harness.timers.keys()][0];227+ harness.timers.get(timerId).callback();228+ harness.timers.delete(timerId);229+ assert.equal(harness.warnings[0][0], 'could not constrain stream window geometry:');230+231+ harness.runtime.scheduleAspectCorrection();232+ const pendingTimer = [...harness.timers.keys()][0];233+ harness.runtime.reset();234+ assert.ok(harness.cancelled.includes(pendingTimer));235+ assert.deepEqual(harness.runtime.snapshot(), {236+ fittedStreamAspect: null,237+ pendingStreamFit: null,238+ lastObservedWindowSize: null,239+ correctionScheduled: false,240+ });241+});242+243+test('window correction delay remains pinned to the existing behavior', () => {244+ assert.equal(STREAM_WINDOW_CORRECTION_DELAY_MS, 80);245+});246+247+test('Portal injects DOM geometry boundaries and delegates orchestration state', () => {248+ assert.match(main, /const windowRuntime = createWindowRuntime\(\{/);249+ assert.match(main, /getSurfaceBounds: \(\) => \{/);250+ assert.match(main, /setSurfaceSize: \(\{ width, height \}\) => \{/);251+ assert.match(main, /applyNativeGeometry: \(geometry, unmaximize\) => invoke\('set_client_window_size'/);252+ assert.match(main, /windowRuntime\.reset\(\)/);253+ assert.match(main, /windowRuntime\.scheduleAspectCorrection\(\)/);254+ assert.doesNotMatch(main, /let fittedStreamAspect =/);255+ assert.doesNotMatch(main, /let streamWindowResizeTimer =/);256+ assert.doesNotMatch(main, /function fitWindowToIncomingStream\(/);257+ assert.doesNotMatch(main, /function scheduleStreamWindowAspectCorrection\(/);258+});