mirror of
https://github.com/flutter/holobooth.git
synced 2025-05-17 13:25:59 +08:00
feat: portal animation on share page (#401)
* feat: share link portal animation * progress on the animation * small fixes
This commit is contained in:
@ -9,7 +9,8 @@ import notFoundTmpl from './templates/notfound';
|
||||
import shareTmpl from './templates/share';
|
||||
import footerTmpl from './templates/footer';
|
||||
import stylesTmpl from './templates/styles';
|
||||
import scriptsTmpl from './templates/scripts';
|
||||
import videoPlayerControllerTmpl from './templates/video-player-controller';
|
||||
import portalAnimationControllerTmpl from './templates/portal-animation-controller';
|
||||
import gaTmpl from './templates/ga';
|
||||
|
||||
|
||||
@ -37,6 +38,8 @@ const BaseHTMLContext: Record<string, string | Record<string, string>> = {
|
||||
playerVolume: bucketPathForFile('public/player-volume.png'),
|
||||
playerFullscreen: bucketPathForFile('public/player-fullscreen.png'),
|
||||
close: bucketPathForFile('public/close.png'),
|
||||
desktopPortalSpritesheet: bucketPathForFile('public/desktop-portal-spritesheet.png'),
|
||||
mobilePortalSpritesheet: bucketPathForFile('public/mobile-portal-spritesheet.png'),
|
||||
},
|
||||
meta: {
|
||||
title: 'Flutter Forward Holobooth',
|
||||
@ -48,7 +51,8 @@ const BaseHTMLContext: Record<string, string | Record<string, string>> = {
|
||||
},
|
||||
ga: gaTmpl,
|
||||
styles: '',
|
||||
scripts: '',
|
||||
videoPlayerController: '',
|
||||
portalAnimationController: '',
|
||||
footer: '',
|
||||
};
|
||||
|
||||
@ -76,7 +80,8 @@ function renderTemplate(
|
||||
tmpl: string, context: Record<string, string | Record<string, string>>
|
||||
): string {
|
||||
context.styles = mustache.render(stylesTmpl, context);
|
||||
context.scripts = mustache.render(scriptsTmpl, context);
|
||||
context.videoPlayerController = mustache.render(videoPlayerControllerTmpl, context);
|
||||
context.portalAnimationController = mustache.render(portalAnimationControllerTmpl, context);
|
||||
context.footer = mustache.render(footerTmpl, context);
|
||||
return mustache.render(tmpl, context);
|
||||
}
|
||||
|
293
functions/src/share/templates/portal-animation-controller.ts
Normal file
293
functions/src/share/templates/portal-animation-controller.ts
Normal file
@ -0,0 +1,293 @@
|
||||
export default `
|
||||
(function() {
|
||||
|
||||
function MiniGameLoop(canvas, container, animation, [frameReference]) {
|
||||
let lastUpdated;
|
||||
let canvasContext;
|
||||
let scale = 1;
|
||||
this.objects = [];
|
||||
const landscape = document.body.offsetWidth > document.body.offsetHeight;
|
||||
|
||||
this.render = function() {
|
||||
this.objects.forEach(function(obj) {
|
||||
obj.render(canvasContext);
|
||||
});
|
||||
}
|
||||
|
||||
this.update = function() {
|
||||
const now = Date.now();
|
||||
const delta = (now - lastUpdated) / 1000;
|
||||
lastUpdated = now;
|
||||
|
||||
this.objects.forEach(function(obj) {
|
||||
obj.update(delta);
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.update();
|
||||
|
||||
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvasContext.save();
|
||||
|
||||
canvasContext.scale(scale, scale);
|
||||
|
||||
this.render(canvasContext);
|
||||
|
||||
canvasContext.restore();
|
||||
});
|
||||
}
|
||||
|
||||
this.addObject = async function(obj) {
|
||||
await obj.load();
|
||||
this.objects.push(obj);
|
||||
}
|
||||
|
||||
this.load = async function() {
|
||||
|
||||
this.objects.push(animation);
|
||||
|
||||
lastUpdated = Date.now();
|
||||
|
||||
canvasContext = canvas.getContext("2d");
|
||||
|
||||
await Promise.all(this.objects.map((o) => o.load(canvas)));
|
||||
|
||||
const observer = new ResizeObserver((_) => {
|
||||
this.fixBounds();
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
this.update();
|
||||
}
|
||||
|
||||
this.fixBounds = function() {
|
||||
const animationSize = animation.size();
|
||||
const [
|
||||
animationW,
|
||||
animationH,
|
||||
] = animationSize;
|
||||
|
||||
const aspect = animationW / animationH;
|
||||
let canvasW;
|
||||
let canvasH;
|
||||
|
||||
if (landscape) {
|
||||
canvasW = container.offsetWidth * .75;
|
||||
canvasH = canvasW * aspect;
|
||||
} else {
|
||||
canvasH = container.offsetHeight * .75;
|
||||
canvasW = canvasH * aspect;
|
||||
}
|
||||
|
||||
canvas.width = canvasW;
|
||||
canvas.height = canvasH;
|
||||
|
||||
|
||||
const scaleX = canvas.width / animationW;
|
||||
const scaleY = canvas.height / animationH;
|
||||
|
||||
scale = Math.min(scaleX, scaleY);
|
||||
}
|
||||
}
|
||||
|
||||
const portalMode = {
|
||||
portrait: {
|
||||
texturePath: '{{{assetUrls.mobilePortalSpritesheet}}}',
|
||||
textureSize: [650, 850],
|
||||
thumbSize: [322, 378],
|
||||
thumbOffset: [168, 104],
|
||||
},
|
||||
landscape: {
|
||||
texturePath: '{{{assetUrls.desktopPortalSpritesheet}}}',
|
||||
textureSize: [710, 750],
|
||||
thumbSize: [498, 280],
|
||||
thumbOffset: [100, 96],
|
||||
},
|
||||
};
|
||||
|
||||
function Sprite({
|
||||
spritePath,
|
||||
position = [0, 0],
|
||||
size,
|
||||
}) {
|
||||
let image;
|
||||
let loaded = false;
|
||||
|
||||
let x = position[0]; y = position[1];
|
||||
|
||||
let renderOffset, renderSize;
|
||||
|
||||
this.load = function(canvas) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (image && loaded) {
|
||||
resolve();
|
||||
} else {
|
||||
image = new Image();
|
||||
image.src = spritePath;
|
||||
image.onload = function() {
|
||||
const [thumbWidth, thumbHeight] = size;
|
||||
const rateX = thumbWidth / image.width;
|
||||
const rateY = thumbHeight / image.height;
|
||||
|
||||
const rate = Math.max(rateX, rateY);
|
||||
|
||||
renderSize = [image.width * rate, image.height * rate];
|
||||
const [renderWidth, renderHeight] = renderSize;
|
||||
|
||||
renderOffset = [
|
||||
(thumbWidth / 2) - renderWidth / 2,
|
||||
(thumbHeight / 2) - renderHeight / 2,
|
||||
];
|
||||
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.update = function(_) {}
|
||||
|
||||
this.render = function(canvas) {
|
||||
const [thumbWidth, thumbHeight] = size;
|
||||
const [renderWidth,renderHeight] = renderSize;
|
||||
const [offsetX, offsetY] = renderOffset;
|
||||
|
||||
canvas.save();
|
||||
|
||||
canvas.clip(canvas.rect(x, y, thumbWidth, thumbHeight));
|
||||
canvas.drawImage(
|
||||
image,
|
||||
0, // sX
|
||||
0, // sY
|
||||
image.width, // sWidth
|
||||
image.height, // sHeight
|
||||
x + offsetX, // dX
|
||||
y + offsetY, // dY
|
||||
renderWidth, // dWidth
|
||||
renderHeight, // dHeight
|
||||
);
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function SpriteAnimation({
|
||||
texturePath,
|
||||
textureSize,
|
||||
stepTime,
|
||||
onComplete,
|
||||
}) {
|
||||
let image;
|
||||
const frames = [];
|
||||
let currentFrame;
|
||||
|
||||
let currentTime = 0;
|
||||
let completed = false;
|
||||
|
||||
let x = 0, y = 0;
|
||||
|
||||
this.size = function() {
|
||||
return textureSize;
|
||||
}
|
||||
|
||||
this.load = function(canvas) {
|
||||
return new Promise((resolve, reject) => {
|
||||
image = new Image();
|
||||
image.src = texturePath;
|
||||
image.onload = function() {
|
||||
const [textureWidth, textureHeight] = textureSize;
|
||||
|
||||
const columns = image.width / textureWidth;
|
||||
const rows = image.height / textureHeight;
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < columns; x++) {
|
||||
frames.push({
|
||||
y: y * textureHeight,
|
||||
x: x * textureWidth,
|
||||
textureWidth,
|
||||
textureHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
currentFrame = frames[0];
|
||||
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.render = function(canvas) {
|
||||
if (currentFrame) {
|
||||
canvas.drawImage(
|
||||
image,
|
||||
currentFrame.x, // sx
|
||||
currentFrame.y, // sy
|
||||
currentFrame.textureWidth, // sWidth
|
||||
currentFrame.textureHeight, // sHeight
|
||||
x, // dx
|
||||
y, // dy
|
||||
currentFrame.textureWidth, // dWidth
|
||||
currentFrame.textureHeight, // dHeight
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
this.update = function(dt) {
|
||||
if (currentFrame && !completed) {
|
||||
currentTime += dt;
|
||||
if (currentTime >= stepTime) {
|
||||
currentTime -= stepTime;
|
||||
|
||||
const i = frames.indexOf(currentFrame);
|
||||
if (i + 1 < frames.length) {
|
||||
currentFrame = frames[i + 1];
|
||||
} else {
|
||||
completed = true;
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const portalCanvas = document.getElementById('portal-animation');
|
||||
const container = document.querySelector('.share-video');
|
||||
|
||||
const mode = document.body.offsetWidth > document.body.offsetHeight
|
||||
? portalMode.landscape
|
||||
: portalMode.portrait;
|
||||
|
||||
const animation = new SpriteAnimation({
|
||||
texturePath: mode.texturePath,
|
||||
textureSize: mode.textureSize,
|
||||
stepTime: 0.05,
|
||||
onComplete: onComplete,
|
||||
});
|
||||
|
||||
const game = new MiniGameLoop(
|
||||
portalCanvas,
|
||||
container,
|
||||
animation,
|
||||
mode.textureSize,
|
||||
);
|
||||
|
||||
const thumb = new Sprite({
|
||||
spritePath: '{{{thumbImageUrl}}}',
|
||||
position: mode.thumbOffset,
|
||||
size: mode.thumbSize,
|
||||
});
|
||||
|
||||
// Pre load the thumb
|
||||
thumb.load();
|
||||
|
||||
function onComplete() {
|
||||
game.addObject(thumb);
|
||||
}
|
||||
|
||||
game.load();
|
||||
|
||||
})();
|
||||
`;
|
@ -31,12 +31,7 @@ export default `
|
||||
<div class="backdrop"></div>
|
||||
<main>
|
||||
<div class="share-video">
|
||||
<img class="holocard" src={{assetUrls.holocard}} />
|
||||
<div class="card-frame">
|
||||
<div class="video-clip">
|
||||
<video src="{{{shareVideoUrl}}}"></video>
|
||||
</div>
|
||||
<img src={{assetUrls.videoFrame}} />
|
||||
<canvas id="portal-animation"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info">
|
||||
@ -102,7 +97,8 @@ export default `
|
||||
</div>
|
||||
</body>
|
||||
<script type="text/javascript">
|
||||
{{{scripts}}}
|
||||
{{{videoPlayerController}}}
|
||||
{{{portalAnimationController}}}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
@ -39,7 +39,9 @@ main {
|
||||
|
||||
.share-video {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.info {
|
||||
@ -127,38 +129,6 @@ h2 {
|
||||
color: #C0C0C0;
|
||||
}
|
||||
|
||||
.card-frame {
|
||||
width: 368px;
|
||||
height: 467px;
|
||||
position: absolute;
|
||||
bottom: 206px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-frame img {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.card-frame .video-clip {
|
||||
height: 318px;
|
||||
width: 286px;
|
||||
top: 48px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translate(-50%, 0);
|
||||
clip-path: inset(0);
|
||||
}
|
||||
|
||||
.card-frame .video-clip video {
|
||||
height: 318px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.holocard {
|
||||
width: 708px;
|
||||
height: 716px;
|
||||
@ -447,24 +417,6 @@ footer .right a {
|
||||
height: 464px;
|
||||
}
|
||||
|
||||
.card-frame {
|
||||
width: 218px;
|
||||
height: 217px;
|
||||
position: absolute;
|
||||
bottom: 256px;
|
||||
}
|
||||
|
||||
.card-frame .video-clip {
|
||||
height: 218px;
|
||||
width: 186px;
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
.card-frame .video-clip video {
|
||||
height: 318px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.holocard {
|
||||
width: 608px;
|
||||
height: 616px;
|
||||
|
@ -1,6 +1,6 @@
|
||||
export default `
|
||||
(function() {
|
||||
const frame = document.querySelector('.card-frame');
|
||||
const frame = document.querySelector('#portal-animation');
|
||||
const playButton = document.querySelector('.play-button');
|
||||
const pauseButton = document.querySelector('.pause-button');
|
||||
const closeButton = document.querySelector('.close-button');
|
Reference in New Issue
Block a user