mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 01:24:31 +02:00
feat: Added repeat toggle for watch lists (#224)
* Added media format filter to shuffle. * Added missing media formats to filter (for both search and shuffle). * Improved mouse relax system. * Fixed media format not displaying on global search.
This commit is contained in:
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog
|
||||
import { Input } from "@/components/form/Input";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { SearchFilterMediaFormat } from "@/components/search-filter/SearchFilterMediaFormat";
|
||||
import { Switcher, SwitcherOption } from "@/components/switcher/Switcher";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
@@ -46,6 +47,7 @@ interface ShuffleFormProps {
|
||||
function ShuffleForm({ onSuccess, onCancel }: ShuffleFormProps) {
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [filterThemeType, setFilterThemeType] = useState("");
|
||||
const [filterMediaFormat, setFilterMediaFormat] = useState<string | null>(null);
|
||||
const [filterAnimeYearMin, setFilterAnimeYearMin] = useState("");
|
||||
const [filterAnimeYearMax, setFilterAnimeYearMax] = useState("");
|
||||
|
||||
@@ -59,6 +61,7 @@ function ShuffleForm({ onSuccess, onCancel }: ShuffleFormProps) {
|
||||
try {
|
||||
await playRandomThemes({
|
||||
themeType: filterThemeType,
|
||||
mediaFormat: filterMediaFormat ?? undefined,
|
||||
animeYearMin: parseInt(filterAnimeYearMin),
|
||||
animeYearMax: parseInt(filterAnimeYearMax),
|
||||
});
|
||||
@@ -96,6 +99,7 @@ function ShuffleForm({ onSuccess, onCancel }: ShuffleFormProps) {
|
||||
inputProps={{ type: "number", placeholder: "2100" }}
|
||||
/>
|
||||
</SearchFilter>
|
||||
<SearchFilterMediaFormat value={filterMediaFormat} setValue={setFilterMediaFormat} />
|
||||
</SearchFilterGroup>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function SearchNavigation() {
|
||||
const { entity, ...query } = router.query;
|
||||
const { q: initialSearchQuery = "" } = query;
|
||||
|
||||
const [inputSearchQuery, setInputSearchQuery] = useState("");
|
||||
const [inputSearchQuery, setInputSearchQuery] = useState(initialSearchQuery as string);
|
||||
|
||||
const updateInputSearchQuery = (newInputSearchQuery: string) => {
|
||||
setInputSearchQuery(newInputSearchQuery);
|
||||
|
||||
@@ -18,6 +18,9 @@ export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFo
|
||||
<ListboxOption value="TV">TV</ListboxOption>
|
||||
<ListboxOption value="TV Short">TV Short</ListboxOption>
|
||||
<ListboxOption value="Movie">Movie</ListboxOption>
|
||||
<ListboxOption value="OVA">OVA</ListboxOption>
|
||||
<ListboxOption value="ONA">ONA</ListboxOption>
|
||||
<ListboxOption value="Special">Special</ListboxOption>
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
);
|
||||
|
||||
@@ -76,13 +76,16 @@ export function VideoPlayer({ video, background, children, overlay, ...props }:
|
||||
isGlobalAutoPlay,
|
||||
isLocalAutoPlay,
|
||||
isWatchListUsingLocalAutoPlay,
|
||||
isRepeat,
|
||||
} = useContext(PlayerContext);
|
||||
const router = useRouter();
|
||||
const [globalVolume, setGlobalVolume] = useSetting(GlobalVolume);
|
||||
const { smallCover, largeCover } = extractImages(anime);
|
||||
const [audioMode, setAudioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
const { addToHistory } = useWatchHistory();
|
||||
const isRelaxed = useMouseRelax();
|
||||
|
||||
const playerMouseRelaxProps = useMouseRelax();
|
||||
const playbackAreaMouseRelaxProps = useMouseRelax();
|
||||
|
||||
const previousVideo = getWatchListVideo(-1);
|
||||
const previousEntry = previousVideo?.entries[0];
|
||||
@@ -123,9 +126,13 @@ export function VideoPlayer({ video, background, children, overlay, ...props }:
|
||||
if (navigate) {
|
||||
router.push(nextVideoPath);
|
||||
}
|
||||
// For repeating videos
|
||||
if (currentWatchListItem?.basename === nextVideo?.basename) {
|
||||
playerRef.current?.play();
|
||||
}
|
||||
}
|
||||
},
|
||||
[nextVideo, nextVideoPath, router, setCurrentWatchListItem],
|
||||
[currentWatchListItem?.basename, nextVideo, nextVideoPath, router, setCurrentWatchListItem],
|
||||
);
|
||||
|
||||
const autoPlayNextTrack = useCallback(() => {
|
||||
@@ -240,7 +247,15 @@ export function VideoPlayer({ video, background, children, overlay, ...props }:
|
||||
const nextTrackIndex = currentTrackIndex + offset;
|
||||
|
||||
if (!watchList[nextTrackIndex]) {
|
||||
return null;
|
||||
if (!isRepeat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nextTrackIndex < 0) {
|
||||
return watchList[watchList.length - 1];
|
||||
} else if (nextTrackIndex > watchList.length - 1) {
|
||||
return watchList[0];
|
||||
}
|
||||
}
|
||||
|
||||
return watchList[nextTrackIndex];
|
||||
@@ -270,7 +285,7 @@ export function VideoPlayer({ video, background, children, overlay, ...props }:
|
||||
<StyledPlayer
|
||||
ref={containerRef}
|
||||
data-background={background || undefined}
|
||||
data-relaxed={isRelaxed || undefined}
|
||||
{...playerMouseRelaxProps}
|
||||
{...props}
|
||||
>
|
||||
<StyledPlayerContent ref={constraintRef}>
|
||||
@@ -289,6 +304,7 @@ export function VideoPlayer({ video, background, children, overlay, ...props }:
|
||||
onLayoutAnimationStart={() => setMiniPlayerAnimating(true)}
|
||||
onLayoutAnimationComplete={() => setMiniPlayerAnimating(false)}
|
||||
onDoubleClick={() => router.push(videoPagePath)}
|
||||
{...playbackAreaMouseRelaxProps}
|
||||
>
|
||||
{audioMode === AudioMode.ENABLED ? (
|
||||
<StyledAudioBackground style={{ aspectRatio }}>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ThemeEntryTags } from "@/components/tag/ThemeEntryTags";
|
||||
import { VideoTags } from "@/components/tag/VideoTags";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { VideoPlayerContext } from "@/components/video-player/VideoPlayer";
|
||||
import { StyledPlaybackArea, StyledPlayer } from "@/components/video-player/VideoPlayer.style";
|
||||
import { StyledPlaybackArea } from "@/components/video-player/VideoPlayer.style";
|
||||
import FullscreenContext from "@/context/fullscreenContext";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
@@ -31,9 +31,9 @@ const StyledOverlay = styled.div`
|
||||
opacity: 0;
|
||||
transition: opacity 500ms;
|
||||
|
||||
&:hover,
|
||||
html:not([data-fullscreen]) ${StyledPlaybackArea}:hover &,
|
||||
${StyledPlayer}:not([data-relaxed]) ${StyledPlaybackArea}:hover & {
|
||||
&:has([data-state="open"]),
|
||||
&:has(:focus-visible),
|
||||
${StyledPlaybackArea}:not([data-relaxed]) & {
|
||||
opacity: 1;
|
||||
transition: opacity 100ms;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ interface PlayerContextInterface {
|
||||
isLocalAutoPlay: boolean;
|
||||
setLocalAutoPlay: (autoPlay: boolean) => void;
|
||||
isWatchListUsingLocalAutoPlay: boolean;
|
||||
isRepeat: boolean;
|
||||
setRepeat: (repeat: boolean) => void;
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextInterface>({
|
||||
@@ -54,6 +56,10 @@ const PlayerContext = createContext<PlayerContextInterface>({
|
||||
// Do nothing
|
||||
},
|
||||
isWatchListUsingLocalAutoPlay: false,
|
||||
isRepeat: false,
|
||||
setRepeat: () => {
|
||||
// Do nothing
|
||||
},
|
||||
});
|
||||
|
||||
export default PlayerContext;
|
||||
|
||||
+29
-18
@@ -1,28 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
// This hook returns an object of props, which should be assigned to an element.
|
||||
export default function useMouseRelax() {
|
||||
const [isRelaxed, setRelaxed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number;
|
||||
const timeoutId = useRef<number>();
|
||||
|
||||
function onMouseMove() {
|
||||
setRelaxed(false);
|
||||
// Whenever the user moves or presses their pointer inside the element,
|
||||
// start a timer and cancel any previous timers. If the timer finishes without
|
||||
// getting cancelled it means the pointer hasn't moved for n seconds, meaning it's relaxed.
|
||||
const onPointerUpdate = useCallback(() => {
|
||||
setRelaxed(false);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(timeoutId.current);
|
||||
|
||||
timeoutId = window.setTimeout(() => setRelaxed(true), 2500);
|
||||
}
|
||||
|
||||
addEventListener("pointermove", onMouseMove);
|
||||
addEventListener("pointerdown", onMouseMove);
|
||||
|
||||
return () => {
|
||||
removeEventListener("pointermove", onMouseMove);
|
||||
removeEventListener("pointerdown", onMouseMove);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
timeoutId.current = window.setTimeout(() => setRelaxed(true), 3000);
|
||||
}, []);
|
||||
|
||||
return isRelaxed;
|
||||
// When the user moves their pointer outside the element, we set it as relaxed immediately.
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setRelaxed(true);
|
||||
}, []);
|
||||
|
||||
// We want to run the logic above once when the component mounts.
|
||||
useEffect(() => {
|
||||
onPointerUpdate();
|
||||
}, [onPointerUpdate]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onPointerMove: onPointerUpdate,
|
||||
onPointerDown: onPointerUpdate,
|
||||
onMouseLeave,
|
||||
"data-relaxed": isRelaxed || undefined,
|
||||
}),
|
||||
[isRelaxed, onMouseLeave, onPointerUpdate],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fetchDataClient } from "@/lib/client/index";
|
||||
|
||||
export interface RandomThemesOptions {
|
||||
themeType?: string;
|
||||
mediaFormat?: string;
|
||||
animeYearMin?: number;
|
||||
animeYearMax?: number;
|
||||
}
|
||||
@@ -22,6 +23,9 @@ export async function fetchRandomThemes(options?: RandomThemesOptions) {
|
||||
if (options?.themeType) {
|
||||
args.filters.push({ key: "type", value: options.themeType });
|
||||
}
|
||||
if (options?.mediaFormat) {
|
||||
args.filters.push({ key: "anime][media_format", value: options.mediaFormat });
|
||||
}
|
||||
if (options?.animeYearMin) {
|
||||
args.filters.push({ key: "anime][year-gte", value: String(options.animeYearMin) });
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ export const searchResolvers: Resolvers = {
|
||||
);
|
||||
searchParams.append("include[artist]", "images,songs");
|
||||
searchParams.append("include[playlist]", "user");
|
||||
searchParams.append("fields[anime]", "id,name,slug,year,season");
|
||||
searchParams.append("fields[anime]", "id,name,slug,year,season,media_format");
|
||||
searchParams.append("fields[animetheme]", "id,type,sequence");
|
||||
searchParams.append("fields[group]", "id,name,slug");
|
||||
searchParams.append("fields[animethemeentry]", "id,version,episodes,spoiler,nsfw");
|
||||
|
||||
@@ -97,6 +97,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
const [isGlobalAutoPlay, setGlobalAutoPlay] = useLocalStorageState("auto-play", { defaultValue: false });
|
||||
const [isLocalAutoPlay, setLocalAutoPlay] = useState(true);
|
||||
const [isWatchListUsingLocalAutoPlay, setIsWatchListUsingLocalAutoPlay] = useState(false);
|
||||
const [isRepeat, setRepeat] = useState(false);
|
||||
|
||||
const currentVideoSlug = getVideoSlugByProps(pageProps);
|
||||
const [previousVideoSlug, setPreviousVideoSlug] = useState<string | null>(() => getVideoSlugByProps(pageProps));
|
||||
@@ -223,6 +224,8 @@ export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
isLocalAutoPlay,
|
||||
setLocalAutoPlay,
|
||||
isWatchListUsingLocalAutoPlay,
|
||||
isRepeat,
|
||||
setRepeat,
|
||||
},
|
||||
}),
|
||||
stackContext(QueryClientProvider, { client: queryClient }),
|
||||
|
||||
@@ -68,6 +68,8 @@ export default function VideoPage({
|
||||
isLocalAutoPlay,
|
||||
setLocalAutoPlay,
|
||||
isWatchListUsingLocalAutoPlay,
|
||||
isRepeat,
|
||||
setRepeat,
|
||||
} = useContext(PlayerContext);
|
||||
const [selectedTab, setSelectedTab] = useState<"watch-list" | "info" | "related">(() => {
|
||||
return watchList.length > 1 ? "watch-list" : "info";
|
||||
@@ -155,18 +157,19 @@ export default function VideoPage({
|
||||
</HorizontalScroll>
|
||||
{selectedTab === "watch-list" ? (
|
||||
<>
|
||||
<Row style={{ "--justify-content": "space-between" }}>
|
||||
{isWatchListUsingLocalAutoPlay ? (
|
||||
<>
|
||||
<Text color="text-muted">Auto-play:</Text>
|
||||
<Row style={{ "--gap": "16px", "--justify-content": "space-between" }}>
|
||||
<Row style={{ "--gap": "16px" }}>
|
||||
<Text color="text-muted">Auto-play:</Text>
|
||||
{isWatchListUsingLocalAutoPlay ? (
|
||||
<Switch isChecked={isLocalAutoPlay} onCheckedChange={setLocalAutoPlay} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text color="text-muted">Auto-play related themes:</Text>
|
||||
) : (
|
||||
<Switch isChecked={isGlobalAutoPlay} onCheckedChange={setGlobalAutoPlay} />
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</Row>
|
||||
<Row style={{ "--gap": "16px" }}>
|
||||
<Text color="text-muted">Repeat:</Text>
|
||||
<Switch isChecked={isRepeat} onCheckedChange={setRepeat} />
|
||||
</Row>
|
||||
</Row>
|
||||
<StyledScrollArea>
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
|
||||
Reference in New Issue
Block a user