Skip to content

Instantly share code, notes, and snippets.

@LifeJustDLC
Created May 11, 2024 14:22
Show Gist options
  • Save LifeJustDLC/35d14c67ecc4f84b4c14f1cee5c77f4d to your computer and use it in GitHub Desktop.
Save LifeJustDLC/35d14c67ecc4f84b4c14f1cee5c77f4d to your computer and use it in GitHub Desktop.
Global Speed for Android interaction design PoC
"use strict";
;
(function main() {
let truePlayRate = 1.0;
let trueCurrentTime = NaN;
const speedList = [0.8, 1.0, 1.25, 1.5, 2.0];
const isFrefox = navigator.userAgent.indexOf("Firefox") === -1 ? false : true;
const isEdge = navigator.userAgent.indexOf("EdgA") === -1 ? false : true; // yeah it's "EdgA"
let player;
const playerSize = { width: NaN, height: NaN };
let display;
function setPlayerOnceFullscreen(_player) {
player = _player;
// default `passive` for touch events possibly be `true`.
player.addEventListener("touchstart", handleContactStart, {
passive: false,
capture: true,
});
// safari doesn't support touch? also for website compatibility
player.addEventListener("pointerdown", handleContactStart, {
capture: true,
});
player.addEventListener("touchmove", handleContactMove, {
passive: false,
capture: true,
});
player.addEventListener("pointermove", handleContactMove, { capture: true });
player.addEventListener("touchend", drawDebugAndResetAccumulator, {
capture: true,
});
player.addEventListener("touchcancel", drawDebugAndResetAccumulator, {
capture: true,
});
player.addEventListener("pointerup", drawDebugAndResetAccumulator, {
capture: true,
});
player.addEventListener("pointercancel", drawDebugAndResetAccumulator, {
capture: true,
});
}
const debugPoints = [];
function drawDebugAndResetAccumulator(ev) {
// TODO: refactor
if (!isStartFromOverrideArea)
return;
ev.stopImmediatePropagation();
accumulator = 0;
console.log("drawing");
if (document.getElementById("svg")) {
player.removeChild(document.getElementById("svg"));
}
const svg = document.createElement("div");
svg.id = "svg";
Object.assign(svg.style, {
zIndex: "1000", // TODO: refactor. this is maintainability hell
width: playerSize.width + "px",
height: playerSize.height + "px",
background: "transparent",
});
player.appendChild(svg);
for (const point of debugPoints) {
const circle = document.createElement("div");
circle.style.top = point.y + "px";
circle.style.left = point.x + "px";
if (point.type === "normal") {
Object.assign(circle.style, {
position: "fixed",
width: "1px",
height: "1px",
background: "white",
});
}
else if (point.type === "vibrate") {
Object.assign(circle.style, {
position: "fixed",
width: "5px",
height: "5px",
background: "yellow",
});
}
svg.appendChild(circle);
}
debugPoints.length = 0;
}
let video;
let isOverridingEnabled = false;
document.addEventListener("fullscreenchange", (ev) => {
// TODO: how to find it without waiting for fullscreen
if (document.fullscreenElement) {
isOverridingEnabled = true;
if (!player) {
setPlayerOnceFullscreen(document.fullscreenElement);
}
}
else {
isOverridingEnabled = false;
}
display = initDisplay();
if (!video) {
video = document.getElementsByTagName("video")[0];
}
video.onratechange = () => {
if (Math.abs(video.playbackRate - truePlayRate) < 0.001)
return;
video.playbackRate = truePlayRate;
};
const { width, height } = player.getBoundingClientRect();
Object.assign(playerSize, { width, height });
});
let isAbleToBeep = true;
// setInterval(() => isAbleToBeep = true, 500)
let isStartFromOverrideArea = false;
const initPos = { x: NaN, y: NaN, id: NaN };
function handleContactStart(ev) {
if (!isOverridingEnabled)
return;
// TODO: multi-touch will be buggy
const evPos = { x: NaN, y: NaN, id: NaN };
if (ev instanceof TouchEvent) {
const touch = ev.touches[0];
evPos.x = touch.clientX;
evPos.y = touch.clientY;
evPos.id = touch.identifier;
}
else if (ev instanceof PointerEvent) {
evPos.x = ev.clientX;
evPos.y = ev.clientY;
evPos.id = ev.pointerId;
}
if (!isInOverrideArea(evPos)) {
isStartFromOverrideArea = false;
return;
}
isStartFromOverrideArea = true;
ev.stopImmediatePropagation();
Object.assign(initPos, evPos);
console.log({ initPos });
}
function isInOverrideArea({ x, y }) {
const { width, height } = player.getBoundingClientRect();
Object.assign(playerSize, { width, height });
if (x < playerSize.width * 0.3 || x > playerSize.width * 0.6) {
return true;
}
else
return false;
}
let accumulator = 0; // TODO: refactor
function handleContactMove(ev) {
if (!isOverridingEnabled)
return;
if (!isStartFromOverrideArea)
return;
// TODO: multi-touch will be buggy
const evPos = { x: NaN, y: NaN, id: NaN };
if (ev instanceof TouchEvent) {
const touch = ev.touches[0];
evPos.id = touch.identifier;
evPos.x = touch.clientX;
evPos.y = touch.clientY;
}
else if (ev instanceof PointerEvent) {
evPos.id = ev.pointerId;
evPos.x = ev.clientX;
evPos.y = ev.clientY;
}
if (evPos.id !== initPos.id)
return;
ev.stopImmediatePropagation();
debugPoints.push({ x: evPos.x, y: evPos.y, type: "normal" });
const vectX = evPos.x - initPos.x;
const vectY = evPos.y - initPos.y;
const interval = (playerSize.height + playerSize.width) * 0.07;
if (Math.abs(vectX) < interval && Math.abs(vectY) < interval)
return;
const tan = -vectY / vectX;
if (vectY < 0 && (tan > Math.tan(rad(60)) || tan < Math.tan(rad(120)))) {
// dragging up
const nextSpeed = Math.min(...speedList.filter((speed) => speed - truePlayRate > 0.001));
video.playbackRate = truePlayRate = nextSpeed;
display("↓ " + truePlayRate);
}
if (vectY > 0 && (tan > Math.tan(rad(-120)) || tan < Math.tan(rad(-60)))) {
// dragging down
const nextSpeed = Math.max(...speedList.filter((speed) => speed - truePlayRate < -0.001));
video.playbackRate = truePlayRate = nextSpeed;
display("↑ " + truePlayRate);
}
if (vectX < 0 && tan > Math.tan(rad(150)) && tan < Math.tan(rad(210))) {
// dragging left
trueCurrentTime = video.currentTime - 1;
video.currentTime = trueCurrentTime;
display("← " + trueCurrentTime.toFixed(1));
}
if (vectX > 0 && tan > Math.tan(rad(-30)) && tan < Math.tan(rad(30))) {
// dragging right
trueCurrentTime = video.currentTime + 1;
video.currentTime = trueCurrentTime;
display("→ " + trueCurrentTime.toFixed(1));
}
if (vectX < 0 &&
vectY < 0 &&
tan > Math.tan(rad(120)) &&
tan < Math.tan(rad(150))) {
// dragging up left
console.log("up left");
if (accumulator < 1) {
accumulator += 1;
display("drag further to confirm: disable");
}
else {
isOverridingEnabled = false;
display("disabled");
accumulator = 0;
}
}
initPos.x = evPos.x;
initPos.y = evPos.y;
debugPoints.push({ x: evPos.x, y: evPos.y, type: "vibrate" });
if (isEdge) {
navigator.vibrate(20);
}
if (isFrefox && isAbleToBeep) {
beep();
// isAbleToBeep = false
}
}
function initDisplay() {
const overlay = document.createElement("div");
Object.assign(overlay.style, {
position: "fixed",
padding: "1rem",
display: "none",
backgroundColor: "rgba(0, 0, 0, 0.5)",
color: "white",
});
player.appendChild(overlay);
let lastUpdate = 0;
const showingTime = 1000;
setInterval(() => {
if (lastUpdate + showingTime < Date.now()) {
overlay.style.display = "none";
}
}, 100); // TODO: refactor, support toggle on-off
return function display(msg) {
overlay.textContent = msg;
overlay.style.display = "block";
lastUpdate = Date.now();
};
}
const audio = new Audio("data:audio/wav;base64,UklGRkIFAABXQVZFZm10IBIAAAADAAIARKwAACBiBQAIACAAAABkYXRhEAUAAB17GLsdexi7qpCGOaqQhjnsur067Lq9OquYhbqrmIW6EuMZuhLjGbqXQzQ7l0M0O/AiATrwIgE6Qqaeu0KmnrufOJS7nziUu66xcbuusXG7dFW7u3RVu7vcNZy73DWcu1tdvrlbXb65kFP0OpBT9DpmelQ7ZnpUO4QXpjuEF6Y7iZ8sO4mfLDsvm6Q6L5ukOkypqTtMqak7yO22O8jttjs5PdW5OT3VueENzTnhDc05OKAzPTigMz1atnw9WrZ8PWxM67xsTOu8d3yxvXd8sb165GS7euRkuyPU2Twj1Nk8Yc4fvWHOH73evwW83r8FvESrBD1EqwQ9m//ou5v/6Ls9XZq8PV2avKAU+DugFPg7QiC7ukIgu7qUG7i8lBu4vM86oLzPOqC84/4fvOP+H7zhUAq84VAKvPLKwzzyysM8l8IoPZfCKD1tw6+6bcOvuuZJpbzmSaW8ryZZPK8mWTxRRQI9UUUCPUH/7zxB/+88zS7GPM0uxjzxO4678TuOuwOTE70DkxO9NoIQvTaCEL0P7OS7D+zku9+NjrvfjY67W3arvFt2q7wFd0s6BXdLOmxc9TxsXPU8/0f6PP9H+jwa4ag7GuGoO/Ppv7zz6b+8SbR4vEm0eLzLNHM8yzRzPBcagjsXGoI7owehvKMHobyFo6W8haOlvMqry7vKq8u7BYwaPAWMGjxuSFE8bkhRPIhegTyIXoE8oSy2PKEstjxIYQY8SGEGPLiPXry4j168HK4/vByuP7wVoVC7FaFQu8oefLvKHny7/AFtu/wBbbtiYXK7YmFyuy/hOjsv4To78XSOO/F0jjvOWz27zls9u5WAJDyVgCQ8/jq7PP46uzyyWjq7slo6u6e4vrynuL687CHeu+wh3rs49Zs7OPWbO5AXrLuQF6y7AbvbuwG727vATxa7wE8Wu3XYLbx12C28We9BvFnvQbx7lk87e5ZPO6RbXjykW148PEN9PDxDfTwcm448HJuOPJtJEDybSRA8C0XYuwtF2Ls2ObC7NjmwuyShljskoZY7UiZTO1ImUzu10Jy7tdCcuyGXfrshl367upeLubqXi7lt4JK5beCSuRQKhLsUCoS7bPFwu2zxcLv+j6I6/o+iOnVUuzp1VLs65QiDuuUIg7oEEJe7BBCXu9/fxLvf38S7zEeAu8xHgLtjxVM5Y8VTOX5Tazt+U2s7DtFVug7RVbofAyy7HwMsu7p6ZTu6emU7LXWrOy11qzs/clE7P3JRO3NssjtzbLI7Hbw+Ox28PjvIl227yJdtu2V6brtlem67LUmsui1JrLq+Eey6vhHsuo/dMLqP3TC6w60CO8OtAjs0I0w7NCNMOxVSvjsVUr47j8gFPI/IBTz5WbY6+Vm2OhQHBrwUBwa8yV41vMleNbzjMfq74zH6uzY0B7s2NAe7zQbKOs0GyjotOMk6LTjJOrIovbmyKL25mCD8Opgg/Do7p6Y7O6emO9SFXDvUhVw7UAqUulAKlLr8a+O6/GvjuhucszkbnLM5MzAzujMwM7qrKoS6qyqEujqPp7o6j6e6b2Xnum9l57qxFuC4sRbguMW/lzrFv5c6gVGdOoFRnTocvzI7HL8yOxqlfzsapX87VIYuOlSGLjq+30y7vt9Mu9dLN7vXSze79NVfuvTVX7rQXlU50F5VOV768zle+vM57cHMOu3BzDqKZAw7imQMO7CmyDqwpsg6Zn9NuGZ/Tbh6mlK6eppSumZhY3QEAAAAogAAAA==");
function beep() {
audio.play(); // FIXME: delay/latency, conflict with the sound playing. maybe an audio lib dependency?
}
function rad(deg) {
return (deg / 360) * 2 * Math.PI;
}
})();
;(function main() {
let truePlayRate = 1.0
let trueCurrentTime = NaN
const speedList = [0.8, 1.0, 1.25, 1.5, 2.0]
const isFrefox = navigator.userAgent.indexOf("Firefox") === -1 ? false : true
const isEdge = navigator.userAgent.indexOf("EdgA") === -1 ? false : true // yeah it's "EdgA"
type Player = HTMLDivElement | HTMLVideoElement
let player: Player
const playerSize = { width: NaN, height: NaN }
let display: Function
function setPlayerOnceFullscreen(_player: Player) {
player = _player
// default `passive` for touch events possibly be `true`.
player.addEventListener("touchstart", handleContactStart, {
passive: false,
capture: true,
})
// safari doesn't support touch? also for website compatibility
player.addEventListener("pointerdown", handleContactStart, {
capture: true,
})
player.addEventListener("touchmove", handleContactMove, {
passive: false,
capture: true,
})
player.addEventListener("pointermove", handleContactMove, { capture: true })
player.addEventListener("touchend", drawDebugAndResetAccumulator, {
capture: true,
})
player.addEventListener("touchcancel", drawDebugAndResetAccumulator, {
capture: true,
})
player.addEventListener("pointerup", drawDebugAndResetAccumulator, {
capture: true,
})
player.addEventListener("pointercancel", drawDebugAndResetAccumulator, {
capture: true,
})
}
const debugPoints: { x: number; y: number; type: "normal" | "vibrate" }[] = []
function drawDebugAndResetAccumulator(ev: Event) {
// TODO: refactor
if (!isStartFromOverrideArea) return
ev.stopImmediatePropagation()
accumulator = 0
console.log("drawing")
if (document.getElementById("svg")) {
player.removeChild(document.getElementById("svg")!)
}
const svg = document.createElement("div")
svg.id = "svg"
Object.assign(svg.style, {
zIndex: "1000", // TODO: refactor. this is maintainability hell
width: playerSize.width + "px",
height: playerSize.height + "px",
background: "transparent",
} as CSSStyleDeclaration)
player.appendChild(svg)
for (const point of debugPoints) {
const circle = document.createElement("div")
circle.style.top = point.y + "px"
circle.style.left = point.x + "px"
if (point.type === "normal") {
Object.assign(circle.style, {
position: "fixed",
width: "1px",
height: "1px",
background: "white",
} as CSSStyleDeclaration)
} else if (point.type === "vibrate") {
Object.assign(circle.style, {
position: "fixed",
width: "5px",
height: "5px",
background: "yellow",
} as CSSStyleDeclaration)
}
svg.appendChild(circle)
}
debugPoints.length = 0
}
let video: HTMLVideoElement
let isOverridingEnabled = false
document.addEventListener("fullscreenchange", (ev) => {
// TODO: how to find it without waiting for fullscreen
if (document.fullscreenElement) {
isOverridingEnabled = true
if (!player) {
setPlayerOnceFullscreen(document.fullscreenElement as Player)
}
} else {
isOverridingEnabled = false
}
display = initDisplay()
if (!video) {
video = document.getElementsByTagName("video")[0]
}
video.onratechange = () => {
if (Math.abs(video.playbackRate - truePlayRate) < 0.001) return
video.playbackRate = truePlayRate
}
const { width, height } = player.getBoundingClientRect()
Object.assign(playerSize, { width, height })
})
let isAbleToBeep = true
// setInterval(() => isAbleToBeep = true, 500)
let isStartFromOverrideArea = false
const initPos = { x: NaN, y: NaN, id: NaN }
function handleContactStart(ev: Event) {
if (!isOverridingEnabled) return
// TODO: multi-touch will be buggy
const evPos = { x: NaN, y: NaN, id: NaN }
if (ev instanceof TouchEvent) {
const touch = ev.touches[0]
evPos.x = touch.clientX
evPos.y = touch.clientY
evPos.id = touch.identifier
} else if (ev instanceof PointerEvent) {
evPos.x = ev.clientX
evPos.y = ev.clientY
evPos.id = ev.pointerId
}
if (!isInOverrideArea(evPos)) {
isStartFromOverrideArea = false
return
}
isStartFromOverrideArea = true
ev.stopImmediatePropagation()
Object.assign(initPos, evPos)
console.log({ initPos })
}
function isInOverrideArea({ x, y }: { x: number; y: number }) {
const { width, height } = player.getBoundingClientRect()
Object.assign(playerSize, { width, height })
if (x < playerSize.width * 0.3 || x > playerSize.width * 0.6) {
return true
} else return false
}
let accumulator = 0 // TODO: refactor
function handleContactMove(ev: Event) {
if (!isOverridingEnabled) return
if (!isStartFromOverrideArea) return
// TODO: multi-touch will be buggy
const evPos = { x: NaN, y: NaN, id: NaN }
if (ev instanceof TouchEvent) {
const touch = ev.touches[0]
evPos.id = touch.identifier
evPos.x = touch.clientX
evPos.y = touch.clientY
} else if (ev instanceof PointerEvent) {
evPos.id = ev.pointerId
evPos.x = ev.clientX
evPos.y = ev.clientY
}
if (evPos.id !== initPos.id) return
ev.stopImmediatePropagation()
debugPoints.push({ x: evPos.x, y: evPos.y, type: "normal" })
const vectX = evPos.x - initPos.x
const vectY = evPos.y - initPos.y
const interval = (playerSize.height + playerSize.width) * 0.07
if (Math.abs(vectX) < interval && Math.abs(vectY) < interval) return
const tan = -vectY / vectX
if (vectY < 0 && (tan > Math.tan(rad(60)) || tan < Math.tan(rad(120)))) {
// dragging up
const nextSpeed = Math.min(
...speedList.filter((speed) => speed - truePlayRate > 0.001)
)
video.playbackRate = truePlayRate = nextSpeed
display("↓ " + truePlayRate)
}
if (vectY > 0 && (tan > Math.tan(rad(-120)) || tan < Math.tan(rad(-60)))) {
// dragging down
const nextSpeed = Math.max(
...speedList.filter((speed) => speed - truePlayRate < -0.001)
)
video.playbackRate = truePlayRate = nextSpeed
display("↑ " + truePlayRate)
}
if (vectX < 0 && tan > Math.tan(rad(150)) && tan < Math.tan(rad(210))) {
// dragging left
trueCurrentTime = video.currentTime - 1
video.currentTime = trueCurrentTime
display("← " + trueCurrentTime.toFixed(1))
}
if (vectX > 0 && tan > Math.tan(rad(-30)) && tan < Math.tan(rad(30))) {
// dragging right
trueCurrentTime = video.currentTime + 1
video.currentTime = trueCurrentTime
display("→ " + trueCurrentTime.toFixed(1))
}
if (
vectX < 0 &&
vectY < 0 &&
tan > Math.tan(rad(120)) &&
tan < Math.tan(rad(150))
) {
// dragging up left
console.log("up left")
if (accumulator < 1) {
accumulator += 1
display("drag further to confirm: disable")
} else {
isOverridingEnabled = false
display("disabled")
accumulator = 0
}
}
initPos.x = evPos.x
initPos.y = evPos.y
debugPoints.push({ x: evPos.x, y: evPos.y, type: "vibrate" })
if (isEdge) {
navigator.vibrate(20)
}
if (isFrefox && isAbleToBeep) {
beep()
// isAbleToBeep = false
}
}
function initDisplay() {
const overlay = document.createElement("div")
Object.assign(overlay.style, {
position: "fixed",
padding: "1rem",
display: "none",
backgroundColor: "rgba(0, 0, 0, 0.5)",
color: "white",
} as CSSStyleDeclaration)
player.appendChild(overlay)
let lastUpdate = 0
const showingTime = 1000
setInterval(() => {
if (lastUpdate + showingTime < Date.now()) {
overlay.style.display = "none"
}
}, 100) // TODO: refactor, support toggle on-off
return function display(msg: string) {
overlay.textContent = msg
overlay.style.display = "block"
lastUpdate = Date.now()
}
}
const audio = new Audio(
"data:audio/wav;base64,UklGRkIFAABXQVZFZm10IBIAAAADAAIARKwAACBiBQAIACAAAABkYXRhEAUAAB17GLsdexi7qpCGOaqQhjnsur067Lq9OquYhbqrmIW6EuMZuhLjGbqXQzQ7l0M0O/AiATrwIgE6Qqaeu0KmnrufOJS7nziUu66xcbuusXG7dFW7u3RVu7vcNZy73DWcu1tdvrlbXb65kFP0OpBT9DpmelQ7ZnpUO4QXpjuEF6Y7iZ8sO4mfLDsvm6Q6L5ukOkypqTtMqak7yO22O8jttjs5PdW5OT3VueENzTnhDc05OKAzPTigMz1atnw9WrZ8PWxM67xsTOu8d3yxvXd8sb165GS7euRkuyPU2Twj1Nk8Yc4fvWHOH73evwW83r8FvESrBD1EqwQ9m//ou5v/6Ls9XZq8PV2avKAU+DugFPg7QiC7ukIgu7qUG7i8lBu4vM86oLzPOqC84/4fvOP+H7zhUAq84VAKvPLKwzzyysM8l8IoPZfCKD1tw6+6bcOvuuZJpbzmSaW8ryZZPK8mWTxRRQI9UUUCPUH/7zxB/+88zS7GPM0uxjzxO4678TuOuwOTE70DkxO9NoIQvTaCEL0P7OS7D+zku9+NjrvfjY67W3arvFt2q7wFd0s6BXdLOmxc9TxsXPU8/0f6PP9H+jwa4ag7GuGoO/Ppv7zz6b+8SbR4vEm0eLzLNHM8yzRzPBcagjsXGoI7owehvKMHobyFo6W8haOlvMqry7vKq8u7BYwaPAWMGjxuSFE8bkhRPIhegTyIXoE8oSy2PKEstjxIYQY8SGEGPLiPXry4j168HK4/vByuP7wVoVC7FaFQu8oefLvKHny7/AFtu/wBbbtiYXK7YmFyuy/hOjsv4To78XSOO/F0jjvOWz27zls9u5WAJDyVgCQ8/jq7PP46uzyyWjq7slo6u6e4vrynuL687CHeu+wh3rs49Zs7OPWbO5AXrLuQF6y7AbvbuwG727vATxa7wE8Wu3XYLbx12C28We9BvFnvQbx7lk87e5ZPO6RbXjykW148PEN9PDxDfTwcm448HJuOPJtJEDybSRA8C0XYuwtF2Ls2ObC7NjmwuyShljskoZY7UiZTO1ImUzu10Jy7tdCcuyGXfrshl367upeLubqXi7lt4JK5beCSuRQKhLsUCoS7bPFwu2zxcLv+j6I6/o+iOnVUuzp1VLs65QiDuuUIg7oEEJe7BBCXu9/fxLvf38S7zEeAu8xHgLtjxVM5Y8VTOX5Tazt+U2s7DtFVug7RVbofAyy7HwMsu7p6ZTu6emU7LXWrOy11qzs/clE7P3JRO3NssjtzbLI7Hbw+Ox28PjvIl227yJdtu2V6brtlem67LUmsui1JrLq+Eey6vhHsuo/dMLqP3TC6w60CO8OtAjs0I0w7NCNMOxVSvjsVUr47j8gFPI/IBTz5WbY6+Vm2OhQHBrwUBwa8yV41vMleNbzjMfq74zH6uzY0B7s2NAe7zQbKOs0GyjotOMk6LTjJOrIovbmyKL25mCD8Opgg/Do7p6Y7O6emO9SFXDvUhVw7UAqUulAKlLr8a+O6/GvjuhucszkbnLM5MzAzujMwM7qrKoS6qyqEujqPp7o6j6e6b2Xnum9l57qxFuC4sRbguMW/lzrFv5c6gVGdOoFRnTocvzI7HL8yOxqlfzsapX87VIYuOlSGLjq+30y7vt9Mu9dLN7vXSze79NVfuvTVX7rQXlU50F5VOV768zle+vM57cHMOu3BzDqKZAw7imQMO7CmyDqwpsg6Zn9NuGZ/Tbh6mlK6eppSumZhY3QEAAAAogAAAA=="
)
function beep() {
audio.play() // FIXME: delay/latency, conflict with the sound playing. maybe an audio lib dependency?
}
function rad(deg: number) {
return (deg / 360) * 2 * Math.PI
}
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment