mirror of
https://github.com/flutter/holobooth.git
synced 2025-05-21 15:26:26 +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 shareTmpl from './templates/share';
|
||||||
import footerTmpl from './templates/footer';
|
import footerTmpl from './templates/footer';
|
||||||
import stylesTmpl from './templates/styles';
|
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';
|
import gaTmpl from './templates/ga';
|
||||||
|
|
||||||
|
|
||||||
@ -37,6 +38,8 @@ const BaseHTMLContext: Record<string, string | Record<string, string>> = {
|
|||||||
playerVolume: bucketPathForFile('public/player-volume.png'),
|
playerVolume: bucketPathForFile('public/player-volume.png'),
|
||||||
playerFullscreen: bucketPathForFile('public/player-fullscreen.png'),
|
playerFullscreen: bucketPathForFile('public/player-fullscreen.png'),
|
||||||
close: bucketPathForFile('public/close.png'),
|
close: bucketPathForFile('public/close.png'),
|
||||||
|
desktopPortalSpritesheet: bucketPathForFile('public/desktop-portal-spritesheet.png'),
|
||||||
|
mobilePortalSpritesheet: bucketPathForFile('public/mobile-portal-spritesheet.png'),
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Flutter Forward Holobooth',
|
title: 'Flutter Forward Holobooth',
|
||||||
@ -48,7 +51,8 @@ const BaseHTMLContext: Record<string, string | Record<string, string>> = {
|
|||||||
},
|
},
|
||||||
ga: gaTmpl,
|
ga: gaTmpl,
|
||||||
styles: '',
|
styles: '',
|
||||||
scripts: '',
|
videoPlayerController: '',
|
||||||
|
portalAnimationController: '',
|
||||||
footer: '',
|
footer: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -76,7 +80,8 @@ function renderTemplate(
|
|||||||
tmpl: string, context: Record<string, string | Record<string, string>>
|
tmpl: string, context: Record<string, string | Record<string, string>>
|
||||||
): string {
|
): string {
|
||||||
context.styles = mustache.render(stylesTmpl, context);
|
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);
|
context.footer = mustache.render(footerTmpl, context);
|
||||||
return mustache.render(tmpl, 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>
|
<div class="backdrop"></div>
|
||||||
<main>
|
<main>
|
||||||
<div class="share-video">
|
<div class="share-video">
|
||||||
<img class="holocard" src={{assetUrls.holocard}} />
|
<canvas id="portal-animation"></canvas>
|
||||||
<div class="card-frame">
|
|
||||||
<div class="video-clip">
|
|
||||||
<video src="{{{shareVideoUrl}}}"></video>
|
|
||||||
</div>
|
|
||||||
<img src={{assetUrls.videoFrame}} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
@ -102,7 +97,8 @@ export default `
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
{{{scripts}}}
|
{{{videoPlayerController}}}
|
||||||
|
{{{portalAnimationController}}}
|
||||||
</script>
|
</script>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
@ -39,7 +39,9 @@ main {
|
|||||||
|
|
||||||
.share-video {
|
.share-video {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
@ -127,38 +129,6 @@ h2 {
|
|||||||
color: #C0C0C0;
|
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 {
|
.holocard {
|
||||||
width: 708px;
|
width: 708px;
|
||||||
height: 716px;
|
height: 716px;
|
||||||
@ -447,24 +417,6 @@ footer .right a {
|
|||||||
height: 464px;
|
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 {
|
.holocard {
|
||||||
width: 608px;
|
width: 608px;
|
||||||
height: 616px;
|
height: 616px;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
export default `
|
export default `
|
||||||
(function() {
|
(function() {
|
||||||
const frame = document.querySelector('.card-frame');
|
const frame = document.querySelector('#portal-animation');
|
||||||
const playButton = document.querySelector('.play-button');
|
const playButton = document.querySelector('.play-button');
|
||||||
const pauseButton = document.querySelector('.pause-button');
|
const pauseButton = document.querySelector('.pause-button');
|
||||||
const closeButton = document.querySelector('.close-button');
|
const closeButton = document.querySelector('.close-button');
|
Reference in New Issue
Block a user