diff --git a/src/lib/components/Avatar.svelte b/src/lib/components/Avatar.svelte index 42c35c0..a68b715 100644 --- a/src/lib/components/Avatar.svelte +++ b/src/lib/components/Avatar.svelte @@ -4,15 +4,17 @@ type Props = { src?: string; alt?: string; + shape?: 'circle' | 'rounded'; class?: string; }; - let { src, alt = '', class: className = 'size-11' }: Props = $props(); + let { src, alt = '', shape = 'circle', class: className = 'size-11' }: Props = $props();
@@ -23,6 +25,7 @@ class="size-full text-base-300 dark:text-base-600" fill="currentColor" viewBox="0 0 24 24" + preserveAspectRatio={shape === 'rounded' ? 'xMidYMax meet' : 'xMidYMid meet'} aria-hidden="true" > fact !== null) ); - let titleLogoUrl = $derived(logoUrl(data.logo?.path, 'w500')); - let titleLogoOriginalUrl = $derived(logoUrl(data.logo?.path, 'original')); - let hasHeroImage = $derived(Boolean(data.item.backdrop || data.item.poster)); let averageReviewRating = $derived( data.reviews.length > 0 ? data.reviews.reduce((total, review) => total + review.rating, 0) / data.reviews.length @@ -80,7 +78,7 @@ let writtenReviews = $derived(data.reviews.filter((review) => review.text.trim())); let displayedReviews = $derived(showAllReviews ? writtenReviews : writtenReviews.slice(0, 3)); let desktopRecommendations = $derived( - showAllRecommendations ? data.recommendations : data.recommendations.slice(0, 10) + showAllRecommendations ? data.recommendations : data.recommendations.slice(0, 5) ); function showFullReview(review: ReviewCardModel) { selectedReview = review; @@ -118,32 +116,6 @@ {/snippet} -{#snippet mediaTitle()} -

- {data.item.title} -

- {#if titleLogoUrl && titleLogoOriginalUrl} - - - - - {/if} -{/snippet} - {data.item.title} | atmo.watch @@ -187,90 +159,58 @@
{/if} -
-
- {#if data.item.backdrop} - - {:else if data.item.poster} - - {/if} -
- {@render mediaTitle()} -
-
+
+
-
- {#if data.item.poster} - - {/if} - -
-

- {data.item.title} -

+
+
+ {#each mediaFacts as fact, index (fact)} + {#if index > 0} + + {/if} + {fact} + {/each} +
-
- {#each mediaFacts as fact, index (fact)} - {#if index > 0} - - {/if} - {fact} - {/each} -
+
+ 0 ? averageReviewRating : null} + popfeedRatingCount={data.reviews.length} + imdbId={data.imdb_id} + imdbVotes={data.imdb_votes} + ratings={data.ratings} + /> +
-
- 0 ? averageReviewRating : null} - popfeedRatingCount={data.reviews.length} - imdbId={data.imdb_id} - imdbVotes={data.imdb_votes} - ratings={data.ratings} - /> +
+
+ {@render reviewButton()}
-
- {@render reviewButton()} -
- -
- - {#if data.trailer_url} - - {/if} - -
+ + {#if data.trailer_url} + + {/if} +
- - {#if data.item.overview} - - {/if}
+ + {#if data.item.overview} + + {/if}
@@ -314,9 +254,16 @@ /> {/each} - - {#if !showAllRecommendations && data.recommendations.length > 10} + {#if !showAllRecommendations && data.recommendations.length > 5}
- {#if !expanded && items.length > 10} + {#if !expanded && items.length > 5} {/if} diff --git a/src/lib/tmdb.server.ts b/src/lib/tmdb.server.ts index dda2ec4..97d4965 100644 --- a/src/lib/tmdb.server.ts +++ b/src/lib/tmdb.server.ts @@ -196,7 +196,8 @@ function toMediaDetails( function toMediaCredit(source: PersonCombinedCastCredit): MediaCredit { return { ...toMediaSummary(source, fromTmdbMediaType(source.media_type)), - order: 'order' in source ? source.order : 0 + order: 'order' in source ? source.order : 0, + popularity: source.popularity }; } @@ -577,9 +578,19 @@ export async function getDetails( return toMediaDetails(details, creativeWorkType); } +export async function getMediaHeader(tmdbId: number, creativeWorkType: SupportedCreativeWorkType) { + const cache = getPublicDataCache(MEDIA_DATA_TTL); + const details = await getMediaSource(tmdbId, creativeWorkType, cache); + return { + item: toMediaDetails(details, creativeWorkType), + logo: getTitleLogo(details) + }; +} + export async function getTvSeasonPage(tmdbId: number, seasonNumber: number) { const cache = getPublicDataCache(TV_SCHEDULE_TTL); - const [show, seasonSource] = await Promise.all([ + const mediaCache = getPublicDataCache(MEDIA_DATA_TTL); + const [show, seasonSource, mediaDetails] = await Promise.all([ getTvScheduleSource(tmdbId, cache), cachePublicData(cache, `tmdb:tv-season:v2:${tmdbId}:${seasonNumber}`, () => getClient().tv_seasons.details({ @@ -587,7 +598,8 @@ export async function getTvSeasonPage(tmdbId: number, seasonNumber: number) { season_number: seasonNumber, append_to_response: TV_SEASON_APPENDS }) - ) + ), + getMediaSource(tmdbId, 'tv_show', mediaCache) ]); const seasons = (show.seasons ?? []).map(toTvSeason); const navigableSeasons = seasons @@ -608,6 +620,7 @@ export async function getTvSeasonPage(tmdbId: number, seasonNumber: number) { return { show: toMediaDetails(show, 'tv_show'), + logo: getTitleLogo(mediaDetails), season, episodes: seasonSource.episodes.flatMap((source) => { const episode = toTvEpisode(source); diff --git a/src/lib/types.ts b/src/lib/types.ts index 684725b..f9aa6f6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -55,6 +55,7 @@ export type TvSeasonSummary = { export type MediaCredit = MediaSummary & { order: number; + popularity: number; }; export type ActorSummary = { diff --git a/src/routes/[kind]/[id]/season/[season]/+page.svelte b/src/routes/[kind]/[id]/season/[season]/+page.svelte index 06a866b..38e335f 100644 --- a/src/routes/[kind]/[id]/season/[season]/+page.svelte +++ b/src/routes/[kind]/[id]/season/[season]/+page.svelte @@ -2,6 +2,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import Container from '$lib/components/Container.svelte'; + import MediaHero from '$lib/components/MediaHero.svelte'; import TrailerDialog from '$lib/components/TrailerDialog.svelte'; import { calendarDayDifference, formatCalendarDate, relativeCalendarDate } from '$lib/dates'; import { backdropUrl, posterUrl, stillUrl } from '$lib/images'; @@ -13,6 +14,8 @@ let showId = $derived(`${data.show.tmdbId}-${slugify(data.show.title)}`); let showUrl = $derived(resolve('/[kind]/[id]', { kind: 'tv', id: showId })); let seasonPoster = $derived(data.season.poster ?? data.show.poster); + let episodeBackdrop = $derived(data.episodes.find((episode) => episode.still)?.still ?? null); + let seasonBackdrop = $derived(episodeBackdrop ?? data.show.backdrop); let canonicalUrl = $derived(`${page.url.origin}${page.url.pathname}`); let seasonStatus = $derived(getSeasonStatus()); let seasonDateRange = $derived(getSeasonDateRange()); @@ -81,55 +84,46 @@
{#if data.show.backdrop} + {:else if seasonPoster} + {/if} -
+ - - + {#if seasonPoster} + +
+ {/if} - {#if seasonPoster || data.trailer_url} +
+ + +
+

+ {data.season.name} +

- {#if seasonPoster} - Poster for {data.season.name} of {data.show.title} - {/if} - - {#if data.trailer_url} -
- -
- {/if} -
- {/if} - -
-
{#if seasonStatus}{seasonStatus}{/if} {#if seasonStatus && seasonDateRange} @@ -145,14 +139,18 @@ {data.network} {/if}
-

- {data.season.name} -

-

{data.show.title}

+ + {#if data.trailer_url} +
+ +
+ {/if}
+
+ {#if data.season.overview} -
+

Overview

{data.season.overview} @@ -160,7 +158,7 @@

{/if} -
+

Episodes

{#if data.episodes.length > 0} diff --git a/src/routes/cast/[id]/+page.server.ts b/src/routes/cast/[id]/+page.server.ts index 580ff97..bc4c652 100644 --- a/src/routes/cast/[id]/+page.server.ts +++ b/src/routes/cast/[id]/+page.server.ts @@ -21,7 +21,7 @@ export const load: PageServerLoad = async ({ params }) => { seen.add(key); return true; }) - .sort((a, b) => b.order - a.order); + .sort((a, b) => b.popularity - a.popularity); return { combinedCredits, personDetails }; } catch (cause) { diff --git a/src/routes/cast/[id]/+page.svelte b/src/routes/cast/[id]/+page.svelte index 8744e5e..b7d4ece 100644 --- a/src/routes/cast/[id]/+page.svelte +++ b/src/routes/cast/[id]/+page.svelte @@ -43,7 +43,8 @@
diff --git a/src/routes/profile/[actor]/review/[rkey]/+page.server.ts b/src/routes/profile/[actor]/review/[rkey]/+page.server.ts index 8d327af..862be07 100644 --- a/src/routes/profile/[actor]/review/[rkey]/+page.server.ts +++ b/src/routes/profile/[actor]/review/[rkey]/+page.server.ts @@ -3,6 +3,7 @@ import type { ResourceUri } from '@atcute/lexicons'; import { isRecordKey } from '@atcute/lexicons/syntax'; import { contrail } from '$lib/contrail-client.server'; import { getReviewInteractions, toReview } from '$lib/reviews.server'; +import { getMediaHeader } from '$lib/tmdb.server'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals, params, parent }) => { @@ -24,15 +25,21 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => { const review = toReview(reviewResponse.data, profile.handle); if (!review) error(404, 'Review not found'); - const interactions = await getReviewInteractions(reviewUri, locals.did).catch((cause) => { - console.error('Could not load review interactions from Contrail', cause); - return { - likeCount: 0, - commentCount: 0, - viewerLikeUri: null, - comments: [] - }; - }); + const [interactions, mediaHeader] = await Promise.all([ + getReviewInteractions(reviewUri, locals.did).catch((cause) => { + console.error('Could not load review interactions from Contrail', cause); + return { + likeCount: 0, + commentCount: 0, + viewerLikeUri: null, + comments: [] + }; + }), + getMediaHeader(review.media.tmdbId, review.media.creativeWorkType).catch((cause) => { + console.error('Could not load media header from TMDB', cause); + return null; + }) + ]); return { review: { @@ -44,6 +51,7 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => { }, createdAt: reviewResponse.data.value.createdAt }, + mediaHeader, ...interactions }; }; diff --git a/src/routes/profile/[actor]/review/[rkey]/+page.svelte b/src/routes/profile/[actor]/review/[rkey]/+page.svelte index fff517f..640a35b 100644 --- a/src/routes/profile/[actor]/review/[rkey]/+page.svelte +++ b/src/routes/profile/[actor]/review/[rkey]/+page.svelte @@ -4,12 +4,12 @@ import { oauthLogin } from '$lib/atproto/oauth.remote'; import Avatar from '$lib/components/Avatar.svelte'; import Container from '$lib/components/Container.svelte'; + import MediaHero from '$lib/components/MediaHero.svelte'; import Rating from '$lib/components/Rating.svelte'; - import { posterUrl } from '$lib/images'; import { likeReview, unlikeReview } from '$lib/review-interactions.remote'; import { slugify, toMediaRouteKind } from '$lib/utils'; import { AtprotoLoginModal } from '@foxui/social'; - import { Heart, Image as ImageIcon, MessageCircle } from '@lucide/svelte'; + import { Heart, MessageCircle } from '@lucide/svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -26,7 +26,19 @@ let likeCount = $state(data.likeCount); let spoilerRevealed = $state(false); - let imageUrl = $derived(posterUrl(data.review.media.poster, 'w342')); + let releaseYear = $derived(data.mediaHeader?.item.releaseDate?.slice(0, 4) || null); + let seasonLabel = $derived( + data.mediaHeader?.item.numberOfSeasons + ? `${data.mediaHeader.item.numberOfSeasons} ${data.mediaHeader.item.numberOfSeasons === 1 ? 'season' : 'seasons'}` + : null + ); + let mediaFacts = $derived( + [ + releaseYear, + formatRuntime(data.mediaHeader?.item.runtime ?? null), + data.review.media.creativeWorkType === 'tv_show' ? seasonLabel : null + ].filter((fact): fact is string => fact !== null) + ); let reviewText = $derived(data.review.text.trim()); let spoilerHidden = $derived( data.review.containsSpoilers && Boolean(reviewText) && !spoilerRevealed @@ -50,6 +62,15 @@ return Number.isNaN(date.getTime()) ? '' : dateFormatter.format(date); } + function formatRuntime(runtime: number | null) { + if (!runtime) return null; + + const hours = Math.floor(runtime / 60); + const minutes = runtime % 60; + if (!hours) return `${minutes}m`; + return minutes ? `${hours}h ${minutes}m` : `${hours}h`; + } + async function login(handle: string) { const returnTo = page.url.pathname + page.url.search + page.url.hash; const { url } = await oauthLogin({ handle: handle.trim(), returnTo }); @@ -102,41 +123,35 @@
- -
+ + -
+

comments

{#if data.comments.length > 0}