mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 17:44:35 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7827a8acd2 | |||
| 10d4144313 | |||
| a3df88e856 | |||
| 65234d474c |
@@ -42,6 +42,8 @@ const config: CodegenConfig = {
|
||||
Performance: "@/lib/common/animethemes/types#ApiPerformance",
|
||||
Playlist: "@/lib/common/animethemes/types#ApiPlaylist",
|
||||
PlaylistTrack: "@/lib/common/animethemes/types#ApiPlaylistTrack",
|
||||
ExternalProfile: "@/lib/common/animethemes/types#ApiExternalProfile",
|
||||
ExternalProfileEntry: "@/lib/common/animethemes/types#ApiExternalProfileEntry",
|
||||
Season: "@/lib/common/animethemes/types#ApiSeason",
|
||||
Series: "@/lib/common/animethemes/types#ApiSeries",
|
||||
Song: "@/lib/common/animethemes/types#ApiSong",
|
||||
|
||||
@@ -24,6 +24,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
staticPageGenerationTimeout: 3600,
|
||||
experimental: {
|
||||
turbo: {},
|
||||
// We don't want to multi-thread page building
|
||||
// to make use of caching between page builds.
|
||||
workerThreads: false,
|
||||
|
||||
Generated
+495
-629
File diff suppressed because it is too large
Load Diff
+9
-10
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"predev": "npm run compile-config",
|
||||
"prebuild": "npm run compile-config",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@graphql-tools/merge": "^9.0.16",
|
||||
"@graphql-tools/schema": "^10.0.15",
|
||||
"@graphql-tools/utils": "^10.7.1",
|
||||
"@next/bundle-analyzer": "15.3.0",
|
||||
"@next/bundle-analyzer": "^15.1.3",
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
@@ -44,12 +44,12 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.3.0",
|
||||
"next": "^15.1.4",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"p-limit": "^3.1.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"rehype-pretty-code": "^0.14.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
@@ -69,11 +69,11 @@
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "19.1.1",
|
||||
"@types/react": "^19.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
||||
"esbuild": "^0.27.1",
|
||||
"esbuild": "^0.24.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"eslint-config-next": "^15.1.3",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"husky": "^9.0.11",
|
||||
@@ -86,7 +86,6 @@
|
||||
"ahooks": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
},
|
||||
"@types/react": "19.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" width="135.95" height="102.56" viewBox="0 0 135.95 102.56"><path d="M92.99,76.36V6.24c0-4.02-2.22-6.24-6.24-6.24h-13.72c-4.02,0-6.24,2.22-6.24,6.24v33.3c0,.94,9.04,5.29,9.27,6.21,6.89,26.9,1.5,48.43-5.03,49.44,10.67.53,11.85,5.66,3.9,2.15,1.22-14.35,5.96-14.33,19.6-.53.12.12,2.8,5.74,2.96,5.74h32.22c4.02,0,6.24-2.22,6.24-6.24v-13.72c0-4.02-2.22-6.24-6.24-6.24h-36.73Z" style="fill:#02a9ff; fill-rule:evenodd;"/><path d="M36.03,0L0,102.56h28l6.1-17.74h30.49l5.96,17.74h27.86L62.5,0h-26.47ZM40.47,62.09l8.73-28.41,9.56,28.41h-18.29Z" style="fill:#fefefe; fill-rule:evenodd;"/></svg>
|
||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" width="194.72" height="79.18" viewBox="0 0 194.72 79.18"><g id="b"><path id="c" d="M0,0v68.71h17.76V26.8l15.47,19.77,16.68-19.77v41.91h17.76V0h-17.76l-16.68,21.41L17.76,0H0Z" style="fill:#fff;"/><path id="d" d="M151.86,0v68.71h39.08l3.78-14.66h-25.1V0h-17.76Z" style="fill:#fff;"/><path id="e" d="M119.01,0c-21.64,0-35.07,10.21-39.37,25.39-4.2,14.82.34,34.37,10.29,53.79l14.86-10.47s-7.06-9.22-8.39-23.04h21.98v23.04h19.73V17.03h-19.73v14.97h-18.22c1.72-11.2,8.29-17.31,15.47-17.31h25.82l-5.12-14.69h-17.31Z" style="fill:#fff;"/></g></svg>
|
||||
|
After Width: | Height: | Size: 624 B |
@@ -1,4 +1,4 @@
|
||||
import { Fragment, type MouseEvent } from "react";
|
||||
import { Fragment, type MouseEvent, type ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -60,7 +60,9 @@ const StyledThemeGroupContainer = styled.div`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
type AnimeSummaryCardProps =
|
||||
type AnimeSummaryCardProps = {
|
||||
children?: ReactNode;
|
||||
} & (
|
||||
| {
|
||||
anime: AnimeSummaryCardAnimeFragment;
|
||||
expandable?: false;
|
||||
@@ -68,9 +70,10 @@ type AnimeSummaryCardProps =
|
||||
| {
|
||||
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment;
|
||||
expandable: true;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
|
||||
export function AnimeSummaryCard({ anime, expandable = false, children, ...props }: AnimeSummaryCardProps) {
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const { smallCover } = extractImages(anime);
|
||||
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
@@ -135,6 +138,7 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
|
||||
)}
|
||||
</StyledThemeContainerInline>
|
||||
)}
|
||||
{children}
|
||||
</SummaryCard>
|
||||
{expandable ? (
|
||||
<Collapse collapse={!isExpanded}>
|
||||
|
||||
@@ -19,7 +19,7 @@ const StyledOverlayButtons = styled.div`
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover &, &:has([data-state="open"]) {
|
||||
${StyledWrapper}:hover & {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
|
||||
@@ -40,7 +40,7 @@ const StyledOverlayButtons = styled.div`
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover &, &:has([data-state="open"]) {
|
||||
${StyledWrapper}:hover & {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
@@ -105,9 +105,9 @@ export function ThemeSummaryCard({
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const href = `/anime/${anime.slug}/${videoSlug}`;
|
||||
|
||||
function handleToggleExpand(event: MouseEvent, filterLinks = false) {
|
||||
if (filterLinks && isLink(event.target)) {
|
||||
return;
|
||||
function handleToggleExpand(event: MouseEvent) {
|
||||
if (isLink(event.target)) {
|
||||
event.stopPropagation();
|
||||
} else if (expandable && !isMobile) {
|
||||
toggleExpanded();
|
||||
}
|
||||
@@ -124,11 +124,11 @@ export function ThemeSummaryCard({
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<SummaryCard {...props}>
|
||||
<SummaryCard onClick={handleToggleExpand} {...props}>
|
||||
<Link href={href} onClick={() => onPlay?.()}>
|
||||
<SummaryCard.Cover src={smallCover} />
|
||||
</Link>
|
||||
<SummaryCard.Body onClick={(event) => handleToggleExpand(event, true)}>
|
||||
<SummaryCard.Body>
|
||||
<SummaryCard.Title>
|
||||
<SongTitle song={theme.song} href={href} onClick={() => onPlay?.()} />
|
||||
<Performances song={theme.song} artist={artist} />
|
||||
@@ -143,7 +143,7 @@ export function ThemeSummaryCard({
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{children}
|
||||
<StyledOverlayButtons>
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
|
||||
<ThemeMenu theme={theme} />
|
||||
{expandable && (
|
||||
<StyledExpandButton
|
||||
|
||||
@@ -7,7 +7,6 @@ import gql from "graphql-tag";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard2";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { VideoMenu } from "@/components/menu/VideoMenu";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import { Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
@@ -27,7 +26,7 @@ const StyledOverlayButtons = styled.div`
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover &, &:has([data-state="open"]) {
|
||||
${StyledWrapper}:hover & {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
@@ -109,8 +108,8 @@ export function VideoSummaryCard({
|
||||
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{menu !== null ? (
|
||||
<StyledOverlayButtons>{menu ?? <VideoMenu entry={entry} video={video} />}</StyledOverlayButtons>
|
||||
{menu ? (
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>{menu}</StyledOverlayButtons>
|
||||
) : null}
|
||||
{append}
|
||||
</SummaryCard>
|
||||
@@ -120,13 +119,11 @@ export function VideoSummaryCard({
|
||||
|
||||
export const VideoSummaryCardFragmentVideo = gql`
|
||||
${createVideoSlug.fragments.video}
|
||||
${VideoMenu.fragments.video}
|
||||
|
||||
fragment VideoSummaryCardVideo on Video {
|
||||
id
|
||||
basename
|
||||
...createVideoSlugVideo
|
||||
...VideoMenuVideo
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
@@ -138,11 +135,9 @@ export const VideoSummaryCardFragmentEntry = gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
${VideoMenu.fragments.entry}
|
||||
|
||||
fragment VideoSummaryCardEntry on Entry {
|
||||
...createVideoSlugEntry
|
||||
...VideoMenuEntry
|
||||
id
|
||||
theme {
|
||||
...createVideoSlugTheme
|
||||
|
||||
@@ -143,7 +143,7 @@ function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormPr
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<VideoSummaryCard video={video} entry={entry} menu={null} />
|
||||
<VideoSummaryCard video={video} entry={entry} />
|
||||
<Row style={{ "--justify-content": "center" }}>
|
||||
<Icon icon={faArrowDown} color="text-disabled" />
|
||||
</Row>
|
||||
|
||||
@@ -149,7 +149,7 @@ function PlaylistTrackRemoveForm({
|
||||
</Text>
|
||||
?
|
||||
</Text>
|
||||
<VideoSummaryCard video={video} entry={entry} menu={null} />
|
||||
<VideoSummaryCard video={video} entry={entry} />
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button variant="silent" onClick={onCancel}>
|
||||
Close
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { Switcher, SwitcherOption } from "@/components/switcher/Switcher";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { ExternalProfileSite } from "@/generated/graphql";
|
||||
import { CLIENT_API_URL } from "@/utils/config";
|
||||
|
||||
interface ExternalProfileAddProps {
|
||||
trigger: ReactNode;
|
||||
preselectedSite?: ExternalProfileSite;
|
||||
}
|
||||
|
||||
export function ExternalProfileAddDialog({ trigger, preselectedSite }: ExternalProfileAddProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent title="Add External Profile">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<ExternalProfileAddForm preselectedSite={preselectedSite} onCancel={() => setOpen(false)} />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledForm = styled.form`
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
`;
|
||||
const StyledSwitcher = styled(Switcher)`
|
||||
width: 100%;
|
||||
|
||||
& > * {
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ExternalProfileAddFormProps {
|
||||
preselectedSite?: ExternalProfileSite;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function ExternalProfileAddForm({ preselectedSite, onCancel }: ExternalProfileAddFormProps) {
|
||||
const [site, setSite] = useState<ExternalProfileSite | "">(preselectedSite ?? "");
|
||||
|
||||
const authUrl = `${CLIENT_API_URL}/externaltoken/auth`;
|
||||
|
||||
return (
|
||||
<StyledForm action={authUrl}>
|
||||
<input type="hidden" name="site" value={site} />
|
||||
<SearchFilterGroup>
|
||||
<SearchFilter>
|
||||
<Text variant="h2">External Site</Text>
|
||||
<StyledSwitcher
|
||||
selectedItem={site}
|
||||
onChange={(newSite) => setSite(newSite as ExternalProfileSite | "")}
|
||||
>
|
||||
<SwitcherOption value="AniList">AniList</SwitcherOption>
|
||||
<SwitcherOption value="MyAnimeList">MyAnimeList</SwitcherOption>
|
||||
</StyledSwitcher>
|
||||
</SearchFilter>
|
||||
</SearchFilterGroup>
|
||||
<Text as="p">
|
||||
To add an external profile you need to grant AnimeThemes access to your profile. We will use this access
|
||||
only to get the anime from your list.
|
||||
</Text>
|
||||
<Text as="p">
|
||||
By default your external profile will be private, so no one can see your list except yourself.
|
||||
</Text>
|
||||
<Text as="p">
|
||||
Clicking on{" "}
|
||||
<Text as="button" type="submit" color="text-primary" link>
|
||||
Authorize
|
||||
</Text>{" "}
|
||||
will redirect you to {site}, where you can grant us access.
|
||||
</Text>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary">
|
||||
Authorize
|
||||
</Button>
|
||||
</Row>
|
||||
</StyledForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { ExternalProfileAddDialog } from "@/components/external-profile/ExternalProfileAddDialog";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { withHover } from "@/styles/mixins";
|
||||
|
||||
const StyledGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto 1fr;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
& > * {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
`;
|
||||
const StyledButton = styled(Button)`
|
||||
grid-column: 1 / -1;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
gap: inherit;
|
||||
|
||||
background-color: var(--background-color);
|
||||
color: #ffffff;
|
||||
|
||||
transition: background-color 200ms;
|
||||
|
||||
${withHover`
|
||||
background-color: color-mix(in oklab, var(--background-color), #000 10%);
|
||||
`}
|
||||
`;
|
||||
const StyledSubgrid = styled.div`
|
||||
grid-column: 2 / -2;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
`;
|
||||
const StyledLogo = styled.img`
|
||||
justify-self: center;
|
||||
align-self: center;
|
||||
height: 1em;
|
||||
`;
|
||||
|
||||
export default function ExternalProfileList() {
|
||||
return (
|
||||
<StyledGrid>
|
||||
<ExternalProfileAddDialog
|
||||
preselectedSite="AniList"
|
||||
trigger={
|
||||
<StyledButton style={{ "--background-color": "#152232" }}>
|
||||
<StyledSubgrid>
|
||||
<StyledLogo src="/img/anilist.svg" alt="AniList" />
|
||||
<Text>Add AniList</Text>
|
||||
</StyledSubgrid>
|
||||
</StyledButton>
|
||||
}
|
||||
/>
|
||||
<ExternalProfileAddDialog
|
||||
preselectedSite="MyAnimeList"
|
||||
trigger={
|
||||
<StyledButton style={{ "--background-color": "#2e51a2" }}>
|
||||
<StyledSubgrid>
|
||||
<StyledLogo src="/img/myanimelist.svg" alt="MyAnimeList" />
|
||||
<Text>Add MyAnimeList</Text>
|
||||
</StyledSubgrid>
|
||||
</StyledButton>
|
||||
}
|
||||
/>
|
||||
</StyledGrid>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,8 @@ import Link from "next/link";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import {
|
||||
VideoSummaryCard,
|
||||
VideoSummaryCardFragmentEntry,
|
||||
VideoSummaryCardFragmentVideo,
|
||||
} from "@/components/card/VideoSummaryCard";
|
||||
import type { FeaturedThemeEntryFragment, FeaturedThemeVideoFragment } from "@/generated/graphql";
|
||||
import { ThemeSummaryCard } from "@/components/card/ThemeSummaryCard";
|
||||
import type { FeaturedThemeThemeFragment } from "@/generated/graphql";
|
||||
import useCompatability from "@/hooks/useCompatability";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import { fetchRandomGrill } from "@/lib/client/randomGrill";
|
||||
@@ -124,14 +120,13 @@ const StyledGrill = styled.img`
|
||||
const Box = styled.div``;
|
||||
|
||||
interface FeaturedThemeProps {
|
||||
entry: FeaturedThemeEntryFragment;
|
||||
video: FeaturedThemeVideoFragment;
|
||||
theme: FeaturedThemeThemeFragment;
|
||||
hasGrill?: boolean;
|
||||
card?: ReactNode;
|
||||
onPlay?(): void;
|
||||
}
|
||||
|
||||
export function FeaturedTheme({ entry, video, hasGrill = true, card, onPlay }: FeaturedThemeProps) {
|
||||
export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: FeaturedThemeProps) {
|
||||
const [grill, setGrill] = useState<string | null>(null);
|
||||
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
|
||||
|
||||
@@ -145,14 +140,14 @@ export function FeaturedTheme({ entry, video, hasGrill = true, card, onPlay }: F
|
||||
|
||||
const featuredThemeSummaryCard =
|
||||
featuredThemePreview !== FeaturedThemePreview.DISABLED ? (
|
||||
<StyledCenter>{card ?? <VideoSummaryCard entry={entry} video={video} />}</StyledCenter>
|
||||
<StyledCenter>{card ?? <ThemeSummaryCard theme={theme} />}</StyledCenter>
|
||||
) : (
|
||||
(card ?? <VideoSummaryCard entry={entry} video={video} />)
|
||||
card ?? <ThemeSummaryCard theme={theme} />
|
||||
);
|
||||
|
||||
return (
|
||||
<FeaturedThemeWrapper>
|
||||
<FeaturedThemeBackground entry={entry} video={video} onPlay={onPlay} />
|
||||
<FeaturedThemeBackground theme={theme} onPlay={onPlay} />
|
||||
{featuredThemePreview !== FeaturedThemePreview.DISABLED && grill && (
|
||||
<StyledGrillContainer>
|
||||
<StyledGrill src={grill} />
|
||||
@@ -163,15 +158,26 @@ export function FeaturedTheme({ entry, video, hasGrill = true, card, onPlay }: F
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedThemeBackground({ entry, video, onPlay }: FeaturedThemeProps) {
|
||||
function FeaturedThemeBackground({ theme, onPlay }: FeaturedThemeProps) {
|
||||
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
|
||||
const { canPlayVideo } = useCompatability();
|
||||
const [fallbackToCover, setFallbackToCover] = useState(false);
|
||||
const { smallCover: featuredCover } = extractImages(entry.theme.anime);
|
||||
const { smallCover: featuredCover } = extractImages(theme.anime);
|
||||
|
||||
const videoSlug = createVideoSlug(entry.theme, entry, video);
|
||||
if (!theme.anime || !theme.entries.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const href = `/anime/${entry.theme.anime.slug}/${videoSlug}`;
|
||||
const entry = theme.entries[0];
|
||||
|
||||
if (!entry.videos.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const video = entry.videos[0];
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
|
||||
const href = `/anime/${theme.anime.slug}/${videoSlug}`;
|
||||
|
||||
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
|
||||
return (
|
||||
@@ -193,24 +199,20 @@ function FeaturedThemeBackground({ entry, video, onPlay }: FeaturedThemeProps) {
|
||||
}
|
||||
|
||||
FeaturedTheme.fragments = {
|
||||
entry: gql`
|
||||
${VideoSummaryCardFragmentEntry}
|
||||
theme: gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment FeaturedThemeEntry on Entry {
|
||||
...VideoSummaryCardEntry
|
||||
theme {
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
fragment FeaturedThemeTheme on Theme {
|
||||
...ThemeSummaryCardTheme
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
entries {
|
||||
videos {
|
||||
basename
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
video: gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
|
||||
fragment FeaturedThemeVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
+13
-13
@@ -9,37 +9,37 @@ import {
|
||||
VideoSummaryCardFragmentVideo,
|
||||
} from "@/components/card/VideoSummaryCard";
|
||||
import { Skeleton } from "@/components/skeleton/Skeleton";
|
||||
import type { HomePageMostPopularQuery } from "@/generated/graphql";
|
||||
import type { HomePageMostViewedQuery } from "@/generated/graphql";
|
||||
import { fetchDataClient } from "@/lib/client/index";
|
||||
|
||||
export function MostPopularEntries() {
|
||||
const { data: mostPopular } = useQuery<HomePageMostPopularQuery["entryAll"] | Array<null>>({
|
||||
export function MostViewedVideos() {
|
||||
const { data: mostViewed } = useQuery<HomePageMostViewedQuery["videoAll"] | Array<null>>({
|
||||
queryKey: ["HomePageTrending"],
|
||||
queryFn: async () => {
|
||||
const { data } = await fetchDataClient<HomePageMostPopularQuery>(gql`
|
||||
${VideoSummaryCardFragmentEntry}
|
||||
const { data } = await fetchDataClient<HomePageMostViewedQuery>(gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
${VideoSummaryCardFragmentEntry}
|
||||
|
||||
query HomePageMostPopular {
|
||||
entryAll(orderBy: "tracks_count", orderDesc: true, limit: 10) {
|
||||
...VideoSummaryCardEntry
|
||||
videos {
|
||||
...VideoSummaryCardVideo
|
||||
query HomePageMostViewed {
|
||||
videoAll(orderBy: "views_count", orderDesc: true, limit: 10) {
|
||||
...VideoSummaryCardVideo
|
||||
entries {
|
||||
...VideoSummaryCardEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
return data.entryAll;
|
||||
return data.videoAll;
|
||||
},
|
||||
placeholderData: range(10).map(() => null),
|
||||
});
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{mostPopular?.map((entry, index) => (
|
||||
{mostViewed?.map((video, index) => (
|
||||
<Skeleton key={index} variant="summary-card" delay={index * 100}>
|
||||
{entry ? <VideoSummaryCard video={entry.videos[0]} entry={entry} /> : null}
|
||||
{video ? <VideoSummaryCard video={video} entry={video.entries[0]} /> : null}
|
||||
</Skeleton>
|
||||
))}
|
||||
</Column>
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { faArrowTurnDown, faArrowTurnUp, faEllipsisVertical, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import type { VideoMenuEntryFragment, VideoMenuVideoFragment } from "@/generated/graphql";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
interface VideoMenuProps {
|
||||
entry: VideoMenuEntryFragment;
|
||||
video: VideoMenuVideoFragment;
|
||||
}
|
||||
|
||||
export function VideoMenu({ entry, video }: VideoMenuProps) {
|
||||
const { watchList, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
|
||||
|
||||
return (
|
||||
<Menu modal={false}>
|
||||
<MenuTrigger asChild>
|
||||
<Button variant="silent" isCircle>
|
||||
<Icon icon={faEllipsisVertical} />
|
||||
</Button>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<PlaylistTrackAddDialog
|
||||
video={video}
|
||||
entry={entry}
|
||||
trigger={
|
||||
<MenuItem onSelect={(event) => event.preventDefault()}>
|
||||
<Icon icon={faPlus} />
|
||||
<Text>Add to Playlist</Text>
|
||||
</MenuItem>
|
||||
}
|
||||
/>
|
||||
{watchList.length ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={() => addWatchListItem(video, entry)}>
|
||||
<Icon icon={faArrowTurnDown} color="text-disabled" />
|
||||
<Text>Add to Watch List</Text>
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => addWatchListItemNext(video, entry)}>
|
||||
<Icon icon={faArrowTurnUp} color="text-disabled" />
|
||||
<Text>Play Next</Text>
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
VideoMenu.fragments = {
|
||||
entry: gql`
|
||||
${SongTitleWithArtists.fragments.song}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
|
||||
fragment VideoMenuEntry on Entry {
|
||||
...createVideoSlugEntry
|
||||
id
|
||||
theme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
video: gql`
|
||||
${createVideoSlug.fragments.video}
|
||||
|
||||
fragment VideoMenuVideo on Video {
|
||||
...createVideoSlugVideo
|
||||
id
|
||||
basename
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -17,7 +17,6 @@ const StyledPlayerProgress = styled.div`
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBackground = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
|
||||
@@ -29,7 +28,7 @@ const StyledPlayerProgressBackground = styled.div`
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBar = styled.div`
|
||||
position: absolute;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
background-color: ${theme.colors["text-primary"]};
|
||||
@@ -72,27 +71,14 @@ const StyledPlayerProgressBarHover = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBuffered = styled.div`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
|
||||
background-color: ${theme.colors["text-muted"]};
|
||||
opacity: 0.2;
|
||||
|
||||
${StyledPlayerProgress}:hover & {
|
||||
height: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function ProgressBar() {
|
||||
const context = useContext(VideoPlayerContext);
|
||||
|
||||
|
||||
if (!context) {
|
||||
throw new Error("ProgressBar needs to be inside VideoPlayer!");
|
||||
}
|
||||
|
||||
const { playerRef, progressRef, bufferedRef } = context;
|
||||
const { playerRef, progressRef } = context;
|
||||
|
||||
const progressHoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -160,7 +146,6 @@ export function ProgressBar() {
|
||||
}}
|
||||
>
|
||||
<StyledPlayerProgressBackground>
|
||||
<StyledPlayerProgressBuffered ref={bufferedRef} />
|
||||
<StyledPlayerProgressBar ref={progressRef} />
|
||||
</StyledPlayerProgressBackground>
|
||||
<StyledPlayerProgressBarHover ref={progressHoverRef} />
|
||||
|
||||
@@ -117,9 +117,8 @@ export const StyledAudioBackground = styled.div`
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
`;
|
||||
|
||||
export const StyledAudio = styled.audio`
|
||||
@@ -127,32 +126,17 @@ export const StyledAudio = styled.audio`
|
||||
`;
|
||||
|
||||
export const StyledAudioCover = styled.img`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
max-width: 80%;
|
||||
height: 80%;
|
||||
border-radius: 8px;
|
||||
|
||||
object-fit: cover;
|
||||
box-shadow: ${theme.shadows.high};
|
||||
outline: none;
|
||||
object-fit: contain;
|
||||
|
||||
[data-background] & {
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledAudioCoverBackground = styled.img`
|
||||
width: calc(100% + 32px);
|
||||
height: calc(100% + 32px);
|
||||
max-width: none;
|
||||
margin: -16px;
|
||||
|
||||
object-fit: cover;
|
||||
filter: blur(8px);
|
||||
`;
|
||||
|
||||
export const StyledAside = styled.aside`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
StyledAudio,
|
||||
StyledAudioBackground,
|
||||
StyledAudioCover,
|
||||
StyledAudioCoverBackground,
|
||||
StyledPlaybackArea,
|
||||
StyledPlayer,
|
||||
StyledPlayerContent,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
StyledVideoBackground,
|
||||
} from "@/components/video-player/VideoPlayer.style";
|
||||
import { VideoPlayerBar } from "@/components/video-player/VideoPlayerBar";
|
||||
import FullscreenContext from "@/context/fullscreenContext";
|
||||
import PlayerContext, { type WatchListItem } from "@/context/playerContext";
|
||||
import type { VideoSummaryCardEntryFragment, VideoSummaryCardVideoFragment } from "@/generated/graphql";
|
||||
import useMouseRelax from "@/hooks/useMouseRelax";
|
||||
@@ -24,7 +22,7 @@ import useWatchHistory from "@/hooks/useWatchHistory";
|
||||
import { AUDIO_URL, VIDEO_URL } from "@/utils/config";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
import { AudioMode, GlobalVolume, Muted } from "@/utils/settings";
|
||||
import { AudioMode, GlobalVolume } from "@/utils/settings";
|
||||
|
||||
interface VideoPlayerContextValue {
|
||||
video: VideoSummaryCardVideoFragment;
|
||||
@@ -33,7 +31,6 @@ interface VideoPlayerContextValue {
|
||||
videoPagePath: string;
|
||||
playerRef: RefObject<HTMLVideoElement | HTMLAudioElement | null>;
|
||||
progressRef: RefObject<HTMLDivElement | null>;
|
||||
bufferedRef: RefObject<HTMLDivElement | null>;
|
||||
previousVideoPath: string | null;
|
||||
playPreviousTrack(navigate: boolean): void;
|
||||
nextVideoPath: string | null;
|
||||
@@ -43,7 +40,6 @@ interface VideoPlayerContextValue {
|
||||
videoUrl: string;
|
||||
audioUrl: string;
|
||||
updateAudioMode(audioMode: string): void;
|
||||
togglePip(): void;
|
||||
}
|
||||
|
||||
export const VideoPlayerContext = createContext<VideoPlayerContextValue | null>(null);
|
||||
@@ -72,9 +68,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const bufferedRef = useRef<HTMLDivElement>(null);
|
||||
const currentTimeBeforeModeSwitch = useRef<number | null>(null);
|
||||
const fpsRef = useRef<number>(24);
|
||||
|
||||
const {
|
||||
watchList,
|
||||
@@ -85,10 +79,8 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
isWatchListUsingLocalAutoPlay,
|
||||
isRepeat,
|
||||
} = useContext(PlayerContext);
|
||||
const { toggleFullscreen } = useContext(FullscreenContext);
|
||||
const router = useRouter();
|
||||
const [globalVolume, setGlobalVolume] = useSetting(GlobalVolume);
|
||||
const [muted, setMuted] = useSetting(Muted);
|
||||
const { smallCover, largeCover } = extractImages(anime);
|
||||
const [audioMode, setAudioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
const { addToHistory } = useWatchHistory();
|
||||
@@ -164,10 +156,9 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
|
||||
useEffect(() => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.muted = muted;
|
||||
playerRef.current.volume = globalVolume;
|
||||
}
|
||||
}, [globalVolume, muted]);
|
||||
}, [globalVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
addToHistory({
|
||||
@@ -185,10 +176,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
progressRef.current.style.width = "0%";
|
||||
}
|
||||
|
||||
if (bufferedRef.current) {
|
||||
bufferedRef.current.style.width = "0%";
|
||||
}
|
||||
|
||||
// We don't want to re-add the theme when the history changes, because it can cause
|
||||
// various issues when multiple tabs are open.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -214,47 +201,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
}
|
||||
}, [anime, theme, smallCover, playNextTrack, playPreviousTrack]);
|
||||
|
||||
// Calculate frame rate
|
||||
// Source - https://stackoverflow.com/questions/72997777/how-do-i-get-the-frame-rate-of-an-html-video-with-javascript
|
||||
useEffect(() => {
|
||||
const videoElement = document.querySelector("video");
|
||||
if (!videoElement) return;
|
||||
|
||||
let lastMediaTime = 0,
|
||||
lastFrameNum = 0,
|
||||
frameNotSeeked = true;
|
||||
const fpsRounder: number[] = [];
|
||||
|
||||
const ticker: VideoFrameRequestCallback = (_, metadata) => {
|
||||
const diff =
|
||||
Math.abs(metadata.mediaTime - lastMediaTime) / Math.abs(metadata.presentedFrames - lastFrameNum);
|
||||
if (
|
||||
diff &&
|
||||
diff < 1 &&
|
||||
frameNotSeeked &&
|
||||
fpsRounder.length < 50 &&
|
||||
videoElement.playbackRate === 1 &&
|
||||
document.hasFocus()
|
||||
) {
|
||||
fpsRounder.push(diff);
|
||||
fpsRef.current = Math.round(fpsRounder.length / fpsRounder.reduce((a, b) => a + b));
|
||||
}
|
||||
frameNotSeeked = true;
|
||||
lastMediaTime = metadata.mediaTime;
|
||||
lastFrameNum = metadata.presentedFrames;
|
||||
videoElement.requestVideoFrameCallback(ticker);
|
||||
};
|
||||
|
||||
const handleSeeked = () => {
|
||||
fpsRounder.pop();
|
||||
frameNotSeeked = false;
|
||||
};
|
||||
|
||||
videoElement.requestVideoFrameCallback(ticker);
|
||||
videoElement.addEventListener("seeked", handleSeeked);
|
||||
return () => videoElement.removeEventListener("seeked", handleSeeked);
|
||||
}, []);
|
||||
|
||||
function onPlayerMount(player: HTMLVideoElement) {
|
||||
playerRef.current = player;
|
||||
if (playerRef.current) {
|
||||
@@ -276,174 +222,26 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
function togglePlay() {
|
||||
if (isPlaying) {
|
||||
playerRef.current?.pause();
|
||||
} else {
|
||||
playerRef.current?.play();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
function togglePip() {
|
||||
const videoElement = document.querySelector("video");
|
||||
if (!videoElement) return;
|
||||
if (document.pictureInPictureElement) {
|
||||
void document.exitPictureInPicture();
|
||||
} else {
|
||||
void videoElement.requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress(event: SyntheticEvent<HTMLVideoElement | HTMLAudioElement>) {
|
||||
const duration = event.currentTarget.duration;
|
||||
|
||||
if (progressRef.current) {
|
||||
// Update the progress bar using a ref to prevent re-rendering.
|
||||
const progress = (event.currentTarget.currentTime / duration) * 100;
|
||||
const progress = (event.currentTarget.currentTime / event.currentTarget.duration) * 100;
|
||||
progressRef.current.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
if (bufferedRef.current) {
|
||||
const buffered = event.currentTarget.buffered;
|
||||
if (buffered.length > 0) {
|
||||
const bufferedEnd = buffered.end(buffered.length - 1);
|
||||
bufferedRef.current.style.width = `${(bufferedEnd / duration) * 100}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateAudioMode = useCallback(
|
||||
(audioMode: string) => {
|
||||
currentTimeBeforeModeSwitch.current = playerRef.current?.currentTime ?? null;
|
||||
setAudioMode(audioMode);
|
||||
},
|
||||
[setAudioMode],
|
||||
);
|
||||
|
||||
// Handle keyboard inputs
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key.toLocaleLowerCase()) {
|
||||
case " ": // Play/Pause
|
||||
case "k":
|
||||
event.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "arrowright": // Seek forward
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime += 5;
|
||||
}
|
||||
break;
|
||||
case "l": // Seek forward large
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime += 10;
|
||||
}
|
||||
break;
|
||||
case "arrowleft": // Seek backward
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime -= 5;
|
||||
}
|
||||
break;
|
||||
case "j": // Seek backward large
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime -= 10;
|
||||
}
|
||||
break;
|
||||
case "n": // Next track
|
||||
event.preventDefault();
|
||||
playNextTrack(true);
|
||||
break;
|
||||
case "b": // Previous track
|
||||
event.preventDefault();
|
||||
playPreviousTrack(true);
|
||||
break;
|
||||
case "m": // Mute
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
setMuted(!muted);
|
||||
}
|
||||
break;
|
||||
case "arrowup": // Volume up
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
setGlobalVolume(Math.min(globalVolume + 0.1, 1));
|
||||
setMuted(false);
|
||||
}
|
||||
break;
|
||||
case "arrowdown": // Volume down
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
setGlobalVolume(Math.max(globalVolume - 0.1, 0));
|
||||
setMuted(false);
|
||||
}
|
||||
break;
|
||||
case "d": // Download
|
||||
event.preventDefault();
|
||||
if (audioMode === AudioMode.ENABLED) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `${audioUrl}?download`;
|
||||
link.click();
|
||||
} else {
|
||||
const link = document.createElement("a");
|
||||
link.href = `${videoUrl}?download`;
|
||||
link.click();
|
||||
}
|
||||
break;
|
||||
case "f": // Fullscreen
|
||||
event.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case "a": // Toggle audio mode
|
||||
event.preventDefault();
|
||||
updateAudioMode(audioMode === AudioMode.ENABLED ? AudioMode.DISABLED : AudioMode.ENABLED);
|
||||
break;
|
||||
case "p": // Toggle Picture-in-Picture
|
||||
event.preventDefault();
|
||||
togglePip();
|
||||
break;
|
||||
case ",": // Frame back
|
||||
event.preventDefault();
|
||||
if (playerRef.current && playerRef.current.paused) {
|
||||
playerRef.current.currentTime -= 1 / fpsRef.current;
|
||||
}
|
||||
break;
|
||||
case ".": // Frame forward
|
||||
event.preventDefault();
|
||||
if (playerRef.current && playerRef.current.paused) {
|
||||
playerRef.current.currentTime += 1 / fpsRef.current;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
togglePlay,
|
||||
playNextTrack,
|
||||
playPreviousTrack,
|
||||
audioMode,
|
||||
toggleFullscreen,
|
||||
updateAudioMode,
|
||||
setMuted,
|
||||
muted,
|
||||
setGlobalVolume,
|
||||
globalVolume,
|
||||
audioUrl,
|
||||
videoUrl,
|
||||
],
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
function updateAudioMode(audioMode: string) {
|
||||
currentTimeBeforeModeSwitch.current = playerRef.current?.currentTime ?? null;
|
||||
setAudioMode(audioMode);
|
||||
}
|
||||
|
||||
function getRelativeWatchListItem(offset: 1 | -1) {
|
||||
if (!currentWatchListItem) {
|
||||
@@ -484,7 +282,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
videoPagePath,
|
||||
playerRef,
|
||||
progressRef,
|
||||
bufferedRef,
|
||||
previousVideoPath,
|
||||
playPreviousTrack,
|
||||
nextVideoPath,
|
||||
@@ -494,7 +291,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
videoUrl,
|
||||
audioUrl,
|
||||
updateAudioMode,
|
||||
togglePip,
|
||||
}}
|
||||
>
|
||||
<StyledPlayer
|
||||
@@ -523,15 +319,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
>
|
||||
{audioMode === AudioMode.ENABLED ? (
|
||||
<StyledAudioBackground style={{ aspectRatio }}>
|
||||
<StyledAudioCoverBackground
|
||||
src={largeCover}
|
||||
onPointerDown={onPlayerClick}
|
||||
onLoad={(event) => {
|
||||
setAspectRatio(
|
||||
event.currentTarget.naturalWidth / event.currentTarget.naturalHeight,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<StyledAudioCover
|
||||
src={largeCover}
|
||||
onPointerDown={onPlayerClick}
|
||||
@@ -545,7 +332,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
ref={onPlayerMount}
|
||||
src={audioUrl}
|
||||
autoPlay
|
||||
playsInline
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => {
|
||||
@@ -565,7 +351,6 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
ref={onPlayerMount}
|
||||
src={videoUrl}
|
||||
autoPlay
|
||||
playsInline
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => {
|
||||
|
||||
@@ -2,21 +2,10 @@ import { Fragment, useContext } from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
faCheck,
|
||||
faCompress,
|
||||
faExpand,
|
||||
faGear,
|
||||
faKeyboard,
|
||||
faPlus,
|
||||
faShare,
|
||||
faUpRightFromSquare,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { Dialog } from "@radix-ui/react-dialog";
|
||||
import { faCheck, faCompress, faExpand, faGear, faPlus, faShare } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Menu, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
|
||||
@@ -30,7 +19,6 @@ import FullscreenContext from "@/context/fullscreenContext";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import type { VideoPageProps } from "@/pages/anime/[animeSlug]/[videoSlug]";
|
||||
import theme from "@/theme";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import { AudioMode } from "@/utils/settings";
|
||||
|
||||
@@ -55,38 +43,6 @@ const StyledOverlayButton = styled(IconTextButton)`
|
||||
backdrop-filter: blur(5px);
|
||||
`;
|
||||
|
||||
const StyledKeyList = styled.dl`
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 16px;
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
`;
|
||||
const StyledKey = styled.dt`
|
||||
text-align: right;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
`;
|
||||
const StyledKeyDescription = styled.dd`
|
||||
margin: 0;
|
||||
color: ${theme.colors["text-disabled"]};
|
||||
`;
|
||||
const StyledKeyHint = styled.kbd`
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid ${theme.colors["text-disabled"]};
|
||||
border-radius: 4px;
|
||||
|
||||
font-size: 1.25rem;
|
||||
background-color: ${theme.colors["background"]};
|
||||
`;
|
||||
const StyledKeyHintSpace = styled(StyledKeyHint)`
|
||||
width: 96px;
|
||||
`;
|
||||
|
||||
export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }: VideoPageProps) {
|
||||
const theme = anime.themes[themeIndex];
|
||||
const entry = theme.entries[entryIndex];
|
||||
@@ -94,8 +50,6 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
|
||||
|
||||
const { watchList, setWatchList, currentWatchListItem } = useContext(PlayerContext);
|
||||
const { isFullscreen, toggleFullscreen } = useContext(FullscreenContext);
|
||||
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
const isPIPSupported = typeof document !== "undefined" && document.pictureInPictureEnabled;
|
||||
const context = useContext(VideoPlayerContext);
|
||||
const [audioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
|
||||
@@ -128,80 +82,6 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
|
||||
entry={{ ...entry, theme }}
|
||||
trigger={<StyledOverlayButton icon={faPlus} isCircle title="Add to playlist" />}
|
||||
/>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<StyledOverlayButton icon={faKeyboard} isCircle title="Keyboard shortcuts" />
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Keyboard Shortcuts">
|
||||
<StyledKeyList>
|
||||
<StyledKey>
|
||||
<StyledKeyHintSpace title="Space">_</StyledKeyHintSpace> or{" "}
|
||||
<StyledKeyHint>K</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Play / Pause</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>→</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek forward 5 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>←</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek backward 5 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>L</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek forward 10 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>J</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek backward 10 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>.</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek forward 1 frame</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>,</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek backward 1 frame</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>N</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Next track</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>B</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Previous track</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>M</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Mute / Unmute</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>↑</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Volume up</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>↓</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Volume down</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>D</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Download track</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>F</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Toggle fullscreen</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>A</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Toggle audio mode</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>P</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Toggle picture-in-picture</StyledKeyDescription>
|
||||
</StyledKeyList>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ShareMenu
|
||||
pagePath={context.videoPagePath}
|
||||
videoUrl={context.videoUrl}
|
||||
@@ -282,22 +162,12 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
{isPIPSupported && audioMode === AudioMode.DISABLED && (
|
||||
<StyledOverlayButton
|
||||
icon={faUpRightFromSquare}
|
||||
isCircle
|
||||
onClick={context.togglePip}
|
||||
title="Toggle picture-in-picture"
|
||||
/>
|
||||
)}
|
||||
{!(isIOS && audioMode === AudioMode.ENABLED) && ( // Hide fullscreen button on iOS when audio mode is enabled
|
||||
<StyledOverlayButton
|
||||
icon={isFullscreen ? faCompress : faExpand}
|
||||
isCircle
|
||||
onClick={toggleFullscreen}
|
||||
title="Toggle fullscreen"
|
||||
/>
|
||||
)}
|
||||
<StyledOverlayButton
|
||||
icon={isFullscreen ? faCompress : faExpand}
|
||||
isCircle
|
||||
onClick={toggleFullscreen}
|
||||
title="Toggle fullscreen"
|
||||
/>
|
||||
</Row>
|
||||
</StyledOverlay>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faVolumeHigh, faVolumeLow, faVolumeOff, faVolumeXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faVolumeHigh, faVolumeLow, faVolumeXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { Slider } from "@/components/slider/Slider";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import { GlobalVolume, Muted } from "@/utils/settings";
|
||||
import { GlobalVolume } from "@/utils/settings";
|
||||
|
||||
const StyledRow = styled(Row)`
|
||||
align-self: stretch;
|
||||
@@ -28,25 +28,22 @@ const StyledSlider = styled(Slider)`
|
||||
|
||||
export function VolumeControl(props: ComponentPropsWithoutRef<typeof StyledRow>) {
|
||||
const [volume, setVolume] = useSetting(GlobalVolume);
|
||||
const [muted, setMuted] = useSetting(Muted);
|
||||
|
||||
let icon;
|
||||
if (muted) {
|
||||
icon = faVolumeXmark;
|
||||
} else if (volume > 0.5) {
|
||||
if (volume > 0.5) {
|
||||
icon = faVolumeHigh;
|
||||
} else if (volume > 0) {
|
||||
icon = faVolumeLow;
|
||||
} else {
|
||||
icon = faVolumeOff;
|
||||
icon = faVolumeXmark;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledRow style={{ "--gap": "8px" }} {...props}>
|
||||
<IconTextButton icon={icon} isCircle onClick={() => setMuted(!muted)} />
|
||||
<IconTextButton icon={icon} isCircle onClick={() => setVolume(volume === 0 ? 1 : 0)} />
|
||||
<StyledSlider
|
||||
value={[volume]}
|
||||
onValueChange={([volume]) => { setVolume(volume); setMuted(false); }}
|
||||
onValueChange={([volume]) => setVolume(volume)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
ApiPerformance,
|
||||
ApiPlaylist,
|
||||
ApiPlaylistTrack,
|
||||
ApiExternalProfile,
|
||||
ApiExternalProfileEntry,
|
||||
ApiSeason,
|
||||
ApiSeries,
|
||||
ApiSong,
|
||||
@@ -180,6 +182,36 @@ export type Entry = {
|
||||
videos: Array<Video>;
|
||||
};
|
||||
|
||||
export type ExternalProfile = {
|
||||
__typename?: "ExternalProfile";
|
||||
entries: Array<ExternalProfileEntry>;
|
||||
id: Scalars["Int"]["output"];
|
||||
name: Scalars["String"]["output"];
|
||||
site: ExternalProfileSite;
|
||||
user?: Maybe<UserPublic>;
|
||||
visibility: ExternalProfileVisibility;
|
||||
};
|
||||
|
||||
export type ExternalProfileEntry = {
|
||||
__typename?: "ExternalProfileEntry";
|
||||
anime: Anime;
|
||||
id: Scalars["Int"]["output"];
|
||||
is_favorite: Scalars["Boolean"]["output"];
|
||||
profile: ExternalProfile;
|
||||
score: Scalars["Float"]["output"];
|
||||
watch_status: Scalars["String"]["output"];
|
||||
};
|
||||
|
||||
export enum ExternalProfileSite {
|
||||
AniList = "AniList",
|
||||
MyAnimeList = "MyAnimeList",
|
||||
}
|
||||
|
||||
export enum ExternalProfileVisibility {
|
||||
Private = "Private",
|
||||
Public = "Public",
|
||||
}
|
||||
|
||||
export type FeaturedTheme = {
|
||||
__typename?: "FeaturedTheme";
|
||||
end_at: Scalars["String"]["output"];
|
||||
@@ -282,7 +314,8 @@ export type Query = {
|
||||
bracket?: Maybe<Bracket>;
|
||||
bracketAll: Array<Bracket>;
|
||||
dumpAll: Array<Dump>;
|
||||
entryAll: Array<Entry>;
|
||||
externalProfile?: Maybe<ExternalProfile>;
|
||||
externalProfileAll: Array<ExternalProfile>;
|
||||
featuredTheme?: Maybe<FeaturedTheme>;
|
||||
imageAll: Array<Image>;
|
||||
me: UserScopedQuery;
|
||||
@@ -333,10 +366,14 @@ export type QueryBracketArgs = {
|
||||
slug: Scalars["String"]["input"];
|
||||
};
|
||||
|
||||
export type QueryEntryAllArgs = {
|
||||
export type QueryExternalProfileArgs = {
|
||||
id: Scalars["Int"]["input"];
|
||||
};
|
||||
|
||||
export type QueryExternalProfileAllArgs = {
|
||||
limit?: InputMaybe<Scalars["Int"]["input"]>;
|
||||
orderBy?: InputMaybe<Scalars["String"]["input"]>;
|
||||
orderDesc?: InputMaybe<Scalars["Boolean"]["input"]>;
|
||||
name?: InputMaybe<Scalars["String"]["input"]>;
|
||||
site?: InputMaybe<ExternalProfileSite>;
|
||||
};
|
||||
|
||||
export type QueryImageAllArgs = {
|
||||
@@ -728,8 +765,13 @@ export type ResolversTypes = {
|
||||
Dump: ResolverTypeWrapper<ApiDump>;
|
||||
EntitySearchResult: ResolverTypeWrapper<ResolversInterfaceTypes<ResolversTypes>["EntitySearchResult"]>;
|
||||
Entry: ResolverTypeWrapper<ApiEntry>;
|
||||
ExternalProfile: ResolverTypeWrapper<ApiExternalProfile>;
|
||||
ExternalProfileEntry: ResolverTypeWrapper<ApiExternalProfileEntry>;
|
||||
ExternalProfileSite: ExternalProfileSite;
|
||||
ExternalProfileVisibility: ExternalProfileVisibility;
|
||||
FeaturedTheme: ResolverTypeWrapper<ApiFeaturedTheme>;
|
||||
Filter: Filter;
|
||||
Float: ResolverTypeWrapper<Scalars["Float"]["output"]>;
|
||||
GlobalSearchResult: ResolverTypeWrapper<
|
||||
Omit<GlobalSearchResult, "anime" | "artists" | "playlists" | "series" | "studios" | "themes"> & {
|
||||
anime: Array<ResolversTypes["Anime"]>;
|
||||
@@ -802,8 +844,11 @@ export type ResolversParentTypes = {
|
||||
Dump: ApiDump;
|
||||
EntitySearchResult: ResolversInterfaceTypes<ResolversParentTypes>["EntitySearchResult"];
|
||||
Entry: ApiEntry;
|
||||
ExternalProfile: ApiExternalProfile;
|
||||
ExternalProfileEntry: ApiExternalProfileEntry;
|
||||
FeaturedTheme: ApiFeaturedTheme;
|
||||
Filter: Filter;
|
||||
Float: Scalars["Float"]["output"];
|
||||
GlobalSearchResult: Omit<
|
||||
GlobalSearchResult,
|
||||
"anime" | "artists" | "playlists" | "series" | "studios" | "themes"
|
||||
@@ -1031,6 +1076,32 @@ export type EntryResolvers<
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type ExternalProfileResolvers<
|
||||
ContextType = any,
|
||||
ParentType extends ResolversParentTypes["ExternalProfile"] = ResolversParentTypes["ExternalProfile"],
|
||||
> = {
|
||||
entries?: Resolver<Array<ResolversTypes["ExternalProfileEntry"]>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes["Int"], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||
site?: Resolver<ResolversTypes["ExternalProfileSite"], ParentType, ContextType>;
|
||||
user?: Resolver<Maybe<ResolversTypes["UserPublic"]>, ParentType, ContextType>;
|
||||
visibility?: Resolver<ResolversTypes["ExternalProfileVisibility"], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type ExternalProfileEntryResolvers<
|
||||
ContextType = any,
|
||||
ParentType extends ResolversParentTypes["ExternalProfileEntry"] = ResolversParentTypes["ExternalProfileEntry"],
|
||||
> = {
|
||||
anime?: Resolver<ResolversTypes["Anime"], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes["Int"], ParentType, ContextType>;
|
||||
is_favorite?: Resolver<ResolversTypes["Boolean"], ParentType, ContextType>;
|
||||
profile?: Resolver<ResolversTypes["ExternalProfile"], ParentType, ContextType>;
|
||||
score?: Resolver<ResolversTypes["Float"], ParentType, ContextType>;
|
||||
watch_status?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type FeaturedThemeResolvers<
|
||||
ContextType = any,
|
||||
ParentType extends ResolversParentTypes["FeaturedTheme"] = ResolversParentTypes["FeaturedTheme"],
|
||||
@@ -1156,7 +1227,18 @@ export type QueryResolvers<
|
||||
>;
|
||||
bracketAll?: Resolver<Array<ResolversTypes["Bracket"]>, ParentType, ContextType>;
|
||||
dumpAll?: Resolver<Array<ResolversTypes["Dump"]>, ParentType, ContextType>;
|
||||
entryAll?: Resolver<Array<ResolversTypes["Entry"]>, ParentType, ContextType, Partial<QueryEntryAllArgs>>;
|
||||
externalProfile?: Resolver<
|
||||
Maybe<ResolversTypes["ExternalProfile"]>,
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<QueryExternalProfileArgs, "id">
|
||||
>;
|
||||
externalProfileAll?: Resolver<
|
||||
Array<ResolversTypes["ExternalProfile"]>,
|
||||
ParentType,
|
||||
ContextType,
|
||||
Partial<QueryExternalProfileAllArgs>
|
||||
>;
|
||||
featuredTheme?: Resolver<Maybe<ResolversTypes["FeaturedTheme"]>, ParentType, ContextType>;
|
||||
imageAll?: Resolver<Array<ResolversTypes["Image"]>, ParentType, ContextType, Partial<QueryImageAllArgs>>;
|
||||
me?: Resolver<ResolversTypes["UserScopedQuery"], ParentType, ContextType>;
|
||||
@@ -1477,6 +1559,8 @@ export type Resolvers<ContextType = any> = {
|
||||
Dump?: DumpResolvers<ContextType>;
|
||||
EntitySearchResult?: EntitySearchResultResolvers<ContextType>;
|
||||
Entry?: EntryResolvers<ContextType>;
|
||||
ExternalProfile?: ExternalProfileResolvers<ContextType>;
|
||||
ExternalProfileEntry?: ExternalProfileEntryResolvers<ContextType>;
|
||||
FeaturedTheme?: FeaturedThemeResolvers<ContextType>;
|
||||
GlobalSearchResult?: GlobalSearchResultResolvers<ContextType>;
|
||||
Image?: ImageResolvers<ContextType>;
|
||||
|
||||
+220
-60
@@ -132,6 +132,28 @@ export type Entry = {
|
||||
videos: Array<Video>;
|
||||
};
|
||||
|
||||
export type ExternalProfile = {
|
||||
entries: Array<ExternalProfileEntry>;
|
||||
id: Scalars["Int"]["output"];
|
||||
name: Scalars["String"]["output"];
|
||||
site: ExternalProfileSite;
|
||||
user: Maybe<UserPublic>;
|
||||
visibility: ExternalProfileVisibility;
|
||||
};
|
||||
|
||||
export type ExternalProfileEntry = {
|
||||
anime: Anime;
|
||||
id: Scalars["Int"]["output"];
|
||||
is_favorite: Scalars["Boolean"]["output"];
|
||||
profile: ExternalProfile;
|
||||
score: Scalars["Float"]["output"];
|
||||
watch_status: Scalars["String"]["output"];
|
||||
};
|
||||
|
||||
export type ExternalProfileSite = "AniList" | "MyAnimeList";
|
||||
|
||||
export type ExternalProfileVisibility = "Private" | "Public";
|
||||
|
||||
export type FeaturedTheme = {
|
||||
end_at: Scalars["String"]["output"];
|
||||
entry: Maybe<Entry>;
|
||||
@@ -220,7 +242,8 @@ export type Query = {
|
||||
bracket: Maybe<Bracket>;
|
||||
bracketAll: Array<Bracket>;
|
||||
dumpAll: Array<Dump>;
|
||||
entryAll: Array<Entry>;
|
||||
externalProfile: Maybe<ExternalProfile>;
|
||||
externalProfileAll: Array<ExternalProfile>;
|
||||
featuredTheme: Maybe<FeaturedTheme>;
|
||||
imageAll: Array<Image>;
|
||||
me: UserScopedQuery;
|
||||
@@ -271,10 +294,14 @@ export type QueryBracketArgs = {
|
||||
slug: Scalars["String"]["input"];
|
||||
};
|
||||
|
||||
export type QueryEntryAllArgs = {
|
||||
export type QueryExternalProfileArgs = {
|
||||
id: Scalars["Int"]["input"];
|
||||
};
|
||||
|
||||
export type QueryExternalProfileAllArgs = {
|
||||
limit: InputMaybe<Scalars["Int"]["input"]>;
|
||||
orderBy: InputMaybe<Scalars["String"]["input"]>;
|
||||
orderDesc: InputMaybe<Scalars["Boolean"]["input"]>;
|
||||
name: InputMaybe<Scalars["String"]["input"]>;
|
||||
site: InputMaybe<ExternalProfileSite>;
|
||||
};
|
||||
|
||||
export type QueryImageAllArgs = {
|
||||
@@ -820,24 +847,23 @@ export type PlaylistTrackRemoveDialogEntryFragment = {
|
||||
};
|
||||
};
|
||||
|
||||
export type FeaturedThemeEntryFragment = {
|
||||
export type FeaturedThemeThemeFragment = {
|
||||
type: string;
|
||||
sequence: number | null;
|
||||
id: number;
|
||||
version: number | null;
|
||||
theme: {
|
||||
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
|
||||
entries: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
sequence: number | null;
|
||||
group: { name: string; slug: string } | null;
|
||||
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
|
||||
song: {
|
||||
title: string | null;
|
||||
performances: Array<{ alias: string | null; as: string | null; artist: { slug: string; name: string } }>;
|
||||
} | null;
|
||||
};
|
||||
version: number | null;
|
||||
videos: Array<{ id: number; basename: string; tags: string; audio: { basename: string } }>;
|
||||
}>;
|
||||
group: { name: string; slug: string } | null;
|
||||
song: {
|
||||
title: string | null;
|
||||
performances: Array<{ alias: string | null; as: string | null; artist: { slug: string; name: string } }>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type FeaturedThemeVideoFragment = { id: number; basename: string; tags: string; audio: { basename: string } };
|
||||
|
||||
export type AnimeThemeFilterThemeFragment = {
|
||||
type: string;
|
||||
sequence: number | null;
|
||||
@@ -871,28 +897,33 @@ export type AnimeThemeFilterThemeFragment = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type HomePageMostPopularQueryVariables = Exact<{ [key: string]: never }>;
|
||||
export type HomePageMostViewedQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type HomePageMostPopularQuery = {
|
||||
entryAll: Array<{
|
||||
export type HomePageMostViewedQuery = {
|
||||
videoAll: Array<{
|
||||
id: number;
|
||||
version: number | null;
|
||||
videos: Array<{ id: number; basename: string; tags: string; audio: { basename: string } }>;
|
||||
theme: {
|
||||
basename: string;
|
||||
tags: string;
|
||||
entries: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
sequence: number | null;
|
||||
group: { name: string; slug: string } | null;
|
||||
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
|
||||
song: {
|
||||
title: string | null;
|
||||
performances: Array<{
|
||||
alias: string | null;
|
||||
as: string | null;
|
||||
artist: { slug: string; name: string };
|
||||
}>;
|
||||
} | null;
|
||||
};
|
||||
version: number | null;
|
||||
theme: {
|
||||
id: number;
|
||||
type: string;
|
||||
sequence: number | null;
|
||||
group: { name: string; slug: string } | null;
|
||||
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
|
||||
song: {
|
||||
title: string | null;
|
||||
performances: Array<{
|
||||
alias: string | null;
|
||||
as: string | null;
|
||||
artist: { slug: string; name: string };
|
||||
}>;
|
||||
} | null;
|
||||
};
|
||||
}>;
|
||||
audio: { basename: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -1013,24 +1044,6 @@ export type ThemeMenuThemeFragment = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type VideoMenuEntryFragment = {
|
||||
id: number;
|
||||
version: number | null;
|
||||
theme: {
|
||||
id: number;
|
||||
type: string;
|
||||
sequence: number | null;
|
||||
group: { name: string; slug: string } | null;
|
||||
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
|
||||
song: {
|
||||
title: string | null;
|
||||
performances: Array<{ alias: string | null; as: string | null; artist: { slug: string; name: string } }>;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoMenuVideoFragment = { id: number; basename: string; tags: string; audio: { basename: string } };
|
||||
|
||||
export type SearchAnimeQueryVariables = Exact<{
|
||||
args: SearchArgs;
|
||||
}>;
|
||||
@@ -2511,19 +2524,167 @@ export type EventPageQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type EventPageQuery = { bracketAll: Array<{ slug: string; name: string }> };
|
||||
|
||||
export type ExternalProfilePageExternalProfileQueryVariables = Exact<{
|
||||
externalProfileId: Scalars["Int"]["input"];
|
||||
}>;
|
||||
|
||||
export type ExternalProfilePageExternalProfileQuery = {
|
||||
externalProfile: {
|
||||
id: number;
|
||||
name: string;
|
||||
site: ExternalProfileSite;
|
||||
visibility: ExternalProfileVisibility;
|
||||
entries: Array<{
|
||||
watch_status: string;
|
||||
is_favorite: boolean;
|
||||
score: number;
|
||||
anime: {
|
||||
slug: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
season: string | null;
|
||||
media_format: string | null;
|
||||
themes: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
sequence: number | null;
|
||||
group: { name: string; slug: string } | null;
|
||||
anime: { slug: string };
|
||||
entries: Array<{
|
||||
version: number | null;
|
||||
episodes: string | null;
|
||||
spoiler: boolean;
|
||||
nsfw: boolean;
|
||||
videos: Array<{
|
||||
tags: string;
|
||||
resolution: number | null;
|
||||
nc: boolean;
|
||||
subbed: boolean;
|
||||
lyrics: boolean;
|
||||
uncen: boolean;
|
||||
source: VideoSource | null;
|
||||
overlap: VideoOverlap;
|
||||
}>;
|
||||
}>;
|
||||
song: { title: string | null } | null;
|
||||
}>;
|
||||
images: Array<{ link: string; facet: string | null }>;
|
||||
};
|
||||
}>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ExternalProfilePageExternalProfileFragment = {
|
||||
id: number;
|
||||
name: string;
|
||||
site: ExternalProfileSite;
|
||||
visibility: ExternalProfileVisibility;
|
||||
entries: Array<{
|
||||
watch_status: string;
|
||||
is_favorite: boolean;
|
||||
score: number;
|
||||
anime: {
|
||||
slug: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
season: string | null;
|
||||
media_format: string | null;
|
||||
themes: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
sequence: number | null;
|
||||
group: { name: string; slug: string } | null;
|
||||
anime: { slug: string };
|
||||
entries: Array<{
|
||||
version: number | null;
|
||||
episodes: string | null;
|
||||
spoiler: boolean;
|
||||
nsfw: boolean;
|
||||
videos: Array<{
|
||||
tags: string;
|
||||
resolution: number | null;
|
||||
nc: boolean;
|
||||
subbed: boolean;
|
||||
lyrics: boolean;
|
||||
uncen: boolean;
|
||||
source: VideoSource | null;
|
||||
overlap: VideoOverlap;
|
||||
}>;
|
||||
}>;
|
||||
song: { title: string | null } | null;
|
||||
}>;
|
||||
images: Array<{ link: string; facet: string | null }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ExternalProfilePageQueryVariables = Exact<{
|
||||
name: Scalars["String"]["input"];
|
||||
site: ExternalProfileSite;
|
||||
}>;
|
||||
|
||||
export type ExternalProfilePageQuery = {
|
||||
externalProfileAll: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
site: ExternalProfileSite;
|
||||
visibility: ExternalProfileVisibility;
|
||||
entries: Array<{
|
||||
watch_status: string;
|
||||
is_favorite: boolean;
|
||||
score: number;
|
||||
anime: {
|
||||
slug: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
season: string | null;
|
||||
media_format: string | null;
|
||||
themes: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
sequence: number | null;
|
||||
group: { name: string; slug: string } | null;
|
||||
anime: { slug: string };
|
||||
entries: Array<{
|
||||
version: number | null;
|
||||
episodes: string | null;
|
||||
spoiler: boolean;
|
||||
nsfw: boolean;
|
||||
videos: Array<{
|
||||
tags: string;
|
||||
resolution: number | null;
|
||||
nc: boolean;
|
||||
subbed: boolean;
|
||||
lyrics: boolean;
|
||||
uncen: boolean;
|
||||
source: VideoSource | null;
|
||||
overlap: VideoOverlap;
|
||||
}>;
|
||||
}>;
|
||||
song: { title: string | null } | null;
|
||||
}>;
|
||||
images: Array<{ link: string; facet: string | null }>;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type HomePageQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type HomePageQuery = {
|
||||
featuredTheme: {
|
||||
entry: {
|
||||
id: number;
|
||||
version: number | null;
|
||||
theme: {
|
||||
id: number;
|
||||
type: string;
|
||||
sequence: number | null;
|
||||
group: { name: string; slug: string } | null;
|
||||
id: number;
|
||||
anime: { slug: string; name: string; images: Array<{ link: string; facet: string | null }> };
|
||||
entries: Array<{
|
||||
id: number;
|
||||
version: number | null;
|
||||
videos: Array<{ id: number; basename: string; tags: string; audio: { basename: string } }>;
|
||||
}>;
|
||||
group: { name: string; slug: string } | null;
|
||||
song: {
|
||||
title: string | null;
|
||||
performances: Array<{
|
||||
@@ -2534,7 +2695,6 @@ export type HomePageQuery = {
|
||||
} | null;
|
||||
};
|
||||
} | null;
|
||||
video: { id: number; basename: string; tags: string; audio: { basename: string } } | null;
|
||||
} | null;
|
||||
announcementAll: Array<{ content: string }>;
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import pLimit from "p-limit";
|
||||
|
||||
import type { InputMaybe, ResolverFn } from "@/generated/graphql-resolvers";
|
||||
import type { ApiIndex } from "@/lib/common/animethemes/types";
|
||||
import { AUTH_REFERER, CLIENT_API_URL, PAGINATION_PAGE_SIZE, SERVER_API_URL } from "@/utils/config";
|
||||
import { AUTH_REFERER, CLIENT_API_URL, PAGINATION_PAGE_SIZE, SERVER_API_KEY, SERVER_API_URL } from "@/utils/config";
|
||||
import devLog from "@/utils/devLog";
|
||||
|
||||
const limit = pLimit(5);
|
||||
@@ -87,6 +87,13 @@ export const INCLUDES = {
|
||||
previous: "previous",
|
||||
next: "next",
|
||||
},
|
||||
ExternalProfile: {
|
||||
entries: "externalentries",
|
||||
},
|
||||
ExternalProfileEntry: {
|
||||
profile: "externalprofile",
|
||||
anime: "anime",
|
||||
},
|
||||
UserAuth: {
|
||||
permissions: "permissions",
|
||||
roles: "roles",
|
||||
@@ -136,12 +143,6 @@ const ALLOWED_INCLUDES: Record<string, Array<string>> = {
|
||||
"animethemes.animethemeentries.videos.animethemeentries.animetheme.group",
|
||||
],
|
||||
Theme: ["anime.images", "animethemeentries.videos", "animethemeentries.videos.audio", "song.artists", "group"],
|
||||
Entry: [
|
||||
"animetheme.group",
|
||||
"animetheme.anime.images",
|
||||
"animetheme.song.artists",
|
||||
"videos.audio",
|
||||
],
|
||||
Artist: [
|
||||
"songs.animethemes.anime",
|
||||
"members",
|
||||
@@ -208,6 +209,12 @@ const ALLOWED_INCLUDES: Record<string, Array<string>> = {
|
||||
"video.audio",
|
||||
"animethemeentry",
|
||||
],
|
||||
ExternalProfile: [
|
||||
"externalentries.anime.animethemes.group",
|
||||
"externalentries.anime.animethemes.animethemeentries.videos",
|
||||
"externalentries.anime.animethemes.song",
|
||||
"externalentries.anime.images",
|
||||
],
|
||||
UserAuth: ["permissions", "roles.permissions"],
|
||||
FeaturedTheme: [
|
||||
"animethemeentry.animetheme.anime.images",
|
||||
@@ -474,6 +481,13 @@ export async function fetchJson<T = Record<string, unknown>>(
|
||||
|
||||
config.credentials = "include";
|
||||
|
||||
if (SERVER_API_KEY && !("cookie" in (config.headers ?? {}))) {
|
||||
config.headers = {
|
||||
Authorization: `Bearer ${SERVER_API_KEY}`,
|
||||
...config.headers,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -14,8 +14,10 @@ import type {
|
||||
ApiArtistShow,
|
||||
ApiAudioShow,
|
||||
ApiDumpIndex,
|
||||
ApiEntryIndex,
|
||||
ApiEntryShow,
|
||||
ApiExternalProfileEntryShow,
|
||||
ApiExternalProfileIndex,
|
||||
ApiExternalProfileShow,
|
||||
ApiFeaturedThemeShow,
|
||||
ApiImageIndex,
|
||||
ApiPageIndex,
|
||||
@@ -65,10 +67,6 @@ const resolvers: Resolvers = {
|
||||
endpoint: () => `/artist`,
|
||||
extractFromResponse: (response) => response.artists,
|
||||
}),
|
||||
entryAll: createApiResolverPaginated<ApiEntryIndex>()({
|
||||
endpoint: (_, { orderBy, orderDesc }) => `/animethemeentry?sort=${orderDesc ? "-" : ""}${orderBy}`,
|
||||
extractFromResponse: (response) => response.animethemeentries,
|
||||
}),
|
||||
series: createApiResolver<ApiSeriesShow>()({
|
||||
endpoint: (_, { slug }) => `/series/${slug}`,
|
||||
extractFromResponse: (response) => response.series,
|
||||
@@ -128,6 +126,15 @@ const resolvers: Resolvers = {
|
||||
`/playlist?sort=${orderDesc ? "-" : ""}${orderBy}&fields[playlist]=id,name,description,visibility,tracks_count${onlyNonEmpty ? "&filter[playlist][tracks_count-gte]=1" : ""}`,
|
||||
extractFromResponse: (response) => response.playlists,
|
||||
}),
|
||||
externalProfile: createApiResolver<ApiExternalProfileShow>()({
|
||||
endpoint: (_, { id }) => `/externalprofile/${id}`,
|
||||
extractFromResponse: (response) => response.externalprofile,
|
||||
}),
|
||||
externalProfileAll: createApiResolverPaginated<ApiExternalProfileIndex>()({
|
||||
endpoint: (_, { name, site }) =>
|
||||
`/externalprofile?filter[externalprofile][name]=${name}&filter[externalprofile][site]=${site}`,
|
||||
extractFromResponse: (response) => response.externalprofiles,
|
||||
}),
|
||||
me: () => ({}),
|
||||
},
|
||||
UserScopedQuery: {
|
||||
@@ -456,6 +463,24 @@ const resolvers: Resolvers = {
|
||||
baseInclude: INCLUDES.PlaylistTrack.video,
|
||||
}),
|
||||
},
|
||||
ExternalProfile: {
|
||||
entries: createApiResolverNotNull<ApiExternalProfileShow<"externalentries">>()({
|
||||
extractFromParent: (externalProfile) => externalProfile.externalentries,
|
||||
endpoint: (externalProfile) => `/externalprofile/${externalProfile.id}`,
|
||||
extractFromResponse: (response) => response.externalprofile.externalentries,
|
||||
type: "ExternalProfile",
|
||||
baseInclude: INCLUDES.ExternalProfile.entries,
|
||||
}),
|
||||
},
|
||||
ExternalProfileEntry: {
|
||||
anime: createApiResolverNotNull<ApiExternalProfileEntryShow<"anime">>()({
|
||||
extractFromParent: (entry) => entry.anime,
|
||||
endpoint: (entry) => `/externalprofile/${entry.externalprofile.id}/externalentry/${entry.id}`,
|
||||
extractFromResponse: (response) => response.externalentry.anime,
|
||||
type: "ExternalProfileEntry",
|
||||
baseInclude: INCLUDES.ExternalProfileEntry.anime,
|
||||
}),
|
||||
},
|
||||
UserAuth: {
|
||||
roles: createApiResolverNotNull<ApiUserShow<"roles">>()({
|
||||
extractFromParent: (user) => user.roles,
|
||||
|
||||
@@ -6,7 +6,6 @@ const typeDefs = gql`
|
||||
animeAll(limit: Int, year: Int, season: String): [Anime!]!
|
||||
theme(id: Int): Theme
|
||||
themeAll(limit: Int, orderBy: String, orderDesc: Boolean, has: String): [Theme!]!
|
||||
entryAll(limit: Int, orderBy: String, orderDesc: Boolean): [Entry!]!
|
||||
artist(id: Int, slug: String): Artist
|
||||
artistAll(limit: Int): [Artist!]!
|
||||
series(id: Int, slug: String): Series
|
||||
@@ -24,6 +23,8 @@ const typeDefs = gql`
|
||||
dumpAll: [Dump!]!
|
||||
playlist(id: String!): Playlist
|
||||
playlistAll(limit: Int, orderBy: String, orderDesc: Boolean, onlyNonEmpty: Boolean): [Playlist!]!
|
||||
externalProfile(id: Int!): ExternalProfile
|
||||
externalProfileAll(limit: Int, name: String, site: ExternalProfileSite): [ExternalProfile!]!
|
||||
announcementAll: [Announcement!]!
|
||||
me: UserScopedQuery!
|
||||
}
|
||||
@@ -272,6 +273,34 @@ const typeDefs = gql`
|
||||
next: PlaylistTrack
|
||||
}
|
||||
|
||||
type ExternalProfile {
|
||||
id: Int!
|
||||
name: String!
|
||||
site: ExternalProfileSite!
|
||||
visibility: ExternalProfileVisibility!
|
||||
entries: [ExternalProfileEntry!]!
|
||||
user: UserPublic
|
||||
}
|
||||
|
||||
enum ExternalProfileSite {
|
||||
AniList
|
||||
MyAnimeList
|
||||
}
|
||||
|
||||
enum ExternalProfileVisibility {
|
||||
Public
|
||||
Private
|
||||
}
|
||||
|
||||
type ExternalProfileEntry {
|
||||
id: Int!
|
||||
score: Float!
|
||||
is_favorite: Boolean!
|
||||
watch_status: String!
|
||||
profile: ExternalProfile!
|
||||
anime: Anime!
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: Int!
|
||||
name: String!
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// General Types
|
||||
// --------------------------------
|
||||
|
||||
import type { ExternalProfileSite, ExternalProfileVisibility } from "@/generated/graphql-resolvers";
|
||||
|
||||
export interface ApiIndex {
|
||||
links: ApiIndexLinks;
|
||||
meta: ApiIndexMeta;
|
||||
@@ -143,6 +145,23 @@ export interface ApiPlaylistTrack {
|
||||
video?: ApiVideo;
|
||||
}
|
||||
|
||||
export interface ApiExternalProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
site: ExternalProfileSite;
|
||||
visibility: ExternalProfileVisibility;
|
||||
externalentries?: Array<ApiExternalProfileEntry>;
|
||||
}
|
||||
|
||||
export interface ApiExternalProfileEntry {
|
||||
id: number;
|
||||
score: number;
|
||||
is_favorite: boolean;
|
||||
watch_status: string;
|
||||
externalprofile: ApiExternalProfile;
|
||||
anime?: ApiAnime;
|
||||
}
|
||||
|
||||
export interface ApiResource {
|
||||
id: number;
|
||||
link: string | null;
|
||||
@@ -324,6 +343,22 @@ export interface ApiPlaylistTrackIndex extends ApiIndex {
|
||||
tracks: Array<ApiPlaylistTrack>;
|
||||
}
|
||||
|
||||
export interface ApiExternalProfileShow<Includes extends keyof ApiExternalProfile = never> {
|
||||
externalprofile: ApiExternalProfile & Required<Pick<ApiExternalProfile, Includes>>;
|
||||
}
|
||||
|
||||
export interface ApiExternalProfileIndex extends ApiIndex {
|
||||
externalprofiles: Array<ApiExternalProfile>;
|
||||
}
|
||||
|
||||
export interface ApiExternalProfileEntryShow<Includes extends keyof ApiExternalProfileEntry = never> {
|
||||
externalentry: ApiExternalProfileEntry & Required<Pick<ApiExternalProfileEntry, Includes>>;
|
||||
}
|
||||
|
||||
export interface ApiExternalProfileEntryIndex extends ApiIndex {
|
||||
externalentries: Array<ApiExternalProfileEntry>;
|
||||
}
|
||||
|
||||
export interface ApiThemeShow<Includes extends keyof ApiTheme = never> {
|
||||
animetheme: ApiTheme & Required<Pick<ApiTheme, Includes>>;
|
||||
}
|
||||
|
||||
+2
-16
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import styled, { ThemeProvider } from "styled-components";
|
||||
import type { AppProps } from "next/app";
|
||||
@@ -103,9 +103,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
const currentVideoSlug = getVideoSlugByProps(pageProps);
|
||||
const [previousVideoSlug, setPreviousVideoSlug] = useState<string | null>(() => getVideoSlugByProps(pageProps));
|
||||
|
||||
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
|
||||
const [isFullscreen, { toggleFullscreen : nativeToggleFullscreen }] = useFullscreen(() => document.documentElement, {
|
||||
const [isFullscreen, { toggleFullscreen }] = useFullscreen(() => document.documentElement, {
|
||||
onEnter() {
|
||||
document.documentElement.dataset.fullscreen = "true";
|
||||
},
|
||||
@@ -114,18 +112,6 @@ export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isIOS) {
|
||||
// iOS does not support the Fullscreen API, so it's handled differently
|
||||
const videoElement = document.querySelector("video");
|
||||
if (videoElement && "webkitEnterFullscreen" in videoElement) {
|
||||
(videoElement as HTMLVideoElement & { webkitEnterFullscreen: () => void }).webkitEnterFullscreen();
|
||||
}
|
||||
} else {
|
||||
nativeToggleFullscreen();
|
||||
}
|
||||
}, [isIOS, nativeToggleFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideoPage && isFullscreen) {
|
||||
toggleFullscreen();
|
||||
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import type { GetServerSideProps } from "next";
|
||||
|
||||
import { faStar } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { FilterToggleButton } from "@/components/button/FilterToggleButton";
|
||||
import { AnimeSummaryCard } from "@/components/card/AnimeSummaryCard";
|
||||
import { SidebarContainer } from "@/components/container/SidebarContainer";
|
||||
import { DescriptionList } from "@/components/description-list/DescriptionList";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { MultiCoverImage } from "@/components/image/MultiCoverImage";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { SearchFilterSortBy } from "@/components/search-filter/SearchFilterSortBy";
|
||||
import { SEO } from "@/components/seo/SEO";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Collapse } from "@/components/utils/Collapse";
|
||||
import type {
|
||||
ExternalProfilePageExternalProfileQuery,
|
||||
ExternalProfilePageExternalProfileQueryVariables,
|
||||
ExternalProfilePageQuery,
|
||||
ExternalProfilePageQueryVariables,
|
||||
ExternalProfileSite,
|
||||
} from "@/generated/graphql";
|
||||
import useToggle from "@/hooks/useToggle";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
import { fetchData } from "@/lib/server";
|
||||
import theme from "@/theme/index";
|
||||
import {
|
||||
ANIME_A_Z,
|
||||
ANIME_NEW_OLD,
|
||||
ANIME_OLD_NEW,
|
||||
ANIME_Z_A,
|
||||
either,
|
||||
getComparator,
|
||||
sortTransformed,
|
||||
} from "@/utils/comparators";
|
||||
import type { SharedPageProps } from "@/utils/getSharedPageProps";
|
||||
import getSharedPageProps from "@/utils/getSharedPageProps";
|
||||
import type { Comparator } from "@/utils/types";
|
||||
|
||||
const SITE_MAPPINGS: Record<string, ExternalProfileSite> = {
|
||||
al: "AniList",
|
||||
anilist: "AniList",
|
||||
mal: "MyAnimeList",
|
||||
myanimelist: "MyAnimeList",
|
||||
};
|
||||
|
||||
const StyledDesktopOnly = styled.div`
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const SCORE_ASC = "score-asc";
|
||||
const SCORE_DESC = "score-desc";
|
||||
const FAVORITES_FIRST = "favorites-first";
|
||||
|
||||
const comparators = {
|
||||
[ANIME_A_Z]: sortTransformed(getComparator(ANIME_A_Z), (entry) => entry.anime),
|
||||
[ANIME_Z_A]: sortTransformed(getComparator(ANIME_Z_A), (entry) => entry.anime),
|
||||
[ANIME_OLD_NEW]: sortTransformed(getComparator(ANIME_OLD_NEW), (entry) => entry.anime),
|
||||
[ANIME_NEW_OLD]: sortTransformed(getComparator(ANIME_NEW_OLD), (entry) => entry.anime),
|
||||
[SCORE_ASC]: (a, b) => a.score - b.score,
|
||||
[SCORE_DESC]: (a, b) => b.score - a.score,
|
||||
[FAVORITES_FIRST]: (a, b) => (b.is_favorite ? 1 : 0) - (a.is_favorite ? 1 : 0),
|
||||
} satisfies Record<string, Comparator<ExternalProfilePageQuery["externalProfileAll"][number]["entries"][number]>>;
|
||||
|
||||
type ExternalProfilePageProps = SharedPageProps & {
|
||||
externalProfile: ExternalProfilePageQuery["externalProfileAll"][number];
|
||||
};
|
||||
|
||||
interface ExternalProfilePageParams extends ParsedUrlQuery {
|
||||
name: string;
|
||||
site: ExternalProfileSite;
|
||||
}
|
||||
|
||||
export default function ExternalProfilePage({ externalProfile: initialExternalProfile }: ExternalProfilePageProps) {
|
||||
const { data: externalProfile } = useSWR(
|
||||
["ExternalProfilePageExternalProfile", `/api/externalprofile/${initialExternalProfile.id}`],
|
||||
async () => {
|
||||
const { data } = await fetchDataClient<
|
||||
ExternalProfilePageExternalProfileQuery,
|
||||
ExternalProfilePageExternalProfileQueryVariables
|
||||
>(
|
||||
gql`
|
||||
${ExternalProfilePage.fragments.externalProfile}
|
||||
|
||||
query ExternalProfilePageExternalProfile($externalProfileId: Int!) {
|
||||
externalProfile(id: $externalProfileId) {
|
||||
...ExternalProfilePageExternalProfile
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ externalProfileId: initialExternalProfile.id },
|
||||
);
|
||||
|
||||
if (!data.externalProfile) {
|
||||
location.reload();
|
||||
throw new Error("External profile was removed or user lost auth.");
|
||||
}
|
||||
|
||||
return data.externalProfile;
|
||||
},
|
||||
{ fallbackData: initialExternalProfile },
|
||||
);
|
||||
|
||||
const favorites = useMemo(
|
||||
() => externalProfile.entries.filter((entry) => entry.is_favorite),
|
||||
[externalProfile.entries],
|
||||
);
|
||||
|
||||
const entriesFiltered = useMemo(
|
||||
() => externalProfile.entries.filter((entry) => entry.watch_status === "Completed"),
|
||||
[externalProfile.entries],
|
||||
);
|
||||
|
||||
// For the cover image we want to show the user's favorites.
|
||||
// If there aren't enough favorites we resort to the entries with the highest score.
|
||||
const coverImageAnime = useMemo(
|
||||
() =>
|
||||
entriesFiltered
|
||||
.sort(either(comparators[FAVORITES_FIRST]).or(comparators[SCORE_DESC]).chain())
|
||||
.map((entry) => entry.anime),
|
||||
[entriesFiltered],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEO title={externalProfile.name} />
|
||||
<Text variant="h1">{externalProfile.name}</Text>
|
||||
<SidebarContainer>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<StyledDesktopOnly>
|
||||
<MultiCoverImage resourcesWithImages={coverImageAnime} />
|
||||
</StyledDesktopOnly>
|
||||
<DescriptionList>
|
||||
<DescriptionList.Item title="Links">
|
||||
<Text link>{externalProfile.site}</Text>
|
||||
</DescriptionList.Item>
|
||||
</DescriptionList>
|
||||
</Column>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
{favorites.length > 0 && <ExternalProfileEntryList title="Favorites" entries={favorites} />}
|
||||
<ExternalProfileEntryList title="All Anime" entries={entriesFiltered} />
|
||||
</Column>
|
||||
</SidebarContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExternalProfileEntryListProps {
|
||||
title: string;
|
||||
entries: ExternalProfilePageProps["externalProfile"]["entries"];
|
||||
}
|
||||
|
||||
const ExternalProfileEntryList = memo(function SeriesAnime({ title, entries }: ExternalProfileEntryListProps) {
|
||||
const [showFilter, toggleShowFilter] = useToggle();
|
||||
const [sortBy, setSortBy] = useState<keyof typeof comparators>(SCORE_DESC);
|
||||
|
||||
const entriesSorted = useMemo(
|
||||
() => [...entries].filter((entry) => entry.watch_status === "Completed").sort(comparators[sortBy]),
|
||||
[entries, sortBy],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row style={{ "--justify-content": "space-between", "--align-items": "center" }}>
|
||||
<Text variant="h2">
|
||||
{title}
|
||||
<Text color="text-disabled"> ({entries.length})</Text>
|
||||
</Text>
|
||||
<FilterToggleButton onClick={toggleShowFilter} />
|
||||
</Row>
|
||||
<Collapse collapse={!showFilter}>
|
||||
<SearchFilterGroup>
|
||||
<SearchFilterSortBy value={sortBy} setValue={setSortBy}>
|
||||
<SearchFilterSortBy.Option value={SCORE_DESC}>Score (High ➜ Low)</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value={SCORE_ASC}>Score (Low ➜ High)</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value={ANIME_A_Z}>A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value={ANIME_Z_A}>Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value={ANIME_OLD_NEW}>Old ➜ New</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value={ANIME_NEW_OLD}>New ➜ Old</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</SearchFilterGroup>
|
||||
</Collapse>
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{entriesSorted.map((entry) => (
|
||||
<AnimeSummaryCard key={entry.anime.slug} anime={entry.anime} expandable>
|
||||
{entry.score > 0 && (
|
||||
<Text variant="small" color={"gold"} noWrap title="Score">
|
||||
<Icon icon={faStar} /> {entry.score}
|
||||
</Text>
|
||||
)}
|
||||
</AnimeSummaryCard>
|
||||
))}
|
||||
</Column>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ExternalProfilePage.fragments = {
|
||||
externalProfile: gql`
|
||||
${AnimeSummaryCard.fragments.anime}
|
||||
${AnimeSummaryCard.fragments.expandable}
|
||||
|
||||
fragment ExternalProfilePageExternalProfile on ExternalProfile {
|
||||
id
|
||||
name
|
||||
site
|
||||
visibility
|
||||
entries {
|
||||
watch_status
|
||||
is_favorite
|
||||
score
|
||||
anime {
|
||||
...AnimeSummaryCardAnime
|
||||
...AnimeSummaryCardAnimeExpandable
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<ExternalProfilePageProps, ExternalProfilePageParams> = async ({
|
||||
params,
|
||||
req,
|
||||
}) => {
|
||||
if (!params) {
|
||||
return { notFound: true };
|
||||
}
|
||||
|
||||
const site = SITE_MAPPINGS[params.site.toLowerCase()];
|
||||
|
||||
if (!site) {
|
||||
return { notFound: true };
|
||||
}
|
||||
|
||||
const { data, apiRequests } = await fetchData<ExternalProfilePageQuery, ExternalProfilePageQueryVariables>(
|
||||
gql`
|
||||
${ExternalProfilePage.fragments.externalProfile}
|
||||
|
||||
query ExternalProfilePage($name: String!, $site: ExternalProfileSite!) {
|
||||
externalProfileAll(name: $name, site: $site) {
|
||||
...ExternalProfilePageExternalProfile
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ name: params.name, site: site },
|
||||
{ req },
|
||||
);
|
||||
|
||||
if (!data.externalProfileAll.length) {
|
||||
return { notFound: true };
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
...getSharedPageProps(apiRequests),
|
||||
externalProfile: data.externalProfileAll[0],
|
||||
},
|
||||
};
|
||||
};
|
||||
+11
-13
@@ -21,7 +21,7 @@ import { AnnouncementCard } from "@/components/card/AnnouncementCard";
|
||||
import { ShuffleDialog } from "@/components/dialog/ShuffleDialog";
|
||||
import { ExternalLink } from "@/components/external-link/ExternalLink";
|
||||
import { FeaturedTheme } from "@/components/featured-theme/FeaturedTheme";
|
||||
import { MostPopularEntries } from "@/components/home/MostPopularEntries";
|
||||
import { MostViewedVideos } from "@/components/home/MostViewedVideos";
|
||||
import { RecentlyAddedPlaylists } from "@/components/home/RecentlyAddedPlaylists";
|
||||
import { RecentlyAddedVideos } from "@/components/home/RecentlyAddedVideos";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -67,7 +67,7 @@ const Grid = styled.div<{ $columns: number }>`
|
||||
`;
|
||||
|
||||
interface HomePageProps {
|
||||
featuredTheme: NonNullable<NonNullable<HomePageQuery["featuredTheme"]>> | null;
|
||||
featuredTheme: NonNullable<NonNullable<HomePageQuery["featuredTheme"]>["entry"]>["theme"] | null;
|
||||
announcementSources: MDXRemoteSerializeResult[];
|
||||
}
|
||||
|
||||
@@ -82,10 +82,10 @@ export default function HomePage({ featuredTheme, announcementSources }: HomePag
|
||||
|
||||
{announcementSources.length > 0 ? <AnnouncementCard announcementSource={announcementSources[0]} /> : null}
|
||||
|
||||
{featuredTheme && featuredTheme.entry && featuredTheme.video ? (
|
||||
{featuredTheme ? (
|
||||
<>
|
||||
<Text variant="h2">Featured Theme</Text>
|
||||
<FeaturedTheme entry={featuredTheme.entry} video={featuredTheme.video} />
|
||||
<FeaturedTheme theme={featuredTheme} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -154,8 +154,8 @@ export default function HomePage({ featuredTheme, announcementSources }: HomePag
|
||||
<RecentlyAddedVideos />
|
||||
</Column>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<Text variant="h2">Most Popular</Text>
|
||||
<MostPopularEntries />
|
||||
<Text variant="h2">Most Viewed</Text>
|
||||
<MostViewedVideos />
|
||||
</Column>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<Text variant="h2">New Playlists</Text>
|
||||
@@ -224,16 +224,14 @@ export default function HomePage({ featuredTheme, announcementSources }: HomePag
|
||||
|
||||
export const getStaticProps: GetStaticProps<HomePageProps> = async () => {
|
||||
const { data, apiRequests } = await fetchData<HomePageQuery>(gql`
|
||||
${FeaturedTheme.fragments.entry}
|
||||
${FeaturedTheme.fragments.video}
|
||||
${FeaturedTheme.fragments.theme}
|
||||
|
||||
query HomePage {
|
||||
featuredTheme {
|
||||
entry {
|
||||
...FeaturedThemeEntry
|
||||
}
|
||||
video {
|
||||
...FeaturedThemeVideo
|
||||
theme {
|
||||
...FeaturedThemeTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
announcementAll {
|
||||
@@ -245,7 +243,7 @@ export const getStaticProps: GetStaticProps<HomePageProps> = async () => {
|
||||
return {
|
||||
props: {
|
||||
...getSharedPageProps(apiRequests),
|
||||
featuredTheme: data?.featuredTheme,
|
||||
featuredTheme: data?.featuredTheme?.entry?.theme ?? null,
|
||||
announcementSources: await Promise.all(
|
||||
data?.announcementAll.map(
|
||||
async (announcement) => (await serializeMarkdownSafe(announcement.content)).source,
|
||||
|
||||
@@ -369,8 +369,15 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
|
||||
) : null}
|
||||
{isRanking && topRankedTrack ? (
|
||||
<FeaturedTheme
|
||||
entry={tracks[0].entry}
|
||||
video={tracks[0].video}
|
||||
theme={{
|
||||
...tracks[0].entry.theme,
|
||||
entries: [
|
||||
{
|
||||
...tracks[0].entry,
|
||||
videos: [tracks[0].video],
|
||||
},
|
||||
],
|
||||
}}
|
||||
hasGrill={false}
|
||||
card={
|
||||
<PlaylistTrack
|
||||
@@ -706,8 +713,6 @@ PlaylistDetailPage.fragments = {
|
||||
${VideoSummaryCardFragmentEntry}
|
||||
${PlaylistEditDialog.fragments.playlist}
|
||||
${PlaylistTrackRemoveDialog.fragments.playlist}
|
||||
${FeaturedTheme.fragments.entry}
|
||||
${FeaturedTheme.fragments.video}
|
||||
|
||||
fragment PlaylistDetailPagePlaylist on Playlist {
|
||||
...PlaylistEditDialogPlaylist
|
||||
@@ -721,12 +726,10 @@ PlaylistDetailPage.fragments = {
|
||||
id
|
||||
video {
|
||||
...VideoSummaryCardVideo
|
||||
...FeaturedThemeVideo
|
||||
id
|
||||
}
|
||||
entry {
|
||||
...VideoSummaryCardEntry
|
||||
...FeaturedThemeEntry
|
||||
theme {
|
||||
anime {
|
||||
year
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PlaylistAddDialog } from "@/components/dialog/PlaylistAddDialog";
|
||||
import { PlaylistRemoveDialog } from "@/components/dialog/PlaylistRemoveDialog";
|
||||
import { RegisterDialog } from "@/components/dialog/RegisterDialog";
|
||||
import { UserInformationDialog } from "@/components/dialog/UserInformationDialog";
|
||||
import ExternalProfileList from "@/components/external-profile/ExternalProfileList";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { ProfileImage } from "@/components/image/ProfileImage";
|
||||
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
|
||||
@@ -65,6 +66,8 @@ const StyledHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
min-height: 38px;
|
||||
`;
|
||||
|
||||
const StyledHeaderTop = styled(StyledHeader)`
|
||||
@@ -240,6 +243,12 @@ export default function ProfilePage({ me: initialMe }: ProfilePageProps) {
|
||||
) : null}
|
||||
<StyledProfileGrid>
|
||||
<Column style={{ "--gap": "48px" }}>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<StyledHeader>
|
||||
<Text variant="h2">External Profiles</Text>
|
||||
</StyledHeader>
|
||||
<ExternalProfileList />
|
||||
</Column>
|
||||
{me.playlistAll ? (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<StyledHeader>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { error, warn } from "@/utils/log";
|
||||
// Server-side
|
||||
|
||||
const SERVER_API_URL = process.env.ANIMETHEMES_API_URL;
|
||||
const SERVER_API_KEY = process.env.ANIMETHEMES_API_KEY;
|
||||
|
||||
const ANALYZE = !!process.env.ANALYZE;
|
||||
const MINIMAL_BUILD = !!process.env.MINIMAL_BUILD;
|
||||
@@ -61,6 +62,7 @@ export {
|
||||
CLIENT_API_URL,
|
||||
MINIMAL_BUILD,
|
||||
PAGINATION_PAGE_SIZE,
|
||||
SERVER_API_KEY,
|
||||
SERVER_API_URL,
|
||||
STAGING,
|
||||
validateConfig,
|
||||
|
||||
@@ -24,11 +24,6 @@ export const GlobalVolume: Setting<number> = Object.freeze({
|
||||
__INITIAL_VALUE__: 1,
|
||||
});
|
||||
|
||||
export const Muted: Setting<boolean> = Object.freeze({
|
||||
__KEY__: "muted",
|
||||
__INITIAL_VALUE__: false,
|
||||
});
|
||||
|
||||
export const ColorTheme: Setting<string> = Object.freeze({
|
||||
__KEY__: "theme",
|
||||
__INITIAL_VALUE__: "system",
|
||||
|
||||
Reference in New Issue
Block a user