Files
apps.apple.com/src/components/AmbientBackgroundArtwork.svelte
2025-11-04 05:03:50 +08:00

203 lines
6.5 KiB
Svelte

<script lang="ts">
import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
import ResizeDetector from '@amp/web-app-components/src/components/helpers/ResizeDetector.svelte';
import { colorAsString } from '~/utils/color';
export let artwork: JetArtworkType;
export let active: boolean = false;
$: isBackgroundImageLoaded = false;
$: backgroundImage = artwork
? buildSrc(
artwork.template,
{
crop: 'sr',
width: 400,
height: Math.floor(400 / 1.6667),
fileType: 'webp',
},
{},
)
: undefined;
$: if (backgroundImage) {
const img = new Image();
img.onload = () => (isBackgroundImageLoaded = true);
img.src = backgroundImage;
}
let resizing = false;
const handleResizeUpdate = (e: CustomEvent<{ isResizing: boolean }>) =>
(resizing = e.detail.isResizing);
let isOutOfView = true;
const handleIntersectionOberserverUpdate = (
isIntersectingViewport: boolean,
) => (isOutOfView = !isIntersectingViewport);
</script>
{#if backgroundImage}
<ResizeDetector on:resizeUpdate={handleResizeUpdate} />
<div
class="container"
class:active
class:resizing
class:loaded={isBackgroundImageLoaded}
class:out-of-view={isOutOfView}
style:--background-image={`url(${backgroundImage})`}
style:--background-color={artwork.backgroundColor &&
colorAsString(artwork.backgroundColor)}
use:intersectionObserver={{
callback: handleIntersectionOberserverUpdate,
threshold: 0,
}}
>
<div class="overlay" />
</div>
{/if}
<style>
.container {
--veil: rgb(240, 240, 240, 0.65);
--speed: 0.66s;
--aspect-ratio: 16/9;
--scale: 1.2;
position: absolute;
top: 0;
left: 0;
width: 100%;
aspect-ratio: var(--aspect-ratio);
max-height: 900px;
opacity: 0;
/*
This stack of background images represents the following three layers, listed front-to-back:
1) A gradient from transparent to white that acts as a mask for the entire container.
`mask-image` caused too much thrashing and CPU usage when animating and resizing,
so we are mimicking its functionality with this top-layer background image.
2) A semi-transparent veil to evenly fade out the bg. Note that this is not technically
a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be
used in `background-image`.
3) The joe color of the background image that will eventualy be loaded.
*/
background-image: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 50%,
var(--pageBg) 80%
),
linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%),
linear-gradient(
0deg,
var(--background-color) 0%,
var(--background-color) 80%
);
background-position: center;
background-size: 120%;
/*
Blurring via the CSS filter does not extend edge-to-edge of the contents width, but we
can mitigate that by ever-so-slightly bumping up the `scale` of content so it bleeds off
the page cleanly.
*/
filter: blur(20px) saturate(1.3);
transform: scale(var(--scale));
transition: opacity calc(var(--speed) * 2) ease-out,
background-size var(--speed) ease-in;
@media (prefers-color-scheme: dark) {
--veil: rgba(0, 0, 0, 0.5);
}
}
.container.loaded {
/*
This stack of background images represents the following three layers, listed front-to-back:
1) A gradient from transparent to white that acts as a mask for the entire container.
`mask-image` caused too much thrashing and CPU usage when animating and resizing,
so we are mimicking its functionality with this top-layer background image.
2) A semi-transparent veil to evenly fade out the image. Note that this is not technically
a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be
used in `background-image`.
3) The actual background image.
*/
background-image: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 50%,
var(--pageBg) 80%
),
linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%),
var(--background-image);
}
.container.active {
opacity: 1;
transition: opacity calc(var(--speed) / 2) ease-in;
background-size: 100%;
}
.overlay {
position: absolute;
z-index: 2;
top: 0;
left: 0;
width: 100%;
aspect-ratio: var(--aspect-ratio);
max-height: 900px;
opacity: 0;
background-image: var(--background-image);
background-position: 100% 100%;
background-size: 250%;
filter: brightness(1.3) saturate(0);
mix-blend-mode: overlay;
will-change: opacity, background-position;
animation: shift-background 60s infinite linear alternate;
animation-play-state: paused;
transition: opacity var(--speed) ease-in;
}
.active .overlay {
opacity: 0.3;
animation-play-state: running;
transition: opacity calc(var(--speed) * 2) ease-in
calc(var(--speed) * 2);
}
.active.out-of-view .overlay,
.active.resizing .overlay {
animation-play-state: paused;
opacity: 0;
}
@keyframes shift-background {
0% {
background-position: 0% 50%;
background-size: 250%;
}
25% {
background-position: 60% 20%;
background-size: 300%;
}
50% {
background-position: 100% 50%;
background-size: 320%;
}
75% {
background-position: 40% 100%;
background-size: 220%;
}
100% {
background-position: 20% 50%;
background-size: 300%;
}
}
</style>