mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
feature: add search
This commit is contained in:
1 parent
eaa0ede2ec
commit
e8b25bb420
6 files changed
+496
-53
No files matched your search
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user