Skip to content

Instantly share code, notes, and snippets.

@thomaswilburn
Created May 16, 2019 14:10
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thomaswilburn/9790d8d8ea6c77ba6f1171c34236a48a to your computer and use it in GitHub Desktop.
Save thomaswilburn/9790d8d8ea6c77ba6f1171c34236a48a to your computer and use it in GitHub Desktop.
Code excerpts from White Lies
var directors = {
"image": require("./image-scene"),
"audio": require("./audio-scene"),
"map": require("./map-scene")
};
// state of the last block and director
var lastBlock = null;
var director = null;
// check blocks in reverse order
var sceneElements = $(".block").reverse();
var threshold = .9;
// called to check scroll position
var onScroll = function() {
var entered = null;
for (var i = 0; i < sceneElements.length; i++) {
var block = sceneElements[i];
// where is this block on screen?
var bounds = block.getBoundingClientRect();
if ((bounds.top < window.innerHeight * threshold) && bounds.bottom > 0) {
// we found the active block
// skip updates if it's the same as last time
if (block == lastBlock) return;
var on = block.dataset.type;
// if we changed block type, let the previous director leave
if (director && director != directors[on]) {
director.exit();
}
// trigger the new director
director = directors[on];
entered = director.enter(block);
lastBlock = block;
// leave the loop
break;
}
}
// wipe all active scenes
$(".scene.active").forEach(el => el.classList.remove("active"));
// if a director entered, set its scene element to active
if (entered) {
entered.classList.add("active");
} else {
// if we hit a non-directed block or left the scroll...
lastBlock = null;
if (director) {
director.exit();
director = null;
}
}
};
window.addEventListener("scroll", debounce(onScroll, 50));
onScroll();
var $ = require("./lib/qsa");
var flags = require("./flags")
var mapElement = $.one(".map.scene");
var svg = $.one("svg", mapElement);
var audioDirector = require("./audio-scene");
var fullMap = svg.getAttribute("viewBox").split(" ").map(Number);
// find the midpoint between two points
var between = (a, b) => a.map((v, i) => (v + b[i]) / 2);
// create a rectangular zone around a point
var around = ([x, y], buffer) => [x - buffer, y - buffer, buffer * 2, buffer * 2];
// create a bounding box containing multiple layers
var combine = function(layers, padding = 0) {
var bounds = {
x: Infinity,
y: Infinity,
x2: -Infinity,
y2: -Infinity
};
layers.forEach(function(l) {
var box = l.getBBox();
var bottom = box.y + box.height;
var right = box.x + box.width;
if (box.x < bounds.x) bounds.x = box.x;
if (box.y < bounds.y) bounds.y = box.y;
if (right > bounds.x2) bounds.x2 = right;
if (bottom > bounds.y2) bounds.y2 = bottom;
});
bounds.width = bounds.x2 - bounds.x;
bounds.height = bounds.y2 - bounds.y;
bounds.x -= padding;
bounds.y -= padding;
bounds.width += padding * 2;
bounds.height += padding * 2;
return [ bounds.x, bounds.y, bounds.width, bounds.height ];
};
var zooms = {
exitWalkers: combine($(".starting, .walkers, .silver-moon", svg), 200),
walking: combine($(".starting, .walkers, .silver-moon", svg), 200),
passingSilverMoon: combine($(".walking, .silver-moon", svg), 200),
attack: combine($(".attack"), 200)
};
// animate viewbox transitions
var tween = null;
var tweenViewbox = function(destination, duration = 6000) {
if (tween) window.cancelAnimationFrame(tween);
// if prefers-reduced-motion, just jump right to the end
if (flags.prefersReducedMotion) {
return svg.setAttribute("viewBox", destination.join(" "));
}
// find the delta for the animation
var start = svg.getAttribute("viewBox").split(" ").map(Number);
var change = start.map((v, i) => destination[i] - v);
var began = Date.now();
// animation update function
var tick = function() {
var now = Date.now();
var elapsed = now - began;
var ratio = elapsed / duration;
if (ratio >= 1) return;
// easing stolen from jQuery
var eased = .5 - Math.cos(ratio * Math.PI) / 2;
var delta = change.map((v, i) => v * eased + start[i]);
svg.setAttribute("viewBox", delta.join(" "));
tween = requestAnimationFrame(tick);
};
tick();
};
var enter = function(block) {
audioDirector.exit();
if (block.classList.contains("has-audio")) audioDirector.enter(block);
var location = block.dataset.location;
if (zooms[location]) {
tweenViewbox(zooms[location]);
} else {
tweenViewbox(fullMap);
}
// trigger visibility for optional layers via CSS
svg.setAttribute("data-location", location);
return mapElement;
};
var exit = function() {
if (tween) {
window.cancelAnimationFrame(tween);
svg.setAttribute("viewBox", fullMap.join(" "));
}
audioDirector.exit();
};
module.exports = { enter, exit };
var $ = require("./lib/qsa");
var trackEvent = require("./lib/tracking");
var element = $.one(".audio-player");
var isSafari = navigator.userAgent.match(/i(phone|pad)/i);
// our Player prototype
// call the constructor with the element containing its UI
var Player = function(element) {
this.element = element;
this.autoplay = $.one(".autoplay", element);
this.playButton = $.one(".play-pause", element);
this.progress = $.one(".ring", element);
this.title = $.one(".label .content", element);
this.bug = $.one(".scroll-bug", element);
this.proxy = $.one("audio", element);
// bind event listeners to the instance
// these are "wrapped" to re-dispatch events to the source track in Safari
"onTime onPlay onPause onEnd"
.split(" ")
.forEach(m => this[m] = this.wrapProxyEvents(this[m]).bind(this));
// bind UI event listeners
"onAutoplay onClickedPlay"
.split(" ")
.forEach(m => this[m] = this[m].bind(this));
this.autoplay.checked = false;
this.autoplay.addEventListener("change", this.onAutoplay);
this.playButton.addEventListener("click", this.onClickedPlay);
if (isSafari) {
console.log("Safari detected - using proxy audio track for playback");
this.bindTrack(this.proxy);
}
document.addEventListener("keyup", e => {
if (e.code == "KeyP") this.playButton.click();
if (e.code == "KeyA") this.autoplay.click();
});
};
Player.prototype = {
track: null,
element: null,
autoplay: null,
playButton: null,
progress: null,
title: null,
bug: null,
proxy: null,
// simple getter for play/pause state
get paused() {
if (!this.track) return true;
return isSafari ? this.proxy.paused : this.track.paused;
},
play(track) {
this.cue(track);
this.bug.classList.remove("show");
if (track.paused) {
this.startAudio(track);
}
this.playButton.setAttribute("aria-pressed", "true");
},
startAudio(track) {
track.dataset.played = true;
if (isSafari) {
this.proxy.src = track.src;
track = this.proxy;
}
track.currentTime = 0;
var promise = track.play();
if (promise) {
promise.catch(err => console.log(err));
}
},
// cue() assigns the track and gets it ready to play
cue(track) {
if (track != this.track) {
this.stop();
// listen for timeupdate and play/pause events
// in safari, we just keep listening to the proxy
if (!isSafari) {
if (this.track) this.unbindTrack(this.track);
this.bindTrack(track);
}
}
this.track = track;
track.volume = 1;
// set the metadata
this.title.innerHTML = track.dataset.title;
},
stop() {
if (!this.track) return;
this.element.classList.remove("playing");
this.track.pause();
this.proxy.pause();
this.title.innerHTML = "";
this.playButton.setAttribute("aria-pressed", "false");
},
show() {
this.bug.classList.remove("show");
this.element.classList.remove("hidden");
},
hide() {
this.element.classList.add("hidden");
this.bug.classList.remove("show");
},
bindTrack(track) {
track.addEventListener("timeupdate", this.onTime);
track.addEventListener("play", this.onPlay);
track.addEventListener("pause", this.onPause);
track.addEventListener("ended", this.onEnd);
},
unbindTrack(track) {
track.removeEventListener("timeupdate", this.onTime);
track.removeEventListener("play", this.onPlay);
track.removeEventListener("pause", this.onPause);
track.removeEventListener("ended", this.onEnd);
},
// the play button is the only place that we can "pause"
// so its logic is slightly different from play()
onClickedPlay() {
if (!this.track) return;
var track = isSafari ? this.proxy : this.track;
trackEvent("play-button", track.paused ? "play" : "pause");
if (!track.paused) {
track.pause();
this.playButton.setAttribute("aria-pressed", "false");
// autoplay.checked = false;
} else {
track.volume = 1;
track.play();
this.playButton.setAttribute("aria-pressed", "true");
}
},
onAutoplay() {
var checked = this.autoplay.checked;
document.body.classList.toggle("autoplay-on", checked);
trackEvent("autoplay-toggled", checked);
if (!checked) {
this.stop();
} else if (this.track && this.track.paused) {
this.play(this.track);
}
},
// update the progress ring
onTime() {
var track = isSafari ? this.proxy : this.track;
var ratio = track.currentTime / track.duration;
this.progress.style.strokeDashoffset = ratio * Math.PI * 30;
},
onPlay() {
this.element.classList.add("playing");
this.show();
},
onPause() {
},
onEnd() {
this.element.classList.remove("playing");
this.bug.classList.add("show");
},
// handle events in Safari
// we still want events fired on the block audio tracks for transcript animations
// however, they're not actually doing the playback in Safari
// this function wraps our listeners up so that they re-dispatch fake events
// when attached to the proxy, they create matching events on the "playing" audio element
wrapProxyEvents(f) {
if (!isSafari) return f;
return function(event) {
f.call(this, event);
var e = new CustomEvent(event.type);
this.track.currentTime = this.proxy.currentTime;
this.track.dispatchEvent(e);
}
}
};
module.exports = new Player(element);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment