You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
272 lines
8.6 KiB
272 lines
8.6 KiB
import { useMemo, useRef, useState } from "react"; |
|
import { Icons } from "@/components/Icon"; |
|
import { useLoading } from "@/hooks/useLoading"; |
|
import { Loading } from "@/components/layout/Loading"; |
|
import { IconPatch } from "@/components/buttons/IconPatch"; |
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
|
import { useMeta } from "@/video/state/logic/meta"; |
|
import { useControls } from "@/video/state/logic/controls"; |
|
import { MWStream } from "@/backend/helpers/streams"; |
|
import { |
|
getEmbedScraperByType, |
|
getProviders, |
|
} from "@/backend/helpers/register"; |
|
import { runEmbedScraper, runProvider } from "@/backend/helpers/run"; |
|
import { MWProviderScrapeResult } from "@/backend/helpers/provider"; |
|
import { useTranslation } from "react-i18next"; |
|
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed"; |
|
import { FloatingCardView } from "@/components/popout/FloatingCard"; |
|
import { FloatingView } from "@/components/popout/FloatingView"; |
|
import { useFloatingRouter } from "@/hooks/useFloatingRouter"; |
|
import { PopoutListEntry } from "./PopoutUtils"; |
|
|
|
interface EmbedEntryProps { |
|
name: string; |
|
type: MWEmbedType; |
|
url: string; |
|
onSelect: (stream: MWStream) => void; |
|
} |
|
|
|
export function EmbedEntry(props: EmbedEntryProps) { |
|
const [scrapeEmbed, loading, error] = useLoading(async () => { |
|
const scraper = getEmbedScraperByType(props.type); |
|
if (!scraper) throw new Error("Embed scraper not found"); |
|
const stream = await runEmbedScraper(scraper, { |
|
progress: () => {}, // no progress tracking for inline scraping |
|
url: props.url, |
|
}); |
|
props.onSelect(stream); |
|
}); |
|
|
|
return ( |
|
<PopoutListEntry |
|
isOnDarkBackground |
|
loading={loading} |
|
errored={!!error} |
|
onClick={() => { |
|
scrapeEmbed(); |
|
}} |
|
> |
|
{props.name} |
|
</PopoutListEntry> |
|
); |
|
} |
|
|
|
export function SourceSelectionPopout(props: { |
|
router: ReturnType<typeof useFloatingRouter>; |
|
prefix: string; |
|
}) { |
|
const { t } = useTranslation(); |
|
|
|
const descriptor = useVideoPlayerDescriptor(); |
|
const controls = useControls(descriptor); |
|
const meta = useMeta(descriptor); |
|
const providers = useMemo( |
|
() => |
|
meta |
|
? getProviders().filter((v) => v.type.includes(meta.meta.meta.type)) |
|
: [], |
|
[meta] |
|
); |
|
|
|
const [selectedProvider, setSelectedProvider] = useState<string | null>(null); |
|
const [scrapeResult, setScrapeResult] = |
|
useState<MWProviderScrapeResult | null>(null); |
|
const selectedProviderPopulated = useMemo( |
|
() => providers.find((v) => v.id === selectedProvider) ?? null, |
|
[providers, selectedProvider] |
|
); |
|
const [runScraper, loading, error] = useLoading( |
|
async (providerId: string) => { |
|
const theProvider = providers.find((v) => v.id === providerId); |
|
if (!theProvider) throw new Error("Invalid provider"); |
|
if (!meta) throw new Error("need meta"); |
|
return runProvider(theProvider, { |
|
media: meta.meta, |
|
progress: () => {}, |
|
type: meta.meta.meta.type, |
|
episode: meta.episode?.episodeId as any, |
|
season: meta.episode?.seasonId as any, |
|
}); |
|
} |
|
); |
|
|
|
function selectSource(stream: MWStream) { |
|
controls.setSource({ |
|
quality: stream.quality, |
|
source: stream.streamUrl, |
|
type: stream.type, |
|
}); |
|
if (meta) { |
|
controls.setMeta({ |
|
...meta, |
|
captions: stream.captions, |
|
}); |
|
} |
|
controls.closePopout(); |
|
} |
|
|
|
const providerRef = useRef<string | null>(null); |
|
const selectProvider = (providerId?: string) => { |
|
if (!providerId) { |
|
providerRef.current = null; |
|
setSelectedProvider(null); |
|
props.router.navigate(`/${props.prefix}/source`); |
|
return; |
|
} |
|
|
|
runScraper(providerId).then(async (v) => { |
|
if (!providerRef.current) return; |
|
if (v) { |
|
const len = v.embeds.length + (v.stream ? 1 : 0); |
|
if (len === 1) { |
|
const realStream = v.stream; |
|
if (!realStream) { |
|
const embed = v?.embeds[0]; |
|
if (!embed) throw new Error("Embed scraper not found"); |
|
const scraper = getEmbedScraperByType(embed.type); |
|
if (!scraper) throw new Error("Embed scraper not found"); |
|
const stream = await runEmbedScraper(scraper, { |
|
progress: () => {}, // no progress tracking for inline scraping |
|
url: embed.url, |
|
}); |
|
selectSource(stream); |
|
return; |
|
} |
|
selectSource(realStream); |
|
return; |
|
} |
|
} |
|
setScrapeResult(v ?? null); |
|
}); |
|
providerRef.current = providerId; |
|
setSelectedProvider(providerId); |
|
props.router.navigate(`/${props.prefix}/source/embeds`); |
|
}; |
|
|
|
const visibleEmbeds = useMemo(() => { |
|
const embeds = scrapeResult?.embeds || []; |
|
|
|
// Count embed types to determine if it should show a number behind the name |
|
const embedsPerType: Record<string, (MWEmbed & { displayName: string })[]> = |
|
{}; |
|
for (const embed of embeds) { |
|
if (!embed.type) continue; |
|
if (!embedsPerType[embed.type]) embedsPerType[embed.type] = []; |
|
embedsPerType[embed.type].push({ |
|
...embed, |
|
displayName: embed.type, |
|
}); |
|
} |
|
|
|
const embedsRes = Object.entries(embedsPerType).flatMap(([_, entries]) => { |
|
if (entries.length > 1) |
|
return entries.map((embed, i) => ({ |
|
...embed, |
|
displayName: `${embed.type} ${i + 1}`, |
|
})); |
|
return entries; |
|
}); |
|
|
|
return embedsRes; |
|
}, [scrapeResult?.embeds]); |
|
|
|
return ( |
|
<> |
|
{/* List providers */} |
|
<FloatingView |
|
{...props.router.pageProps(props.prefix)} |
|
width={320} |
|
height={500} |
|
> |
|
<FloatingCardView.Header |
|
title={t("videoPlayer.popouts.sources")} |
|
description={t("videoPlayer.popouts.descriptions.sources")} |
|
goBack={() => props.router.navigate("/")} |
|
/> |
|
<FloatingCardView.Content> |
|
{providers.map((v) => ( |
|
<PopoutListEntry |
|
key={v.id} |
|
onClick={() => { |
|
selectProvider(v.id); |
|
}} |
|
> |
|
{v.displayName} |
|
</PopoutListEntry> |
|
))} |
|
</FloatingCardView.Content> |
|
</FloatingView> |
|
|
|
{/* List embeds */} |
|
<FloatingView |
|
{...props.router.pageProps(`embeds`)} |
|
width={320} |
|
height={500} |
|
> |
|
<FloatingCardView.Header |
|
title={selectedProviderPopulated?.displayName ?? ""} |
|
description={t("videoPlayer.popouts.descriptions.embeds")} |
|
goBack={() => props.router.navigate(`/${props.prefix}`)} |
|
/> |
|
<FloatingCardView.Content> |
|
{loading ? ( |
|
<div className="flex h-full w-full items-center justify-center"> |
|
<Loading /> |
|
</div> |
|
) : error ? ( |
|
<div className="flex h-full w-full items-center justify-center"> |
|
<div className="flex flex-col flex-wrap items-center text-slate-400"> |
|
<IconPatch |
|
icon={Icons.EYE_SLASH} |
|
className="text-xl text-bink-600" |
|
/> |
|
<p className="mt-6 w-full text-center"> |
|
{t("videoPlayer.popouts.errors.embedsError")} |
|
</p> |
|
</div> |
|
</div> |
|
) : ( |
|
<> |
|
{scrapeResult?.stream ? ( |
|
<PopoutListEntry |
|
isOnDarkBackground |
|
onClick={() => { |
|
if (scrapeResult.stream) selectSource(scrapeResult.stream); |
|
}} |
|
> |
|
Native source |
|
</PopoutListEntry> |
|
) : null} |
|
{(visibleEmbeds?.length || 0) > 0 ? ( |
|
visibleEmbeds?.map((v) => ( |
|
<EmbedEntry |
|
type={v.type} |
|
name={v.displayName ?? ""} |
|
key={v.url} |
|
url={v.url} |
|
onSelect={(stream) => { |
|
selectSource(stream); |
|
}} |
|
/> |
|
)) |
|
) : ( |
|
<div className="flex h-full w-full items-center justify-center"> |
|
<div className="flex flex-col flex-wrap items-center text-slate-400"> |
|
<IconPatch |
|
icon={Icons.EYE_SLASH} |
|
className="text-xl text-bink-600" |
|
/> |
|
<p className="mt-6 w-full text-center"> |
|
{t("videoPlayer.popouts.noEmbeds")} |
|
</p> |
|
</div> |
|
</div> |
|
)} |
|
</> |
|
)} |
|
</FloatingCardView.Content> |
|
</FloatingView> |
|
</> |
|
); |
|
}
|
|
|