diff --git a/xblocks_contrib/video/static/css/video.css b/xblocks_contrib/video/static/css/video.css index 563fa0d..a5f0b84 100644 --- a/xblocks_contrib/video/static/css/video.css +++ b/xblocks_contrib/video/static/css/video.css @@ -1,9 +1,1165 @@ /* CSS for VideoBlock */ -.video .count { +@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); + +.xblock { + margin-bottom: calc((var(--baseline, 20px) * 1.5)); +} + +.is-hidden, +.video.closed .subtitles { + display: none; +} + +.video { + background: whitesmoke; + display: block; + margin: 0 -12px; + padding: 12px; + border-radius: 5px; + outline: none; +} + +.video:after { + content: ""; + display: table; + clear: both; +} + +.video:focus, +.video:active, +.video:hover { + border: 0; +} + +.video.is-initialized .video-wrapper .spinner { + display: none; +} + +.video.is-pre-roll .slider { + visibility: hidden; +} + +.video.is-pre-roll .video-player { + position: relative; +} + +.video.is-pre-roll .video-player::before { + display: block; + content: ""; + width: 100%; + padding-top: 55%; +} + +.video .tc-wrapper { + position: relative; +} + +.video .tc-wrapper:after { + content: ""; + display: table; + clear: both; +} + +.video .focus_grabber { + position: relative; + display: inline; + width: 0; + height: 0; +} + +.video .downloads-heading { + margin: 1em 0 0; +} + +.video .wrapper-video-bottom-section { + display: flex; + justify-content: space-between; +} + +.video .wrapper-video-bottom-section .wrapper-download-video, +.video .wrapper-video-bottom-section .wrapper-download-transcripts, +.video .wrapper-video-bottom-section .wrapper-handouts, +.video .wrapper-video-bottom-section .branding, +.video .wrapper-video-bottom-section .wrapper-transcript-feedback { + flex: 1; + margin-top: var(--baseline, 20px); + padding-right: var(--baseline, 20px); + vertical-align: top; +} + +@media (min-width: 768px) { + .video .wrapper-downloads { + display: flex; + } +} + +.video .wrapper-downloads .hd { + margin: 0; +} + +.video .wrapper-downloads .wrapper-download-video .video-sources { + margin: 0; +} + +.video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts { + margin: 0; + padding: 0; + list-style: none; +} + +.video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option { + margin: 0; +} + +.video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option a.btn, +.video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option a.btn-link { + font-size: 16px !important; + font-weight: unset; +} + +.video .wrapper-downloads .branding { + padding-right: 0; +} + +.video .wrapper-downloads .branding .host-tag { + position: absolute; + left: -9999em; + display: inline-block; + vertical-align: middle; + color: var(--body-color, #313131); +} + +.video .wrapper-downloads .branding .brand-logo { + display: inline-block; + max-width: 100%; + max-height: calc((var(--baseline, 20px) * 2)); + padding: calc((var(--baseline, 20px) / 4)) 0; + vertical-align: middle; +} + +.video .wrapper-transcript-feedback { + display: none; +} + +.video .wrapper-transcript-feedback .transcript-feedback-buttons { + display: flex; +} + +.video .wrapper-transcript-feedback .transcript-feedback-btn-wrapper { + margin-right: 10px; +} + +.video .wrapper-transcript-feedback .thumbs-up-btn, +.video .wrapper-transcript-feedback .thumbs-down-btn { + border: none; + box-shadow: none; + background: transparent; +} + +.video .google-disclaimer { + display: none; + margin-top: var(--baseline, 20px); + padding-right: var(--baseline, 20px); + vertical-align: top; +} + +.video .video-wrapper { + float: left; + margin-right: 2.27273%; + width: 65.90909%; + background-color: black; + position: relative; +} + +.video .video-wrapper:hover .btn-play { + color: #0075b4; +} + +.video .video-wrapper:hover .btn-play::after { + background: #fff; +} + +.video .video-wrapper .video-player-pre, +.video .video-wrapper .video-player-post { + height: 50px; + background-color: #111010; +} + +.video .video-wrapper .spinner { + transform: translate(-50%, -50%); + position: absolute; + z-index: 1; + background: rgba(0, 0, 0, 0.7); + top: 50%; + left: 50%; + padding: 30px; + border-radius: 25%; +} + +.video .video-wrapper .spinner::after { + animation: rotateCW 3s infinite linear; + content: ''; + display: block; + width: 30px; + height: 30px; + border: 7px solid white; + border-top-color: transparent; + border-radius: 100%; + position: relative; +} + +.video .video-wrapper .btn-play { + transform: translate(-50%, -50%); + position: absolute; + z-index: 1; + top: 46%; + left: 50%; + font-size: 4em; + cursor: pointer; + opacity: 0.1; +} + +.video .video-wrapper .btn-play::after { + background: var(--white, #fff); + position: absolute; + width: 50%; + height: 50%; + content: ''; + left: 0; + top: 0; + bottom: 0; + right: 0; + margin: auto; + z-index: -1; +} + +.video .video-wrapper .closed-captions { + left: 5%; + position: absolute; + width: 90%; + box-sizing: border-box; + top: 70%; + text-align: center; +} + +.video .video-wrapper .closed-captions.is-visible { + max-height: calc((var(--baseline, 20px) * 3)); + border-radius: calc((var(--baseline, 20px) / 5)); + padding: 8px calc((var(--baseline, 20px) / 2)) 8px calc((var(--baseline, 20px) * 1.5)); + background: rgba(0, 0, 0, 0.75); + color: var(--yellow, #e2c01f); +} + +.video .video-wrapper .closed-captions.is-visible::before { + position: absolute; + display: inline-block; + top: 50%; + left: var(--baseline, 20px); + margin-top: -0.6em; + font-family: 'FontAwesome'; + content: "\f142"; + color: var(--white, #fff); + opacity: 0.5; +} + +.video .video-wrapper .closed-captions.is-visible:hover, +.video .video-wrapper .closed-captions.is-visible.is-dragging { + background: black; + cursor: move; +} + +.video .video-wrapper .closed-captions.is-visible:hover::before, +.video .video-wrapper .closed-captions.is-visible.is-dragging::before { + opacity: 1; +} + +.video .video-wrapper .video-player { + overflow: hidden; + min-height: 158px; +} + +.video .video-wrapper .video-player > div { + height: 100%; +} + +.video .video-wrapper .video-player > div.hidden { + display: none; +} + +.video .video-wrapper .video-player .video-error, +.video .video-wrapper .video-player .video-hls-error { + padding: calc((var(--baseline, 20px) / 5)); + background: black; + color: white !important; +} + +.video .video-wrapper .video-player object, +.video .video-wrapper .video-player iframe, +.video .video-wrapper .video-player video { + left: 0; + display: block; + border: none; + width: 100%; +} + +.video .video-wrapper .video-player h4 { + text-align: center; + color: white; +} + +.video .video-wrapper .video-player h4.hidden { + display: none; +} + +.video .video-wrapper .video-controls { + position: relative; + border: 0; + background: #282c2e; + color: #f0f3f5; +} + +.video .video-wrapper .video-controls:after { + content: ""; + display: table; + clear: both; +} + +.video .video-wrapper .video-controls:hover ul, +.video .video-wrapper .video-controls:hover div, +.video .video-wrapper .video-controls:focus ul, +.video .video-wrapper .video-controls:focus div { + opacity: 1; +} + +.video .video-wrapper .video-controls .control { + display: inline-block; + vertical-align: middle; + margin: 0; + border: 0; + border-radius: 0; + padding: calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 1.5)); + background: #282c2e; + box-shadow: none; + text-shadow: none; + color: #cfd8dc; +} + +.video .video-wrapper .video-controls .control:hover, +.video .video-wrapper .video-controls .control:focus { + background: #171a1b; +} + +.video .video-wrapper .video-controls .control:active, +.video .video-wrapper .video-controls .is-active.control, +.video .video-wrapper .video-controls .active.control { + color: #0ea6ec; +} + +.video .video-wrapper .video-controls .control .icon { + width: 1em; +} + +.video .video-wrapper .video-controls .control .icon.icon-hd { + width: auto; +} + +.video .video-wrapper .video-controls .slider { + transform-origin: bottom left; + transition: height 0.7s ease-in-out 0s; + box-sizing: border-box; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + z-index: 1; + height: calc((var(--baseline, 20px) / 4)); + margin-left: 0; + border: 1px solid #4f595d; + border-radius: 0; + background: #4f595d; +} + +.video .video-wrapper .video-controls .slider:after { + content: ""; + display: table; + clear: both; +} + +.video .video-wrapper .video-controls .slider .ui-widget-header { + background: #8e3e63; + border: 1px solid #8e3e63; + box-shadow: none; + top: -1px; + left: -1px; +} + +.video .video-wrapper .video-controls .slider .ui-corner-all.slider-range { + opacity: 0.3; + background-color: #1e91d3; +} + +.video .video-wrapper .video-controls .slider .ui-slider-handle { + transform-origin: bottom left; + transition: all 0.7s ease-in-out 0s; + box-sizing: border-box; + top: -1px; + height: calc((var(--baseline, 20px) / 4)); + width: calc((var(--baseline, 20px) / 4)); + margin-left: calc(-1 * (var(--baseline, 20px) / 8)); + border: 1px solid #cb598d; + border-radius: calc((var(--baseline, 20px) / 5)); + padding: 0; + background: #cb598d; + box-shadow: none; +} + +.video .video-wrapper .video-controls .slider .ui-slider-handle:focus, +.video .video-wrapper .video-controls .slider .ui-slider-handle:hover { + background-color: #db8baf; + border-color: #db8baf; +} + +.video .video-wrapper .video-controls .vcr { + float: left; + list-style: none; + border-right: 1px solid #282c2e; + padding: 0; +} + +@media (max-width: 1120px) { + .video .video-wrapper .video-controls .vcr { + margin-right: lh(0.5); + font-size: 0.875em; + } +} + +.video .video-wrapper .video-controls .vcr .video_control:focus { + position: relative; +} + +.video .video-wrapper .video-controls .vcr .video_control.skip { + white-space: nowrap; +} + +.video .video-wrapper .video-controls .vcr .vidtime { + padding-left: lh(0.75); + display: inline-block; + color: #cfd8dc; + -webkit-font-smoothing: antialiased; +} + +@media (max-width: 1120px) { + .video .video-wrapper .video-controls .vcr .vidtime { + padding-left: lh(0.5); + } +} + +.video .video-wrapper .video-controls .secondary-controls { + float: right; + border-left: 1px dotted #4f595d; +} + +.video .video-wrapper .video-controls .secondary-controls .volume, +.video .video-wrapper .video-controls .secondary-controls .add-fullscreen, +.video .video-wrapper .video-controls .secondary-controls .grouped-controls, +.video .video-wrapper .video-controls .secondary-controls .auto-advance, +.video .video-wrapper .video-controls .secondary-controls .quality-control { + border-left: 1px dotted #4f595d; +} + +.video .video-wrapper .video-controls .secondary-controls .speed-button:focus, +.video .video-wrapper .video-controls .secondary-controls .volume > .control:focus, +.video .video-wrapper .video-controls .secondary-controls .add-fullscreen:focus, +.video .video-wrapper .video-controls .secondary-controls .auto-advance:focus, +.video .video-wrapper .video-controls .secondary-controls .quality-control:focus, +.video .video-wrapper .video-controls .secondary-controls .toggle-transcript:focus { + position: relative; +} + +.video .video-wrapper .video-controls .secondary-controls .menu-container { + position: relative; +} + +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu { + transition: none; + position: absolute; + display: none; + bottom: calc((var(--baseline, 20px) * 2)); + right: 0; + width: 120px; + margin: 0; + border: none; + padding: 0; + box-shadow: none; + background-color: #282c2e; + list-style: none; +} + +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu li { + color: #e7ecee; +} + +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .speed-option, +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .control-lang { + text-align: left; + display: block; + width: 100%; + border: 0; + border-radius: 0; + padding: lh(0.5); + background: #282c2e; + box-shadow: none; + color: #e7ecee; + overflow: hidden; + text-shadow: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .speed-option:hover, +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .speed-option:focus, +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .control-lang:hover, +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .control-lang:focus { + background-color: #4f595d; + color: #fcfcfc; +} + +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu li.is-active .speed-option, +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu li.is-active .control-lang { + border-left: calc(var(--baseline, 20px) / 10) solid #0ea6ec; + font-weight: var(--font-bold, 700); + color: #0ea6ec; +} + +.video .video-wrapper .video-controls .secondary-controls .menu-container.is-opened .menu { + display: block; +} + +.video .video-wrapper .video-controls .secondary-controls .speeds, +.video .video-wrapper .video-controls .secondary-controls .lang, +.video .video-wrapper .video-controls .secondary-controls .grouped-controls { + display: inline-block; +} + +.video .video-wrapper .video-controls .secondary-controls .speeds.is-opened .control .icon { + transform: rotate(-90deg); +} + +.video .video-wrapper .video-controls .secondary-controls .speeds .speed-button .label { + padding: 0 calc((var(--baseline, 20px) / 3)) 0 0; + font-family: var(--font-family-sans-serif); + color: #e7ecee; +} + +@media (max-width: 1120px) { + .video .video-wrapper .video-controls .secondary-controls .speeds .speed-button .label { + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + } +} + +.video .video-wrapper .video-controls .secondary-controls .speeds .speed-button .value { + padding: 0 lh(0.5) 0 0; + color: #e7ecee; font-weight: bold; } -.video p { +@media (max-width: 1120px) { + .video .video-wrapper .video-controls .secondary-controls .speeds .speed-button .value { + padding: 0 lh(0.5); + } +} + +.video .video-wrapper .video-controls .secondary-controls .lang .language-menu { + width: var(--baseline, 20px); + padding: calc((var(--baseline, 20px) / 2)) 0; +} + +.video .video-wrapper .video-controls .secondary-controls .lang.is-opened .control .icon { + transform: rotate(90deg); +} + +.video .video-wrapper .video-controls .secondary-controls .volume { + display: inline-block; + position: relative; +} + +.video .video-wrapper .video-controls .secondary-controls .volume.is-opened .volume-slider-container { + display: block; + opacity: 1; +} + +.video .video-wrapper .video-controls .secondary-controls .volume:not(:first-child) > a { + border-left: none; +} + +.video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container { + transition: none; + display: none; + position: absolute; + bottom: calc((var(--baseline, 20px) * 2)); + right: 0; + width: 41px; + height: 120px; + background-color: #282c2e; +} + +.video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider { + height: 100px; + width: calc((var(--baseline, 20px) / 4)); + margin: 14px auto; + box-sizing: border-box; + border: 1px solid #4f595d; + background: #4f595d; +} + +.video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle { + transition: height var(--tmg-s2, 2s) ease-in-out 0s, width var(--tmg-s2, 2s) ease-in-out 0s; + left: -5px; + box-sizing: border-box; + height: 13px; + width: 13px; + border: 1px solid #cb598d; + border-radius: calc((var(--baseline, 20px) / 5)); + padding: 0; + background: #cb598d; + box-shadow: none; +} + +.video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle:hover, +.video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle:focus { + background: #db8baf; + border-color: #db8baf; +} + +.video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-range { + background: #8e3e63; + border: 1px solid #8e3e63; + left: -1px; + bottom: -1px; +} + +.video .video-wrapper .video-controls .secondary-controls .quality-control { + font-weight: 700; + letter-spacing: -1px; +} + +.video .video-wrapper .video-controls .secondary-controls .quality-control.active { + color: #0ea6ec; +} + +.video .video-wrapper .video-controls .secondary-controls .quality-control.is-hidden, +.video.closed .video-wrapper .video-controls .secondary-controls .quality-control.subtitles { + display: none !important; +} + +.video .video-wrapper .video-controls .secondary-controls .toggle-transcript.is-active { + color: #0ea6ec; +} + +.video .video-wrapper .video-controls .secondary-controls .lang > .hide-subtitles { + transition: none; +} + +.video .video-wrapper:hover .video-controls .slider { + height: calc((var(--baseline, 20px) / 1.5)); +} + +.video .video-wrapper:hover .video-controls .slider .ui-slider-handle { + height: calc((var(--baseline, 20px) / 1.5)); + width: calc((var(--baseline, 20px) / 1.5)); +} + +.video.video-fullscreen .closed-captions { + width: 65%; +} + +.video.video-fullscreen.closed .closed-captions { + width: 90%; +} + +.video .subtitles { + float: left; + overflow: auto; + max-height: 460px; + width: 31.81818%; + padding: 0; + font-size: 14px; + visibility: visible; +} + +.video .subtitles a { + color: #0074b5; +} + +.video .subtitles .subtitles-menu { + height: 100%; + margin: 0; + padding: 0 3px; + list-style: none; +} + +.video .subtitles .subtitles-menu li { + margin-bottom: 8px; + border: 0; + padding: 0; + color: #0074b5; + line-height: lh(); +} + +.video .subtitles .subtitles-menu li span { + display: block; +} + +.video .subtitles .subtitles-menu li.current { + color: #333; + font-weight: 700; +} + +.video .subtitles .subtitles-menu li.focused { + outline: #000 dotted thin; + outline-offset: -1px; +} + +.video .subtitles .subtitles-menu li:hover, +.video .subtitles .subtitles-menu li:focus { + text-decoration: underline; +} + +.video .subtitles .subtitles-menu li:empty { + margin-bottom: 0; +} + +.video .subtitles .subtitles-menu li.spacing:last-of-type { + position: relative; +} + +.video .subtitles .subtitles-menu li.spacing:last-of-type .transcript-end { + position: absolute; + bottom: 0; +} + +.video.closed .video-wrapper { + width: 100%; + background-color: inherit; +} + +.video.closed .video-wrapper .video-controls.html5 { + bottom: 0; + left: 0; + right: 0; + position: absolute; + z-index: 1; +} + +.video.closed .video-wrapper .video-player-pre, +.video.closed .video-wrapper .video-player-post { + height: 0; +} + +.video.closed .video-wrapper .video-player h3 { + color: black; +} + +.video.closed .subtitles.html5 { + background-color: rgba(243, 243, 243, 0.8); + height: 100%; + position: absolute; + right: 0; + bottom: 0; + top: 0; + width: 275px; + padding: 0 var(--baseline, 20px); + display: none; +} + +.video.video-fullscreen { + background: rgba(0, 0, 0, 0.95); + border: 0; + bottom: 0; + height: 100%; + left: 0; + margin: 0; + padding: 0; + position: fixed; + top: 0; + width: 100%; + vertical-align: middle; + border-radius: 0; +} + +.video.video-fullscreen.closed .tc-wrapper .video-wrapper { + width: 100%; +} + +.video.video-fullscreen .video-wrapper .video-player-pre, +.video.video-fullscreen .video-wrapper .video-player-post { + height: 0; +} + +.video.video-fullscreen .video-wrapper { + position: static; +} + +.video.video-fullscreen .video-wrapper .video-player h3 { + color: white; +} + +.video.video-fullscreen .tc-wrapper { + width: 100%; + height: 100%; + position: static; +} + +.video.video-fullscreen .tc-wrapper:after { + content: ""; + display: table; + clear: both; +} + +.video.video-fullscreen .tc-wrapper .video-wrapper { + height: 100%; + width: 75%; + margin-right: 0; + vertical-align: middle; +} + +.video.video-fullscreen .tc-wrapper .video-wrapper object, +.video.video-fullscreen .tc-wrapper .video-wrapper iframe, +.video.video-fullscreen .tc-wrapper .video-wrapper video { + position: absolute; + width: auto; + height: auto; +} + +.video.video-fullscreen .tc-wrapper .video-controls { + position: absolute; + bottom: 0; + left: 0; + width: 100%; +} + +.video.video-fullscreen .subtitles { + height: 100%; + width: 25%; + padding: lh(); + box-sizing: border-box; + transition: none; + background: var(--black, #000); + visibility: visible; +} + +.video.video-fullscreen .subtitles li { + color: #aaa; +} + +.video.video-fullscreen .subtitles li.current { + color: var(--white, #fff); +} + +.video.is-touch .tc-wrapper .video-wrapper object, +.video.is-touch .tc-wrapper .video-wrapper iframe, +.video.is-touch .tc-wrapper .video-wrapper video { + width: 100%; + height: 100%; +} + +.video .video-pre-roll { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-position: 50% 50%; + background-repeat: no-repeat; + background-size: 100%; + background-color: var(--black, #000); +} + +.video .video-pre-roll.is-html5 { + background-size: 15%; +} + +.video .video-pre-roll .btn-play.btn-pre-roll { + padding: var(--baseline, 20px); + border: none; + border-radius: var(--baseline, 20px); + background: var(--black-t2, rgba(0, 0, 0, 0.5)); + box-shadow: none; +} + +.video .video-pre-roll .btn-play.btn-pre-roll::after { + display: none; +} + +.video .video-pre-roll .btn-play.btn-pre-roll img { + height: calc((var(--baseline, 20px) * 4)); + width: calc((var(--baseline, 20px) * 4)); +} + +.video .video-pre-roll .btn-play.btn-pre-roll:hover, +.video .video-pre-roll .btn-play.btn-pre-roll:focus { + background: var(--blue, #0075b4); +} + +.video .video-wrapper .video-controls .slider .ui-slider-handle, +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu li, +.video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle, +.video .subtitles .subtitles-menu li, +.a11y-menu-container .a11y-menu-list li { cursor: pointer; } + +.video.closed .subtitles.html5 { + z-index: 0; +} + +.video .video-wrapper .video-controls .secondary-controls .menu-container .menu, +.video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container { + z-index: 10; +} + +.video .video-pre-roll, +.a11y-menu-container .a11y-menu-list { + z-index: 1000; +} + +.video.video-fullscreen, +.video.video-fullscreen .tc-wrapper .video-controls, +.overlay { + z-index: 10000; +} + +.contextmenu, +.submenu { + z-index: 100000; +} + +.video-tracks .a11y-menu-container > a::after { + font-family: FontAwesome; + -webkit-font-smoothing: antialiased; + display: inline-block; + speak: none; +} + +.a11y-menu-container { + position: relative; +} + +.a11y-menu-container.open .a11y-menu-list { + display: block; +} + +.a11y-menu-container .a11y-menu-list { + top: 100%; + margin: 0; + padding: 0; + display: none; + position: absolute; + list-style: none; + background-color: var(--white, #fff); + border: 1px solid #eee; +} + +.a11y-menu-container .a11y-menu-list li { + margin: 0; + padding: 0; + border-bottom: 1px solid #eee; + color: var(--white, #fff); +} + +.a11y-menu-container .a11y-menu-list li a { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--gray-l2, #adadad); + font-size: 14px; + line-height: 23px; +} + +.a11y-menu-container .a11y-menu-list li a:hover, +.a11y-menu-container .a11y-menu-list li a:focus { + color: var(--gray-d1, #5e5e5e); +} + +.a11y-menu-container .a11y-menu-list li.active a { + color: #009fe6; +} + +.a11y-menu-container .a11y-menu-list li:last-child { + box-shadow: none; + border-bottom: 0; + margin-top: 0; +} + +.video-tracks .a11y-menu-container { + display: inline-block; + vertical-align: top; + border-left: 1px solid #eee; +} + +.video-tracks .a11y-menu-container.open > a { + background-color: var(--action-primary-active-bg, #0075b4); + color: var(--very-light-text, white); +} + +.video-tracks .a11y-menu-container.open > a::after { + color: var(--very-light-text, white); +} + +.video-tracks .a11y-menu-container > a { + transition: all var(--tmg-f2, 0.25s) ease-in-out 0s; + font-size: 12px; + display: block; + border-radius: 0 3px 3px 0; + background-color: var(--very-light-text, white); + padding: calc((var(--baseline, 20px) * 0.75)) calc((var(--baseline, 20px) * 1.25)) calc((var(--baseline, 20px) * 0.75)) calc((var(--baseline, 20px) * 0.75)); + color: var(--gray-l2, #adadad); + min-width: 1.5em; + line-height: 14px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; +} + +.video-tracks .a11y-menu-container > a::after { + content: "\f0d7"; + position: absolute; + right: calc((var(--baseline, 20px) * 0.5)); + top: 33%; + color: var(--lighter-base-font-color, #646464); +} + +.video-tracks .a11y-menu-container .a11y-menu-list { + right: 0; +} + +.video-tracks .a11y-menu-container .a11y-menu-list li { + font-size: 0.875em; +} + +.video-tracks .a11y-menu-container .a11y-menu-list li a { + border: 0; + display: block; + padding: 0.70788em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.contextmenu, +.submenu { + border: 1px solid #333; + background: var(--white, #fff); + color: #333; + padding: 0; + margin: 0; + list-style: none; + position: absolute; + top: 0; + display: none; + outline: none; + cursor: default; + white-space: nowrap; +} + +.contextmenu.is-opened, +.submenu.is-opened { + display: block; +} + +.contextmenu .menu-item, +.contextmenu .submenu-item, +.submenu .menu-item, +.submenu .submenu-item { + border-top: 1px solid var(--gray-l3, #c8c8c8); + padding: calc((var(--baseline, 20px) / 4)) calc((var(--baseline, 20px) / 2)); + outline: none; +} + +.contextmenu .menu-item > span, +.contextmenu .submenu-item > span, +.submenu .menu-item > span, +.submenu .submenu-item > span { + color: #333; +} + +.contextmenu .menu-item:first-child, +.contextmenu .submenu-item:first-child, +.submenu .menu-item:first-child, +.submenu .submenu-item:first-child { + border-top: none; +} + +.contextmenu .menu-item:focus, +.contextmenu .submenu-item:focus, +.submenu .menu-item:focus, +.submenu .submenu-item:focus { + background: #333; + color: var(--white, #fff); +} + +.contextmenu .menu-item:focus > span, +.contextmenu .submenu-item:focus > span, +.submenu .menu-item:focus > span, +.submenu .submenu-item:focus > span { + color: var(--white, #fff); +} + +.contextmenu .submenu-item, +.submenu .submenu-item { + position: relative; + padding: calc((var(--baseline, 20px) / 4)) var(--baseline, 20px) calc((var(--baseline, 20px) / 4)) calc((var(--baseline, 20px) / 2)); +} + +.contextmenu .submenu-item::after, +.submenu .submenu-item::after { + content: '\25B6'; + position: absolute; + right: 5px; + line-height: 25px; + font-size: 10px; +} + +.contextmenu .submenu-item .submenu, +.submenu .submenu-item .submenu { + display: none; +} + +.contextmenu .submenu-item.is-opened, +.submenu .submenu-item.is-opened { + background: #333; + color: var(--white, #fff); +} + +.contextmenu .submenu-item.is-opened > span, +.submenu .submenu-item.is-opened > span { + color: var(--white, #fff); +} + +.contextmenu .submenu-item.is-opened > .submenu, +.submenu .submenu-item.is-opened > .submenu { + display: block; +} + +.contextmenu .submenu-item .is-selected, +.submenu .submenu-item .is-selected { + font-weight: bold; +} + +.contextmenu .is-disabled, +.submenu .is-disabled { + pointer-events: none; + color: var(--gray-l3, #c8c8c8); +} + +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: transparent; +} diff --git a/xblocks_contrib/video/static/js/src/00_async_process.js b/xblocks_contrib/video/static/js/src/00_async_process.js new file mode 100644 index 0000000..1dcf629 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_async_process.js @@ -0,0 +1,59 @@ +(function(define) { + define( + 'video/00_async_process.js', + [], + function() { + 'use strict'; + + /** + * Provides convenient way to process big amount of data without UI blocking. + * + * @param {array} list Array to process. + * @param {function} process Calls this function on each item in the list. + * @return {array} Returns a Promise object to observe when all actions of a + * certain type bound to the collection, queued or not, have finished. + */ + var AsyncProcess = { + array: function(list, process) { + if (!_.isArray(list)) { + return $.Deferred().reject().promise(); + } + + if (!_.isFunction(process) || !list.length) { + return $.Deferred().resolve(list).promise(); + } + + var MAX_DELAY = 50, // maximum amount of time that js code should be allowed to run continuously + dfd = $.Deferred(), + result = [], + index = 0, + len = list.length; + + var getCurrentTime = function() { + return (new Date()).getTime(); + }; + + var handler = function() { + var start = getCurrentTime(); + + do { + result[index] = process(list[index], index); + index++; + } while (index < len && getCurrentTime() - start < MAX_DELAY); + + if (index < len) { + setTimeout(handler, 25); + } else { + dfd.resolve(result); + } + }; + + setTimeout(handler, 25); + + return dfd.promise(); + } + }; + + return AsyncProcess; + }); +}(RequireJS.define)); diff --git a/xblocks_contrib/video/static/js/src/00_component.js b/xblocks_contrib/video/static/js/src/00_component.js new file mode 100644 index 0000000..ebf9697 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_component.js @@ -0,0 +1,83 @@ +(function(define) { + 'use strict'; + + define('video/00_component.js', [], + function() { + /** + * Creates a new object with the specified prototype object and properties. + * @param {Object} o The object which should be the prototype of the + * newly-created object. + * @private + * @throws {TypeError, Error} + * @return {Object} + */ + var inherit = Object.create || (function() { + var F = function() {}; + + return function(o) { + if (arguments.length > 1) { + throw Error('Second argument not supported'); + } + if (_.isNull(o) || _.isUndefined(o)) { + throw Error('Cannot set a null [[Prototype]]'); + } + if (!_.isObject(o)) { + throw TypeError('Argument must be an object'); + } + + F.prototype = o; + + return new F(); + }; + }()); + + /** + * Component module. + * @exports video/00_component.js + * @constructor + * @return {jquery Promise} + */ + var Component = function() { + if ($.isFunction(this.initialize)) { + // eslint-disable-next-line prefer-spread + return this.initialize.apply(this, arguments); + } + }; + + /** + * Returns new constructor that inherits form the current constructor. + * @static + * @param {Object} protoProps The object containing which will be added to + * the prototype. + * @return {Object} + */ + Component.extend = function(protoProps, staticProps) { + var Parent = this, + Child = function() { + if ($.isFunction(this.initialize)) { + // eslint-disable-next-line prefer-spread + return this.initialize.apply(this, arguments); + } + }; + + // Inherit methods and properties from the Parent prototype. + Child.prototype = inherit(Parent.prototype); + Child.constructor = Parent; + // Provide access to parent's methods and properties + Child.__super__ = Parent.prototype; + + // Extends inherited methods and properties by methods/properties + // passed as argument. + if (protoProps) { + $.extend(Child.prototype, protoProps); + } + + // Inherit static methods and properties + $.extend(Child, Parent, staticProps); + + return Child; + }; + + return Component; + }); +}(RequireJS.define)); diff --git a/xblocks_contrib/video/static/js/src/00_i18n.js b/xblocks_contrib/video/static/js/src/00_i18n.js new file mode 100644 index 0000000..9f82712 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_i18n.js @@ -0,0 +1,40 @@ +(function(define) { + 'use strict'; + + define( + 'video/00_i18n.js', + [], + function() { + /** + * i18n module. + * @exports video/00_i18n.js + * @return {object} + */ + + return { + Play: gettext('Play'), + Pause: gettext('Pause'), + Mute: gettext('Mute'), + Unmute: gettext('Unmute'), + 'Exit full browser': gettext('Exit full browser'), + 'Fill browser': gettext('Fill browser'), + Speed: gettext('Speed'), + 'Auto-advance': gettext('Auto-advance'), + Volume: gettext('Volume'), + // Translators: Volume level equals 0%. + Muted: gettext('Muted'), + // Translators: Volume level in range ]0,20]% + 'Very low': gettext('Very low'), + // Translators: Volume level in range ]20,40]% + Low: gettext('Low'), + // Translators: Volume level in range ]40,60]% + Average: gettext('Average'), + // Translators: Volume level in range ]60,80]% + Loud: gettext('Loud'), + // Translators: Volume level in range ]80,99]% + 'Very loud': gettext('Very loud'), + // Translators: Volume level equals 100%. + Maximum: gettext('Maximum') + }; + }); +}(RequireJS.define)); diff --git a/xblocks_contrib/video/static/js/src/00_iterator.js b/xblocks_contrib/video/static/js/src/00_iterator.js new file mode 100644 index 0000000..c0a7656 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_iterator.js @@ -0,0 +1,90 @@ +(function(define) { + define( + 'video/00_iterator.js', + [], + function() { + 'use strict'; + + /** + * Provides convenient way to work with iterable data. + * @exports video/00_iterator.js + * @constructor + * @param {array} list Array to be iterated. + */ + var Iterator = function(list) { + this.list = list; + this.index = 0; + this.size = this.list.length; + this.lastIndex = this.list.length - 1; + }; + + Iterator.prototype = { + + /** + * Checks validity of provided index for the iterator. + * @access protected + * @param {numebr} index + * @return {boolean} + */ + _isValid: function(index) { + return _.isNumber(index) && index < this.size && index >= 0; + }, + + /** + * Returns next element. + * @param {number} [index] Updates current position. + * @return {any} + */ + next: function(index) { + if (!(this._isValid(index))) { + index = this.index; + } + + this.index = (index >= this.lastIndex) ? 0 : index + 1; + + return this.list[this.index]; + }, + + /** + * Returns previous element. + * @param {number} [index] Updates current position. + * @return {any} + */ + prev: function(index) { + if (!(this._isValid(index))) { + index = this.index; + } + + this.index = (index < 1) ? this.lastIndex : index - 1; + + return this.list[this.index]; + }, + + /** + * Returns last element in the list. + * @return {any} + */ + last: function() { + return this.list[this.lastIndex]; + }, + + /** + * Returns first element in the list. + * @return {any} + */ + first: function() { + return this.list[0]; + }, + + /** + * Returns `true` if current position is last for the iterator. + * @return {boolean} + */ + isEnd: function() { + return this.index === this.lastIndex; + } + }; + + return Iterator; + }); +}(RequireJS.define)); diff --git a/xblocks_contrib/video/static/js/src/00_resizer.js b/xblocks_contrib/video/static/js/src/00_resizer.js new file mode 100644 index 0000000..9af33e9 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_resizer.js @@ -0,0 +1,238 @@ +(function(requirejs, require, define) { + define( + 'video/00_resizer.js', + [], + function() { + var Resizer = function(params) { + var defaults = { + container: window, + element: null, + containerRatio: null, + elementRatio: null + }, + callbacksList = [], + delta = { + height: 0, + width: 0 + }, + module = {}, + mode = null, + config; + + // eslint-disable-next-line no-shadow + var initialize = function(params) { + if (!config) { + config = defaults; + } + + config = $.extend(true, {}, config, params); + + if (!config.element) { + console.log( + 'Required parameter `element` is not passed.' + ); + } + + return module; + }; + + var getData = function() { + var $container = $(config.container), + containerWidth = $container.width() + delta.width, + containerHeight = $container.height() + delta.height, + containerRatio = config.containerRatio, + + $element = $(config.element), + elementRatio = config.elementRatio; + + if (!containerRatio) { + containerRatio = containerWidth / containerHeight; + } + + if (!elementRatio) { + elementRatio = $element.width() / $element.height(); + } + + return { + containerWidth: containerWidth, + containerHeight: containerHeight, + containerRatio: containerRatio, + element: $element, + elementRatio: elementRatio + }; + }; + + var align = function() { + var data = getData(); + + switch (mode) { + case 'height': + alignByHeightOnly(); + break; + + case 'width': + alignByWidthOnly(); + break; + + default: + if (data.containerRatio >= data.elementRatio) { + alignByHeightOnly(); + } else { + alignByWidthOnly(); + } + break; + } + + fireCallbacks(); + + return module; + }; + + var alignByWidthOnly = function() { + var data = getData(), + height = data.containerWidth / data.elementRatio; + + data.element.css({ + height: height, + width: data.containerWidth, + top: 0.5 * (data.containerHeight - height), + left: 0 + }); + + return module; + }; + + var alignByHeightOnly = function() { + var data = getData(), + width = data.containerHeight * data.elementRatio; + + data.element.css({ + height: data.containerHeight, + width: data.containerHeight * data.elementRatio, + top: 0, + left: 0.5 * (data.containerWidth - width) + }); + + return module; + }; + + var setMode = function(param) { + if (_.isString(param)) { + mode = param; + align(); + } + + return module; + }; + + var setElement = function(element) { + config.element = element; + + return module; + }; + + var addCallback = function(func) { + if ($.isFunction(func)) { + callbacksList.push(func); + } else { + console.error('[Video info]: TypeError: Argument is not a function.'); + } + + return module; + }; + + var addOnceCallback = function(func) { + if ($.isFunction(func)) { + var decorator = function() { + func(); + removeCallback(func); + }; + + addCallback(decorator); + } else { + console.error('TypeError: Argument is not a function.'); + } + + return module; + }; + + var fireCallbacks = function() { + $.each(callbacksList, function(index, callback) { + callback(); + }); + }; + + var removeCallbacks = function() { + callbacksList.length = 0; + + return module; + }; + + var removeCallback = function(func) { + var index = $.inArray(func, callbacksList); + + if (index !== -1) { + return callbacksList.splice(index, 1); + } + }; + + var resetDelta = function() { + // eslint-disable-next-line no-multi-assign + delta.height = delta.width = 0; + + return module; + }; + + var addDelta = function(value, side) { + if (_.isNumber(value) && _.isNumber(delta[side])) { + delta[side] += value; + } + + return module; + }; + + var substractDelta = function(value, side) { + if (_.isNumber(value) && _.isNumber(delta[side])) { + delta[side] -= value; + } + + return module; + }; + + var destroy = function() { + var data = getData(); + data.element.css({ + height: '', width: '', top: '', left: '' + }); + removeCallbacks(); + resetDelta(); + mode = null; + }; + + initialize.apply(module, arguments); + + return $.extend(true, module, { + align: align, + alignByWidthOnly: alignByWidthOnly, + alignByHeightOnly: alignByHeightOnly, + destroy: destroy, + setParams: initialize, + setMode: setMode, + setElement: setElement, + callbacks: { + add: addCallback, + once: addOnceCallback, + remove: removeCallback, + removeAll: removeCallbacks + }, + delta: { + add: addDelta, + substract: substractDelta, + reset: resetDelta + } + }); + }; + + return Resizer; + }); +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/xblocks_contrib/video/static/js/src/00_sjson.js b/xblocks_contrib/video/static/js/src/00_sjson.js new file mode 100644 index 0000000..1dda367 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_sjson.js @@ -0,0 +1,115 @@ +(function(define) { + define( + 'video/00_sjson.js', + [], + function() { + 'use strict'; + + var Sjson = function(data) { + var sjson = { + start: data.start.concat(), + text: data.text.concat() + }, + module = {}; + + var getter = function(propertyName) { + return function() { + return sjson[propertyName]; + }; + }; + + var getStartTimes = getter('start'); + + var getCaptions = getter('text'); + + var size = function() { + return sjson.text.length; + }; + + function search(time, startTime, endTime) { + var start = getStartTimes(), + max = size() - 1, + min = 0, + results, + index; + + // if we specify a start and end time to search, + // search the filtered list of captions in between + // the start / end times. + // Else, search the unfiltered list. + if (typeof startTime !== 'undefined' + && typeof endTime !== 'undefined') { + results = filter(startTime, endTime); + start = results.start; + max = results.captions.length - 1; + } else { + start = getStartTimes(); + } + while (min < max) { + index = Math.ceil((max + min) / 2); + + if (time < start[index]) { + max = index - 1; + } + + if (time >= start[index]) { + min = index; + } + } + + return min; + } + + function filter(start, end) { + /* filters captions that occur between inputs + * `start` and `end`. Start and end should + * be Numbers (doubles) corresponding to the + * number of seconds elapsed since the beginning + * of the video. + * + * Returns an object with properties + * "start" and "captions" representing + * parallel arrays of start times and + * their corresponding captions. + */ + var filteredTimes = []; + var filteredCaptions = []; + var startTimes = getStartTimes(); + var captions = getCaptions(); + + if (startTimes.length !== captions.length) { + console.warn('video caption and start time arrays do not match in length'); + } + + // if end is null, then it's been set to + // some erroneous value, so filter using the + // entire array as long as it's not empty + if (end === null && startTimes.length) { + end = startTimes[startTimes.length - 1]; + } + + _.filter(startTimes, function(currentStartTime, i) { + if (currentStartTime >= start && currentStartTime <= end) { + filteredTimes.push(currentStartTime); + filteredCaptions.push(captions[i]); + } + }); + + return { + start: filteredTimes, + captions: filteredCaptions + }; + } + + return { + getCaptions: getCaptions, + getStartTimes: getStartTimes, + getSize: size, + filter: filter, + search: search + }; + }; + + return Sjson; + }); +}(RequireJS.define)); diff --git a/xblocks_contrib/video/static/js/src/00_video_storage.js b/xblocks_contrib/video/static/js/src/00_video_storage.js new file mode 100644 index 0000000..b0ec88c --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_video_storage.js @@ -0,0 +1,103 @@ +(function(requirejs, require, define) { + define( + 'video/00_video_storage.js', + [], + function() { + 'use strict'; + + /** + * Provides convenient way to store key value pairs. + * + * @param {string} namespace Namespace that is used to store data. + * @return {object} VideoStorage API. + */ + var VideoStorage = function(namespace, id) { + /** + * Adds new value to the storage or rewrites existent. + * + * @param {string} name Identifier of the data. + * @param {any} value Data to store. + * @param {boolean} instanceSpecific Data with this flag will be added + * to instance specific storage. + */ + var setItem = function(name, value, instanceSpecific) { + if (name) { + if (instanceSpecific) { + window[namespace][id][name] = value; + } else { + window[namespace][name] = value; + } + } + }; + + /** + * Returns the current value associated with the given name. + * + * @param {string} name Identifier of the data. + * @param {boolean} instanceSpecific Data with this flag will be added + * to instance specific storage. + * @return {any} The current value associated with the given name. + * If the given key does not exist in the list + * associated with the object then this method must return null. + */ + var getItem = function(name, instanceSpecific) { + if (instanceSpecific) { + return window[namespace][id][name]; + } else { + return window[namespace][name]; + } + }; + + /** + * Removes the current value associated with the given name. + * + * @param {string} name Identifier of the data. + * @param {boolean} instanceSpecific Data with this flag will be added + * to instance specific storage. + */ + var removeItem = function(name, instanceSpecific) { + if (instanceSpecific) { + delete window[namespace][id][name]; + } else { + delete window[namespace][name]; + } + }; + + /** + * Empties the storage. + * + */ + var clear = function() { + window[namespace] = {}; + window[namespace][id] = {}; + }; + + /** + * Initializes the module: creates a storage with proper namespace. + * + * @private + */ + (function initialize() { + if (!namespace) { + namespace = 'VideoStorage'; + } + if (!id) { + // Generate random alpha-numeric string. + id = Math.random().toString(36).slice(2); + } + + window[namespace] = window[namespace] || {}; + window[namespace][id] = window[namespace][id] || {}; + }()); + + return { + clear: clear, + getItem: getItem, + removeItem: removeItem, + setItem: setItem + }; + }; + + return VideoStorage; + }); +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/xblocks_contrib/video/static/js/src/01_initialize.js b/xblocks_contrib/video/static/js/src/01_initialize.js new file mode 100644 index 0000000..1aaddb5 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/01_initialize.js @@ -0,0 +1,845 @@ +/* eslint-disable no-console, no-param-reassign */ +/** + * @file Initialize module works with the JSON config, and sets up various + * settings, parameters, variables. After all setup actions are performed, it + * invokes the video player to play the specified video. This module must be + * invoked first. It provides several functions which do not fit in with other + * modules. + * + * @external VideoPlayer + * + * @module Initialize + */ + +(function(requirejs, require, define) { + define( + 'video/01_initialize.js', + ['video/03_video_player.js', 'video/00_i18n.js', 'moment', 'underscore'], + function(VideoPlayer, i18n, moment, _) { + var moment = moment || window.moment; + /** + * @function + * + * Initialize module exports this function. + * + * @param {object} state The object containg the state of the video player. + * All other modules, their parameters, public variables, etc. are + * available via this object. + * @param {DOM element} element Container of the entire Video DOM element. + */ + var Initialize = function(state, element) { + _makeFunctionsPublic(state); + + state.initialize(element) + .done(function() { + if (state.isYoutubeType()) { + state.parseSpeed(); + } + // On iPhones and iPods native controls are used. + if (/iP(hone|od)/i.test(state.isTouch[0])) { + _hideWaitPlaceholder(state); + state.el.trigger('initialize', arguments); + + return false; + } + + _initializeModules(state, i18n) + .done(function() { + // On iPad ready state occurs just after start playing. + // We hide controls before video starts playing. + if (/iPad|Android/i.test(state.isTouch[0])) { + state.el.on('play', _.once(function() { + state.trigger('videoControl.show', null); + })); + } else { + // On PC show controls immediately. + state.trigger('videoControl.show', null); + } + + _hideWaitPlaceholder(state); + state.el.trigger('initialize', arguments); + }); + }); + }, + + /* eslint-disable no-use-before-define */ + methodsDict = { + bindTo: bindTo, + fetchMetadata: fetchMetadata, + getCurrentLanguage: getCurrentLanguage, + getDuration: getDuration, + getPlayerMode: getPlayerMode, + getVideoMetadata: getVideoMetadata, + initialize: initialize, + isHtml5Mode: isHtml5Mode, + isFlashMode: isFlashMode, + isYoutubeType: isYoutubeType, + parseSpeed: parseSpeed, + parseYoutubeStreams: parseYoutubeStreams, + setPlayerMode: setPlayerMode, + setSpeed: setSpeed, + setAutoAdvance: setAutoAdvance, + speedToString: speedToString, + trigger: trigger, + youtubeId: youtubeId, + loadHtmlPlayer: loadHtmlPlayer, + loadYoutubePlayer: loadYoutubePlayer, + loadYouTubeIFrameAPI: loadYouTubeIFrameAPI + }, + /* eslint-enable no-use-before-define */ + + _youtubeApiDeferred = null, + _oldOnYouTubeIframeAPIReady; + + Initialize.prototype = methodsDict; + + return Initialize; + + // *************************************************************** + // Private functions start here. Private functions start with underscore. + // *************************************************************** + + /** + * @function _makeFunctionsPublic + * + * Functions which will be accessible via 'state' object. When called, + * these functions will get the 'state' + * object as a context. + * + * @param {object} state The object containg the state (properties, + * methods, modules) of the Video player. + */ + function _makeFunctionsPublic(state) { + bindTo(methodsDict, state, state); + } + + // function _renderElements(state) + // + // Create any necessary DOM elements, attach them, and set their + // initial configuration. Also make the created DOM elements available + // via the 'state' object. Much easier to work this way - you don't + // have to do repeated jQuery element selects. + function _renderElements(state) { + // Launch embedding of actual video content, or set it up so that it + // will be done as soon as the appropriate video player (YouTube or + // stand-alone HTML5) is loaded, and can handle embedding. + // + // Note that the loading of stand alone HTML5 player API is handled by + // Require JS. At the time when we reach this code, the stand alone + // HTML5 player is already loaded, so no further testing in that case + // is required. + var video, onYTApiReady, setupOnYouTubeIframeAPIReady; + + if (state.videoType === 'youtube') { + state.youtubeApiAvailable = false; + + onYTApiReady = function() { + console.log('[Video info]: YouTube API is available and is loaded.'); + if (state.htmlPlayerLoaded) { return; } + + console.log('[Video info]: Starting YouTube player.'); + video = VideoPlayer(state); + + state.modules.push(video); + state.__dfd__.resolve(); + state.youtubeApiAvailable = true; + }; + + if (window.YT) { + // If we have a Deferred object responsible for calling OnYouTubeIframeAPIReady + // callbacks, make sure that they have all been called by trying to resolve the + // Deferred object. Upon resolving, all the OnYouTubeIframeAPIReady will be + // called. If the object has been already resolved, the callbacks will not + // be called a second time. + if (_youtubeApiDeferred) { + _youtubeApiDeferred.resolve(); + } + + window.YT.ready(onYTApiReady); + } else { + // There is only one global variable window.onYouTubeIframeAPIReady which + // is supposed to be a function that will be called by the YouTube API + // when it finished initializing. This function will update this global function + // so that it resolves our Deferred object, which will call all of the + // OnYouTubeIframeAPIReady callbacks. + // + // If this global function is already defined, we store it first, and make + // sure that it gets executed when our Deferred object is resolved. + setupOnYouTubeIframeAPIReady = function() { + _oldOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady || undefined; + + window.onYouTubeIframeAPIReady = function() { + _youtubeApiDeferred.resolve(); + }; + + window.onYouTubeIframeAPIReady.done = _youtubeApiDeferred.done; + + if (_oldOnYouTubeIframeAPIReady) { + window.onYouTubeIframeAPIReady.done(_oldOnYouTubeIframeAPIReady); + } + }; + + // If a Deferred object hasn't been created yet, create one now. It will + // be responsible for calling OnYouTubeIframeAPIReady callbacks once the + // YouTube API loads. After creating the Deferred object, load the YouTube + // API. + if (!_youtubeApiDeferred) { + _youtubeApiDeferred = $.Deferred(); + setupOnYouTubeIframeAPIReady(); + } else if (!window.onYouTubeIframeAPIReady || !window.onYouTubeIframeAPIReady.done) { + // The Deferred object could have been already defined in a previous + // initialization of the video module. However, since then the global variable + // window.onYouTubeIframeAPIReady could have been overwritten. If so, + // we should set it up again. + setupOnYouTubeIframeAPIReady(); + } + + // Attach a callback to our Deferred object to be called once the + // YouTube API loads. + window.onYouTubeIframeAPIReady.done(function() { + window.YT.ready(onYTApiReady); + }); + } + } else { + video = VideoPlayer(state); + + state.modules.push(video); + state.__dfd__.resolve(); + state.htmlPlayerLoaded = true; + } + } + + function _waitForYoutubeApi(state) { + console.log('[Video info]: Starting to wait for YouTube API to load.'); + window.setTimeout(function() { + // If YouTube API will load OK, it will run `onYouTubeIframeAPIReady` + // callback, which will set `state.youtubeApiAvailable` to `true`. + // If something goes wrong at this stage, `state.youtubeApiAvailable` is + // `false`. + if (!state.youtubeApiAvailable) { + console.log('[Video info]: YouTube API is not available.'); + if (!state.htmlPlayerLoaded) { + state.loadHtmlPlayer(); + } + } + state.el.trigger('youtube_availability', [state.youtubeApiAvailable]); + }, state.config.ytTestTimeout); + } + + function loadYouTubeIFrameAPI(scriptTag) { + var firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(scriptTag, firstScriptTag); + } + + // function _parseYouTubeIDs(state) + // The function parse YouTube stream ID's. + // @return + // false: We don't have YouTube video IDs to work with; most likely + // we have HTML5 video sources. + // true: Parsing of YouTube video IDs went OK, and we can proceed + // onwards to play YouTube videos. + function _parseYouTubeIDs(state) { + if (state.parseYoutubeStreams(state.config.streams)) { + state.videoType = 'youtube'; + + return true; + } + + console.log( + '[Video info]: Youtube Video IDs are incorrect or absent.' + ); + + return false; + } + + /** + * Extract HLS video URLs from available video URLs. + * + * @param {object} state The object contaning the state (properties, methods, modules) of the Video player. + * @returns Array of available HLS video source urls. + */ + function extractHLSVideoSources(state) { + return _.filter(state.config.sources, function(source) { + return /\.m3u8(\?.*)?$/.test(source); + }); + } + + // function _prepareHTML5Video(state) + // The function prepare HTML5 video, parse HTML5 + // video sources etc. + function _prepareHTML5Video(state) { + state.speeds = ['0.75', '1.0', '1.25', '1.50', '2.0']; + // If none of the supported video formats can be played and there is no + // short-hand video links, than hide the spinner and show error message. + if (!state.config.sources.length) { + _hideWaitPlaceholder(state); + state.el + .find('.video-player div') + .addClass('hidden'); + state.el + .find('.video-player .video-error') + .removeClass('is-hidden'); + + return false; + } + + state.videoType = 'html5'; + + if (!_.keys(state.config.transcriptLanguages).length) { + state.config.showCaptions = false; + } + state.setSpeed(state.speed); + + return true; + } + + function _hideWaitPlaceholder(state) { + state.el + .addClass('is-initialized') + .find('.spinner') + .attr({ + 'aria-hidden': 'true', + tabindex: -1 + }); + } + + function _setConfigurations(state) { + state.setPlayerMode(state.config.mode); + // Possible value are: 'visible', 'hiding', and 'invisible'. + state.controlState = 'visible'; + state.controlHideTimeout = null; + state.captionState = 'invisible'; + state.captionHideTimeout = null; + state.HLSVideoSources = extractHLSVideoSources(state); + } + + // eslint-disable-next-line no-shadow + function _initializeModules(state, i18n) { + var dfd = $.Deferred(), + modulesList = $.map(state.modules, function(module) { + var options = state.options[module.moduleName] || {}; + if (_.isFunction(module)) { + return module(state, i18n, options); + } else if ($.isPlainObject(module)) { + return module; + } + }); + + $.when.apply(null, modulesList) + .done(dfd.resolve); + + return dfd.promise(); + } + + function _getConfiguration(data, storage) { + var isBoolean = function(value) { + var regExp = /^true$/i; + return regExp.test(value.toString()); + }, + // List of keys that will be extracted form the configuration. + extractKeys = [], + // Compatibility keys used to change names of some parameters in + // the final configuration. + compatKeys = { + start: 'startTime', + end: 'endTime' + }, + // Conversions used to pre-process some configuration data. + conversions = { + showCaptions: isBoolean, + autoplay: isBoolean, + autohideHtml5: isBoolean, + autoAdvance: function(value) { + var shouldAutoAdvance = storage.getItem('auto_advance'); + if (_.isUndefined(shouldAutoAdvance)) { + return isBoolean(value) || false; + } else { + return shouldAutoAdvance; + } + }, + savedVideoPosition: function(value) { + return storage.getItem('savedVideoPosition', true) + || Number(value) + || 0; + }, + speed: function(value) { + return storage.getItem('speed', true) || value; + }, + generalSpeed: function(value) { + return storage.getItem('general_speed') + || value + || '1.0'; + }, + transcriptLanguage: function(value) { + return storage.getItem('language') + || value + || 'en'; + }, + ytTestTimeout: function(value) { + value = parseInt(value, 10); + + if (!isFinite(value)) { + value = 1500; + } + + return value; + }, + startTime: function(value) { + value = parseInt(value, 10); + if (!isFinite(value) || value < 0) { + return 0; + } + + return value; + }, + endTime: function(value) { + value = parseInt(value, 10); + + if (!isFinite(value) || value === 0) { + return null; + } + + return value; + } + }, + config = {}; + + data = _.extend({ + startTime: 0, + endTime: null, + sub: '', + streams: '' + }, data); + + $.each(data, function(option, value) { + // Extract option that is in `extractKeys`. + if ($.inArray(option, extractKeys) !== -1) { + return; + } + + // Change option name to key that is in `compatKeys`. + if (compatKeys[option]) { + option = compatKeys[option]; + } + + // Pre-process data. + if (conversions[option]) { + if (_.isFunction(conversions[option])) { + value = conversions[option].call(this, value); + } else { + throw new TypeError(option + ' is not a function.'); + } + } + config[option] = value; + }); + + return config; + } + + // *************************************************************** + // Public functions start here. + // These are available via the 'state' object. Their context ('this' + // keyword) is the 'state' object. The magic private function that makes + // them available and sets up their context is makeFunctionsPublic(). + // *************************************************************** + + // function bindTo(methodsDict, obj, context, rewrite) + // Creates a new function with specific context and assigns it to the provided + // object. + // eslint-disable-next-line no-shadow + function bindTo(methodsDict, obj, context, rewrite) { + $.each(methodsDict, function(name, method) { + if (_.isFunction(method)) { + if (_.isUndefined(rewrite)) { + rewrite = true; + } + + if (_.isUndefined(obj[name]) || rewrite) { + obj[name] = _.bind(method, context); + } + } + }); + } + + function loadYoutubePlayer() { + if (this.htmlPlayerLoaded) { return; } + + console.log( + '[Video info]: Fetch metadata for YouTube video.' + ); + + this.fetchMetadata(); + this.parseSpeed(); + } + + function loadHtmlPlayer() { + // When the youtube link doesn't work for any reason + // (for example, firewall) any + // alternate sources should automatically play. + if (!_prepareHTML5Video(this)) { + console.log( + '[Video info]: Continue loading ' + + 'YouTube video.' + ); + + // Non-YouTube sources were not found either. + + this.el.find('.video-player div') + .removeClass('hidden'); + this.el.find('.video-player .video-error') + .addClass('is-hidden'); + + // If in reality the timeout was to short, try to + // continue loading the YouTube video anyways. + this.loadYoutubePlayer(); + } else { + console.log( + '[Video info]: Start HTML5 player.' + ); + + // In-browser HTML5 player does not support quality + // control. + this.el.find('.quality_control').hide(); + _renderElements(this); + } + } + + // function initialize(element) + // The function set initial configuration and preparation. + + function initialize(element) { + var self = this, + el = this.el, + id = this.id, + container = el.find('.video-wrapper'), + __dfd__ = $.Deferred(), + isTouch = onTouchBasedDevice() || ''; + + if (isTouch) { + el.addClass('is-touch'); + } + + $.extend(this, { + __dfd__: __dfd__, + container: container, + isFullScreen: false, + isTouch: isTouch + }); + + console.log('[Video info]: Initializing video with id "%s".', id); + + // We store all settings passed to us by the server in one place. These + // are "read only", so don't modify them. All variable content lives in + // 'state' object. + // jQuery .data() return object with keys in lower camelCase format. + this.config = $.extend({}, _getConfiguration(this.metadata, this.storage), { + element: element, + fadeOutTimeout: 1400, + captionsFreezeTime: 10000, + mode: $.cookie('edX_video_player_mode'), + // Available HD qualities will only be accessible once the video has + // been played once, via player.getAvailableQualityLevels. + availableHDQualities: [] + }); + + if (this.config.endTime < this.config.startTime) { + this.config.endTime = null; + } + + this.lang = this.config.transcriptLanguage; + this.speed = this.speedToString( + this.config.speed || this.config.generalSpeed + ); + this.auto_advance = this.config.autoAdvance; + this.htmlPlayerLoaded = false; + this.duration = this.metadata.duration; + + _setConfigurations(this); + + // If `prioritizeHls` is set to true than `hls` is the primary playback + if (this.config.prioritizeHls || !(_parseYouTubeIDs(this))) { + // If we do not have YouTube ID's, try parsing HTML5 video sources. + if (!_prepareHTML5Video(this)) { + __dfd__.reject(); + // Non-YouTube sources were not found either. + return __dfd__.promise(); + } + + console.log('[Video info]: Start player in HTML5 mode.'); + _renderElements(this); + } else { + _renderElements(this); + + _waitForYoutubeApi(this); + + var scriptTag = document.createElement('script'); + + scriptTag.src = this.config.ytApiUrl; + scriptTag.async = true; + + $(scriptTag).on('load', function() { + self.loadYoutubePlayer(); + }); + $(scriptTag).on('error', function() { + console.log( + '[Video info]: YouTube returned an error for ' + + 'video with id "' + self.id + '".' + ); + // If the video is already loaded in `_waitForYoutubeApi` by the + // time we get here, then we shouldn't load it again. + if (!self.htmlPlayerLoaded) { + self.loadHtmlPlayer(); + } + }); + + window.Video.loadYouTubeIFrameAPI(scriptTag); + } + return __dfd__.promise(); + } + + // function parseYoutubeStreams(state, youtubeStreams) + // + // Take a string in the form: + // "iCawTYPtehk:0.75,KgpclqP-LBA:1.0,9-2670d5nvU:1.5" + // parse it, and make it available via the 'state' object. If we are + // not given a string, or it's length is zero, then we return false. + // + // @return + // false: We don't have YouTube video IDs to work with; most likely + // we have HTML5 video sources. + // true: Parsing of YouTube video IDs went OK, and we can proceed + // onwards to play YouTube videos. + function parseYoutubeStreams(youtubeStreams) { + if (_.isUndefined(youtubeStreams) || !youtubeStreams.length) { + return false; + } + + this.videos = {}; + + _.each(youtubeStreams.split(/,/), function(video) { + var speed; + video = video.split(/:/); + speed = this.speedToString(video[0]); + this.videos[speed] = video[1]; + }, this); + + return _.isString(this.videos['1.0']); + } + + // function fetchMetadata() + // + // When dealing with YouTube videos, we must fetch meta data that has + // certain key facts not available while the video is loading. For + // example the length of the video can be determined from the meta + // data. + function fetchMetadata() { + var self = this, + metadataXHRs = []; + + this.metadata = {}; + + metadataXHRs = _.map(this.videos, function(url, speed) { + return self.getVideoMetadata(url, function(data) { + if (data.items.length > 0) { + var metaDataItem = data.items[0]; + self.metadata[metaDataItem.id] = metaDataItem.contentDetails; + } + }); + }); + + $.when.apply(this, metadataXHRs).done(function() { + self.el.trigger('metadata_received'); + + // Not only do we trigger the "metadata_received" event, we also + // set a flag to notify that metadata has been received. This + // allows for code that will miss the "metadata_received" event + // to know that metadata has been received. This is important in + // cases when some code will subscribe to the "metadata_received" + // event after it has been triggered. + self.youtubeMetadataReceived = true; + }); + } + + // function parseSpeed() + // + // Create a separate array of available speeds. + function parseSpeed() { + this.speeds = _.keys(this.videos).sort(); + } + + function setSpeed(newSpeed) { + // Possible speeds for each player type. + // HTML5 = [0.75, 1, 1.25, 1.5, 2] + // Youtube Flash = [0.75, 1, 1.25, 1.5] + // Youtube HTML5 = [0.25, 0.5, 1, 1.5, 2] + var map = { + 0.25: '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + '0.50': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + 0.75: '0.50', // HTML5 or Youtube Flash -> Youtube HTML5 + 1.25: '1.50', // HTML5 or Youtube Flash -> Youtube HTML5 + 2.0: '1.50' // HTML5 or Youtube HTML5 -> Youtube Flash + }; + + if (_.contains(this.speeds, newSpeed)) { + this.speed = newSpeed; + } else { + newSpeed = map[newSpeed]; + this.speed = _.contains(this.speeds, newSpeed) ? newSpeed : '1.0'; + } + this.speed = parseFloat(this.speed); + } + + function setAutoAdvance(enabled) { + this.auto_advance = enabled; + } + + function getVideoMetadata(url, callback) { + var youTubeEndpoint; + if (!(_.isString(url))) { + url = this.videos['1.0'] || ''; + } + // Will hit the API URL to get the youtube video metadata. + youTubeEndpoint = this.config.ytMetadataEndpoint; // The new runtime supports anonymous users + // and uses an XBlock handler to get YouTube metadata + if (!youTubeEndpoint) { + // The old runtime has a full/separate LMS API for getting YouTube metadata, but it doesn't + // support anonymous users nor videos that play in a sandboxed iframe. + youTubeEndpoint = [this.config.lmsRootURL, '/courses/yt_video_metadata', '?id=', url].join(''); + } + return $.ajax({ + url: youTubeEndpoint, + success: _.isFunction(callback) ? callback : null, + error: function() { + console.warn( + 'Unable to get youtube video metadata. Some video metadata may be unavailable.' + ); + }, + notifyOnError: false + }); + } + + function youtubeId(speed) { + var currentSpeed = this.isFlashMode() ? this.speed : '1.0'; + + return this.videos[speed] + || this.videos[currentSpeed] + || this.videos['1.0']; + } + + function getDuration() { + try { + return moment.duration(this.metadata[this.youtubeId()].duration, moment.ISO_8601).asSeconds(); + } catch (err) { + return _.result(this.metadata[this.youtubeId('1.0')], 'duration') || 0; + } + } + + /** + * Sets player mode. + * + * @param {string} mode Mode to set for the video player if it is supported. + * Otherwise, `html5` is used by default. + */ + function setPlayerMode(mode) { + var supportedModes = ['html5', 'flash']; + + mode = _.contains(supportedModes, mode) ? mode : 'html5'; + this.currentPlayerMode = mode; + } + + /** + * Returns current player mode. + * + * @return {string} Returns string that describes player mode + */ + function getPlayerMode() { + return this.currentPlayerMode; + } + + /** + * Checks if current player mode is Flash. + * + * @return {boolean} Returns `true` if current mode is `flash`, otherwise + * it returns `false` + */ + function isFlashMode() { + return this.getPlayerMode() === 'flash'; + } + + /** + * Checks if current player mode is Html5. + * + * @return {boolean} Returns `true` if current mode is `html5`, otherwise + * it returns `false` + */ + function isHtml5Mode() { + return this.getPlayerMode() === 'html5'; + } + + function isYoutubeType() { + return this.videoType === 'youtube'; + } + + function speedToString(speed) { + return parseFloat(speed).toFixed(2).replace(/\.00$/, '.0'); + } + + function getCurrentLanguage() { + var keys = _.keys(this.config.transcriptLanguages); + + if (keys.length) { + if (!_.contains(keys, this.lang)) { + if (_.contains(keys, 'en')) { + this.lang = 'en'; + } else { + this.lang = keys.pop(); + } + } + } else { + return null; + } + + return this.lang; + } + + /* + * The trigger() function will assume that the @objChain is a complete + * chain with a method (function) at the end. It will call this function. + * So for example, when trigger() is called like so: + * + * state.trigger('videoPlayer.pause', {'param1': 10}); + * + * Then trigger() will execute: + * + * state.videoPlayer.pause({'param1': 10}); + */ + function trigger(objChain) { + var extraParameters = Array.prototype.slice.call(arguments, 1), + i, tmpObj, chain; + + // Remember that 'this' is the 'state' object. + tmpObj = this; + chain = objChain.split('.'); + + // At the end of the loop the variable 'tmpObj' will either be the + // correct object/function to trigger/invoke. If the 'chain' chain of + // object is incorrect (one of the link is non-existent), then the loop + // will immediately exit. + while (chain.length) { + i = chain.shift(); + + if (tmpObj.hasOwnProperty(i)) { + tmpObj = tmpObj[i]; + } else { + // An incorrect object chain was specified. + + return false; + } + } + + tmpObj.apply(this, extraParameters); + + return true; + } + }); +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/xblocks_contrib/video/static/js/src/025_focus_grabber.js b/xblocks_contrib/video/static/js/src/025_focus_grabber.js new file mode 100644 index 0000000..3f95871 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/025_focus_grabber.js @@ -0,0 +1,135 @@ +/* + * 025_focus_grabber.js + * + * Purpose: Provide a way to focus on autohidden Video controls. + * + * + * Because in HTML player mode we have a feature of autohiding controls on + * mouse inactivity, sometimes focus is lost from the currently selected + * control. What's more, when all controls are autohidden, we can't get to any + * of them because by default browser does not place hidden elements on the + * focus chain. + * + * To get around this minor annoyance, this module will manage 2 placeholder + * elements that will be invisible to the user's eye, but visible to the + * browser. This will allow for a sneaky stealing of focus and placing it where + * we need (on hidden controls). + * + * This code has been moved to a separate module because it provides a concrete + * block of functionality that can be turned on (off). + */ + +/* + * "If you want to climb a mountain, begin at the top." + * + * ~ Zen saying + */ + +(function(requirejs, require, define) { +// FocusGrabber module. + define( + 'video/025_focus_grabber.js', + [], + function() { + return function(state) { + var dfd = $.Deferred(); + + state.focusGrabber = {}; + + _makeFunctionsPublic(state); + _renderElements(state); + _bindHandlers(state); + + dfd.resolve(); + return dfd.promise(); + }; + + // Private functions. + + function _makeFunctionsPublic(state) { + var methodsDict = { + disableFocusGrabber: disableFocusGrabber, + enableFocusGrabber: enableFocusGrabber, + onFocus: onFocus + }; + + state.bindTo(methodsDict, state.focusGrabber, state); + } + + function _renderElements(state) { + state.focusGrabber.elFirst = state.el.find('.focus_grabber.first'); + state.focusGrabber.elLast = state.el.find('.focus_grabber.last'); + + // From the start, the Focus Grabber must be disabled so that + // tabbing (switching focus) does not land the user on one of the + // placeholder elements (elFirst, elLast). + state.focusGrabber.disableFocusGrabber(); + } + + function _bindHandlers(state) { + state.focusGrabber.elFirst.on('focus', state.focusGrabber.onFocus); + state.focusGrabber.elLast.on('focus', state.focusGrabber.onFocus); + + // When the video container element receives programmatic focus, then + // on un-focus ('blur' event) we should trigger a 'mousemove' event so + // as to reveal autohidden controls. + state.el.on('blur', function() { + state.el.trigger('mousemove'); + }); + } + + // Public functions. + + function enableFocusGrabber() { + var tabIndex; + + // When the Focus Grabber is being enabled, there are two different + // scenarios: + // + // 1.) Currently focused element was inside the video player. + // 2.) Currently focused element was somewhere else on the page. + // + // In the first case we must make sure that the video player doesn't + // loose focus, even though the controls are autohidden. + if ($(document.activeElement).parents().hasClass('video')) { + tabIndex = -1; + } else { + tabIndex = 0; + } + + this.focusGrabber.elFirst.attr('tabindex', tabIndex); + this.focusGrabber.elLast.attr('tabindex', tabIndex); + + // Don't loose focus. We are inside video player on some control, but + // because we can't remain focused on a hidden element, we will shift + // focus to the main video element. + // + // Once the main element will receive the un-focus ('blur') event, a + // 'mousemove' event will be triggered, and the video controls will + // receive focus once again. + if (tabIndex === -1) { + this.el.focus(); + + this.focusGrabber.elFirst.attr('tabindex', 0); + this.focusGrabber.elLast.attr('tabindex', 0); + } + } + + function disableFocusGrabber() { + // Only programmatic focusing on these elements will be available. + // We don't want the user to focus on them (for example with the 'Tab' + // key). + this.focusGrabber.elFirst.attr('tabindex', -1); + this.focusGrabber.elLast.attr('tabindex', -1); + } + + function onFocus(event, params) { + // Once the Focus Grabber placeholder elements will gain focus, we will + // trigger 'mousemove' event so that the autohidden controls will + // become visible. + this.el.trigger('mousemove'); + + this.focusGrabber.disableFocusGrabber(); + } + }); +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/xblocks_contrib/video/static/js/src/02_html5_hls_video.js b/xblocks_contrib/video/static/js/src/02_html5_hls_video.js new file mode 100644 index 0000000..cb6a1a2 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/02_html5_hls_video.js @@ -0,0 +1,146 @@ +/* eslint-disable no-console, no-param-reassign */ +/** + * HTML5 video player module to support HLS video playback. + * + */ + +(function(requirejs, require, define) { + 'use strict'; + + define('video/02_html5_hls_video.js', ['underscore', 'video/02_html5_video.js', 'hls'], + function(_, HTML5Video, HLS) { + var HLSVideo = {}; + + HLSVideo.Player = (function() { + /** + * Initialize HLS video player. + * + * @param {jQuery} el Reference to video player container element + * @param {Object} config Contains common config for video player + */ + function Player(el, config) { + var self = this; + + this.config = config; + + // do common initialization independent of player type + this.init(el, config); + + _.bindAll(this, 'playVideo', 'pauseVideo', 'onReady'); + + // If we have only HLS sources and browser doesn't support HLS then show error message. + if (config.HLSOnlySources && !config.canPlayHLS) { + this.showErrorMessage(null, '.video-hls-error'); + return; + } + + this.config.state.el.on('initialize', _.once(function() { + console.log('[HLS Video]: HLS Player initialized'); + self.showPlayButton(); + })); + + // Safari has native support to play HLS videos + if (config.browserIsSafari) { + this.videoEl.attr('src', config.videoSources[0]); + } else { + // load auto start if auto_advance is enabled + if (config.state.auto_advance) { + this.hls = new HLS({autoStartLoad: true}); + } else { + this.hls = new HLS({autoStartLoad: false}); + } + this.hls.loadSource(config.videoSources[0]); + this.hls.attachMedia(this.video); + + this.hls.on(HLS.Events.ERROR, this.onError.bind(this)); + + this.hls.on(HLS.Events.MANIFEST_PARSED, function(event, data) { + console.log( + '[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo: ', + data.levels.map(function(level) { + return { + bitrate: level.bitrate, + resolution: level.width + 'x' + level.height + }; + }) + ); + self.config.onReadyHLS(); + }); + this.hls.on(HLS.Events.LEVEL_SWITCHED, function(event, data) { + var level = self.hls.levels[data.level]; + console.log( + '[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo: ', + { + bitrate: level.bitrate, + resolution: level.width + 'x' + level.height + } + ); + }); + } + } + + Player.prototype = Object.create(HTML5Video.Player.prototype); + Player.prototype.constructor = Player; + + Player.prototype.playVideo = function() { + HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['show']); + if (!this.config.browserIsSafari) { + this.hls.startLoad(); + } + HTML5Video.Player.prototype.playVideo.apply(this); + }; + + Player.prototype.pauseVideo = function() { + HTML5Video.Player.prototype.pauseVideo.apply(this); + HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['hide']); + }; + + Player.prototype.onPlaying = function() { + HTML5Video.Player.prototype.onPlaying.apply(this); + HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['hide']); + }; + + Player.prototype.onReady = function() { + this.config.events.onReady(null); + }; + + /** + * Handler for HLS video errors. This only takes care of fatal erros, non-fatal errors + * are automatically handled by hls.js + * + * @param {String} event `hlsError` + * @param {Object} data Contains the information regarding error occurred. + */ + Player.prototype.onError = function(event, data) { + if (data.fatal) { + switch (data.type) { + case HLS.ErrorTypes.NETWORK_ERROR: + console.error( + '[HLS Video]: Fatal network error encountered, try to recover. Details: %s', + data.details + ); + this.hls.startLoad(); + break; + case HLS.ErrorTypes.MEDIA_ERROR: + console.error( + '[HLS Video]: Fatal media error encountered, try to recover. Details: %s', + data.details + ); + this.hls.recoverMediaError(); + break; + default: + console.error( + '[HLS Video]: Unrecoverable error encountered. Details: %s', + data.details + ); + break; + } + } + }; + + return Player; + }()); + + return HLSVideo; + }); +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/xblocks_contrib/video/static/js/src/02_html5_video.js b/xblocks_contrib/video/static/js/src/02_html5_video.js new file mode 100644 index 0000000..12f19ee --- /dev/null +++ b/xblocks_contrib/video/static/js/src/02_html5_video.js @@ -0,0 +1,390 @@ +/* eslint-disable no-console, no-param-reassign */ +/** + * @file HTML5 video player module. Provides methods to control the in-browser + * HTML5 video player. + * + * The goal was to write this module so that it closely resembles the YouTube + * API. The main reason for this is because initially the edX video player + * supported only YouTube videos. When HTML5 support was added, for greater + * compatibility, and to reduce the amount of code that needed to be modified, + * it was decided to write a similar API as the one provided by YouTube. + * + * @external RequireJS + * + * @module HTML5Video + */ + +(function(requirejs, require, define) { + define( + 'video/02_html5_video.js', + ['underscore'], + function(_) { + var HTML5Video = {}; + + HTML5Video.Player = (function() { + /* + * Constructor function for HTML5 Video player. + * + * @param {String|Object} el A DOM element where the HTML5 player will + * be inserted (as returned by jQuery(selector) function), or a + * selector string which will be used to select an element. This is a + * required parameter. + * + * @param config - An object whose properties will be used as + * configuration options for the HTML5 video player. This is an + * optional parameter. In the case if this parameter is missing, or + * some of the config object's properties are missing, defaults will be + * used. The available options (and their defaults) are as + * follows: + * + * config = { + * + * videoSources: [], // An array with properties being video + * // sources. The property name is the + * // video format of the source. Supported + * // video formats are: 'mp4', 'webm', and + * // 'ogg'. + * poster: Video poster URL + * + * browserIsSafari: Flag to tell if current browser is Safari + * + * events: { // Object's properties identify the + * // events that the API fires, and the + * // functions (event listeners) that the + * // API will call when those events occur. + * // If value is null, or property is not + * // specified, then no callback will be + * // called for that event. + * + * onReady: null, + * onStateChange: null + * } + * } + */ + function Player(el, config) { + var errorMessage, lastSource, sourceList; + + // Create HTML markup for individual sources of the HTML5