bookmarks, progress and editing of those

This commit is contained in:
Jelle van Snik 2023-01-19 22:29:56 +01:00
parent fb96026195
commit 02cc4b7f1d
21 changed files with 214 additions and 112 deletions

View File

@ -4,6 +4,7 @@
"private": true, "private": true,
"homepage": "https://movie.squeezebox.dev", "homepage": "https://movie.squeezebox.dev",
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^1.0.0-beta.5",
"@headlessui/react": "^1.5.0", "@headlessui/react": "^1.5.0",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"fscreen": "^1.2.0", "fscreen": "^1.2.0",

View File

@ -3,6 +3,7 @@ import { memo } from "react";
export enum Icons { export enum Icons {
SEARCH = "search", SEARCH = "search",
BOOKMARK = "bookmark", BOOKMARK = "bookmark",
BOOKMARK_OUTLINE = "bookmark_outline",
CLOCK = "clock", CLOCK = "clock",
EYE_SLASH = "eyeSlash", EYE_SLASH = "eyeSlash",
ARROW_LEFT = "arrowLeft", ARROW_LEFT = "arrowLeft",
@ -23,6 +24,7 @@ export enum Icons {
VOLUME = "volume", VOLUME = "volume",
VOLUME_X = "volume_x", VOLUME_X = "volume_x",
X = "x", X = "x",
EDIT = "edit",
} }
export interface IconProps { 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: `<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>`, 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>`, 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) => { export const Icon = memo((props: IconProps) => {

View 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>
);
}

View File

@ -6,17 +6,24 @@ export interface IconPatchProps {
clickable?: boolean; clickable?: boolean;
className?: string; className?: string;
icon: Icons; icon: Icons;
transparent?: boolean;
} }
export function IconPatch(props: IconPatchProps) { 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 ( return (
<div className={props.className || undefined} onClick={props.onClick}> <div className={props.className || undefined} onClick={props.onClick}>
<div <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 ${ 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}`}
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" : ""}`}
> >
<Icon icon={props.icon} /> <Icon icon={props.icon} />
</div> </div>

View File

@ -20,8 +20,8 @@ export function SectionHeading(props: SectionHeadingProps) {
) : null} ) : null}
{props.title} {props.title}
</p> </p>
{props.children}
</div> </div>
{props.children}
</div> </div>
); );
} }

View File

@ -2,6 +2,8 @@ import { Link } from "react-router-dom";
import { DotList } from "@/components/text/DotList"; import { DotList } from "@/components/text/DotList";
import { MWMediaMeta } from "@/backend/metadata/types"; import { MWMediaMeta } from "@/backend/metadata/types";
import { mediaTypeToJW } from "@/backend/metadata/justwatch"; import { mediaTypeToJW } from "@/backend/metadata/justwatch";
import { Icons } from "../Icon";
import { IconPatch } from "../buttons/IconPatch";
export interface MediaCardProps { export interface MediaCardProps {
media: MWMediaMeta; media: MWMediaMeta;
@ -11,6 +13,8 @@ export interface MediaCardProps {
season: number; season: number;
}; };
percentage?: number; percentage?: number;
closable?: boolean;
onClose?: () => void;
} }
function MediaCardContent({ function MediaCardContent({
@ -18,18 +22,22 @@ function MediaCardContent({
linkable, linkable,
series, series,
percentage, percentage,
closable,
onClose,
}: MediaCardProps) { }: MediaCardProps) {
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`; const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
const canLink = linkable && !closable;
return ( return (
<div <div
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${ 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 <article
className={`relative mb-2 p-3 transition-transform duration-100 ${ className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
linkable ? "group-hover:scale-95" : "" canLink ? "group-hover:scale-95" : ""
}`} }`}
> >
<div <div
@ -48,8 +56,16 @@ function MediaCardContent({
{percentage !== undefined ? ( {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
<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" /> 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="absolute inset-x-0 bottom-0 p-3">
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600"> <div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
<div <div
@ -62,6 +78,19 @@ function MediaCardContent({
</div> </div>
</> </>
) : null} ) : 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> </div>
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3"> <h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
<span>{media.title}</span> <span>{media.title}</span>
@ -75,14 +104,14 @@ function MediaCardContent({
export function MediaCard(props: MediaCardProps) { export function MediaCard(props: MediaCardProps) {
const content = <MediaCardContent {...props} />; const content = <MediaCardContent {...props} />;
if (!props.linkable) return <span>{content}</span>; const canLink = props.linkable && !props.closable;
return (
<Link const link = canLink
to={`/media/${encodeURIComponent( ? `/media/${encodeURIComponent(
mediaTypeToJW(props.media.type) mediaTypeToJW(props.media.type)
)}-${encodeURIComponent(props.media.id)}`} )}-${encodeURIComponent(props.media.id)}`
> : "#";
{content}
</Link> if (!props.linkable) return <span>{content}</span>;
); return <Link to={link}>{content}</Link>;
} }

View File

@ -1,11 +1,15 @@
import { forwardRef } from "react";
interface MediaGridProps { interface MediaGridProps {
children?: React.ReactNode; children?: React.ReactNode;
} }
export function MediaGrid(props: MediaGridProps) { export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
return ( (props, ref) => {
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3"> return (
{props.children} <div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}>
</div> {props.children}
); </div>
} );
}
);

View File

@ -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}
// />
// );
}

View File

@ -5,6 +5,8 @@ import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps { export interface WatchedMediaCardProps {
media: MWMediaMeta; media: MWMediaMeta;
closable?: boolean;
onClose?: () => void;
} }
export function WatchedMediaCard(props: WatchedMediaCardProps) { export function WatchedMediaCard(props: WatchedMediaCardProps) {
@ -19,6 +21,8 @@ export function WatchedMediaCard(props: WatchedMediaCardProps) {
series={watchedMedia?.item?.series} series={watchedMedia?.item?.series}
linkable linkable
percentage={watchedMedia?.percentage} percentage={watchedMedia?.percentage}
onClose={props.onClose}
closable={props.closable}
/> />
); );
} }

View File

@ -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>;
}

View File

@ -1,3 +1,4 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { CSSTransition } from "react-transition-group"; import { CSSTransition } from "react-transition-group";
import { BackdropControl } from "./controls/BackdropControl"; import { BackdropControl } from "./controls/BackdropControl";
@ -14,7 +15,7 @@ import { useVideoPlayerState } from "./VideoContext";
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer"; import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
interface DecoratedVideoPlayerProps { interface DecoratedVideoPlayerProps {
title?: string; media?: MWMediaMeta;
onGoBack?: () => void; onGoBack?: () => void;
} }
@ -57,7 +58,7 @@ export function DecoratedVideoPlayer(
return ( return (
<VideoPlayer autoPlay={props.autoPlay}> <VideoPlayer autoPlay={props.autoPlay}>
<VideoPlayerError title={props.title} onGoBack={props.onGoBack}> <VideoPlayerError media={props.media} onGoBack={props.onGoBack}>
<BackdropControl onBackdropChange={onBackdropChange}> <BackdropControl onBackdropChange={onBackdropChange}>
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<LoadingControl /> <LoadingControl />
@ -107,7 +108,7 @@ export function DecoratedVideoPlayer(
ref={top} ref={top}
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2" 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>
</CSSTransition> </CSSTransition>
</BackdropControl> </BackdropControl>

View File

@ -1,3 +1,4 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { ErrorMessage } from "@/components/layout/ErrorBoundary"; import { ErrorMessage } from "@/components/layout/ErrorBoundary";
import { Link } from "@/components/text/Link"; import { Link } from "@/components/text/Link";
import { conf } from "@/setup/config"; import { conf } from "@/setup/config";
@ -15,7 +16,7 @@ interface ErrorBoundaryState {
interface VideoErrorBoundaryProps { interface VideoErrorBoundaryProps {
children?: ReactNode; children?: ReactNode;
title?: string; media?: MWMediaMeta;
onGoBack?: () => void; onGoBack?: () => void;
} }
@ -61,7 +62,7 @@ export class VideoErrorBoundary extends Component<
<div className="absolute inset-0 bg-denim-100"> <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"> <div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
<VideoPlayerHeader <VideoPlayerHeader
title={this.props.title} media={this.props.media}
onClick={this.props.onGoBack} onClick={this.props.onGoBack}
/> />
</div> </div>

View File

@ -1,3 +1,4 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { IconPatch } from "@/components/buttons/IconPatch"; import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
@ -6,7 +7,7 @@ import { useVideoPlayerState } from "../VideoContext";
import { VideoPlayerHeader } from "./VideoPlayerHeader"; import { VideoPlayerHeader } from "./VideoPlayerHeader";
interface VideoPlayerErrorProps { interface VideoPlayerErrorProps {
title?: string; media?: MWMediaMeta;
onGoBack?: () => void; onGoBack?: () => void;
children?: ReactNode; children?: ReactNode;
} }
@ -28,7 +29,7 @@ export function VideoPlayerError(props: VideoPlayerErrorProps) {
</p> </p>
</div> </div>
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"> <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>
</div> </div>
); );

View File

@ -1,13 +1,23 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { BrandPill } from "@/components/layout/BrandPill"; import { BrandPill } from "@/components/layout/BrandPill";
import {
getIfBookmarkedFromPortable,
useBookmarkContext,
} from "@/state/bookmark";
interface VideoPlayerHeaderProps { interface VideoPlayerHeaderProps {
title?: string; media?: MWMediaMeta;
onClick?: () => void; onClick?: () => void;
} }
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) { 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 ( return (
<div className="flex items-center"> <div className="flex items-center">
<div className="flex flex-1 items-center"> <div className="flex flex-1 items-center">
@ -24,8 +34,18 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
{showDivider ? ( {showDivider ? (
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" /> <span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
) : null} ) : null}
{props.title ? ( {props.media ? (
<span className="text-white">{props.title}</span> <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} ) : null}
</p> </p>
</div> </div>

View File

@ -59,24 +59,17 @@ export function BookmarkContextProvider(props: { children: ReactNode }) {
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
setItemBookmark(media: MWMediaMeta, bookmarked: boolean) { setItemBookmark(media: MWMediaMeta, bookmarked: boolean) {
setBookmarked((data: BookmarkStoreData) => { setBookmarked((data: BookmarkStoreData): BookmarkStoreData => {
if (bookmarked) { let bookmarks = [...data.bookmarks];
const itemIndex = getBookmarkIndexFromMedia(data.bookmarks, media); bookmarks = bookmarks.filter((v) => v.id !== media.id);
if (itemIndex === -1) { if (bookmarked) bookmarks.push({ ...media });
const item: MWMediaMeta = { ...media }; return {
data.bookmarks.push(item); bookmarks,
} };
} else {
const itemIndex = getBookmarkIndexFromMedia(data.bookmarks, media);
if (itemIndex !== -1) {
data.bookmarks.splice(itemIndex);
}
}
return data;
}); });
}, },
getFilteredBookmarks() { getFilteredBookmarks() {
return []; return [...bookmarkStorage.bookmarks];
}, },
bookmarkStore: bookmarkStorage, bookmarkStore: bookmarkStorage,
}), }),

View File

@ -50,12 +50,14 @@ export interface WatchedStoreData {
interface WatchedStoreDataWrapper { interface WatchedStoreDataWrapper {
updateProgress(media: MediaItem, progress: number, total: number): void; updateProgress(media: MediaItem, progress: number, total: number): void;
getFilteredWatched(): WatchedStoreItem[]; getFilteredWatched(): WatchedStoreItem[];
removeProgress(id: string): void;
watched: WatchedStoreData; watched: WatchedStoreData;
} }
const WatchedContext = createContext<WatchedStoreDataWrapper>({ const WatchedContext = createContext<WatchedStoreDataWrapper>({
updateProgress: () => {}, updateProgress: () => {},
getFilteredWatched: () => [], getFilteredWatched: () => [],
removeProgress: () => {},
watched: { watched: {
items: [], items: [],
}, },
@ -84,6 +86,13 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
const contextValue = useMemo( 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 { updateProgress(media: MediaItem, progress: number, total: number): void {
// TODO series support // TODO series support
setWatched((data: WatchedStoreData) => { setWatched((data: WatchedStoreData) => {

View File

@ -1,3 +1,4 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { ErrorMessage } from "@/components/layout/ErrorBoundary"; import { ErrorMessage } from "@/components/layout/ErrorBoundary";
import { Link } from "@/components/text/Link"; import { Link } from "@/components/text/Link";
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader"; 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(); const goBack = useGoBack();
return ( return (
<div className="h-screen flex-1"> <div className="h-screen flex-1">
<div className="fixed inset-x-0 top-0 py-6 px-8"> <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> </div>
<ErrorMessage> <ErrorMessage>
<p className="my-6 max-w-lg"> <p className="my-6 max-w-lg">

View File

@ -51,10 +51,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
return ( return (
<div className="relative flex h-screen items-center justify-center"> <div className="relative flex h-screen items-center justify-center">
<div className="absolute inset-x-0 top-0 py-6 px-8"> <div className="absolute inset-x-0 top-0 py-6 px-8">
<VideoPlayerHeader <VideoPlayerHeader onClick={props.onGoBack} media={props.meta.meta} />
onClick={props.onGoBack}
title={props.meta.meta.title}
/>
</div> </div>
<div className="flex flex-col items-center transition-opacity duration-200"> <div className="flex flex-col items-center transition-opacity duration-200">
{pending ? ( {pending ? (
@ -142,7 +139,7 @@ export function MediaView() {
// show stream once we have a stream // show stream once we have a stream
return ( return (
<div className="h-screen w-screen"> <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} /> <SourceControl source={stream.streamUrl} type={stream.type} />
<ProgressListenerControl <ProgressListenerControl
startAt={watchedItem?.progress} startAt={watchedItem?.progress}

View File

@ -8,32 +8,47 @@ import {
} from "@/state/bookmark"; } from "@/state/bookmark";
import { useWatchedContext } from "@/state/watched"; import { useWatchedContext } from "@/state/watched";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 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() { function Bookmarks() {
const { t } = useTranslation(); const { t } = useTranslation();
const { getFilteredBookmarks } = useBookmarkContext(); const { getFilteredBookmarks, setItemBookmark } = useBookmarkContext();
const bookmarks = getFilteredBookmarks(); const bookmarks = getFilteredBookmarks();
const [editing, setEditing] = useState(false);
const [gridRef] = useAutoAnimate<HTMLDivElement>();
if (bookmarks.length === 0) return null; if (bookmarks.length === 0) return null;
return ( return (
<SectionHeading <div>
title={t("search.bookmarks") || "Bookmarks"} <SectionHeading
icon={Icons.BOOKMARK} title={t("search.bookmarks") || "Bookmarks"}
> icon={Icons.BOOKMARK}
<MediaGrid> >
<EditButton editing={editing} onEdit={setEditing} />
</SectionHeading>
<MediaGrid ref={gridRef}>
{bookmarks.map((v) => ( {bookmarks.map((v) => (
<WatchedMediaCard key={v.id} media={v} /> <WatchedMediaCard
key={v.id}
media={v}
closable={editing}
onClose={() => setItemBookmark(v, false)}
/>
))} ))}
</MediaGrid> </MediaGrid>
</SectionHeading> </div>
); );
} }
function Watched() { function Watched() {
const { t } = useTranslation(); const { t } = useTranslation();
const { getFilteredBookmarks } = useBookmarkContext(); const { getFilteredBookmarks } = useBookmarkContext();
const { getFilteredWatched } = useWatchedContext(); const { getFilteredWatched, removeProgress } = useWatchedContext();
const [editing, setEditing] = useState(false);
const [gridRef] = useAutoAnimate<HTMLDivElement>();
const bookmarks = getFilteredBookmarks(); const bookmarks = getFilteredBookmarks();
const watchedItems = getFilteredWatched().filter( const watchedItems = getFilteredWatched().filter(
@ -43,16 +58,24 @@ function Watched() {
if (watchedItems.length === 0) return null; if (watchedItems.length === 0) return null;
return ( return (
<SectionHeading <div>
title={t("search.continueWatching") || "Continue Watching"} <SectionHeading
icon={Icons.CLOCK} title={t("search.continueWatching") || "Continue Watching"}
> icon={Icons.CLOCK}
<MediaGrid> >
<EditButton editing={editing} onEdit={setEditing} />
</SectionHeading>
<MediaGrid ref={gridRef}>
{watchedItems.map((v) => ( {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> </MediaGrid>
</SectionHeading> </div>
); );
} }

View File

@ -68,16 +68,17 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
return ( return (
<div> <div>
{results.length > 0 ? ( {results.length > 0 ? (
<SectionHeading <div>
title={t("search.headingTitle") || "Search results"} <SectionHeading
icon={Icons.SEARCH} title={t("search.headingTitle") || "Search results"}
> icon={Icons.SEARCH}
/>
<MediaGrid> <MediaGrid>
{results.map((v) => ( {results.map((v) => (
<WatchedMediaCard key={v.id.toString()} media={v} /> <WatchedMediaCard key={v.id.toString()} media={v} />
))} ))}
</MediaGrid> </MediaGrid>
</SectionHeading> </div>
) : null} ) : null}
<SearchSuffix results={results.length} /> <SearchSuffix results={results.length} />

View File

@ -40,6 +40,11 @@
"minimatch" "^3.1.2" "minimatch" "^3.1.2"
"strip-json-comments" "^3.1.1" "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": "@gar/promisify@^1.1.3":
"version" "1.1.3" "version" "1.1.3"