refactored context menu links, + next episode button styling + mobile UI

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-20 15:54:10 +02:00
parent 2c38e8281c
commit 75109ce45c
24 changed files with 519 additions and 390 deletions

View File

@ -43,6 +43,7 @@ export enum Icons {
TACHOMETER = "tachometer",
MAIL = "mail",
CIRCLE_CHECK = "circle_check",
SKIP_EPISODE = "skip_episode",
}
export interface IconProps {
@ -93,6 +94,7 @@ const iconList: Record<Icons, string> = {
tachometer: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 576 512"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M128 288c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zm154.65-97.08l16.24-48.71c1.16-3.45 3.18-6.35 4.92-9.43-4.73-2.76-9.94-4.78-15.81-4.78-17.67 0-32 14.33-32 32 0 15.78 11.63 28.29 26.65 30.92zM176 176c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zM288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm212.27 400H75.73C57.56 397.63 48 359.12 48 320 48 187.66 155.66 80 288 80s240 107.66 240 240c0 39.12-9.56 77.63-27.73 112zM416 320c0 17.67 14.33 32 32 32s32-14.33 32-32-14.33-32-32-32-32 14.33-32 32zm-56.41-182.77c-12.72-4.23-26.16 2.62-30.38 15.17l-45.34 136.01C250.49 290.58 224 318.06 224 352c0 11.72 3.38 22.55 8.88 32h110.25c5.5-9.45 8.88-20.28 8.88-32 0-19.45-8.86-36.66-22.55-48.4l45.34-136.01c4.17-12.57-2.64-26.17-15.21-30.36zM432 208c0-15.8-11.66-28.33-26.72-30.93-.07.21-.07.43-.14.65l-19.5 58.49c4.37 2.24 9.11 3.8 14.36 3.8 17.67-.01 32-14.34 32-32.01z"/></svg>`,
mail: `<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19.25 4.125H2.75C2.56766 4.125 2.3928 4.19743 2.26386 4.32636C2.13493 4.4553 2.0625 4.63016 2.0625 4.8125V16.5C2.0625 16.8647 2.20737 17.2144 2.46523 17.4723C2.72309 17.7301 3.07283 17.875 3.4375 17.875H18.5625C18.9272 17.875 19.2769 17.7301 19.5348 17.4723C19.7926 17.2144 19.9375 16.8647 19.9375 16.5V4.8125C19.9375 4.63016 19.8651 4.4553 19.7361 4.32636C19.6072 4.19743 19.4323 4.125 19.25 4.125ZM8.48289 11L3.4375 15.6243V6.3757L8.48289 11ZM9.50039 11.9324L10.5316 12.882C10.6585 12.9985 10.8244 13.0631 10.9966 13.0631C11.1687 13.0631 11.3346 12.9985 11.4615 12.882L12.4927 11.9324L17.4771 16.5H4.51773L9.50039 11.9324ZM13.5171 11L18.5625 6.37484V15.6252L13.5171 11Z" fill="currentColor" /></svg>`,
circle_check: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path fill="currentColor" d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>`,
skip_episode: `<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.625 2.8125V15.1875C14.625 15.3367 14.5657 15.4798 14.4602 15.5852C14.3548 15.6907 14.2117 15.75 14.0625 15.75C13.9133 15.75 13.7702 15.6907 13.6648 15.5852C13.5593 15.4798 13.5 15.3367 13.5 15.1875V10.3198L5.09273 15.5777C4.92342 15.684 4.72878 15.7431 4.52895 15.7489C4.32913 15.7547 4.13139 15.707 3.95621 15.6107C3.78102 15.5144 3.63477 15.373 3.53258 15.2012C3.43039 15.0294 3.37599 14.8333 3.375 14.6334V3.36656C3.37599 3.16666 3.43039 2.97065 3.53258 2.79883C3.63477 2.62702 3.78102 2.48564 3.95621 2.38933C4.13139 2.29303 4.32913 2.2453 4.52895 2.25109C4.72878 2.25688 4.92342 2.31598 5.09273 2.42227L13.5 7.68023V2.8125C13.5 2.66332 13.5593 2.52024 13.6648 2.41475C13.7702 2.30926 13.9133 2.25 14.0625 2.25C14.2117 2.25 14.3548 2.30926 14.4602 2.41475C14.5657 2.52024 14.625 2.66332 14.625 2.8125Z" fill="currentColor"/></svg>`,
};
function ChromeCastButton() {

View File

@ -1,4 +1,4 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsync } from "react-use";
@ -11,7 +11,7 @@ import { OverlayPage } from "@/components/overlays/OverlayPage";
import { OverlayRouter } from "@/components/overlays/OverlayRouter";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { VideoPlayerButton } from "@/components/player/internals/Button";
import { Context } from "@/components/player/internals/ContextUtils";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { PlayerMeta } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
@ -56,16 +56,18 @@ function SeasonsView({
let content: ReactNode = null;
if (seasons) {
content = (
<Context.Section className="pb-6">
<Menu.Section className="pb-6">
{seasons?.map((season) => {
return (
<Context.Link key={season.id} onClick={() => setSeason(season.id)}>
<Context.LinkTitle>{season.title}</Context.LinkTitle>
<Context.LinkChevron />
</Context.Link>
<Menu.ChevronLink
key={season.id}
onClick={() => setSeason(season.id)}
>
{season.title}
</Menu.ChevronLink>
);
})}
</Context.Section>
</Menu.Section>
);
} else if (loadingState.error)
content = <CenteredText>Error loading season</CenteredText>;
@ -73,10 +75,10 @@ function SeasonsView({
content = <CenteredText>Loading...</CenteredText>;
return (
<Context.CardWithScrollable>
<Context.Title>{meta?.title}</Context.Title>
<Menu.CardWithScrollable>
<Menu.Title>{meta?.title}</Menu.Title>
{content}
</Context.CardWithScrollable>
</Menu.CardWithScrollable>
);
}
@ -115,37 +117,36 @@ function EpisodesView({
content = <CenteredText>Loading...</CenteredText>;
else if (loadingState.value) {
content = (
<Context.Section className="pb-6">
<Menu.Section className="pb-6">
{loadingState.value.season.episodes.map((ep) => {
return (
<Context.Link
<Menu.ChevronLink
key={ep.id}
onClick={() => playEpisode(ep.id)}
active={ep.id === meta?.episode?.tmdbId}
>
<Context.LinkTitle>
<Menu.LinkTitle>
<div className="text-left flex items-center space-x-3">
<span className="p-0.5 px-2 rounded inline bg-video-context-border">
E{ep.number}
</span>
<span className="line-clamp-1 break-all">{ep.title}</span>
</div>
</Context.LinkTitle>
<Context.LinkChevron />
</Context.Link>
</Menu.LinkTitle>
</Menu.ChevronLink>
);
})}
</Context.Section>
</Menu.Section>
);
}
return (
<Context.CardWithScrollable>
<Context.BackLink onClick={goBack}>
<Menu.CardWithScrollable>
<Menu.BackLink onClick={goBack}>
{loadingState?.value?.season.title || t("videoPlayer.loading")}
</Context.BackLink>
</Menu.BackLink>
{content}
</Context.CardWithScrollable>
</Menu.CardWithScrollable>
);
}

View File

@ -0,0 +1,84 @@
import classNames from "classnames";
import { Icon, Icons } from "@/components/Icon";
import { Transition } from "@/components/Transition";
import { usePlayerStore } from "@/stores/player/store";
function shouldShowNextEpisodeButton(
time: number,
duration: number
): "always" | "hover" | "none" {
const percentage = time / duration;
const secondsFromEnd = duration - time;
if (secondsFromEnd <= 30) return "always";
if (percentage >= 0.9) return "hover";
return "none";
}
function Button(props: {
className: string;
onClick?: () => void;
children: React.ReactNode;
}) {
return (
<button
className={classNames(
"font-bold rounded h-10 w-40 scale-95 hover:scale-100 transition-all duration-200",
props.className
)}
type="button"
onClick={props.onClick}
>
{props.children}
</button>
);
}
// TODO check if has next episode
export function NextEpisodeButton(props: { controlsShowing: boolean }) {
const duration = usePlayerStore((s) => s.progress.duration);
const isHidden = usePlayerStore((s) => s.interface.hideNextEpisodeBtn);
const hideNextEpisodeButton = usePlayerStore((s) => s.hideNextEpisodeButton);
const metaType = usePlayerStore((s) => s.meta?.type);
const time = usePlayerStore((s) => s.progress.time);
const showingState = shouldShowNextEpisodeButton(time, duration);
const status = usePlayerStore((s) => s.status);
let show = false;
if (showingState === "always") show = true;
else if (showingState === "hover" && props.controlsShowing) show = true;
if (isHidden || status !== "playing" || duration === 0) show = false;
const animation = showingState === "hover" ? "slide-up" : "fade";
let bottom = "bottom-24";
if (showingState === "always")
bottom = props.controlsShowing ? "bottom-24" : "bottom-12";
if (metaType !== "show") return null;
return (
<Transition
animation={animation}
show={show}
className="absolute right-12 bottom-0"
>
<div
className={classNames([
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex space-x-3",
bottom,
])}
>
<Button
className="bg-video-buttons-secondary hover:bg-video-buttons-secondaryHover bg-opacity-90 text-video-buttons-secondaryText"
onClick={hideNextEpisodeButton}
>
Cancel
</Button>
<Button className="bg-video-buttons-primary hover:bg-video-buttons-primaryHover text-video-buttons-primaryText flex justify-center items-center">
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
Next episode
</Button>
</div>
</Transition>
);
}

View File

@ -32,7 +32,7 @@ export function ProgressBar() {
}, [setDraggingTime, duration, dragPercentage]);
return (
<div ref={ref}>
<div className="w-full" ref={ref}>
<div
className="group w-full h-8 flex items-center cursor-pointer"
onMouseDown={dragMouseDown}

View File

@ -11,7 +11,7 @@ import {
SourceSelectionView,
} from "@/components/player/atoms/settings/SourceSelectingView";
import { VideoPlayerButton } from "@/components/player/internals/Button";
import { Context } from "@/components/player/internals/ContextUtils";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
@ -41,34 +41,34 @@ function SettingsOverlay({ id }: { id: string }) {
<SettingsMenu id={id} />
</OverlayPage>
<OverlayPage id={id} path="/quality" width={343} height={400}>
<Context.Card>
<Menu.Card>
<QualityView id={id} />
</Context.Card>
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/captions" width={343} height={431}>
<Context.Card>
<Menu.Card>
<CaptionsView id={id} />
</Context.Card>
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/captions/settings" width={343} height={310}>
<Context.Card>
<Menu.Card>
<CaptionSettingsView id={id} />
</Context.Card>
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/source" width={343} height={431}>
<Context.Card>
<Menu.Card>
<SourceSelectionView id={id} onChoose={setChosenSourceId} />
</Context.Card>
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/source/embeds" width={343} height={431}>
<Context.Card>
<Menu.Card>
<EmbedSelectionView id={id} sourceId={chosenSourceId} />
</Context.Card>
</Menu.Card>
</OverlayPage>
<OverlayPage id={id} path="/playback" width={343} height={215}>
<Context.Card>
<Menu.Card>
<PlaybackSettingsView id={id} />
</Context.Card>
</Menu.Card>
</OverlayPage>
</OverlayRouter>
</Overlay>

View File

@ -9,7 +9,7 @@ function durationExceedsHour(secs: number): boolean {
return secs > 60 * 60;
}
export function Time() {
export function Time(props: { short?: boolean }) {
const timeFormat = usePlayerStore((s) => s.interface.timeFormat);
const setTimeFormat = usePlayerStore((s) => s.setTimeFormat);
@ -40,16 +40,26 @@ export function Time() {
},
});
const timeString = `${formatSeconds(currentTime, hasHours)} / ${formatSeconds(
duration,
hasHours
)}`;
const timeFinishedString = `${t("videoPlayer.timeLeft", {
timeLeft: formatSeconds(
let timeString;
let timeFinishedString;
if (props.short) {
timeString = formatSeconds(currentTime, hasHours);
timeFinishedString = `-${formatSeconds(
secondsRemaining,
durationExceedsHour(secondsRemaining)
),
})} ${formattedTimeFinished}`;
)}`;
} else {
timeString = `${formatSeconds(currentTime, hasHours)} / ${formatSeconds(
duration,
hasHours
)}`;
timeFinishedString = `${t("videoPlayer.timeLeft", {
timeLeft: formatSeconds(
secondsRemaining,
durationExceedsHour(secondsRemaining)
),
})} ${formattedTimeFinished}`;
}
const child =
timeFormat === VideoPlayerTimeFormat.REGULAR ? (

View File

@ -12,3 +12,4 @@ export * from "./Settings";
export * from "./Episodes";
export * from "./Airplay";
export * from "./VolumeChangedPopout";
export * from "./NextEpisodeButton";

View File

@ -2,7 +2,7 @@ import classNames from "classnames";
import { useCallback, useEffect, useRef, useState } from "react";
import { Icon, Icons } from "@/components/Icon";
import { Context } from "@/components/player/internals/ContextUtils";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { useProgressBar } from "@/hooks/useProgressBar";
import { useSubtitleStore } from "@/stores/subtitles";
@ -80,7 +80,7 @@ function CaptionSetting(props: {
return (
<div>
<Context.FieldTitle>{props.label}</Context.FieldTitle>
<Menu.FieldTitle>{props.label}</Menu.FieldTitle>
<div className="grid items-center grid-cols-[1fr,auto] gap-4">
<div ref={ref}>
<div
@ -165,10 +165,10 @@ export function CaptionSettingsView({ id }: { id: string }) {
return (
<>
<Context.BackLink onClick={() => router.navigate("/captions")}>
<Menu.BackLink onClick={() => router.navigate("/captions")}>
Custom captions
</Context.BackLink>
<Context.Section className="space-y-6">
</Menu.BackLink>
<Menu.Section className="space-y-6">
<CaptionSetting
label="Text size"
max={200}
@ -186,7 +186,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
textTransformer={(s) => `${s}%`}
/>
<div className="flex justify-between items-center">
<Context.FieldTitle>Color</Context.FieldTitle>
<Menu.FieldTitle>Color</Menu.FieldTitle>
<div className="flex justify-center items-center">
{colors.map((v) => (
<ColorOption
@ -197,7 +197,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
))}
</div>
</div>
</Context.Section>
</Menu.Section>
</>
);
}

View File

@ -1,8 +1,6 @@
import classNames from "classnames";
import { FlagIcon } from "@/components/FlagIcon";
import { Icon, Icons } from "@/components/Icon";
import { Context } from "@/components/player/internals/ContextUtils";
import { Menu } from "@/components/player/internals/ContextMenu";
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { Caption } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
@ -26,25 +24,14 @@ export function CaptionOption(props: {
onClick?: () => void;
}) {
return (
<div
className="grid grid-cols-[auto,1fr,auto] items-center gap-3 rounded -ml-3 -mr-3 px-3 py-2 cursor-pointer hover:bg-video-context-border"
onClick={props.onClick}
>
<div>
<FlagIcon countryCode={props.countryCode} />
</div>
<span
className={classNames(props.selected && "text-white", "font-medium")}
>
{props.children}
<SelectableLink selected={props.selected} onClick={props.onClick}>
<span className="flex items-center">
<span className="mr-3">
<FlagIcon countryCode={props.countryCode} />
</span>
<span>{props.children}</span>
</span>
{props.selected ? (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
) : null}
</div>
</SelectableLink>
);
}
@ -75,7 +62,7 @@ export function CaptionsView({ id }: { id: string }) {
return (
<>
<Context.BackLink
<Menu.BackLink
onClick={() => router.navigate("/")}
rightSide={
<button
@ -87,8 +74,8 @@ export function CaptionsView({ id }: { id: string }) {
}
>
Captions
</Context.BackLink>
<Context.Section>
</Menu.BackLink>
<Menu.Section>
<CaptionOption onClick={() => disableCaption()} selected={!lang}>
Off
</CaptionOption>
@ -102,7 +89,7 @@ export function CaptionsView({ id }: { id: string }) {
{v.title}
</CaptionOption>
))}
</Context.Section>
</Menu.Section>
</>
);
}

View File

@ -1,7 +1,7 @@
import classNames from "classnames";
import { useCallback } from "react";
import { Context } from "@/components/player/internals/ContextUtils";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
@ -49,19 +49,19 @@ export function PlaybackSettingsView({ id }: { id: string }) {
return (
<>
<Context.BackLink onClick={() => router.navigate("/")}>
<Menu.BackLink onClick={() => router.navigate("/")}>
Playback settings
</Context.BackLink>
<Context.Section>
</Menu.BackLink>
<Menu.Section>
<div className="space-y-4 mt-3">
<Context.FieldTitle>Playback speed</Context.FieldTitle>
<Menu.FieldTitle>Playback speed</Menu.FieldTitle>
<ButtonList
options={options}
selected={playbackRate}
onClick={setPlaybackRate}
/>
</div>
</Context.Section>
</Menu.Section>
</>
);
}

View File

@ -1,8 +1,8 @@
import { useCallback } from "react";
import { Toggle } from "@/components/buttons/Toggle";
import { Icon, Icons } from "@/components/Icon";
import { Context } from "@/components/player/internals/ContextUtils";
import { Menu } from "@/components/player/internals/ContextMenu";
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
import {
@ -12,32 +12,6 @@ import {
} from "@/stores/player/utils/qualities";
import { useQualityStore } from "@/stores/quality";
export function QualityOption(props: {
children: React.ReactNode;
selected?: boolean;
disabled?: boolean;
onClick?: () => void;
}) {
let textClasses;
if (props.selected) textClasses = "text-white";
if (props.disabled)
textClasses = "text-video-context-type-main text-opacity-40";
return (
<Context.Link onClick={props.disabled ? undefined : props.onClick}>
<Context.LinkTitle textClass={textClasses}>
{props.children}
</Context.LinkTitle>
{props.selected ? (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
) : null}
</Context.Link>
);
}
export function QualityView({ id }: { id: string }) {
const router = useOverlayRouter(id);
const availableQualities = usePlayerStore((s) => s.qualities);
@ -70,12 +44,12 @@ export function QualityView({ id }: { id: string }) {
return (
<>
<Context.BackLink onClick={() => router.navigate("/")}>
<Menu.BackLink onClick={() => router.navigate("/")}>
Quality
</Context.BackLink>
<Context.Section>
</Menu.BackLink>
<Menu.Section>
{allVisibleQualities.map((v) => (
<QualityOption
<SelectableLink
key={v}
selected={v === currentQuality}
onClick={
@ -84,21 +58,22 @@ export function QualityView({ id }: { id: string }) {
disabled={!availableQualities.includes(v)}
>
{qualityToString(v)}
</QualityOption>
</SelectableLink>
))}
<Context.Divider />
<Context.Link>
<Context.LinkTitle>Automatic quality</Context.LinkTitle>
<Toggle onClick={changeAutomatic} enabled={autoQuality} />
</Context.Link>
<Context.SmallText>
<Menu.Divider />
<Menu.Link
rightSide={<Toggle onClick={changeAutomatic} enabled={autoQuality} />}
>
Automatic quality
</Menu.Link>
<Menu.SmallText>
You can try{" "}
<Context.Anchor onClick={() => router.navigate("/source")}>
<Menu.Anchor onClick={() => router.navigate("/source")}>
switching source
</Context.Anchor>{" "}
</Menu.Anchor>{" "}
to get different quality options.
</Context.SmallText>
</Context.Section>
</Menu.SmallText>
</Menu.Section>
</>
);
}

View File

@ -1,8 +1,8 @@
import { useMemo } from "react";
import { Toggle } from "@/components/buttons/Toggle";
import { Icons } from "@/components/Icon";
import { Context } from "@/components/player/internals/ContextUtils";
import { Icon, Icons } from "@/components/Icon";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
import { qualityToString } from "@/stores/player/utils/qualities";
@ -35,45 +35,51 @@ export function SettingsMenu({ id }: { id: string }) {
}
return (
<Context.Card>
<Context.SectionTitle>Video settings</Context.SectionTitle>
<Context.Section>
<Context.Link onClick={() => router.navigate("/quality")}>
<Context.LinkTitle>Quality</Context.LinkTitle>
<Context.LinkChevron>
{currentQuality ? qualityToString(currentQuality) : ""}
</Context.LinkChevron>
</Context.Link>
<Context.Link onClick={() => router.navigate("/source")}>
<Context.LinkTitle>Video source</Context.LinkTitle>
<Context.LinkChevron>{sourceName}</Context.LinkChevron>
</Context.Link>
<Context.Link>
<Context.LinkTitle>Download</Context.LinkTitle>
<Context.IconButton icon={Icons.DOWNLOAD} />
</Context.Link>
</Context.Section>
<Menu.Card>
<Menu.SectionTitle>Video settings</Menu.SectionTitle>
<Menu.Section>
<Menu.ChevronLink
onClick={() => router.navigate("/quality")}
rightText={currentQuality ? qualityToString(currentQuality) : ""}
>
Quality
</Menu.ChevronLink>
<Menu.ChevronLink
onClick={() => router.navigate("/source")}
rightText={sourceName}
>
Video source
</Menu.ChevronLink>
<Menu.Link
clickable
rightSide={<Icon className="text-xl" icon={Icons.DOWNLOAD} />}
>
Download
</Menu.Link>
</Menu.Section>
<Context.SectionTitle>Viewing Experience</Context.SectionTitle>
<Context.Section>
<Context.Link>
<Context.LinkTitle>Enable Captions</Context.LinkTitle>
<Toggle
enabled={subtitlesEnabled}
onClick={() => toggleSubtitles()}
/>
</Context.Link>
<Context.Link onClick={() => router.navigate("/captions")}>
<Context.LinkTitle>Caption settings</Context.LinkTitle>
<Context.LinkChevron>
{selectedCaptionLanguage ?? ""}
</Context.LinkChevron>
</Context.Link>
<Context.Link onClick={() => router.navigate("/playback")}>
<Context.LinkTitle>Playback settings</Context.LinkTitle>
<Context.LinkChevron />
</Context.Link>
</Context.Section>
</Context.Card>
<Menu.SectionTitle>Viewing Experience</Menu.SectionTitle>
<Menu.Section>
<Menu.Link
rightSide={
<Toggle
enabled={subtitlesEnabled}
onClick={() => toggleSubtitles()}
/>
}
>
Enable Captions
</Menu.Link>
<Menu.ChevronLink
onClick={() => router.navigate("/captions")}
rightText={selectedCaptionLanguage}
>
Caption settings
</Menu.ChevronLink>
<Menu.ChevronLink onClick={() => router.navigate("/playback")}>
Playback settings
</Menu.ChevronLink>
</Menu.Section>
</Menu.Card>
);
}

View File

@ -1,9 +1,8 @@
import classNames from "classnames";
import { ReactNode, useEffect, useMemo, useRef } from "react";
import { useAsyncFn } from "react-use";
import { Icon, Icons } from "@/components/Icon";
import { Context } from "@/components/player/internals/ContextUtils";
import { Menu } from "@/components/player/internals/ContextMenu";
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { metaToScrapeMedia } from "@/stores/player/slices/source";
@ -20,31 +19,6 @@ export interface EmbedSelectionViewProps {
sourceId: string | null;
}
export function SourceOption(props: {
children: React.ReactNode;
selected?: boolean;
onClick?: () => void;
}) {
return (
<div
onClick={props.onClick}
className="grid grid-cols-[1fr,auto] items-center gap-3 rounded -ml-3 -mr-3 px-3 py-2 cursor-pointer hover:bg-video-context-border"
>
<span
className={classNames(props.selected && "text-white", "font-medium")}
>
{props.children}
</span>
{props.selected ? (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
) : null}
</div>
);
}
export function EmbedOption(props: {
embedId: string;
url: string;
@ -76,12 +50,12 @@ export function EmbedOption(props: {
else if (request.error) content = <span>Failed to scrape</span>;
return (
<SourceOption onClick={run}>
<SelectableLink onClick={run}>
<span className="flex flex-col">
<span>{embedName}</span>
{content}
</span>
</SourceOption>
</SelectableLink>
);
}
@ -150,10 +124,10 @@ export function EmbedSelectionView({ sourceId, id }: EmbedSelectionViewProps) {
return (
<>
<Context.BackLink onClick={() => router.navigate("/source")}>
<Menu.BackLink onClick={() => router.navigate("/source")}>
{sourceName}
</Context.BackLink>
<Context.Section>{content}</Context.Section>
</Menu.BackLink>
<Menu.Section>{content}</Menu.Section>
</>
);
}
@ -174,12 +148,12 @@ export function SourceSelectionView({
return (
<>
<Context.BackLink onClick={() => router.navigate("/")}>
<Menu.BackLink onClick={() => router.navigate("/")}>
Sources
</Context.BackLink>
<Context.Section>
</Menu.BackLink>
<Menu.Section>
{sources.map((v) => (
<SourceOption
<SelectableLink
key={v.id}
onClick={() => {
onChoose?.(v.id);
@ -188,9 +162,9 @@ export function SourceSelectionView({
selected={v.id === currentSourceId}
>
{v.name}
</SourceOption>
</SelectableLink>
))}
</Context.Section>
</Menu.Section>
</>
);
}

View File

@ -0,0 +1,17 @@
export function Card(props: { children: React.ReactNode }) {
return (
<div className="h-full grid grid-rows-[1fr]">
<div className="px-6 h-full overflow-y-auto overflow-x-hidden">
{props.children}
</div>
</div>
);
}
export function CardWithScrollable(props: { children: React.ReactNode }) {
return (
<div className="[&>*]:px-6 h-full grid grid-rows-[auto,1fr] [&>*:nth-child(2)]:overflow-y-auto [&>*:nth-child(2)]:overflow-x-hidden">
{props.children}
</div>
);
}

View File

@ -0,0 +1 @@
export function test() {}

View File

@ -0,0 +1,133 @@
import classNames from "classnames";
import { ReactNode } from "react";
import { Icon, Icons } from "@/components/Icon";
import { Title } from "@/components/player/internals/ContextMenu/Misc";
export function Chevron(props: { children?: React.ReactNode }) {
return (
<span className="text-white flex items-center font-medium">
{props.children}
<Icon className="text-xl ml-1 -mr-1.5" icon={Icons.CHEVRON_RIGHT} />
</span>
);
}
export function LinkTitle(props: {
children: React.ReactNode;
textClass?: string;
}) {
return (
<span
className={classNames([
"font-medium text-left",
props.textClass || "text-video-context-type-main",
])}
>
{props.children}
</span>
);
}
export function BackLink(props: {
onClick?: () => void;
children: React.ReactNode;
rightSide?: React.ReactNode;
}) {
return (
<Title rightSide={props.rightSide}>
<button
type="button"
className="-ml-2 p-2 rounded hover:bg-video-context-light hover:bg-opacity-10"
onClick={props.onClick}
>
<Icon className="text-xl" icon={Icons.ARROW_LEFT} />
</button>
<span className="line-clamp-1 break-all">{props.children}</span>
</Title>
);
}
export function Link(props: {
rightSide?: ReactNode;
clickable?: boolean;
active?: boolean;
onClick?: () => void;
children?: ReactNode;
className?: string;
}) {
const classes = classNames(
"flex py-2 px-3 rounded w-full -ml-3 w-[calc(100%+1.5rem)]",
{
"cursor-default": !props.clickable,
"hover:bg-video-context-border cursor-pointer": props.clickable,
"bg-video-context-border": props.active,
}
);
const content = (
<div className={classNames("flex items-center flex-1", props.className)}>
<div className="flex-1 text-left">{props.children}</div>
<div>{props.rightSide}</div>
</div>
);
if (!props.onClick) {
return <div className={classes}>{content}</div>;
}
return (
<button type="button" className={classes} onClick={props.onClick}>
{content}
</button>
);
}
export function ChevronLink(props: {
rightText?: string;
onClick?: () => void;
children?: ReactNode;
active?: boolean;
}) {
const rightContent = <Chevron>{props.rightText}</Chevron>;
return (
<Link
onClick={props.onClick}
active={props.active}
clickable
rightSide={rightContent}
>
<LinkTitle>{props.children}</LinkTitle>
</Link>
);
}
export function SelectableLink(props: {
selected?: boolean;
onClick?: () => void;
children?: ReactNode;
disabled?: boolean;
}) {
const rightContent = (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
);
return (
<Link
onClick={props.onClick}
clickable={!props.disabled}
rightSide={props.selected ? rightContent : null}
>
<LinkTitle
textClass={classNames({
"text-white": props.selected,
"text-video-context-type-main text-opacity-40": props.disabled,
})}
>
{props.children}
</LinkTitle>
</Link>
);
}

View File

@ -0,0 +1,50 @@
import { Icon, Icons } from "@/components/Icon";
export function Title(props: {
children: React.ReactNode;
rightSide?: React.ReactNode;
}) {
return (
<div>
<h3 className="font-bold text-video-context-type-main pb-3 pt-5 border-b border-video-context-border flex justify-between items-center">
<div className="flex items-center space-x-3">{props.children}</div>
<div>{props.rightSide}</div>
</h3>
</div>
);
}
export function IconButton(props: { icon: Icons; onClick?: () => void }) {
return (
<button type="button" onClick={props.onClick}>
<Icon className="text-xl" icon={props.icon} />
</button>
);
}
export function Divider() {
return <hr className="!my-4 border-0 w-full h-px bg-video-context-border" />;
}
export function SmallText(props: { children: React.ReactNode }) {
return <p className="text-sm mt-8 font-medium">{props.children}</p>;
}
export function Anchor(props: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<a
type="button"
className="text-video-context-type-accent cursor-pointer"
onClick={props.onClick}
>
{props.children}
</a>
);
}
export function FieldTitle(props: { children: React.ReactNode }) {
return <p className="font-medium">{props.children}</p>;
}

View File

@ -0,0 +1,20 @@
import classNames from "classnames";
export function SectionTitle(props: { children: React.ReactNode }) {
return (
<h3 className="uppercase font-bold text-video-context-type-secondary text-xs pt-8 pl-1 pb-2.5 border-b border-video-context-border">
{props.children}
</h3>
);
}
export function Section(props: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={classNames("pt-4 space-y-1", props.className)}>
{props.children}
</div>
);
}

View File

@ -0,0 +1,11 @@
import * as Cards from "./Cards";
import * as Links from "./Links";
import * as Misc from "./Misc";
import * as Sections from "./Sections";
export const Menu = {
...Cards,
...Links,
...Sections,
...Misc,
};

View File

@ -1,176 +0,0 @@
import classNames from "classnames";
import { Icon, Icons } from "@/components/Icon";
function Card(props: { children: React.ReactNode }) {
return (
<div className="h-full grid grid-rows-[1fr]">
<div className="px-6 h-full overflow-y-auto overflow-x-hidden">
{props.children}
</div>
</div>
);
}
function CardWithScrollable(props: { children: React.ReactNode }) {
return (
<div className="[&>*]:px-6 h-full grid grid-rows-[auto,1fr] [&>*:nth-child(2)]:overflow-y-auto [&>*:nth-child(2)]:overflow-x-hidden">
{props.children}
</div>
);
}
function SectionTitle(props: { children: React.ReactNode }) {
return (
<h3 className="uppercase font-bold text-video-context-type-secondary text-xs pt-8 pl-1 pb-2.5 border-b border-video-context-border">
{props.children}
</h3>
);
}
function LinkTitle(props: { children: React.ReactNode; textClass?: string }) {
return (
<span
className={classNames([
"font-medium text-left",
props.textClass || "text-video-context-type-main",
])}
>
<div>{props.children}</div>
</span>
);
}
function Section(props: { children: React.ReactNode; className?: string }) {
return (
<div className={classNames("pt-4 space-y-1", props.className)}>
{props.children}
</div>
);
}
function Link(props: {
onClick?: () => void;
children: React.ReactNode;
active?: boolean;
}) {
const classes = classNames(
"flex justify-between items-center py-2 pl-3 pr-3 -ml-3 rounded w-full",
{
"cursor-default": !props.onClick,
"hover:bg-video-context-border": !!props.onClick,
"bg-video-context-border": props.active,
}
);
const styles = { width: "calc(100% + 1.5rem)" };
if (!props.onClick) {
return (
<div className={classes} style={styles}>
{props.children}
</div>
);
}
return (
<button
type="button"
className={classes}
style={styles}
onClick={props.onClick}
>
{props.children}
</button>
);
}
function Title(props: {
children: React.ReactNode;
rightSide?: React.ReactNode;
}) {
return (
<div>
<h3 className="font-bold text-video-context-type-main pb-3 pt-5 border-b border-video-context-border flex justify-between items-center">
<div className="flex items-center space-x-3">{props.children}</div>
<div>{props.rightSide}</div>
</h3>
</div>
);
}
function BackLink(props: {
onClick?: () => void;
children: React.ReactNode;
rightSide?: React.ReactNode;
}) {
return (
<Title rightSide={props.rightSide}>
<button
type="button"
className="-ml-2 p-2 rounded hover:bg-video-context-light hover:bg-opacity-10"
onClick={props.onClick}
>
<Icon className="text-xl" icon={Icons.ARROW_LEFT} />
</button>
<span className="line-clamp-1 break-all">{props.children}</span>
</Title>
);
}
function LinkChevron(props: { children?: React.ReactNode }) {
return (
<span className="text-white flex items-center font-medium">
{props.children}
<Icon className="text-xl ml-1 -mr-1.5" icon={Icons.CHEVRON_RIGHT} />
</span>
);
}
function IconButton(props: { icon: Icons; onClick?: () => void }) {
return (
<button type="button" onClick={props.onClick}>
<Icon className="text-xl" icon={props.icon} />
</button>
);
}
function Divider() {
return <hr className="!my-4 border-0 w-full h-px bg-video-context-border" />;
}
function SmallText(props: { children: React.ReactNode }) {
return <p className="text-sm mt-8 font-medium">{props.children}</p>;
}
function Anchor(props: { children: React.ReactNode; onClick: () => void }) {
return (
<a
type="button"
className="text-video-context-type-accent cursor-pointer"
onClick={props.onClick}
>
{props.children}
</a>
);
}
function FieldTitle(props: { children: React.ReactNode }) {
return <p className="font-medium">{props.children}</p>;
}
export const Context = {
CardWithScrollable,
SectionTitle,
LinkChevron,
IconButton,
FieldTitle,
SmallText,
BackLink,
LinkTitle,
Section,
Divider,
Anchor,
Title,
Link,
Card,
};

View File

@ -3,6 +3,7 @@ import { ReactNode } from "react";
import { BrandPill } from "@/components/layout/BrandPill";
import { Player } from "@/components/player";
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
import { useIsMobile } from "@/hooks/useIsMobile";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
@ -16,6 +17,7 @@ export interface PlayerPartProps {
export function PlayerPart(props: PlayerPartProps) {
const { showTargets, showTouchTargets } = useShouldShowControls();
const status = usePlayerStore((s) => s.status);
const { isMobile } = useIsMobile();
return (
<Player.Container onLoad={props.onLoad}>
@ -53,23 +55,25 @@ export function PlayerPart(props: PlayerPartProps) {
<div className="hidden sm:flex items-center justify-end">
<BrandPill />
</div>
<div className="flex sm:hidden items-center justify-end">
<Player.Airplay />
</div>
</div>
</Player.TopControls>
<Player.BottomControls show={showTargets}>
<Player.ProgressBar />
<div className="flex justify-between">
<Player.LeftSideControls className="hidden lg:flex">
<div className="flex items-center space-x-3">
{isMobile ? <Player.Time short /> : null}
<Player.ProgressBar />
</div>
<div className="hidden lg:flex justify-between">
<Player.LeftSideControls>
<Player.Pause />
<Player.SkipBackward />
<Player.SkipForward />
<Player.Volume />
<Player.Time />
</Player.LeftSideControls>
<Player.LeftSideControls className="flex lg:hidden">
{/* Do mobile controls here :) */}
<Player.Time />
</Player.LeftSideControls>
<div className="flex items-center space-x-3">
<Player.Episodes onChange={props.onMetaChange} />
<Player.Airplay />
@ -77,9 +81,20 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.Fullscreen />
</div>
</div>
<div className="grid grid-cols-[2.5rem,1fr,2.5rem] gap-3 lg:hidden">
<div />
<div className="flex justify-center space-x-3">
<Player.Episodes />
<Player.Settings />
</div>
<div>
<Player.Fullscreen />
</div>
</div>
</Player.BottomControls>
<Player.VolumeChangedPopout />
<Player.NextEpisodeButton controlsShowing={showTargets} />
</Player.Container>
);
}

View File

@ -20,6 +20,7 @@ export interface InterfaceSlice {
hovering: PlayerHoverState;
lastHoveringState: PlayerHoverState;
canAirplay: boolean;
hideNextEpisodeBtn: boolean;
volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently?
volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig"
@ -33,6 +34,7 @@ export interface InterfaceSlice {
setHoveringLeftControls(state: boolean): void;
setHasOpenOverlay(state: boolean): void;
setLastVolume(state: number): void;
hideNextEpisodeButton(): void;
}
export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
@ -48,6 +50,7 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
volumeChangedWithKeybindDebounce: null,
timeFormat: VideoPlayerTimeFormat.REGULAR,
canAirplay: false,
hideNextEpisodeBtn: false,
},
setLastVolume(state) {
@ -84,4 +87,9 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
s.interface.leftControlHovering = state;
});
},
hideNextEpisodeButton() {
set((s) => {
s.interface.hideNextEpisodeBtn = true;
});
},
});

View File

@ -109,6 +109,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
setMeta(meta) {
set((s) => {
s.meta = meta;
s.interface.hideNextEpisodeBtn = false;
});
},
setCaption(caption) {

View File

@ -131,6 +131,15 @@ module.exports = {
set: "#A75FC9"
},
buttons: {
secondary: "#161F25",
secondaryText: "#8EA3B0",
secondaryHover: "#1B262E",
primary: "#fff",
primaryText: "#000",
primaryHover: "#dedede"
},
context: {
background: "#0C1216",
light: "#4D79A8",