feature: add search

This commit is contained in:
WonderAliceUwU committed 2026-07-08 18:49:30 +02:00
1 parent eaa0ede2ec
commit e8b25bb420
6 files changed
+496 -53

No files matched your search

+8
View File
@@ -19,6 +19,7 @@ import {
getDashboard,
getDownloads,
getHubPage,
searchTitles,
getTitleDetails,
getTmdbLogos,
deleteSeasonFiles,
@@ -237,6 +238,13 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
return;
}
if (url.pathname === '/api/search' && method === 'GET') {
const config = await readConfig(session!.encryptionKey);
const query = String(url.searchParams.get('query') || '');
sendJson(response, 200, await searchTitles(config, query));
return;
}
if (url.pathname === '/api/title-details' && method === 'POST') {
const config = await readConfig(session!.encryptionKey);
const body = await readJson<TitleDetailsRequest>(request);
+150 -14
View File
@@ -129,6 +129,11 @@ interface DownloadMetadataHint {
subtitle?: string;
}
interface TmdbBackdropChoice {
url: string;
backdropLanguageFallback: boolean;
}
interface SonarrSeriesRecord {
id?: number;
title?: string;
@@ -333,7 +338,7 @@ export async function getHubPage(
return getHubPage(config, 'home');
}
const backdropCache = new Map<string, Promise<string>>();
const backdropCache = new Map<string, Promise<TmdbBackdropChoice>>();
const networkScopedContinueWatching = isNetworkPage
? (() => {
const sectionTmdbIds = new Set(
@@ -369,13 +374,15 @@ export async function getHubPage(
'en'
)
]);
const continueWatchingWithLogos = await applyTitleLogosToCards(config, continueWatching || []);
const heroWithLogos = await applyTitleLogosToCards(config, heroWithHighResBackdrops || []);
const sectionsWithLogos = await applyTitleLogosToSections(config, sectionsWithHighResBackdrops || []);
return {
page,
hero: heroWithLogos,
continueWatching,
sections: (sectionsWithHighResBackdrops || [])
continueWatching: continueWatchingWithLogos,
sections: sectionsWithLogos
.filter((section) => section.items.some((item) => item.backdropUrl || item.posterUrl))
.concat(
page === 'library' && downloads.length
@@ -460,7 +467,7 @@ export async function getCalendar(config: AppConfig): Promise<CalendarResponse>
const allItems = [...sonarrItems, ...radarrItems]
.filter((item): item is CalendarEntry => Boolean(item));
const backdropCache = new Map<string, Promise<string>>();
const backdropCache = new Map<string, Promise<TmdbBackdropChoice>>();
const cards = await applyHighResBackdropsToCards(config, allItems.map(i => i.card), shouldUseHighResBackdropForCard, backdropCache, 'en');
const itemsWithBackdrops = allItems.map(item => {
@@ -481,6 +488,73 @@ export async function getCalendar(config: AppConfig): Promise<CalendarResponse>
}
}
export async function searchTitles(
config: AppConfig,
rawQuery: string
): Promise<{ query: string; items: MediaCard[] }> {
const query = rawQuery.trim();
if (!query) {
return { query: '', items: [] };
}
const library = await getLibrarySnapshot(config);
const libraryCards = library.allItems
.map((item) => jellyfinToCard(item, item.Name || 'Library'))
.filter(Boolean) as MediaCard[];
const normalizedQuery = normalizeSearchValue(query);
const libraryMatches = libraryCards.filter((card) => {
const haystack = normalizeSearchValue([card.title, card.subtitle].filter(Boolean).join(' '));
return haystack.includes(normalizedQuery);
});
const libraryCardByTmdbId = new Map(
libraryCards
.filter((card) => card.tmdbId)
.map((card) => [card.tmdbId!, card] as const)
);
const remoteResults = await tmdbFetch(
config,
`/search/multi?query=${encodeURIComponent(query)}&language=${encodeURIComponent(config.preferences.language)}&include_adult=false`
);
const remoteCards = asArray(remoteResults?.results)
.map((entry) => asRecord(entry))
.filter((entry) => entry.media_type === 'movie' || entry.media_type === 'tv')
.map((entry) => {
const mediaType = entry.media_type === 'movie' ? 'movie' : 'tv';
const card = tmdbToCard(entry, mediaType);
const libraryMatch = card.tmdbId ? libraryCardByTmdbId.get(card.tmdbId) : undefined;
return libraryMatch
? {
...card,
providerId: libraryMatch.providerId,
badge: libraryMatch.badge,
progress: libraryMatch.progress,
secondaryValue: libraryMatch.secondaryValue,
hoverLabel: libraryMatch.hoverLabel
}
: card;
});
const searchBackdropCache = new Map<string, Promise<TmdbBackdropChoice>>();
const enrichedCards = await applyTitleLogosToCards(
config,
await applyHighResBackdropsToCards(
config,
uniqueSearchCards([...libraryMatches, ...remoteCards]).slice(0, 36),
shouldUseHighResBackdropForCard,
searchBackdropCache,
'en'
)
);
return {
query,
items: enrichedCards.filter((card) => Boolean(card.backdropUrl))
};
}
export async function removeDownload(config: AppConfig, id: string): Promise<void> {
const [source, rawId] = id.split('-', 2);
if (!source || !rawId) {
@@ -1777,7 +1851,7 @@ async function getLibrarySnapshot(config: AppConfig): Promise<{
jellyfinFetch<JellyfinItem[]>(config, `/Shows/NextUp?userId=${config.jellyfin.userId}&fields=ProviderIds,Overview,SeriesId,ParentBackdropItemId,ParentBackdropImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber`),
jellyfinFetch<JellyfinItem[]>(
config,
`/Users/${config.jellyfin.userId}/Items?recursive=true&includeItemTypes=Movie,Series&hasTmdbId=true&fields=ProviderIds,DateCreated,DateLastMediaAdded,Overview`
`/Users/${config.jellyfin.userId}/Items?recursive=true&includeItemTypes=Movie,Series&hasTmdbId=true&fields=ProviderIds,DateCreated,DateLastMediaAdded,Overview,ImageTags,BackdropImageTags,ProductionYear`
)
]);
} catch {
@@ -2741,6 +2815,7 @@ function customCard(partial: Partial<MediaCard> & Pick<MediaCard, 'id' | 'title'
posterUrl: partial.posterUrl || '',
backdropUrl: partial.backdropUrl || partial.posterUrl || '',
logoUrl: partial.logoUrl,
backdropLanguageFallback: partial.backdropLanguageFallback,
badge: partial.badge,
progress: partial.progress,
secondaryValue: partial.secondaryValue,
@@ -2859,11 +2934,35 @@ function uniqueCards(items: MediaCard[]): MediaCard[] {
});
}
function uniqueSearchCards(items: MediaCard[]): MediaCard[] {
const seen = new Set<string>();
return items.filter((item) => {
const key = item.tmdbId
? `tmdb:${item.tmdbId}`
: item.providerId
? `provider:${item.providerId}`
: item.id;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function normalizeSearchValue(value: string): string {
return value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
// Match TMDb's English backdrop tab for landscape cards where available.
async function applyHighResBackdropsToSections(
config: AppConfig,
sections: HubSection[],
backdropCache: Map<string, Promise<string>>,
backdropCache: Map<string, Promise<TmdbBackdropChoice>>,
preferredLanguage?: string | null
): Promise<HubSection[]> {
return Promise.all(
@@ -2888,11 +2987,31 @@ async function applyHighResBackdropsToSections(
);
}
async function applyTitleLogosToSections(
config: AppConfig,
sections: HubSection[]
): Promise<HubSection[]> {
return Promise.all(
sections.map(async (section) => {
if (section.style !== 'landscape') {
return section;
}
const items = await applyTitleLogosToCards(config, section.items);
return {
...section,
items
};
})
);
}
async function applyHighResBackdropsToCards(
config: AppConfig,
cards: MediaCard[],
shouldApply: (card: MediaCard) => boolean,
backdropCache: Map<string, Promise<string>>,
backdropCache: Map<string, Promise<TmdbBackdropChoice>>,
preferredLanguage?: string | null
): Promise<MediaCard[]> {
return Promise.all(
@@ -2905,15 +3024,16 @@ async function applyHighResBackdropsToCards(
const cacheKey = `${mediaType}:${card.tmdbId}:${preferredLanguage || 'any'}:${card.originalLanguage || 'none'}`;
let backdropPromise = backdropCache.get(cacheKey);
if (!backdropPromise) {
backdropPromise = getTmdbHighResBackdrop(config, card.tmdbId!, mediaType, preferredLanguage, card.originalLanguage);
backdropPromise = getTmdbHighResBackdropChoice(config, card.tmdbId!, mediaType, preferredLanguage, card.originalLanguage);
backdropCache.set(cacheKey, backdropPromise);
}
const backdropUrl = await backdropPromise;
return backdropUrl
const backdropChoice = await backdropPromise;
return backdropChoice.url
? {
...card,
backdropUrl
backdropUrl: backdropChoice.url,
backdropLanguageFallback: backdropChoice.backdropLanguageFallback
}
: card;
})
@@ -3032,13 +3152,24 @@ async function getTmdbHighResBackdrop(
preferredLanguage?: string | null,
originalLanguage?: string | null
): Promise<string> {
const choice = await getTmdbHighResBackdropChoice(config, tmdbId, mediaType, preferredLanguage, originalLanguage);
return choice.url;
}
async function getTmdbHighResBackdropChoice(
config: AppConfig,
tmdbId: number,
mediaType: 'movie' | 'tv',
preferredLanguage?: string | null,
originalLanguage?: string | null
): Promise<{ url: string; backdropLanguageFallback: boolean }> {
try {
const response = await tmdbFetch(config, tmdbBackdropImagesPath(tmdbId, mediaType));
let backdrops = asArray(response?.backdrops)
.map((entry) => asRecord(entry));
if (backdrops.length === 0) {
return '';
return { url: '', backdropLanguageFallback: false };
}
const sortByWidthDesc = (items: Record<string, unknown>[]) =>
@@ -3083,9 +3214,14 @@ async function getTmdbHighResBackdrop(
(entry) => typeof entry.file_path === 'string' && entry.file_path
);
return selectedBackdrop?.file_path ? `${TMDB_IMAGE}/original${String(selectedBackdrop.file_path)}` : '';
return selectedBackdrop?.file_path
? {
url: `${TMDB_IMAGE}/original${String(selectedBackdrop.file_path)}`,
backdropLanguageFallback: preferredLanguage === 'en' && selectedBackdrop.iso_639_1 == null
}
: { url: '', backdropLanguageFallback: false };
} catch {
return '';
return { url: '', backdropLanguageFallback: false };
}
}
+7
View File
@@ -1,6 +1,7 @@
export type AppRoute =
| 'stream'
| 'home'
| 'search'
| 'anime'
| 'dorama'
| 'netflix'
@@ -90,6 +91,7 @@ export interface MediaCard {
posterUrl: string;
backdropUrl: string;
logoUrl?: string;
backdropLanguageFallback?: boolean;
badge?: string;
progress?: number;
secondaryValue?: string;
@@ -207,6 +209,11 @@ export interface CalendarResponse {
items: CalendarEntry[];
}
export interface SearchResponse {
query: string;
items: MediaCard[];
}
export interface DashboardResponse {
connections: ConnectionStatus[];
hero: MediaCard[];
+182 -19
View File
@@ -19,6 +19,7 @@ import {
type DownloadItem,
type HubPageResponse,
type MediaCard,
type SearchResponse,
type SelectOption,
type ServiceOptionsResponse,
type TitleDetailsCastMember,
@@ -36,8 +37,9 @@ const EPISODE_SCROLL_DISTANCE_RATIO = 0.72
const SEASON_DOMINANCE_SWITCH_RATIO = 0.12
const SEASON_DOMINANCE_SWITCH_MIN_PX = 56
const sidebarItems: Array<{ route: AppRoute; label: string; short: string; icon?: 'calendar' }> = [
const sidebarItems: Array<{ route: AppRoute; label: string; short: string; icon?: 'calendar' | 'search'; iconClassName?: string }> = [
{ route: 'home', label: 'Inicio', short: 'H' },
{ route: 'search', label: 'Search', short: 'S', icon: 'search', iconClassName: 'sidebar-search-icon' },
{ route: 'dorama', label: 'K-Drama', short: 'K' },
{ route: 'anime', label: 'Anime', short: 'A' },
{ route: 'library', label: 'Library', short: 'L' },
@@ -77,6 +79,10 @@ function App() {
const [hubLoading, setHubLoading] = useState(false)
const [calendarItems, setCalendarItems] = useState<CalendarEntry[]>([])
const [calendarLoading, setCalendarLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<SearchResponse['items']>([])
const [searchLoading, setSearchLoading] = useState(false)
const [lastSearchQuery, setLastSearchQuery] = useState('')
const [selectedCard, setSelectedCard] = useState<SelectedCardState | null>(null)
const [downloadDeleteId, setDownloadDeleteId] = useState('')
const [entryTransitionState, setEntryTransitionState] = useState<EntryTransitionState>('idle')
@@ -190,7 +196,7 @@ function App() {
return
}
if (route === 'downloads' || route === 'settings' || route === 'calendar') {
if (route === 'downloads' || route === 'settings' || route === 'calendar' || route === 'search') {
return
}
@@ -234,7 +240,7 @@ function App() {
void refreshBaseData()
if (route === 'calendar') {
void loadCalendar({ silent: true })
} else if (route !== 'downloads' && route !== 'settings') {
} else if (route !== 'downloads' && route !== 'settings' && route !== 'search') {
void loadHub(route as HubPageResponse['page'], { silent: true })
}
}, 30000)
@@ -338,7 +344,7 @@ function App() {
}
if (route === 'calendar') {
await loadCalendar({ silent: true })
} else if (route !== 'downloads' && route !== 'settings') {
} else if (route !== 'downloads' && route !== 'settings' && route !== 'search') {
await loadHub(route as HubPageResponse['page'], { silent: true })
}
setNotice(`${card.title} sent to ${card.mediaType === 'movie' ? 'Radarr' : 'Sonarr'}.`)
@@ -365,6 +371,29 @@ function App() {
}
}
async function handleSearchSubmit(query: string) {
const trimmedQuery = query.trim()
setSearchQuery(query)
setError('')
if (!trimmedQuery) {
setLastSearchQuery('')
setSearchResults([])
return
}
setSearchLoading(true)
try {
const response = await api.searchTitles(trimmedQuery)
setSearchResults(response.items)
setLastSearchQuery(response.query)
} catch (searchError) {
setError(searchError instanceof Error ? searchError.message : 'Failed to search the catalog.')
} finally {
setSearchLoading(false)
}
}
function updateConfig<K extends keyof AppConfig>(key: K, value: AppConfig[K]) {
setConfig((current) => ({
...current,
@@ -427,7 +456,7 @@ function App() {
const currentJellyfinUser =
options.jellyfinUsers.find((option) => option.id === config.jellyfin.userId)?.label || 'Media cockpit'
const currentHub = route !== 'downloads' && route !== 'settings' && route !== 'calendar' ? hubPages[route as HubPageResponse['page']] : undefined
const currentHub = route !== 'downloads' && route !== 'settings' && route !== 'calendar' && route !== 'search' ? hubPages[route as HubPageResponse['page']] : undefined
return (
<div className={`omniarr-app ${getEntryTransitionClassName(entryTransitionState)}`}>
@@ -450,6 +479,17 @@ function App() {
{route === 'calendar' ? <CalendarPage entries={calendarItems} loading={calendarLoading} onOpenCard={(card, originRect) => setSelectedCard({ card, originRect: originRect ?? null })} /> : null}
{route === 'downloads' ? <DownloadsPage busyId={downloadDeleteId} downloads={downloads} onDelete={handleDeleteDownload} /> : null}
{route === 'search' ? (
<SearchPage
loading={searchLoading}
onOpenCard={(card, originRect) => setSelectedCard({ card, originRect: originRect ?? null })}
onSearch={handleSearchSubmit}
query={searchQuery}
results={searchResults}
submittedQuery={lastSearchQuery}
updateQuery={setSearchQuery}
/>
) : null}
{route === 'settings' ? (
<SettingsView
busy={busy}
@@ -462,7 +502,7 @@ function App() {
updateConfig={updateConfig}
/>
) : null}
{route !== 'downloads' && route !== 'settings' && route !== 'calendar' ? (
{route !== 'downloads' && route !== 'settings' && route !== 'calendar' && route !== 'search' ? (
<HubPageView
busy={busy}
hub={currentHub}
@@ -527,8 +567,8 @@ function Sidebar(props: {
onClick={() => startTransition(() => props.setRoute(item.route))}
type="button"
>
<span className="sidebar-link-short" aria-hidden="true">
{item.icon === 'calendar' ? <CalendarGlyph /> : item.short}
<span className={item.iconClassName ? `sidebar-link-short ${item.iconClassName}` : 'sidebar-link-short'} aria-hidden="true">
{item.icon === 'calendar' ? <CalendarGlyph /> : item.icon === 'search' ? <SearchGlyph /> : item.short}
</span>
<span>{item.label}</span>
</button>
@@ -579,6 +619,51 @@ function LoadingScreen() {
)
}
function SearchGlyph() {
return (
<svg aria-hidden="true" className="sidebar-glyph-icon" fill="none" viewBox="0 0 16 16">
<circle cx="7" cy="7" r="4.25" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.25 10.25L13.1 13.1" stroke="currentColor" strokeLinecap="round" strokeWidth="1.5" />
</svg>
)
}
function ArrowRightGlyph() {
return (
<svg aria-hidden="true" className="sidebar-glyph-icon" fill="none" viewBox="0 0 16 16">
<path d="M3 8H13" stroke="currentColor" strokeLinecap="round" strokeWidth="1.6" />
<path d="M9.25 4.25L13 8L9.25 11.75" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6" />
</svg>
)
}
function ActionButton(props: {
children: ReactNode
className?: string
disabled?: boolean
onClick?: () => void
type?: 'button' | 'submit'
variant?: 'default' | 'play'
}) {
const className = [
'secondary-button',
'title-action-button',
props.variant === 'play' ? 'title-action-button-play' : '',
props.className || '',
].filter(Boolean).join(' ')
return (
<button
className={className}
disabled={props.disabled}
onClick={props.onClick}
type={props.type || 'button'}
>
{props.children}
</button>
)
}
function AuthScreen({
mode,
password,
@@ -1048,9 +1133,11 @@ function MediaTile(props: {
? (props.card.backdropUrl || props.card.posterUrl)
: props.card.posterUrl
const continueEpisodeMeta = props.isContinueWatching ? parseContinueEpisodeMeta(props.card) : null
const showLandscapeLogo = (props.styleType === 'landscape' || props.styleType === 'episode') && props.card.logoUrl && props.card.backdropLanguageFallback
const tileClassName = [
'media-tile',
props.styleType,
showLandscapeLogo ? 'has-landscape-logo' : '',
props.isContinueWatching ? 'continue-tile' : '',
props.isContinueWatching && props.card.progress ? 'has-progress' : '',
].filter(Boolean).join(' ')
@@ -1066,6 +1153,11 @@ function MediaTile(props: {
type="button"
>
{imageUrl ? <img alt={props.card.title} loading="lazy" src={imageUrl} /> : <div className="media-fallback" />}
{showLandscapeLogo ? (
<div className="media-landscape-logo-wrap">
<img alt="" className="media-landscape-logo" loading="lazy" src={props.card.logoUrl} />
</div>
) : null}
{continueEpisodeMeta ? (
<div className="continue-episode-meta">
<span className="continue-episode-meta-primary">{`Season ${continueEpisodeMeta.seasonNumber}`}</span>
@@ -1652,10 +1744,10 @@ function TitleDetailsModal(props: {
</p>
<div className="title-details-summary-header">
{canPlay && details?.mediaType !== 'series' ? (
<button className="secondary-button title-action-button title-action-button-play" onClick={() => setPlayerTarget({ id: playId!, title: playLabel })} type="button">
<ActionButton onClick={() => setPlayerTarget({ id: playId!, title: playLabel })} variant="play">
<PlayGlyph />
<span>Play</span>
</button>
</ActionButton>
) : canRequest && details?.mediaType !== 'series' ? (
<button className="secondary-button title-action-button" onClick={() => void props.onRequest(props.card)} type="button">
<DownloadGlyph />
@@ -1758,15 +1850,14 @@ function TitleDetailsModal(props: {
) : null}
<div className="title-season-strip-spacer" />
{canPlay ? (
<button
aria-label={`Play ${playLabel}`}
className="secondary-button title-action-button title-action-button-play title-season-play-button"
<ActionButton
className="title-season-play-button"
onClick={() => setPlayerTarget({ id: playId!, title: playLabel })}
type="button"
variant="play"
>
<PlayGlyph />
<span>Play</span>
</button>
</ActionButton>
) : null}
{hasNextSeason ? (
<button
@@ -2056,6 +2147,78 @@ function truncateOverview(text: string, maxLength: number) {
return `${(safeBreak > maxLength * 0.7 ? clipped.slice(0, safeBreak) : clipped).trimEnd()}...`
}
function SearchPage(props: {
loading: boolean
onOpenCard: (card: MediaCard, originRect?: ModalOriginRect | null) => void
onSearch: (query: string) => Promise<void>
query: string
results: MediaCard[]
submittedQuery: string
updateQuery: (query: string) => void
}) {
const inputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
const hasSearched = Boolean(props.submittedQuery)
return (
<div className="search-page">
<section className="search-header">
<h2><SearchGlyph /> <span>Search</span></h2>
<form
className="search-form"
onSubmit={(event) => {
event.preventDefault()
void props.onSearch(props.query)
}}
>
<input
className="input search-input"
onChange={(event) => props.updateQuery(event.target.value)}
placeholder="Search movies and shows"
ref={inputRef}
type="search"
value={props.query}
/>
<ActionButton className="search-submit-button" disabled={props.loading || props.query.trim().length < 2} type="submit" variant="play">
<ArrowRightGlyph />
</ActionButton>
</form>
</section>
{props.loading ? <LoadingHub /> : null}
{hasSearched && !props.loading && props.results.length === 0 ? (
<EmptyState
copy={`No results found for "${props.submittedQuery}". Try another title or a broader query.`}
title="Nothing matched"
/>
) : null}
{props.results.length ? (
<section className="search-results-section">
<div className="search-results-grid">
{props.results.map((card) => (
<MediaTile
busy={false}
key={card.id}
card={card}
isContinueWatching={false}
onOpenCard={props.onOpenCard}
onRequest={async () => {}}
styleType="landscape"
/>
))}
</div>
</section>
) : null}
</div>
)
}
function DownloadsPage(props: {
downloads: DownloadItem[]
busyId: string
@@ -2064,7 +2227,7 @@ function DownloadsPage(props: {
return (
<div className="downloads-page">
<div className="downloads-header">
<h2>Downloads</h2>
<h2><DownloadGlyph /> <span>Downloads</span></h2>
</div>
<section className="downloads-section">
<div className="downloads-grid">
@@ -2363,11 +2526,11 @@ function ServiceFields(props: {
)
}
function EmptyState({ title, copy }: { title: string; copy: string }) {
function EmptyState({ title, copy, className }: { title: string; copy: string; className?: string }) {
return (
<div className="empty-state">
<div className={className ? `empty-state ${className}` : 'empty-state'}>
<h4>{title}</h4>
<p>{copy}</p>
{copy ? <p>{copy}</p> : null}
</div>
)
}
+2
View File
@@ -7,6 +7,7 @@ import type {
DownloadItem,
HubPageResponse,
MediaCard,
SearchResponse,
ServiceOptionsResponse,
SessionResponse,
EpisodeWatchStateRequest,
@@ -84,6 +85,7 @@ export const api = {
getDashboard: () => request<DashboardResponse>('/api/dashboard'),
getCalendar: () => request<CalendarResponse>('/api/calendar'),
getHub: (page: HubPageResponse['page']) => request<HubPageResponse>(`/api/hubs/${page}`),
searchTitles: (query: string) => request<SearchResponse>(`/api/search?query=${encodeURIComponent(query)}`),
getTitleDetails: (card: MediaCard, seasonId?: string) =>
request<TitleDetailsResponse>('/api/title-details', {
method: 'POST',
+147 -20
View File
@@ -375,6 +375,16 @@ img {
display: block;
}
.sidebar-search-icon {
color: #ffffff;
}
.sidebar-search-icon .sidebar-glyph-icon {
width: 1.02rem;
height: 1.02rem;
stroke: currentColor;
}
.sidebar-network-icon {
overflow: hidden;
padding: 0.2rem;
@@ -470,6 +480,7 @@ img {
}
.hub-page,
.search-page,
.calendar-page,
.downloads-page,
.settings-page {
@@ -549,8 +560,10 @@ img {
position: relative;
z-index: 1;
display: grid;
align-items: end;
align-items: center;
align-content: center;
gap: 1.5rem;
min-height: 100%;
animation: hero-copy-in 420ms ease;
}
@@ -741,6 +754,39 @@ img {
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.media-tile.has-landscape-logo .media-art::after {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at left bottom, rgba(4, 6, 10, 0.68) 0%, rgba(4, 6, 10, 0.5) 18%, rgba(4, 6, 10, 0.18) 36%, rgba(4, 6, 10, 0) 58%);
pointer-events: none;
}
.media-landscape-logo-wrap {
position: absolute;
left: 1rem;
bottom: 1rem;
z-index: 2;
display: flex;
align-items: center;
max-width: min(56%, 15rem);
pointer-events: none;
}
.continue-tile.has-progress .media-landscape-logo-wrap {
bottom: 1.65rem;
}
.media-landscape-logo {
width: auto;
max-width: 100%;
max-height: 4.1rem;
object-fit: contain;
object-position: left center;
filter: drop-shadow(0 0.45rem 1rem rgba(0, 0, 0, 0.58));
}
.media-hover-label {
position: absolute;
left: 0.85rem;
@@ -1310,7 +1356,7 @@ img {
.title-details-hero-heading {
position: relative;
top: -1rem;
top: 0;
max-width: 34rem;
animation: titleHeroRise 420ms ease-out;
}
@@ -1401,7 +1447,7 @@ img {
.title-details-body .title-details-shell {
display: grid;
align-content: end;
align-content: center;
width: min(100%, 84rem);
min-height: 100%;
padding-top: 12rem;
@@ -1459,13 +1505,13 @@ img {
.title-action-button-play {
color: #f3f6ff;
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.title-action-button-play:hover,
.title-action-button-play:focus-visible {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.12);
}
.title-seasons-panel {
@@ -2336,8 +2382,93 @@ img {
.calendar-groups,
.downloads-grid,
.search-results-section,
.settings-page {
padding: 0 1.25rem 1rem;
padding: 0.4rem 2.35rem 0;
}
.search-page {
align-content: start;
gap: 1.6rem;
min-height: calc(100vh - 9rem);
}
.search-header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
padding: 0.4rem 2.35rem 0;
}
.search-header h2,
.downloads-header h2 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.65rem;
font-size: 1.35rem;
font-weight: 700;
color: #d6d8de;
}
.search-header h2 .sidebar-glyph-icon,
.downloads-header h2 .title-action-icon {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
.search-header p {
margin: 0.35rem 0 0;
color: #95a2ba;
}
.search-form {
display: flex;
align-items: center;
gap: 0.65rem;
width: min(38rem, 100%);
}
.search-input {
flex: 1 1 auto;
min-width: 0;
min-height: 2.9rem;
padding: 0.78rem 1rem;
border-radius: 999px;
}
.search-submit-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.9rem;
min-width: 2.9rem;
margin-top: 0;
padding-left: 0.85rem;
padding-right: 0.85rem;
border-radius: 999px;
}
.search-submit-button .sidebar-glyph-icon {
width: 0.95rem;
height: 0.95rem;
}
.search-results-section {
display: grid;
gap: 1rem;
}
.search-results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(23rem, 1fr));
gap: 1.15rem;
}
.search-results-grid .media-tile.landscape {
width: 100%;
}
.calendar-page {
@@ -2501,14 +2632,7 @@ img {
}
.downloads-header {
padding: 0 2.35rem;
}
.downloads-header h2 {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
color: #d6d8de;
padding: calc(env(safe-area-inset-top, 0px) + 1.1rem) 2.35rem 0;
}
.downloads-grid {
@@ -2668,12 +2792,6 @@ img {
}
.empty-state {
padding: 1.5rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.04);
}
.settings-page {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -3164,6 +3282,15 @@ img {
padding-right: 1.25rem;
}
.search-header {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.search-form {
grid-template-columns: 1fr;
}
.calendar-entry-card {
grid-template-columns: 1fr;
}