summaryrefslogtreecommitdiff
path: root/src/components/jet/today-card/media
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /src/components/jet/today-card/media
init commit
Diffstat (limited to 'src/components/jet/today-card/media')
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte78
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte62
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte41
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaList.svelte86
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaRiver.svelte78
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaVideo.svelte72
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte100
7 files changed, 517 insertions, 0 deletions
diff --git a/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte
new file mode 100644
index 0000000..1faa933
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte
@@ -0,0 +1,78 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaAppEvent,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaAppEvent(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaAppEvent {
+ return media.kind === 'appEvent';
+ }
+</script>
+
+<script lang="ts">
+ import type { Profile } from '~/components/Artwork.svelte';
+ import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+ import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte';
+ import AppEventDate from '~/components/AppEventDate.svelte';
+
+ export let media: TodayCardMediaAppEvent;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: Profile | undefined = undefined;
+</script>
+
+<div class="event-container">
+ <span class="time-container">
+ <AppEventDate formattedDates={media.formattedDates} />
+ </span>
+
+ <div class="artwork-container">
+ {#if media.videos.length > 0}
+ <TodayCardMediaVideo {media} {artworkProfile} />
+ {:else if media.artworks.length > 0}
+ <TodayCardMediaWithArtwork {media} {artworkProfile} />
+ {/if}
+ </div>
+</div>
+
+<style>
+ .event-container {
+ --today-card-border-width: 4px;
+ border: var(--today-card-border-width) solid
+ var(--today-card-accent-color);
+ border-radius: var(--today-card-border-radius);
+ position: relative;
+ aspect-ratio: 0.75;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ @container (orientation: landscape) {
+ .event-container {
+ aspect-ratio: 16/9;
+ }
+ }
+
+ .artwork-container {
+ height: 100%;
+ border-radius: calc(
+ var(--today-card-border-radius) - var(--today-card-border-width)
+ );
+ }
+
+ .time-container :global(time),
+ .time-container :global(span) {
+ background: var(--today-card-accent-color);
+ border-end-end-radius: var(--today-card-border-radius);
+ font: var(--headline);
+ padding: 6px 10px 6px 8px;
+ position: absolute;
+ top: 0;
+ z-index: 3;
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte
new file mode 100644
index 0000000..a6db985
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte
@@ -0,0 +1,62 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaAppIcon,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediAppIcon(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaAppIcon {
+ return media.kind === 'appIcon';
+ }
+</script>
+
+<script lang="ts">
+ import AppIcon from '~/components/AppIcon.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let media: TodayCardMediaAppIcon;
+
+ $: backgroundColor = media.icon.backgroundColor
+ ? colorAsString(media.icon.backgroundColor)
+ : null;
+</script>
+
+<div class="container" style:--background-color={backgroundColor}>
+ <div class="artwork-container">
+ <AppIcon
+ icon={media.icon}
+ profile="app-icon-xlarge"
+ fixedWidth={false}
+ />
+ </div>
+</div>
+
+<style>
+ .container {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: var(--background-color);
+ border-radius: var(--today-card-border-radius);
+ }
+
+ .artwork-container {
+ width: 50%;
+ height: 50%;
+ }
+
+ @container (orientation: landscape) {
+ .container {
+ align-items: start;
+ padding-top: 5%;
+ }
+
+ .artwork-container {
+ width: 30%;
+ height: 30%;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte
new file mode 100644
index 0000000..dfdaa0f
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte
@@ -0,0 +1,41 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaBrandedSingleApp,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaBrandedSingleApp(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaBrandedSingleApp {
+ return media.kind === 'brandedSingleApp';
+ }
+</script>
+
+<script lang="ts">
+ import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+ import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte';
+ import type { Profile } from '~/components/Artwork.svelte';
+
+ export let media: TodayCardMediaBrandedSingleApp;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: Profile | undefined = undefined;
+
+ // There is a small but non-zero set of old legacy Today Cards that can appear on the Today page,
+ // and those cards have their safe area on the left side of the artwork, rather than the center,
+ // like all the modern artwork. For those cases, we pin the artwork to the left edge of the card.
+ $: pinnedToLeft =
+ media.artworkLayoutsWithMetrics[0].ltr.collapsedLayoutInsets.left < 0;
+</script>
+
+{#if media.videos.length > 0}
+ <TodayCardMediaVideo {media} {artworkProfile} />
+{:else if media.artworks.length > 0}
+ <TodayCardMediaWithArtwork
+ {media}
+ {artworkProfile}
+ pinArtworkToLeft={pinnedToLeft}
+ />
+{/if}
diff --git a/src/components/jet/today-card/media/TodayCardMediaList.svelte b/src/components/jet/today-card/media/TodayCardMediaList.svelte
new file mode 100644
index 0000000..00f8688
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaList.svelte
@@ -0,0 +1,86 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaList,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaList(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaList {
+ return media.kind === 'list';
+ }
+</script>
+
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+
+ export let media: TodayCardMediaList;
+
+ let container: HTMLDivElement;
+ let fadeTop = '0%';
+ let fadeBottom = '0%';
+
+ function calculateFadeAmounts() {
+ const { scrollTop, scrollHeight, clientHeight } = container;
+
+ fadeTop = scrollTop > 0 ? '10%' : `${scrollTop}%`;
+ fadeBottom = scrollTop + clientHeight < scrollHeight - 1 ? '15%' : '0%';
+ }
+
+ onMount(() => {
+ calculateFadeAmounts();
+ container.addEventListener('scroll', calculateFadeAmounts);
+
+ return () =>
+ container.removeEventListener('scroll', calculateFadeAmounts);
+ });
+</script>
+
+<div
+ class="container"
+ style:--fade-top-size={fadeTop}
+ style:--fade-bottom-size={fadeBottom}
+ bind:this={container}
+>
+ <ul>
+ {#each media.lockups as item}
+ <li>
+ <SmallLockupItem {item} />
+ </li>
+ {/each}
+ </ul>
+</div>
+
+<style>
+ @property --fade-top-size {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 0%;
+ }
+
+ @property --fade-bottom-size {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 0%;
+ }
+
+ .container {
+ width: 100%;
+ overflow: scroll;
+ padding: 0 var(--today-card-gutter);
+ mask-image: linear-gradient(
+ to bottom,
+ transparent 0%,
+ black var(--fade-top-size),
+ black calc(100% - var(--fade-bottom-size)),
+ transparent 100%
+ );
+ transition: --fade-top-size 105ms cubic-bezier(0.5, 1, 0.89, 1),
+ --fade-bottom-size 420ms cubic-bezier(0.45, 0, 0.55, 1);
+ }
+
+ li {
+ margin-bottom: 16px;
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaRiver.svelte b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte
new file mode 100644
index 0000000..d3f9666
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte
@@ -0,0 +1,78 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaRiver,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaRiver(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaRiver {
+ return media.kind === 'river';
+ }
+</script>
+
+<script lang="ts">
+ import {
+ getBackgroundGradientCSSVarsFromArtworks,
+ getLuminanceForRGB,
+ } from '~/utils/color';
+ import AppIconRiver from '~/components/AppIconRiver.svelte';
+
+ /**
+ * The actual properties of {@linkcode TodayCardMediaRiver} that are required
+ * to render this component
+ */
+ type TodayCardMediaRiverRequirements = Pick<TodayCardMediaRiver, 'lockups'>;
+
+ export let media: TodayCardMediaRiverRequirements;
+
+ $: icons = media.lockups.map((lockup) => lockup.icon);
+ $: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks(
+ icons,
+ {
+ // sorts from darkest to lightest
+ sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
+ },
+ );
+</script>
+
+<div class="container" style={backgroundGradientCssVars}>
+ {#if icons.length}
+ <AppIconRiver {icons} />
+ {/if}
+</div>
+
+<style>
+ .container {
+ --app-icon-river-icon-width: 96px;
+ height: 100%;
+ width: 100%;
+ padding-top: 10%;
+ overflow: hidden;
+ border-radius: var(--today-card-border-radius);
+ background: radial-gradient(
+ circle at 3% -50%,
+ var(--top-left, #000) 20%,
+ transparent 70%
+ ),
+ radial-gradient(
+ circle at -50% 120%,
+ var(--bottom-left, #000) 40%,
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 140% -50%,
+ var(--top-right, #000) 60%,
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 62% 100%,
+ var(--bottom-right, #000) 50%,
+ transparent 100%
+ );
+
+ @media (--range-small-only) {
+ padding-top: 5%;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaVideo.svelte b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte
new file mode 100644
index 0000000..f2524c6
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte
@@ -0,0 +1,72 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaVideo,
+ Video as VideoModel,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaVideo(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaVideo {
+ return (
+ media.kind === 'video' ||
+ (media.kind === 'artwork' && 'videos' in media)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import mediaQueries from '~/utils/media-queries';
+ import type { Profile as ArtworkProfile } from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let media: TodayCardMediaVideo;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: ArtworkProfile | undefined = undefined;
+
+ let videoToDisplay: VideoModel | undefined;
+ $: videoToDisplay = media.videos[0];
+
+ let profile: ArtworkProfile;
+ $: profile =
+ artworkProfile ??
+ ($mediaQueries === 'small' ? 'card' : 'card-horizontal');
+ $: backgroundColor = videoToDisplay?.preview.backgroundColor
+ ? colorAsString(videoToDisplay?.preview.backgroundColor)
+ : null;
+</script>
+
+{#if videoToDisplay}
+ <div class="video-wrapper" style:--background-color={backgroundColor}>
+ <Video
+ autoplay
+ loop
+ {profile}
+ useControls={false}
+ video={videoToDisplay}
+ />
+ </div>
+{/if}
+
+<style>
+ .video-wrapper {
+ background: black;
+ aspect-ratio: 3/4;
+ width: 100%;
+ position: relative;
+ overflow: hidden;
+ border-radius: var(--today-card-border-radius);
+ background-color: var(--background-color);
+ }
+
+ @container (orientation: landscape) {
+ .video-wrapper {
+ aspect-ratio: 16/9;
+ height: 100%;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte
new file mode 100644
index 0000000..e604708
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte
@@ -0,0 +1,100 @@
+<script lang="ts" context="module">
+ import type {
+ Artwork as ArtworkModel,
+ TodayCardMedia,
+ TodayCardMediaWithArtwork,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaWithArtwork(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaWithArtwork {
+ return (
+ media.kind === 'artwork' &&
+ 'artworks' in media &&
+ Array.isArray(media.artworks) &&
+ media.artworks.length > 0
+ );
+ }
+</script>
+
+<script lang="ts">
+ import Artwork, {
+ type Profile as ArtworkProfile,
+ } from '~/components/Artwork.svelte';
+
+ export let media: TodayCardMediaWithArtwork;
+
+ export let pinArtworkToLeft: boolean = false;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: ArtworkProfile | undefined = undefined;
+
+ let artworkToDisplay: ArtworkModel;
+ // Today Card artwork comes back from Jet with a width of 800px, even though the source artwork
+ // is _much_ larger. The shared `Artwork` component doesn't let us render an image beyond the
+ // artwork's `width` and `height` properties, and we absolutely need to render these images
+ // larger than 800px wide, so we are forcing these new upper bounds for the artworks dimensions.
+ // Eventually, we should rethink this and have the proper dimensions come back from Jet:
+ // rdar://148730199 (Bigger images for TodayCard)
+ $: artworkToDisplay = Object.assign({}, media.artworks[0], {
+ width: 3840,
+ height: 2160,
+ });
+</script>
+
+{#if artworkProfile}
+ <Artwork profile={artworkProfile} artwork={artworkToDisplay} />
+{:else}
+ <div class="wrapper">
+ <div class="artwork-container portrait">
+ <Artwork profile="card" artwork={artworkToDisplay} />
+ </div>
+
+ <div
+ class="artwork-container landscape"
+ class:pinned-to-left={pinArtworkToLeft}
+ >
+ <Artwork profile="card-horizontal" artwork={artworkToDisplay} />
+ </div>
+ </div>
+{/if}
+
+<style>
+ .wrapper,
+ .artwork-container {
+ height: 100%;
+ width: 100%;
+ }
+
+ .wrapper .artwork-container :global(.artwork-component),
+ .wrapper .artwork-container :global(img) {
+ object-fit: cover;
+ height: 100%;
+ }
+
+ .pinned-to-left {
+ --artwork-override-object-position: left;
+ }
+
+ @container (orientation: landscape) {
+ .artwork-container.landscape {
+ display: block;
+ }
+
+ .artwork-container.portrait {
+ display: none;
+ }
+ }
+
+ @container (orientation: portrait) {
+ .artwork-container.landscape {
+ display: none;
+ }
+
+ .artwork-container.portrait {
+ display: block;
+ }
+ }
+</style>