diff --git a/components/media-controller/src/vwc-media-controller.scss b/components/media-controller/src/vwc-media-controller.scss index dda2cb5e0..6420cb73d 100644 --- a/components/media-controller/src/vwc-media-controller.scss +++ b/components/media-controller/src/vwc-media-controller.scss @@ -2,66 +2,75 @@ display: block; box-sizing: border-box; min-width: 5rem; -} -div.component { - $basic: #B779FF; - $track: #E1E2E6; - $button_size: 16px; - display: flex; - align-items: center; - > button.play-pause-control { - flex: 0 0 $button_size; - background-image: url(data:image/svg+xml,%3Csvg%20width%3D%2264%22%20height%3D%2232%22%20viewBox%3D%220%200%2064%2032%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M27.0826%2014.872C28.3058%2015.5955%2028.3058%2017.4045%2027.0826%2018.128L5.7523%2030.7453C4.52905%2031.4689%203%2030.5644%203%2029.1173L3%203.8827C3%202.43556%204.52905%201.53109%205.75229%202.25466L27.0826%2014.872Z%22%20fill%3D%22%23B779FF%22%2F%3E%0A%3Crect%20x%3D%2251.2174%22%20y%3D%222%22%20width%3D%229.78261%22%20height%3D%2229%22%20rx%3D%221%22%20fill%3D%22%23B779FF%22%2F%3E%0A%3Crect%20x%3D%2236%22%20y%3D%222%22%20width%3D%229.78261%22%20height%3D%2229%22%20rx%3D%221%22%20fill%3D%22%23B779FF%22%2F%3E%0A%3C%2Fsvg%3E%0A); - background-repeat: no-repeat; - background-color: transparent; - background-size: $button_size * 2 $button_size; - width: $button_size; - height: $button_size; - background-position: 0 center; - border: none; - cursor: pointer; - - &.engaged { - background-position: -$button_size center; - } - - &:focus { - outline: none; - } - } - - > div.scrubber { - height: 3px; - position: relative; - margin-left: 1rem; - user-select: none; + > div { + $scrub_size: 12px; + $basic: #B779FF; + $track: #E1E2E6; + $button_size: 16px; + display: flex; + align-items: center; + padding: 5px 5px; > button { - $size: 12px; - cursor: pointer; - position: absolute; - top: 50%; - left: 0; - transform: translate(-50%, -50%); - width: $size; - height: $size; - border-radius: $size/2; - background-color: $basic; + flex: 0 0 $button_size; + background-image: url(data:image/svg+xml,%3Csvg%20width%3D%2264%22%20height%3D%2232%22%20viewBox%3D%220%200%2064%2032%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M27.0826%2014.872C28.3058%2015.5955%2028.3058%2017.4045%2027.0826%2018.128L5.7523%2030.7453C4.52905%2031.4689%203%2030.5644%203%2029.1173L3%203.8827C3%202.43556%204.52905%201.53109%205.75229%202.25466L27.0826%2014.872Z%22%20fill%3D%22%23B779FF%22%2F%3E%0A%3Crect%20x%3D%2251.2174%22%20y%3D%222%22%20width%3D%229.78261%22%20height%3D%2229%22%20rx%3D%221%22%20fill%3D%22%23B779FF%22%2F%3E%0A%3Crect%20x%3D%2236%22%20y%3D%222%22%20width%3D%229.78261%22%20height%3D%2229%22%20rx%3D%221%22%20fill%3D%22%23B779FF%22%2F%3E%0A%3C%2Fsvg%3E%0A); + background-repeat: no-repeat; + background-color: transparent; + background-size: $button_size * 2 $button_size; + width: $button_size; + height: $button_size; + background-position: 0 center; border: none; - transition: background-color 0.2s; - &:hover { - background-color: lighten($basic, 3%); - } + cursor: pointer; + &:focus { outline: none; } } + &.play { + > button { + background-position: -$button_size center; + } + } + > div { + height: 3px; + position: relative; + margin-left: 1rem; + user-select: none; + box-sizing: border-box; width: 100%; - height: 100%; - background-color: $track; + cursor: pointer; + + > button { + position: absolute; + top: 50%; + left: 0; + transform-origin: center; + transform: translate(-50%, -50%); + width: $scrub_size; + height: $scrub_size; + border-radius: $scrub_size/2; + background-color: $basic; + border: none; + transition: background-color 0.2s, box-shadow 200ms, border-radius 0.2s; + box-shadow: 0 0 0 0 #00000000; + cursor: pointer; + + &:focus { + outline: none; + } + } + } + + &.scrub { + > div { + > button { + box-shadow: 0 0 0 15px #00000017; + } + } } } } \ No newline at end of file diff --git a/components/media-controller/src/vwc-media-controller.ts b/components/media-controller/src/vwc-media-controller.ts index 45e5fc13f..340ad9030 100644 --- a/components/media-controller/src/vwc-media-controller.ts +++ b/components/media-controller/src/vwc-media-controller.ts @@ -1,22 +1,27 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck import kefir from 'kefir'; -import { pipe, not, always, clamp } from 'ramda'; -import { style } from './vwc-media-controller.css'; +import { pipe, partial, clamp, prop, always, not, allPass } from 'ramda'; +import { style as vwcMediaControllerStyle } from './vwc-media-controller.css'; const - noop = ()=> { - // do nothing - }, - TRACK_RESPONSE_MARGIN = 5, - TRACK_VERTICAL_RESPONSIVITY_MARGIN = 15; + SIGNAL = Symbol('signal'), + TRACK_KNOB_HORIZONTAL_MARGIN = 5, + TRACK_VERTICAL_RESPONSIVITY_MARGIN = 10, + TRACK_INACTIVE_COLOR = '#E1E2E6', + TRACK_ACTIVE_COLOR = '#999'; -const [ - SET_POSITION, - SET_PLAY_STATE, - ON_CONNECT, - ON_DISCONNECT -] = ['set_position', 'set_play_state', 'on_connect', 'on_disconnect'].map((name)=> Symbol(name)); +const + byType = (typeName)=> ({ type })=> type === typeName, + createTag = function(tagName, ...children){ + const el = document.createElement(tagName); + children.forEach((child)=> typeof(child) === 'string' ? el.innerHTML += child : el.appendChild(child)); + return el; + }, + sendCustomEventFactory = (target)=> (eventType, detail, options = { bubbles: true, composed: true })=> { + const event = new CustomEvent(eventType, { ...options, detail }); + target.dispatchEvent(event); + }; /** * Displays controllers for media playback. Includes play/pause button and a scrub bar @@ -31,124 +36,123 @@ class MediaController extends HTMLElement { constructor() { super(); + // Local bus for collecting signals from the end-user/custom element api const - rootDom = this.attachShadow({ mode: 'open' }), - baseStyle = document.createElement('style'); - - baseStyle.innerHTML = style.cssText; - rootDom.appendChild(baseStyle); - - [SET_PLAY_STATE, SET_POSITION].forEach((symbol)=> this[symbol] = noop); - - const - triggerEvent = (eventName, payload)=> { - const event = new CustomEvent(eventName.replace(/_(\w)/g, (m, group)=> group.toUpperCase()), { bubbles: true, composed: true, detail: payload }); - this.dispatchEvent(event); - }; - - const componentRootEl = document.createElement('div'); - componentRootEl.className = 'component'; - rootDom.appendChild(componentRootEl); - - const playPauseControlEl = document.createElement('button'); - playPauseControlEl.className = 'play-pause-control'; - componentRootEl.appendChild(playPauseControlEl); - - const - [scrubberEl, trackEl] = [1,2].map(()=> document.createElement('div')), - knobEl = document.createElement('button'); - - knobEl.tabIndex = 0; - scrubberEl.appendChild(trackEl); - scrubberEl.appendChild(knobEl); - scrubberEl.className = 'scrubber'; - scrubberEl.style.width = '100%'; - componentRootEl.appendChild(scrubberEl); - - const - mouseClickStream = kefir.fromEvents(rootDom, 'click'), - [ - mouseDownStream, - mouseUpStream, - mouseMoveStream, - contentMenuStream - ] = [ - 'mousedown', - 'mouseup', - 'mousemove', - 'contextmenu' - ].map((eventName)=> kefir.fromEvents(document, eventName)); - - const connectedProperty = kefir.merge([ - kefir.stream(({ emit })=> this[ON_CONNECT] = emit).map(always(true)), - kefir.stream(({ emit })=> this[ON_DISCONNECT] = emit).map(always(false)) - ]).toProperty(always(false)); + apiBus = kefir.pool(), + sendCustomEvent = sendCustomEventFactory(this); + + this[SIGNAL] = pipe(kefir.constant, apiBus.plug.bind(apiBus)); + + // Set component's elemental structure + const rootDoc = this.attachShadow({ mode: 'open' }); + let trackEl, ScrubberKnobEl, playPauseControlEl, rootEl; + + const componentContent = (function(){ + const [style, div, button] = ['style', 'div', 'button'].map((tagName)=> partial(createTag, [tagName])); + return [ + style( vwcMediaControllerStyle.cssText), + rootEl = div( + playPauseControlEl = button(), + trackEl = div( + ScrubberKnobEl = button() + ) + ), + ]; + })(); const - rafStream = kefir.repeat(()=> kefir.fromCallback((cb)=> requestAnimationFrame(cb))), - trackElBoundingRectProperty = connectedProperty - .filter(Boolean) - .flatMapLatest(()=> { + rafStream = kefir.repeat(()=> kefir.fromCallback(requestAnimationFrame)), + componentConnectedStream = apiBus.filter(byType('component_connected')), + mouseDownStream = kefir.fromEvents(rootDoc, 'mousedown'), + [mouseUpStream, mouseMoveStream, contextMenuStream, windowResizeStream] = ['mouseup', 'mousemove', 'contextmenu', 'resize'].map((eventName)=> kefir.fromEvents(window, eventName)); + + const userScrubStream = mouseDownStream + .map(({ clientX, clientY })=> ({ mouseX: clientX, mouseY: clientY, ...(({ x: rectX, y: rectY, width: rectWidth, height: rectHeight })=> ({ rectX, rectY, rectWidth, rectHeight }))(trackEl.getBoundingClientRect()) })) + .filter(({ mouseX, mouseY, rectX, rectY, rectWidth, rectHeight })=> mouseX > rectX - TRACK_KNOB_HORIZONTAL_MARGIN && mouseX < rectX + rectWidth + TRACK_KNOB_HORIZONTAL_MARGIN && mouseY > rectY - TRACK_VERTICAL_RESPONSIVITY_MARGIN && mouseY < rectY + rectHeight + TRACK_VERTICAL_RESPONSIVITY_MARGIN) + .flatMapLatest(({ mouseX, mouseY, rectX, rectWidth })=> { return kefir.concat([ - kefir.constant(trackEl.getBoundingClientRect()), + kefir.constant({ type: 'start', rectWidth, rectX }), kefir - .fromEvents(window, 'resize') - .debounce(10) - .map(()=> trackEl.getBoundingClientRect()) - .takeUntilBy(connectedProperty.filter(pipe(Boolean, not))) - ]); - }) - .toProperty(), - userScrubInteractionProperty = kefir - .merge([ - kefir - .combine([mouseDownStream], [trackElBoundingRectProperty]) - .filter(([{ clientY, clientX }, { x, y, width, height }])=> - clientX > x - && clientX < x + width - && clientY > y - TRACK_VERTICAL_RESPONSIVITY_MARGIN - && clientY < y + height + TRACK_VERTICAL_RESPONSIVITY_MARGIN - ) - .map(()=> true), - kefir.merge([ - mouseUpStream, - contentMenuStream + .concat([ + kefir.constant({ mouseX, mouseY }), + mouseMoveStream.map(({ clientX: mouseX, clientY: mouseY })=> ({ mouseX, mouseY })) + ]) + .map(pipe(prop('mouseX'), clamp(rectX + TRACK_KNOB_HORIZONTAL_MARGIN, rectX + rectWidth - TRACK_KNOB_HORIZONTAL_MARGIN), (pos)=> (pos - (rectX + TRACK_KNOB_HORIZONTAL_MARGIN)) / (rectWidth - TRACK_KNOB_HORIZONTAL_MARGIN * 2))) + .takeUntilBy(kefir.merge([mouseUpStream, contextMenuStream]).take(1)) + .map((position)=> ({ type: 'position_change', position })), + kefir.constant({ type: 'end' }) ]) - .map(()=> false) - ]) - .skipDuplicates() - .toProperty(()=> false), - userScrubRequestStream = kefir - .combine( - [userScrubInteractionProperty, mouseMoveStream], - [trackElBoundingRectProperty] - ) - .filter(([interaction])=> interaction) - .map(([interaction, { clientX }, { x, width }])=> - clamp( - 0, - width - (TRACK_RESPONSE_MARGIN * 2), - clientX - (x + TRACK_RESPONSE_MARGIN)) / (width - TRACK_RESPONSE_MARGIN * 2) - ), - userPlayPauseRequestStream = mouseClickStream - .filter(({ target })=> target === playPauseControlEl), - positionProperty = kefir.stream(({ emit })=> this[SET_POSITION] = emit).toProperty(()=> 0), - playStateProperty = kefir - .stream(({ emit })=> this[SET_PLAY_STATE] = emit) - .toProperty(()=> false); - - userScrubRequestStream.onValue(triggerEvent.bind(null, 'user_scrub_request')); - userPlayPauseRequestStream.onValue(triggerEvent.bind(null, 'user_play_pause_request')); - playStateProperty.onValue((isPlaying)=> playPauseControlEl.classList.toggle('engaged', isPlaying)); - positionProperty.onValue((percentage)=> trackEl.style.backgroundImage = `linear-gradient(90deg, #999 0%, #999 ${percentage * 100}%, #00000000 ${percentage * 100}%, #00000000 100%)`); - - kefir - .combine([ - kefir.combine([userScrubInteractionProperty, userScrubRequestStream.toProperty(()=> false), positionProperty], (scrub, request, actual)=> scrub ? request : actual), - trackElBoundingRectProperty - ], (percentage, { width })=> TRACK_RESPONSE_MARGIN + (width - (TRACK_RESPONSE_MARGIN * 2)) * percentage) - .sampledBy(rafStream) - .onValue((xPos)=> knobEl.style.left = `${xPos}px`); + }); + + const userScrubInteractionProperty = kefir + .merge(['start', 'end'].map((eventType)=> userScrubStream.filter(byType(eventType)).map(always(eventType === 'start')))) + .skipDuplicates() + .toProperty(always(false)); + + const apiPositionProperty = apiBus + .filter(byType('set_position')) + .map(prop('value')) + .toProperty(always(0)); + + const playStateProperty = apiBus + .filter(byType('set_play_state')) + .map(prop('value')) + .toProperty(always(false)); + + // Draw component internals + componentConnectedStream.take(1).onValue(()=> { + componentContent.forEach((el)=> rootDoc.appendChild(el)); + }); + + // Update knob position + apiBus + .filter(byType('component_connected')) + .flatMapLatest(()=> { + return kefir + .combine([ + userScrubInteractionProperty + .flatMap((active)=> { + return active + ? userScrubStream.filter(byType('position_change')).map(prop('position')) + : apiPositionProperty.filterBy(userScrubInteractionProperty.map(not)) + }) + .skipDuplicates(), + windowResizeStream.toProperty(always(0)) + ], (val)=> val) + .flatMapLatest((value)=> rafStream.take(1).map(always(value))) + .takeUntilBy(apiBus.filter(byType('component_disconnected')).take(1)); + }) + .onValue((position)=> { + const { width: trackWidth } = trackEl.getBoundingClientRect(); + ScrubberKnobEl.style.transform = `translate(-50%, -50%) translateX(${TRACK_KNOB_HORIZONTAL_MARGIN + position * (trackWidth - TRACK_KNOB_HORIZONTAL_MARGIN * 2)}px)`; + }); + + // Update track state + apiPositionProperty + .skipDuplicates() + .onValue((percentage)=> trackEl.style.backgroundImage = `linear-gradient(90deg, ${TRACK_ACTIVE_COLOR} 0%, ${TRACK_ACTIVE_COLOR} ${percentage * 100}%, ${TRACK_INACTIVE_COLOR} ${percentage * 100}%, ${TRACK_INACTIVE_COLOR} 100%)`); + + // Update scrub state + userScrubInteractionProperty + .onValue((scrub)=> rootEl.classList.toggle('scrub', scrub)); + + // Update play state + playStateProperty + .onValue((isPlaying)=> rootEl.classList.toggle('play', isPlaying)); + + // Send user scrub event + userScrubStream + .filter(byType('position_change')) + .map(prop('position')) + .onValue(partial(sendCustomEvent,['userScrubRequest'])); + + // Send user play/pause event + mouseDownStream + .filter(allPass([ + ({ target })=> target === playPauseControlEl, + ({ which })=> which === 1 + ])) + .onValue(partial(sendCustomEvent,['userPlayPauseRequest', null])); } /** @@ -156,7 +160,7 @@ class MediaController extends HTMLElement { * @param {number} position - The relative position of the scrubber (a value between 0-1). **/ setPosition(position:number):void { - this[SET_POSITION](position); + this[SIGNAL]({ type: 'set_position', value: position }); } /** @@ -164,15 +168,15 @@ class MediaController extends HTMLElement { * @param {boolean} isPlaying - A boolean stating whether the component is playing or not (displayed pause/play buttons respectively). **/ setPlayState(isPlaying:boolean):void { - this[SET_PLAY_STATE](isPlaying); + this[SIGNAL]({ type: 'set_play_state', value: isPlaying }); } connectedCallback():void{ - this[ON_CONNECT](); + this[SIGNAL]({ type: 'component_connected' }); } disconnectedCallback():void{ - this[ON_DISCONNECT](); + this[SIGNAL]({ type: 'component_disconnected' }); } } diff --git a/components/media-controller/test/media-controller.test.js b/components/media-controller/test/media-controller.test.js index 2cac0b3b9..29a13a142 100644 --- a/components/media-controller/test/media-controller.test.js +++ b/components/media-controller/test/media-controller.test.js @@ -1,7 +1,113 @@ -import '@vonage/vwc-media-controller'; +import '../vwc-media-controller'; +import kefir from "kefir"; +import { textToDomToParent } from '../../../test/test-helpers'; -describe('vwc-media-controller', ()=>{ - it('should register as a custom element', async ()=> { - assert.exists(customElements.get('vwc-media-controller', 'vwc-media-controller element is not defined')); +const + CENTER_Y = 8, + TRACK_X = 37, + TRACK_X_MARGIN = 5, + BUTTON_X = 6, + PERCENTAGE_TOLERANCE = 2, + RESPONSE_TIMEOUT = 100; //ms + +const setStyle = (el, style = {})=> { + return Object.entries(style).reduce((el, [k, v])=> { el.style[k] = v; return el; }, el); +}; + +const simulateMouseFactory = + ({ x: baseX = 0, y: baseY = 0 })=> { + + let findTarget = (root = document, x, y)=> { + let target = root.elementFromPoint(x, y); + return !target + ? root + : !target.shadowRoot + ? target + : findTarget(target.shadowRoot, x, y); + }; + + return ( x, y , eventType, options = { bubbles: true, composed: true }) => { + let + targetX = baseX + x, + targetY = baseY + y; + + findTarget(document, targetX, targetY) + .dispatchEvent(new MouseEvent(eventType, { + clientX: targetX, + clientY: targetY, + ...options + })); + } + }; + +describe('vwc-media-controller', function(){ + + describe('Custom Component', function(){ + it('Should register as a custom element', function(){ + assert.exists(customElements.get(`vwc-media-controller`, 'vwc-media-controller element is not defined')); + }); + }); + + describe(`Component Interaction`, function(){ + + let addedElements, controllerEl, componentX, componentY, componentWidth, simulateMouse; + + beforeEach(function(){ + addedElements = textToDomToParent(''); + controllerEl = addedElements[0]; + setStyle(controllerEl, { top:0, left: 0, width: "200px", position: "fixed" }); + const rect = controllerEl.getBoundingClientRect(); + componentX = rect.x; + componentY = rect.y; + componentWidth = rect.width; + simulateMouse = simulateMouseFactory({ x: componentX, y: componentY }); + }); + + afterEach(function() { + addedElements.forEach(elm => elm.remove()); + }); + + it('Should emit an event when clicking play/pause ', function(){ + return new Promise((resolve, reject)=> { + controllerEl.addEventListener('userPlayPauseRequest', resolve); + simulateMouse( BUTTON_X, CENTER_Y, 'mousedown'); + setTimeout( reject, RESPONSE_TIMEOUT, new Error('Play/pause button did not emit an event, make sure the layout\'s hasn\'t changed')) + }); + }); + + it('Should report userScrubRequest events when clicking the trackbar', function(){ + const SAMPLES = 10; + return kefir + .concat( + Array(SAMPLES) + .fill(0) + .map((val, index)=> ({ + x: TRACK_X + TRACK_X_MARGIN + ((componentWidth - 5 - TRACK_X - TRACK_X_MARGIN * 2) / SAMPLES * index), + y: CENTER_Y, + expected: Math.floor(index / SAMPLES * 100) + })) + .map(({ x, y, expected })=> { + return kefir.merge([ + kefir + .fromEvents(controllerEl, 'userScrubRequest') + .take(1) + .flatMap( + ({ detail })=> { + const got = Math.floor(detail * 100); + return kefir[ + (got <= expected + PERCENTAGE_TOLERANCE && got >= expected - PERCENTAGE_TOLERANCE) + ? "constant" + : "constantError" + ](new Error(`Wrong value returned, expected ${expected}, got ${got}`)); + }), + kefir.later(RESPONSE_TIMEOUT).flatMap(()=> kefir.constantError('Did not receive a "userScrubRequest" event following a click on the trackbar')), + kefir.fromCallback((cb)=> cb(["mousedown", "mouseup"].forEach((eventName)=> simulateMouse( x, y, eventName)))).ignoreValues() + ]).take(1).takeErrors(1) + }) + ) + .takeErrors(1) + .mapErrors((des)=> new Error(des)) + .toPromise(); + }); }); }); \ No newline at end of file