diff options
Diffstat (limited to 'src/components/jet/today-card/media')
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> |
