Progress for buffer, Safari 8 fix, validating 'html' option

This commit is contained in:
Sam Potts 2015-02-22 10:17:39 +11:00
parent 5f96172dbd
commit 49038e3ca9
10 changed files with 243 additions and 115 deletions

View File

@ -1,6 +1,6 @@
// ==========================================================================
// Plyr
// plyr.js v1.0.8
// plyr.js v1.0.9
// https://github.com/sampotts/plyr
// ==========================================================================
// Credits: http://paypal.github.io/accessible-html5-video-player/
@ -33,7 +33,11 @@
captions: "[data-player='captions']",
fullscreen: "[data-player='fullscreen']"
},
progress: ".player-progress",
progress: {
container: ".player-progress",
buffer: ".player-progress-buffer",
played: ".player-progress-played"
},
captions: ".player-captions",
duration: ".player-duration",
seekTime: ".player-seek-time"
@ -205,6 +209,11 @@
element.removeEventListener(event, callback, false);
}
// Get percentage
function _getPercentage(current, max) {
return Math.floor((current / max) * 100);
}
// Get click position relative to parent
// http://www.kirupa.com/html5/getting_mouse_click_position.htm
function _getClickPosition(event) {
@ -419,30 +428,47 @@
// Find the UI controls and store references
function _findElements() {
player.controls = _getElement(config.selectors.controls);
try {
player.controls = _getElement(config.selectors.controls);
// Buttons
player.buttons = {};
player.buttons.play = _getElement(config.selectors.buttons.play);
player.buttons.pause = _getElement(config.selectors.buttons.pause);
player.buttons.restart = _getElement(config.selectors.buttons.restart);
player.buttons.rewind = _getElement(config.selectors.buttons.rewind);
player.buttons.forward = _getElement(config.selectors.buttons.forward);
player.buttons.mute = _getElement(config.selectors.buttons.mute);
player.buttons.captions = _getElement(config.selectors.buttons.captions);
player.buttons.fullscreen = _getElement(config.selectors.buttons.fullscreen);
// Buttons
player.buttons = {};
player.buttons.play = _getElement(config.selectors.buttons.play);
player.buttons.pause = _getElement(config.selectors.buttons.pause);
player.buttons.restart = _getElement(config.selectors.buttons.restart);
player.buttons.rewind = _getElement(config.selectors.buttons.rewind);
player.buttons.forward = _getElement(config.selectors.buttons.forward);
player.buttons.mute = _getElement(config.selectors.buttons.mute);
player.buttons.captions = _getElement(config.selectors.buttons.captions);
player.buttons.fullscreen = _getElement(config.selectors.buttons.fullscreen);
// Progress
player.progress = {};
player.progress.bar = _getElement(config.selectors.progress);
player.progress.text = player.progress.bar.getElementsByTagName("span")[0];
// Progress
player.progress = {};
player.progress.container = _getElement(config.selectors.progress.container);
// Volume
player.volume = _getElement(config.selectors.buttons.volume);
// Progress - Buffering
player.progress.buffer = {};
player.progress.buffer.bar = _getElement(config.selectors.progress.buffer);
player.progress.buffer.text = player.progress.buffer.bar.getElementsByTagName("span")[0];
// Timing
player.duration = _getElement(config.selectors.duration);
player.seekTime = _getElements(config.selectors.seekTime);
// Progress - Played
player.progress.played = {};
player.progress.played.bar = _getElement(config.selectors.progress.played);
player.progress.played.text = player.progress.played.bar.getElementsByTagName("span")[0];
// Volume
player.volume = _getElement(config.selectors.buttons.volume);
// Timing
player.duration = _getElement(config.selectors.duration);
player.seekTime = _getElements(config.selectors.seekTime);
return true;
}
catch(e) {
_log("It looks like there's a problem with your controls html. Bailing.", true);
return false;
}
}
// Setup media
@ -526,7 +552,15 @@
}
// If caption file exists, process captions
else {
var track = {}, tracks, j;
// Turn off native caption rendering to avoid double captions
// This doesn't seem to work in Safari 7+, so the <track> elements are removed from the dom below
var tracks = player.media.textTracks;
for (var x=0; x < tracks.length; x++) {
tracks[x].mode = "hidden";
}
// Enable UI
_showCaptions(player);
// If IE 10/11 or Firefox 31+ or Safari 7+, don"t use native captioning (still doesn"t work although they claim it"s now supported)
if ((player.browserName === "IE" && player.browserMajorVersion === 10) ||
@ -538,28 +572,18 @@
// Set to false so skips to "manual" captioning
player.isTextTracks = false;
// Turn off native caption rendering to avoid double captions [doesn"t work in Safari 7; see patch below]
track = {};
tracks = player.media.textTracks;
for (j=0; j < tracks.length; j++) {
track = player.media.textTracks[j];
track.mode = "hidden";
}
}
// Rendering caption tracks - native support required - http://caniuse.com/webvtt
// Rendering caption tracks
// Native support required - http://caniuse.com/webvtt
if (player.isTextTracks) {
_log("textTracks supported");
_showCaptions(player);
for (var y=0; y < tracks.length; y++) {
var track = tracks[y];
track = {};
tracks = player.media.textTracks;
for (j=0; j < tracks.length; j++) {
track = player.media.textTracks[j];
track.mode = "hidden";
if (track.kind === "captions") {
_on(track, "cuechange",function() {
_on(track, "cuechange", function() {
if (this.activeCues[0]) {
if (this.activeCues[0].hasOwnProperty("text")) {
player.captionsContainer.innerHTML = this.activeCues[0].text;
@ -572,7 +596,6 @@
// Caption tracks not natively supported
else {
_log("textTracks not supported so rendering captions manually");
_showCaptions(player);
// Render captions from array at appropriate time
player.currentCaption = "";
@ -601,12 +624,12 @@
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
player.captions = [];
var records = [],
record,
req = xhr.responseText;
records = req.split("\n\n");
for (var r=0; r < records.length; r++) {
record = records[r];
player.captions[r] = [];
@ -630,13 +653,17 @@
}
}
// If Safari 7, removing track from DOM [see "turn off native caption rendering" above]
if (player.browserName === "Safari" && player.browserMajorVersion === 7) {
_log("Safari 7 detected; removing track from DOM");
// If Safari 7+, removing track from DOM [see "turn off native caption rendering" above]
if (player.browserName === "Safari" && player.browserMajorVersion >= 7) {
_log("Safari 7+ detected; removing track from DOM");
// Find all <track> elements
tracks = player.media.getElementsByTagName("track");
player.media.removeChild(tracks[0]);
// Loop through and remove one by one
for (var t=0; t < tracks.length; t++) {
player.media.removeChild(tracks[t]);
}
}
}
}
@ -848,6 +875,54 @@
_toggleClass(player.container, config.classes.muted, (player.media.volume === 0 || player.media.muted));
}
// Update <progress> elements
function _updateProgress(event) {
var progress, text, value = 0;
switch(event.type) {
// Video playing
case "timeupdate":
progress = player.progress.played.bar;
text = player.progress.played.text;
value = _getPercentage(player.media.currentTime, player.media.duration);
break;
// Check buffer status
case "playing":
case "progress":
progress = player.progress.buffer.bar;
text = player.progress.buffer.text;
value = (function() {
var buffered = player.media.buffered;
if(buffered.length) {
return _getPercentage(buffered.end(0), player.media.duration);
}
return 0;
})();
break;
}
if (progress && value > 0) {
progress.value = value;
text.innerHTML = value;
}
}
// Update the displayed play time
function _updateTimeDisplay() {
player.secs = parseInt(player.media.currentTime % 60);
player.mins = parseInt((player.media.currentTime / 60) % 60);
// Ensure it"s two digits. For example, 03 rather than 3.
player.secs = ("0" + player.secs).slice(-2);
player.mins = ("0" + player.mins).slice(-2);
// Render
player.duration.innerHTML = player.mins + ":" + player.secs;
}
// Listen for events
function _listeners() {
// Play
@ -907,30 +982,13 @@
}
// Duration
_on(player.media, "timeupdate", function() {
player.secs = parseInt(player.media.currentTime % 60);
player.mins = parseInt((player.media.currentTime / 60) % 60);
// Ensure it"s two digits. For example, 03 rather than 3.
player.secs = ("0" + player.secs).slice(-2);
player.mins = ("0" + player.mins).slice(-2);
_on(player.media, "timeupdate", _updateTimeDisplay);
// Render
player.duration.innerHTML = player.mins + ":" + player.secs;
});
// Progress bar
_on(player.media, "timeupdate", function() {
player.percent = (100 / player.media.duration) * player.media.currentTime;
if (player.percent > 0) {
player.progress.bar.value = player.percent;
player.progress.text.innerHTML = player.percent;
}
});
// Playing progress
_on(player.media, "timeupdate", _updateProgress);
// Skip when clicking progress bar
_on(player.progress.bar, "click", function(event) {
_on(player.progress.played.bar, "click", function(event) {
player.pos = _getClickPosition(event).x / this.offsetWidth;
player.media.currentTime = player.pos * player.media.duration;
@ -953,6 +1011,15 @@
_toggleClass(player.container, config.classes.stopped, true);
_toggleClass(player.container, config.classes.playing);
});
_on(player.media, "loadstart", function() {
_log("loadstart");
});
// Check for buffer progress
_on(player.media, "progress", _updateProgress);
// Also check on start of playing
_on(player.media, "playing", _updateProgress);
}
function _init() {
@ -992,7 +1059,12 @@
_injectControls();
// Find the elements
_findElements();
if(!_findElements()) {
return false;
}
// Captions
_setupCaptions();
// Set volume
_setVolume();
@ -1000,9 +1072,6 @@
// Setup fullscreen
_setupFullscreen();
// Captions
_setupCaptions();
// Seeking
_setupSeeking();

View File

@ -18,8 +18,9 @@
@control-spacing: 10px;
// Progress
@progress-bg: @gray;
@progress-value-bg: @blue;
@progress-bg: lighten(@gray, 10%);
@progress-playing-bg: @blue;
@progress-buffered-bg: @gray;
// Range
@range-track-height: 6px;
@ -96,9 +97,8 @@
max-width: 100%;
min-width: 290px;
overflow: hidden; // For the controls
background: #000;
// BORDER-BOX ALL THE THINGS!
// border-box everything
// http://paulirish.com/2012/box-sizing-border-box-ftw/
&,
*,
@ -130,10 +130,10 @@
font-size: 16px;
font-weight: 600;
text-shadow:
-1px -1px 0 rgba(red(@gray-dark), green(@gray-dark), blue(@gray-dark), .5),
1px -1px 0 rgba(red(@gray-dark), green(@gray-dark), blue(@gray-dark), .5),
-1px 1px 0 rgba(red(@gray-dark), green(@gray-dark), blue(@gray-dark), .5),
1px 1px 0 rgba(red(@gray-dark), green(@gray-dark), blue(@gray-dark), .5);
-1px -1px 0 @gray,
1px -1px 0 @gray,
-1px 1px 0 @gray,
1px 1px 0 @gray;
text-align: center;
.font-smoothing();
@ -206,18 +206,12 @@
input:focus + label,
button:focus {
.tab-focus();
svg {
fill: #fff;
}
color: #fff;
}
button:hover,
input + label:hover {
background: @control-color-active;
svg {
fill: #fff;
}
color: #fff;
}
.icon-exit-fullscreen,
.icon-muted {
@ -243,27 +237,48 @@
right: 0;
width: 100%;
height: @control-spacing;
margin: 0;
vertical-align: top;
&[value] {
-webkit-appearance: none;
border: none;
background: @progress-bg;
background: @progress-bg;
&-buffer,
&-played {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
margin: 0;
vertical-align: top;
&[value] {
-webkit-appearance: none;
border: none;
background: transparent;
&::-webkit-progress-bar {
background: transparent;
}
// Inherit from currentColor;
&::-webkit-progress-value {
background: currentColor;
transition: width .1s ease;
}
&::-moz-progress-bar {
background: currentColor;
transition: width .1s ease;
}
}
}
&-played {
z-index: 2;
}
&-played[value] {
cursor: pointer;
color: @progress-value-bg;
&::-webkit-progress-bar {
background: @progress-bg;
}
// Inherit from currentColor;
&::-webkit-progress-value {
background: currentColor;
}
&::-moz-progress-bar {
background: currentColor;
}
color: @progress-playing-bg;
}
&-buffer[value] {
color: @progress-buffered-bg;
}
}

View File

@ -1,7 +1,12 @@
<div class="player-controls">
<progress class="player-progress" max="100" value="0">
<span>0</span>% played
</progress>
<div class="player-progress">
<progress class="player-progress-played" max="100" value="0">
<span>0</span>% played
</progress>
<progress class="player-progress-buffer" max="100" value="0">
<span>0</span>% buffered
</progress>
</div>
<span class="player-controls-playback">
<button type="button" data-player="restart">
<svg><use xlink:href="#icon-refresh"></use></svg>

36
changelog.md Normal file
View File

@ -0,0 +1,36 @@
# Changelog
## v1.0.9
- Added buffer progress bar
- Fixed Safari 8 caption track (it needs to be removed from the DOM like in Safari 7)
- Added validation (it works or it doesn't basically) of the `html` option passed
## v1.0.8
- Bug fix
## v1.0.7
- Storing user selected volume in local storage
## v1.0.6
- Fullscreen fallback for older browsers to use "full window"
## v1.0.5
- More minor bug fixes and improvements
## v1.0.4
- Fixed caption legibility issues
## v1.0.3
- Minor bug fixes
## v1.0.2
- Added OGG to <audio> example for Firefox
- Fixed IE11 fullscreen issues
## v1.0.1
- Bug fixes for IE (as per usual)
- Added CSS hooks for media type
- Return instances of Plyr to the element
## v1.0.0
- Initial release

2
dist/css/plyr.css vendored

File diff suppressed because one or more lines are too long

2
dist/js/docs.js vendored

File diff suppressed because one or more lines are too long

2
dist/js/plyr.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
var templates = {};
templates['controls'] = new Hogan.Template({code: function (c,p,i) { var t=this;t.b(i=i||"");t.b("<div class=\"player-controls\">");t.b("\n" + i);t.b(" <progress class=\"player-progress\" max=\"100\" value=\"0\">");t.b("\n" + i);t.b(" <span>0</span>% played");t.b("\n" + i);t.b(" </progress>");t.b("\n" + i);t.b(" <span class=\"player-controls-playback\">");t.b("\n" + i);t.b(" <button type=\"button\" data-player=\"restart\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-refresh\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Restart</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" <button type=\"button\" data-player=\"rewind\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-rewind\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Rewind <span class=\"player-seek-time\">10</span> seconds</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" <button type=\"button\" aria-label=\"{aria-label}\" data-player=\"play\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-play\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Play</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" <button type=\"button\" data-player=\"pause\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-pause\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Pause</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" <button type=\"button\" data-player=\"fast-forward\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-fast-forward\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Fast forward <span class=\"player-seek-time\">10</span> seconds</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" <span class=\"player-time\">");t.b("\n" + i);t.b(" <span class=\"sr-only\">Time</span>");t.b("\n" + i);t.b(" <span class=\"player-duration\">00:00</span>");t.b("\n" + i);t.b(" </span>");t.b("\n" + i);t.b(" </span>");t.b("\n" + i);t.b(" <span class=\"player-controls-sound\">");t.b("\n" + i);t.b(" <input class=\"inverted sr-only\" id=\"mute{id}\" type=\"checkbox\" data-player=\"mute\">");t.b("\n" + i);t.b(" <label id=\"mute{id}\" for=\"mute{id}\">");t.b("\n" + i);t.b(" <svg class=\"icon-muted\"><use xlink:href=\"#icon-muted\"></use></svg>");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-sound\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Mute</span>");t.b("\n" + i);t.b(" </label>");t.b("\n");t.b("\n" + i);t.b(" <label for=\"volume{id}\" class=\"sr-only\">Volume:</label>");t.b("\n" + i);t.b(" <input id=\"volume{id}\" class=\"player-volume\" type=\"range\" min=\"0\" max=\"10\" value=\"5\" data-player=\"volume\">");t.b("\n");t.b("\n" + i);t.b(" <input class=\"sr-only\" id=\"captions{id}\" type=\"checkbox\" data-player=\"captions\">");t.b("\n" + i);t.b(" <label for=\"captions{id}\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-bubble\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Captions</span>");t.b("\n" + i);t.b(" </label>");t.b("\n");t.b("\n" + i);t.b(" <button type=\"button\" data-player=\"fullscreen\">");t.b("\n" + i);t.b(" <svg class=\"icon-exit-fullscreen\"><use xlink:href=\"#icon-collapse\"></use></svg>");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-expand\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Toggle fullscreen</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" </span>");t.b("\n" + i);t.b("</div>");return t.fl(); },partials: {}, subs: { }});
templates['controls'] = new Hogan.Template({code: function (c,p,i) { var t=this;t.b(i=i||"");t.b("<div class=\"player-controls\">");t.b("\n" + i);t.b(" <div class=\"player-progress\">");t.b("\n" + i);t.b(" <progress class=\"player-progress-played\" max=\"100\" value=\"0\">");t.b("\n" + i);t.b(" <span>0</span>% played");t.b("\n" + i);t.b(" </progress>");t.b("\n" + i);t.b(" <progress class=\"player-progress-buffer\" max=\"100\" value=\"0\">");t.b("\n" + i);t.b(" <span>0</span>% buffered");t.b("\n" + i);t.b(" </progress>");t.b("\n" + i);t.b(" </div>");t.b("\n" + i);t.b(" <span class=\"player-controls-playback\">");t.b("\n" + i);t.b(" <button type=\"button\" data-player=\"restart\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-refresh\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Restart</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" <button type=\"button\" data-player=\"rewind\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-rewind\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Rewind <span class=\"player-seek-time\">10</span> seconds</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" <button type=\"button\" aria-label=\"{aria-label}\" data-player=\"play\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-play\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Play</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" <button type=\"button\" data-player=\"pause\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-pause\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Pause</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" <button type=\"button\" data-player=\"fast-forward\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-fast-forward\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Fast forward <span class=\"player-seek-time\">10</span> seconds</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" <span class=\"player-time\">");t.b("\n" + i);t.b(" <span class=\"sr-only\">Time</span>");t.b("\n" + i);t.b(" <span class=\"player-duration\">00:00</span>");t.b("\n" + i);t.b(" </span>");t.b("\n" + i);t.b(" </span>");t.b("\n" + i);t.b(" <span class=\"player-controls-sound\">");t.b("\n" + i);t.b(" <input class=\"inverted sr-only\" id=\"mute{id}\" type=\"checkbox\" data-player=\"mute\">");t.b("\n" + i);t.b(" <label id=\"mute{id}\" for=\"mute{id}\">");t.b("\n" + i);t.b(" <svg class=\"icon-muted\"><use xlink:href=\"#icon-muted\"></use></svg>");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-sound\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Mute</span>");t.b("\n" + i);t.b(" </label>");t.b("\n");t.b("\n" + i);t.b(" <label for=\"volume{id}\" class=\"sr-only\">Volume:</label>");t.b("\n" + i);t.b(" <input id=\"volume{id}\" class=\"player-volume\" type=\"range\" min=\"0\" max=\"10\" value=\"5\" data-player=\"volume\">");t.b("\n");t.b("\n" + i);t.b(" <input class=\"sr-only\" id=\"captions{id}\" type=\"checkbox\" data-player=\"captions\">");t.b("\n" + i);t.b(" <label for=\"captions{id}\">");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-bubble\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Captions</span>");t.b("\n" + i);t.b(" </label>");t.b("\n");t.b("\n" + i);t.b(" <button type=\"button\" data-player=\"fullscreen\">");t.b("\n" + i);t.b(" <svg class=\"icon-exit-fullscreen\"><use xlink:href=\"#icon-collapse\"></use></svg>");t.b("\n" + i);t.b(" <svg><use xlink:href=\"#icon-expand\"></use></svg>");t.b("\n" + i);t.b(" <span class=\"sr-only\">Toggle fullscreen</span>");t.b("\n" + i);t.b(" </button>");t.b("\n" + i);t.b(" </span>");t.b("\n" + i);t.b("</div>");return t.fl(); },partials: {}, subs: { }});

View File

@ -26,7 +26,7 @@
<source src="//cdn.sampotts.me/plyr/movie.webm" type="video/webm">
<!-- Text track file -->
<track kind="captions" label="English captions" src="//cdn.sampotts.me/plyr/movie_captions.vtt" srclang="en" default>
<track kind="captions" label="English" srclang="en" src="assets/movie_en_captions.vtt" default>
<!-- Fallback for browsers that don't support the <video> element -->
<div>

View File

@ -8,7 +8,7 @@ We wanted a lightweight, accessible and customisable media player that just supp
## Features
- **Accessible** - full support for captions and screen readers.
- **Lightweight** - just 4KB minified and gzipped.
- **Lightweight** - just 4.8KB minified and gzipped.
- **Customisable** - make the player look how you want with the markup you want.
- **Semantic** - uses HTML5 form inputs for volume (range) and progress element for playback progress.
- **No dependencies** - written in native JS.
@ -20,6 +20,9 @@ We wanted a lightweight, accessible and customisable media player that just supp
- Accept a string selector, a node, or a nodelist for the `container` property of `selectors`.
- Accept a selector for the `html` template property.
## Changelog
Check out [the changelog](changelog.md)
## Implementation
### Bower