mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-10 21:45:06 +01:00
bookmarks, progress and editing of those
This commit is contained in:
parent
fb96026195
commit
02cc4b7f1d
@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"homepage": "https://movie.squeezebox.dev",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"fscreen": "^1.2.0",
|
||||
|
@ -3,6 +3,7 @@ import { memo } from "react";
|
||||
export enum Icons {
|
||||
SEARCH = "search",
|
||||
BOOKMARK = "bookmark",
|
||||
BOOKMARK_OUTLINE = "bookmark_outline",
|
||||
CLOCK = "clock",
|
||||
EYE_SLASH = "eyeSlash",
|
||||
ARROW_LEFT = "arrowLeft",
|
||||
@ -23,6 +24,7 @@ export enum Icons {
|
||||
VOLUME = "volume",
|
||||
VOLUME_X = "volume_x",
|
||||
X = "x",
|
||||
EDIT = "edit",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
@ -53,6 +55,8 @@ const iconList: Record<Icons, string> = {
|
||||
volume: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"/></svg>`,
|
||||
volume_x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>`,
|
||||
x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"/></svg>`,
|
||||
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>`,
|
||||
bookmark_outline: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M336 0h-288C21.49 0 0 21.49 0 48v431.9c0 24.7 26.79 40.08 48.12 27.64L192 423.6l143.9 83.93C357.2 519.1 384 504.6 384 479.9V48C384 21.49 362.5 0 336 0zM336 452L192 368l-144 84V54C48 50.63 50.63 48 53.1 48h276C333.4 48 336 50.63 336 54V452z"/></svg>`,
|
||||
};
|
||||
|
||||
export const Icon = memo((props: IconProps) => {
|
||||
|
32
src/components/buttons/EditButton.tsx
Normal file
32
src/components/buttons/EditButton.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback } from "react";
|
||||
import { ButtonControl } from "./ButtonControl";
|
||||
|
||||
export interface EditButtonProps {
|
||||
editing: boolean;
|
||||
onEdit?: (editing: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditButton(props: EditButtonProps) {
|
||||
const [parent] = useAutoAnimate<HTMLSpanElement>();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
props.onEdit?.(!props.editing);
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<ButtonControl
|
||||
onClick={onClick}
|
||||
className="flex h-12 items-center overflow-hidden rounded-full bg-denim-400 px-4 py-2 text-white transition-[background-color,transform] hover:bg-denim-500 active:scale-105"
|
||||
>
|
||||
<span ref={parent}>
|
||||
{props.editing ? (
|
||||
<span className="mx-4">Stop editing</span>
|
||||
) : (
|
||||
<Icon icon={Icons.EDIT} />
|
||||
)}
|
||||
</span>
|
||||
</ButtonControl>
|
||||
);
|
||||
}
|
@ -6,17 +6,24 @@ export interface IconPatchProps {
|
||||
clickable?: boolean;
|
||||
className?: string;
|
||||
icon: Icons;
|
||||
transparent?: boolean;
|
||||
}
|
||||
|
||||
export function IconPatch(props: IconPatchProps) {
|
||||
const clickableClasses = props.clickable
|
||||
? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125"
|
||||
: "";
|
||||
const transparentClasses = props.transparent
|
||||
? "bg-opacity-0 hover:bg-opacity-50"
|
||||
: "";
|
||||
const activeClasses = props.active
|
||||
? "border-bink-600 bg-bink-100 text-bink-600"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className={props.className || undefined} onClick={props.onClick}>
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-500 transition-[color,transform,border-color] duration-75 ${
|
||||
props.clickable
|
||||
? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125"
|
||||
: ""
|
||||
} ${props.active ? "border-bink-600 bg-bink-100 text-bink-600" : ""}`}
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-500 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses}`}
|
||||
>
|
||||
<Icon icon={props.icon} />
|
||||
</div>
|
||||
|
@ -20,8 +20,8 @@ export function SectionHeading(props: SectionHeadingProps) {
|
||||
) : null}
|
||||
{props.title}
|
||||
</p>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -2,6 +2,8 @@ import { Link } from "react-router-dom";
|
||||
import { DotList } from "@/components/text/DotList";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { mediaTypeToJW } from "@/backend/metadata/justwatch";
|
||||
import { Icons } from "../Icon";
|
||||
import { IconPatch } from "../buttons/IconPatch";
|
||||
|
||||
export interface MediaCardProps {
|
||||
media: MWMediaMeta;
|
||||
@ -11,6 +13,8 @@ export interface MediaCardProps {
|
||||
season: number;
|
||||
};
|
||||
percentage?: number;
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function MediaCardContent({
|
||||
@ -18,18 +22,22 @@ function MediaCardContent({
|
||||
linkable,
|
||||
series,
|
||||
percentage,
|
||||
closable,
|
||||
onClose,
|
||||
}: MediaCardProps) {
|
||||
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
||||
|
||||
const canLink = linkable && !closable;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
||||
linkable ? "hover:bg-opacity-100" : ""
|
||||
canLink ? "hover:bg-opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
<article
|
||||
className={`relative mb-2 p-3 transition-transform duration-100 ${
|
||||
linkable ? "group-hover:scale-95" : ""
|
||||
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
||||
canLink ? "group-hover:scale-95" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
@ -48,8 +56,16 @@ function MediaCardContent({
|
||||
|
||||
{percentage !== undefined ? (
|
||||
<>
|
||||
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors group-hover:from-denim-100" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors group-hover:from-denim-100" />
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 p-3">
|
||||
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
|
||||
<div
|
||||
@ -62,6 +78,19 @@ function MediaCardContent({
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
|
||||
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<IconPatch
|
||||
clickable
|
||||
className="text-2xl text-slate-400"
|
||||
onClick={() => closable && onClose?.()}
|
||||
icon={Icons.X}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
||||
<span>{media.title}</span>
|
||||
@ -75,14 +104,14 @@ function MediaCardContent({
|
||||
export function MediaCard(props: MediaCardProps) {
|
||||
const content = <MediaCardContent {...props} />;
|
||||
|
||||
if (!props.linkable) return <span>{content}</span>;
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${encodeURIComponent(
|
||||
const canLink = props.linkable && !props.closable;
|
||||
|
||||
const link = canLink
|
||||
? `/media/${encodeURIComponent(
|
||||
mediaTypeToJW(props.media.type)
|
||||
)}-${encodeURIComponent(props.media.id)}`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
)}-${encodeURIComponent(props.media.id)}`
|
||||
: "#";
|
||||
|
||||
if (!props.linkable) return <span>{content}</span>;
|
||||
return <Link to={link}>{content}</Link>;
|
||||
}
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { forwardRef } from "react";
|
||||
|
||||
interface MediaGridProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MediaGrid(props: MediaGridProps) {
|
||||
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -1,24 +0,0 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched";
|
||||
import { Episode } from "./EpisodeButton";
|
||||
|
||||
export interface WatchedEpisodeProps {
|
||||
media: MWMediaMeta;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export function WatchedEpisode(props: WatchedEpisodeProps) {
|
||||
// const { watched } = useWatchedContext();
|
||||
// const foundWatched = getWatchedFromPortable(watched.items, props.media);
|
||||
// // const episode = getEpisodeFromMedia(props.media);
|
||||
// const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
|
||||
// return (
|
||||
// <Episode
|
||||
// progress={watchedPercentage}
|
||||
// episodeNumber={episode?.episode?.sort ?? 1}
|
||||
// active={props.active}
|
||||
// onClick={props.onClick}
|
||||
// />
|
||||
// );
|
||||
}
|
@ -5,6 +5,8 @@ import { MediaCard } from "./MediaCard";
|
||||
|
||||
export interface WatchedMediaCardProps {
|
||||
media: MWMediaMeta;
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||
@ -19,6 +21,8 @@ export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||
series={watchedMedia?.item?.series}
|
||||
linkable
|
||||
percentage={watchedMedia?.percentage}
|
||||
onClose={props.onClose}
|
||||
closable={props.closable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
export interface TaglineProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tagline(props: TaglineProps) {
|
||||
return <p className="font-bold text-bink-600">{props.children}</p>;
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
import { BackdropControl } from "./controls/BackdropControl";
|
||||
@ -14,7 +15,7 @@ import { useVideoPlayerState } from "./VideoContext";
|
||||
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
|
||||
|
||||
interface DecoratedVideoPlayerProps {
|
||||
title?: string;
|
||||
media?: MWMediaMeta;
|
||||
onGoBack?: () => void;
|
||||
}
|
||||
|
||||
@ -57,7 +58,7 @@ export function DecoratedVideoPlayer(
|
||||
|
||||
return (
|
||||
<VideoPlayer autoPlay={props.autoPlay}>
|
||||
<VideoPlayerError title={props.title} onGoBack={props.onGoBack}>
|
||||
<VideoPlayerError media={props.media} onGoBack={props.onGoBack}>
|
||||
<BackdropControl onBackdropChange={onBackdropChange}>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingControl />
|
||||
@ -107,7 +108,7 @@ export function DecoratedVideoPlayer(
|
||||
ref={top}
|
||||
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
|
||||
>
|
||||
<VideoPlayerHeader title={props.title} onClick={props.onGoBack} />
|
||||
<VideoPlayerHeader media={props.media} onClick={props.onGoBack} />
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</BackdropControl>
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { conf } from "@/setup/config";
|
||||
@ -15,7 +16,7 @@ interface ErrorBoundaryState {
|
||||
|
||||
interface VideoErrorBoundaryProps {
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
media?: MWMediaMeta;
|
||||
onGoBack?: () => void;
|
||||
}
|
||||
|
||||
@ -61,7 +62,7 @@ export class VideoErrorBoundary extends Component<
|
||||
<div className="absolute inset-0 bg-denim-100">
|
||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
|
||||
<VideoPlayerHeader
|
||||
title={this.props.title}
|
||||
media={this.props.media}
|
||||
onClick={this.props.onGoBack}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Title } from "@/components/text/Title";
|
||||
@ -6,7 +7,7 @@ import { useVideoPlayerState } from "../VideoContext";
|
||||
import { VideoPlayerHeader } from "./VideoPlayerHeader";
|
||||
|
||||
interface VideoPlayerErrorProps {
|
||||
title?: string;
|
||||
media?: MWMediaMeta;
|
||||
onGoBack?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
@ -28,7 +29,7 @@ export function VideoPlayerError(props: VideoPlayerErrorProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
|
||||
<VideoPlayerHeader title={props.title} onClick={props.onGoBack} />
|
||||
<VideoPlayerHeader media={props.media} onClick={props.onGoBack} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,13 +1,23 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { BrandPill } from "@/components/layout/BrandPill";
|
||||
import {
|
||||
getIfBookmarkedFromPortable,
|
||||
useBookmarkContext,
|
||||
} from "@/state/bookmark";
|
||||
|
||||
interface VideoPlayerHeaderProps {
|
||||
title?: string;
|
||||
media?: MWMediaMeta;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
const showDivider = props.title && props.onClick;
|
||||
const { bookmarkStore, setItemBookmark } = useBookmarkContext();
|
||||
const isBookmarked = props.media
|
||||
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media)
|
||||
: false;
|
||||
const showDivider = props.media && props.onClick;
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-1 items-center">
|
||||
@ -24,8 +34,18 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
{showDivider ? (
|
||||
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
|
||||
) : null}
|
||||
{props.title ? (
|
||||
<span className="text-white">{props.title}</span>
|
||||
{props.media ? (
|
||||
<span className="flex items-center space-x-2 text-white">
|
||||
<span>{props.media.title}</span>
|
||||
<IconPatch
|
||||
clickable
|
||||
transparent
|
||||
icon={isBookmarked ? Icons.BOOKMARK : Icons.BOOKMARK_OUTLINE}
|
||||
onClick={() =>
|
||||
props.media && setItemBookmark(props.media, !isBookmarked)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
@ -59,24 +59,17 @@ export function BookmarkContextProvider(props: { children: ReactNode }) {
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
setItemBookmark(media: MWMediaMeta, bookmarked: boolean) {
|
||||
setBookmarked((data: BookmarkStoreData) => {
|
||||
if (bookmarked) {
|
||||
const itemIndex = getBookmarkIndexFromMedia(data.bookmarks, media);
|
||||
if (itemIndex === -1) {
|
||||
const item: MWMediaMeta = { ...media };
|
||||
data.bookmarks.push(item);
|
||||
}
|
||||
} else {
|
||||
const itemIndex = getBookmarkIndexFromMedia(data.bookmarks, media);
|
||||
if (itemIndex !== -1) {
|
||||
data.bookmarks.splice(itemIndex);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
setBookmarked((data: BookmarkStoreData): BookmarkStoreData => {
|
||||
let bookmarks = [...data.bookmarks];
|
||||
bookmarks = bookmarks.filter((v) => v.id !== media.id);
|
||||
if (bookmarked) bookmarks.push({ ...media });
|
||||
return {
|
||||
bookmarks,
|
||||
};
|
||||
});
|
||||
},
|
||||
getFilteredBookmarks() {
|
||||
return [];
|
||||
return [...bookmarkStorage.bookmarks];
|
||||
},
|
||||
bookmarkStore: bookmarkStorage,
|
||||
}),
|
||||
|
@ -50,12 +50,14 @@ export interface WatchedStoreData {
|
||||
interface WatchedStoreDataWrapper {
|
||||
updateProgress(media: MediaItem, progress: number, total: number): void;
|
||||
getFilteredWatched(): WatchedStoreItem[];
|
||||
removeProgress(id: string): void;
|
||||
watched: WatchedStoreData;
|
||||
}
|
||||
|
||||
const WatchedContext = createContext<WatchedStoreDataWrapper>({
|
||||
updateProgress: () => {},
|
||||
getFilteredWatched: () => [],
|
||||
removeProgress: () => {},
|
||||
watched: {
|
||||
items: [],
|
||||
},
|
||||
@ -84,6 +86,13 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
removeProgress(id: string) {
|
||||
setWatched((data: WatchedStoreData) => {
|
||||
const newData = { ...data };
|
||||
newData.items = newData.items.filter((v) => v.item.meta.id !== id);
|
||||
return newData;
|
||||
});
|
||||
},
|
||||
updateProgress(media: MediaItem, progress: number, total: number): void {
|
||||
// TODO series support
|
||||
setWatched((data: WatchedStoreData) => {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||
@ -22,13 +23,13 @@ export function MediaFetchErrorView() {
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaPlaybackErrorView(props: { title?: string }) {
|
||||
export function MediaPlaybackErrorView(props: { media?: MWMediaMeta }) {
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1">
|
||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||
<VideoPlayerHeader onClick={goBack} title={props.title} />
|
||||
<VideoPlayerHeader onClick={goBack} media={props.media} />
|
||||
</div>
|
||||
<ErrorMessage>
|
||||
<p className="my-6 max-w-lg">
|
||||
|
@ -51,10 +51,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
|
||||
return (
|
||||
<div className="relative flex h-screen items-center justify-center">
|
||||
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
||||
<VideoPlayerHeader
|
||||
onClick={props.onGoBack}
|
||||
title={props.meta.meta.title}
|
||||
/>
|
||||
<VideoPlayerHeader onClick={props.onGoBack} media={props.meta.meta} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center transition-opacity duration-200">
|
||||
{pending ? (
|
||||
@ -142,7 +139,7 @@ export function MediaView() {
|
||||
// show stream once we have a stream
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<DecoratedVideoPlayer title={meta.meta.title} onGoBack={goBack} autoPlay>
|
||||
<DecoratedVideoPlayer media={meta.meta} onGoBack={goBack} autoPlay>
|
||||
<SourceControl source={stream.streamUrl} type={stream.type} />
|
||||
<ProgressListenerControl
|
||||
startAt={watchedItem?.progress}
|
||||
|
@ -8,32 +8,47 @@ import {
|
||||
} from "@/state/bookmark";
|
||||
import { useWatchedContext } from "@/state/watched";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { useState } from "react";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
|
||||
function Bookmarks() {
|
||||
const { t } = useTranslation();
|
||||
const { getFilteredBookmarks } = useBookmarkContext();
|
||||
const { getFilteredBookmarks, setItemBookmark } = useBookmarkContext();
|
||||
const bookmarks = getFilteredBookmarks();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
if (bookmarks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeading
|
||||
title={t("search.bookmarks") || "Bookmarks"}
|
||||
icon={Icons.BOOKMARK}
|
||||
>
|
||||
<MediaGrid>
|
||||
<EditButton editing={editing} onEdit={setEditing} />
|
||||
</SectionHeading>
|
||||
<MediaGrid ref={gridRef}>
|
||||
{bookmarks.map((v) => (
|
||||
<WatchedMediaCard key={v.id} media={v} />
|
||||
<WatchedMediaCard
|
||||
key={v.id}
|
||||
media={v}
|
||||
closable={editing}
|
||||
onClose={() => setItemBookmark(v, false)}
|
||||
/>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Watched() {
|
||||
const { t } = useTranslation();
|
||||
const { getFilteredBookmarks } = useBookmarkContext();
|
||||
const { getFilteredWatched } = useWatchedContext();
|
||||
const { getFilteredWatched, removeProgress } = useWatchedContext();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
const bookmarks = getFilteredBookmarks();
|
||||
const watchedItems = getFilteredWatched().filter(
|
||||
@ -43,16 +58,24 @@ function Watched() {
|
||||
if (watchedItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeading
|
||||
title={t("search.continueWatching") || "Continue Watching"}
|
||||
icon={Icons.CLOCK}
|
||||
>
|
||||
<MediaGrid>
|
||||
<EditButton editing={editing} onEdit={setEditing} />
|
||||
</SectionHeading>
|
||||
<MediaGrid ref={gridRef}>
|
||||
{watchedItems.map((v) => (
|
||||
<WatchedMediaCard key={v.item.meta.id} media={v.item.meta} />
|
||||
<WatchedMediaCard
|
||||
key={v.item.meta.id}
|
||||
media={v.item.meta}
|
||||
closable={editing}
|
||||
onClose={() => removeProgress(v.item.meta.id)}
|
||||
/>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -68,16 +68,17 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
|
||||
return (
|
||||
<div>
|
||||
{results.length > 0 ? (
|
||||
<div>
|
||||
<SectionHeading
|
||||
title={t("search.headingTitle") || "Search results"}
|
||||
icon={Icons.SEARCH}
|
||||
>
|
||||
/>
|
||||
<MediaGrid>
|
||||
{results.map((v) => (
|
||||
<WatchedMediaCard key={v.id.toString()} media={v} />
|
||||
))}
|
||||
</MediaGrid>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SearchSuffix results={results.length} />
|
||||
|
@ -40,6 +40,11 @@
|
||||
"minimatch" "^3.1.2"
|
||||
"strip-json-comments" "^3.1.1"
|
||||
|
||||
"@formkit/auto-animate@^1.0.0-beta.5":
|
||||
"integrity" "sha512-WoSwyhAZPOe6RB/IgicOtCHtrWwEpfKIZ/H/nxpKfnZL9CB6hhhBGU5bCdMRw7YpAUF2CDlQa+WWh+gCqz5lDg=="
|
||||
"resolved" "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-1.0.0-beta.5.tgz"
|
||||
"version" "1.0.0-beta.5"
|
||||
|
||||
"@gar/promisify@^1.1.3":
|
||||
"version" "1.1.3"
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user