diff --git a/package.json b/package.json index e7947361..ee11ba00 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 7866066f..2b8fa81e 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -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 = { volume: ``, volume_x: ``, x: ``, + edit: ``, + bookmark_outline: ``, }; export const Icon = memo((props: IconProps) => { diff --git a/src/components/buttons/EditButton.tsx b/src/components/buttons/EditButton.tsx new file mode 100644 index 00000000..01988b51 --- /dev/null +++ b/src/components/buttons/EditButton.tsx @@ -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(); + + const onClick = useCallback(() => { + props.onEdit?.(!props.editing); + }, [props]); + + return ( + + + {props.editing ? ( + Stop editing + ) : ( + + )} + + + ); +} diff --git a/src/components/buttons/IconPatch.tsx b/src/components/buttons/IconPatch.tsx index 53980322..d51f20b1 100644 --- a/src/components/buttons/IconPatch.tsx +++ b/src/components/buttons/IconPatch.tsx @@ -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 (
diff --git a/src/components/layout/SectionHeading.tsx b/src/components/layout/SectionHeading.tsx index eb245725..a9d01cb7 100644 --- a/src/components/layout/SectionHeading.tsx +++ b/src/components/layout/SectionHeading.tsx @@ -20,8 +20,8 @@ export function SectionHeading(props: SectionHeadingProps) { ) : null} {props.title}

+ {props.children}
- {props.children} ); } diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 1b23b974..0a72d463 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -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 (
-
-
+
+
) : null} + +
+ closable && onClose?.()} + icon={Icons.X} + /> +

{media.title} @@ -75,14 +104,14 @@ function MediaCardContent({ export function MediaCard(props: MediaCardProps) { const content = ; - if (!props.linkable) return {content}; - return ( - - {content} - - ); + )}-${encodeURIComponent(props.media.id)}` + : "#"; + + if (!props.linkable) return {content}; + return {content}; } diff --git a/src/components/media/MediaGrid.tsx b/src/components/media/MediaGrid.tsx index 59a3e39e..a9f75b22 100644 --- a/src/components/media/MediaGrid.tsx +++ b/src/components/media/MediaGrid.tsx @@ -1,11 +1,15 @@ +import { forwardRef } from "react"; + interface MediaGridProps { children?: React.ReactNode; } -export function MediaGrid(props: MediaGridProps) { - return ( -
- {props.children} -
- ); -} +export const MediaGrid = forwardRef( + (props, ref) => { + return ( +
+ {props.children} +
+ ); + } +); diff --git a/src/components/media/WatchedEpisodeButton.tsx b/src/components/media/WatchedEpisodeButton.tsx deleted file mode 100644 index 779f2aef..00000000 --- a/src/components/media/WatchedEpisodeButton.tsx +++ /dev/null @@ -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 ( - // - // ); -} diff --git a/src/components/media/WatchedMediaCard.tsx b/src/components/media/WatchedMediaCard.tsx index 1dd11d5e..62ffcc73 100644 --- a/src/components/media/WatchedMediaCard.tsx +++ b/src/components/media/WatchedMediaCard.tsx @@ -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} /> ); } diff --git a/src/components/text/Tagline.tsx b/src/components/text/Tagline.tsx deleted file mode 100644 index 88633f5e..00000000 --- a/src/components/text/Tagline.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export interface TaglineProps { - children?: React.ReactNode; -} - -export function Tagline(props: TaglineProps) { - return

{props.children}

; -} diff --git a/src/components/video/DecoratedVideoPlayer.tsx b/src/components/video/DecoratedVideoPlayer.tsx index f5a471ee..7c68bf6f 100644 --- a/src/components/video/DecoratedVideoPlayer.tsx +++ b/src/components/video/DecoratedVideoPlayer.tsx @@ -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 ( - +
@@ -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" > - +
diff --git a/src/components/video/parts/VideoErrorBoundary.tsx b/src/components/video/parts/VideoErrorBoundary.tsx index c05bbd5a..205e27ae 100644 --- a/src/components/video/parts/VideoErrorBoundary.tsx +++ b/src/components/video/parts/VideoErrorBoundary.tsx @@ -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<
diff --git a/src/components/video/parts/VideoPlayerError.tsx b/src/components/video/parts/VideoPlayerError.tsx index 26ae3dee..4b3e42da 100644 --- a/src/components/video/parts/VideoPlayerError.tsx +++ b/src/components/video/parts/VideoPlayerError.tsx @@ -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) {

- +

); diff --git a/src/components/video/parts/VideoPlayerHeader.tsx b/src/components/video/parts/VideoPlayerHeader.tsx index 2296caf7..0fbde691 100644 --- a/src/components/video/parts/VideoPlayerHeader.tsx +++ b/src/components/video/parts/VideoPlayerHeader.tsx @@ -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 (
@@ -24,8 +34,18 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) { {showDivider ? ( ) : null} - {props.title ? ( - {props.title} + {props.media ? ( + + {props.media.title} + + props.media && setItemBookmark(props.media, !isBookmarked) + } + /> + ) : null}

diff --git a/src/state/bookmark/context.tsx b/src/state/bookmark/context.tsx index b4593be2..65485a7b 100644 --- a/src/state/bookmark/context.tsx +++ b/src/state/bookmark/context.tsx @@ -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, }), diff --git a/src/state/watched/context.tsx b/src/state/watched/context.tsx index dd44ba58..00208956 100644 --- a/src/state/watched/context.tsx +++ b/src/state/watched/context.tsx @@ -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({ 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) => { diff --git a/src/views/media/MediaErrorView.tsx b/src/views/media/MediaErrorView.tsx index 586f123a..df5e9943 100644 --- a/src/views/media/MediaErrorView.tsx +++ b/src/views/media/MediaErrorView.tsx @@ -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 (
- +

diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index 4c92fd4d..f6bb4272 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -51,10 +51,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) { return (

- +
{pending ? ( @@ -142,7 +139,7 @@ export function MediaView() { // show stream once we have a stream return (
- + (); if (bookmarks.length === 0) return null; return ( - - +
+ + + + {bookmarks.map((v) => ( - + setItemBookmark(v, false)} + /> ))} - +
); } function Watched() { const { t } = useTranslation(); const { getFilteredBookmarks } = useBookmarkContext(); - const { getFilteredWatched } = useWatchedContext(); + const { getFilteredWatched, removeProgress } = useWatchedContext(); + const [editing, setEditing] = useState(false); + const [gridRef] = useAutoAnimate(); const bookmarks = getFilteredBookmarks(); const watchedItems = getFilteredWatched().filter( @@ -43,16 +58,24 @@ function Watched() { if (watchedItems.length === 0) return null; return ( - - +
+ + + + {watchedItems.map((v) => ( - + removeProgress(v.item.meta.id)} + /> ))} - +
); } diff --git a/src/views/search/SearchResultsView.tsx b/src/views/search/SearchResultsView.tsx index 60a61115..0726ae5f 100644 --- a/src/views/search/SearchResultsView.tsx +++ b/src/views/search/SearchResultsView.tsx @@ -68,16 +68,17 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) { return (
{results.length > 0 ? ( - +
+ {results.map((v) => ( ))} - +
) : null} diff --git a/yarn.lock b/yarn.lock index d3d5529c..50e52b88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"