diff options
| author | Factiven <[email protected]> | 2023-12-24 13:03:54 +0700 |
|---|---|---|
| committer | Factiven <[email protected]> | 2023-12-24 13:03:54 +0700 |
| commit | 50a0f0240d7fef133eb5acc1bea2b1168b08e9db (patch) | |
| tree | 307e09e505580415a58d64b5fc3580e9235869f1 /components/watch/new-player | |
| parent | Update README.md (#104) (diff) | |
| download | moopa-50a0f0240d7fef133eb5acc1bea2b1168b08e9db.tar.xz moopa-50a0f0240d7fef133eb5acc1bea2b1168b08e9db.zip | |
migrate to typescript
Diffstat (limited to 'components/watch/new-player')
| -rw-r--r-- | components/watch/new-player/components/bufferingIndicator.tsx | 15 | ||||
| -rw-r--r-- | components/watch/new-player/components/buttons.tsx | 277 | ||||
| -rw-r--r-- | components/watch/new-player/components/chapter-title.tsx | 11 | ||||
| -rw-r--r-- | components/watch/new-player/components/layouts/captions.module.css | 80 | ||||
| -rw-r--r-- | components/watch/new-player/components/layouts/video-layout.module.css | 13 | ||||
| -rw-r--r-- | components/watch/new-player/components/layouts/video-layout.tsx | 173 | ||||
| -rw-r--r-- | components/watch/new-player/components/menus.tsx | 387 | ||||
| -rw-r--r-- | components/watch/new-player/components/sliders.tsx | 73 | ||||
| -rw-r--r-- | components/watch/new-player/components/time-group.tsx | 11 | ||||
| -rw-r--r-- | components/watch/new-player/components/title.tsx | 35 | ||||
| -rw-r--r-- | components/watch/new-player/player.module.css | 50 | ||||
| -rw-r--r-- | components/watch/new-player/player.tsx | 471 | ||||
| -rw-r--r-- | components/watch/new-player/tracks.tsx | 184 |
13 files changed, 1780 insertions, 0 deletions
diff --git a/components/watch/new-player/components/bufferingIndicator.tsx b/components/watch/new-player/components/bufferingIndicator.tsx new file mode 100644 index 0000000..4793d55 --- /dev/null +++ b/components/watch/new-player/components/bufferingIndicator.tsx @@ -0,0 +1,15 @@ +import { Spinner } from "@vidstack/react"; + +export default function BufferingIndicator() { + return ( + <div className="pointer-events-none absolute inset-0 z-50 flex h-full w-full items-center justify-center"> + <Spinner.Root + className="text-white opacity-0 transition-opacity duration-200 ease-linear media-buffering:animate-spin media-buffering:opacity-100" + size={84} + > + <Spinner.Track className="opacity-25" width={8} /> + <Spinner.TrackFill className="opacity-75" width={8} /> + </Spinner.Root> + </div> + ); +} diff --git a/components/watch/new-player/components/buttons.tsx b/components/watch/new-player/components/buttons.tsx new file mode 100644 index 0000000..18c2b42 --- /dev/null +++ b/components/watch/new-player/components/buttons.tsx @@ -0,0 +1,277 @@ +import { useWatchProvider } from "@/lib/context/watchPageProvider"; +import { + CaptionButton, + FullscreenButton, + isTrackCaptionKind, + MuteButton, + PIPButton, + PlayButton, + Tooltip, + useMediaState, + type TooltipPlacement, + useMediaRemote, + useMediaStore, +} from "@vidstack/react"; +import { + ClosedCaptionsIcon, + ClosedCaptionsOnIcon, + FullscreenExitIcon, + FullscreenIcon, + MuteIcon, + PauseIcon, + PictureInPictureExitIcon, + PictureInPictureIcon, + PlayIcon, + ReplayIcon, + TheatreModeExitIcon, + TheatreModeIcon, + VolumeHighIcon, + VolumeLowIcon, +} from "@vidstack/react/icons"; +import { useRouter } from "next/router"; +import { Navigation } from "../player"; + +export interface MediaButtonProps { + tooltipPlacement: TooltipPlacement; + navigation?: Navigation; + host?: boolean; +} + +export const buttonClass = + "group ring-media-focus relative inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-md outline-none ring-inset hover:bg-white/20 data-[focus]:ring-4"; + +export const tooltipClass = + "animate-out fade-out slide-out-to-bottom-2 data-[visible]:animate-in data-[visible]:fade-in data-[visible]:slide-in-from-bottom-4 z-10 rounded-sm bg-black/90 px-2 py-0.5 text-sm font-medium text-white parent-data-[open]:hidden"; + +export function Play({ tooltipPlacement }: MediaButtonProps) { + const isPaused = useMediaState("paused"), + ended = useMediaState("ended"), + tooltipText = isPaused ? "Play" : "Pause", + Icon = ended ? ReplayIcon : isPaused ? PlayIcon : PauseIcon; + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <PlayButton className={buttonClass}> + <Icon className="w-8 h-8" /> + </PlayButton> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + {tooltipText} + </Tooltip.Content> + </Tooltip.Root> + ); +} + +export function MobilePlayButton({ tooltipPlacement, host }: MediaButtonProps) { + const isPaused = useMediaState("paused"), + ended = useMediaState("ended"), + Icon = ended ? ReplayIcon : isPaused ? PlayIcon : PauseIcon; + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <PlayButton + className={`${ + host ? "" : "pointer-events-none" + } group ring-media-focus relative inline-flex h-16 w-16 media-paused:cursor-pointer cursor-default items-center justify-center rounded-full outline-none`} + > + <Icon className="w-10 h-10" /> + </PlayButton> + </Tooltip.Trigger> + {/* <Tooltip.Content + className="animate-out fade-out slide-out-to-bottom-2 data-[visible]:animate-in data-[visible]:fade-in data-[visible]:slide-in-from-bottom-4 z-10 rounded-sm bg-black/90 px-2 py-0.5 text-sm font-medium text-white parent-data-[open]:hidden" + placement={tooltipPlacement} + > + {tooltipText} + </Tooltip.Content> */} + </Tooltip.Root> + ); +} + +export function Mute({ tooltipPlacement }: MediaButtonProps) { + const volume = useMediaState("volume"), + isMuted = useMediaState("muted"); + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <MuteButton className={buttonClass}> + {isMuted || volume == 0 ? ( + <MuteIcon className="w-8 h-8" /> + ) : volume < 0.5 ? ( + <VolumeLowIcon className="w-8 h-8" /> + ) : ( + <VolumeHighIcon className="w-8 h-8" /> + )} + </MuteButton> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + {isMuted ? "Unmute" : "Mute"} + </Tooltip.Content> + </Tooltip.Root> + ); +} + +export function Caption({ tooltipPlacement }: MediaButtonProps) { + const track = useMediaState("textTrack"), + isOn = track && isTrackCaptionKind(track); + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <CaptionButton className={buttonClass}> + {isOn ? ( + <ClosedCaptionsOnIcon className="w-8 h-8" /> + ) : ( + <ClosedCaptionsIcon className="w-8 h-8" /> + )} + </CaptionButton> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + {isOn ? "Closed-Captions On" : "Closed-Captions Off"} + </Tooltip.Content> + </Tooltip.Root> + ); +} + +export function TheaterButton({ tooltipPlacement }: MediaButtonProps) { + const playerState = useMediaState("currentTime"), + isPlaying = useMediaState("playing"); + + const { setPlayerState, setTheaterMode, theaterMode } = useWatchProvider(); + + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <button + type="button" + className={buttonClass} + onClick={() => { + setPlayerState((prev: any) => ({ + ...prev, + currentTime: playerState, + isPlaying: isPlaying, + })); + setTheaterMode((prev: any) => !prev); + }} + > + {!theaterMode ? ( + <TheatreModeIcon className="w-8 h-8" /> + ) : ( + <TheatreModeExitIcon className="w-8 h-8" /> + )} + </button> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + Theatre Mode + </Tooltip.Content> + </Tooltip.Root> + ); +} + +export function PIP({ tooltipPlacement }: MediaButtonProps) { + const isActive = useMediaState("pictureInPicture"); + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <PIPButton className={buttonClass}> + {isActive ? ( + <PictureInPictureExitIcon className="w-8 h-8" /> + ) : ( + <PictureInPictureIcon className="w-8 h-8" /> + )} + </PIPButton> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + {isActive ? "Exit PIP" : "Enter PIP"} + </Tooltip.Content> + </Tooltip.Root> + ); +} + +export function PlayNextButton({ + tooltipPlacement, + navigation, +}: MediaButtonProps) { + // const remote = useMediaRemote(); + const router = useRouter(); + const { dataMedia, track } = useWatchProvider(); + return ( + <button + title="next-button" + type="button" + onClick={() => { + if (navigation?.next) { + router.push( + `/en/anime/watch/${dataMedia.id}/${track.provider}?id=${ + navigation?.next?.id + }&num=${navigation?.next?.number}${ + track?.isDub ? `&dub=${track?.isDub}` : "" + }` + ); + } + }} + className="next-button hidden" + > + Next Episode + </button> + ); +} + +export function SkipOpButton({ tooltipPlacement }: MediaButtonProps) { + const remote = useMediaRemote(); + const { track } = useWatchProvider(); + const op = track?.skip?.find((item: any) => item.text === "Opening"); + + return ( + <button + type="button" + onClick={() => { + remote.seek(op?.endTime); + }} + className="op-button hidden hover:bg-white/80 bg-white px-4 py-2 text-primary font-karla font-semibold rounded-md" + > + Skip Opening + </button> + ); +} + +export function SkipEdButton({ tooltipPlacement }: MediaButtonProps) { + const remote = useMediaRemote(); + const { duration } = useMediaStore(); + const { track } = useWatchProvider(); + const ed = track?.skip?.find((item: any) => item.text === "Ending"); + + const endTime = + Math.round(duration) === ed?.endTime ? ed?.endTime - 1 : ed?.endTime; + + // console.log(endTime); + + return ( + <button + title="ed-button" + type="button" + onClick={() => remote.seek(endTime)} + className="ed-button hidden cursor-pointer hover:bg-white/80 bg-white px-4 py-2 text-primary font-karla font-semibold rounded-md" + > + Skip Ending + </button> + ); +} + +export function Fullscreen({ tooltipPlacement }: MediaButtonProps) { + const isActive = useMediaState("fullscreen"); + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <FullscreenButton className={buttonClass}> + {isActive ? ( + <FullscreenExitIcon className="w-8 h-8" /> + ) : ( + <FullscreenIcon className="w-8 h-8" /> + )} + </FullscreenButton> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + {isActive ? "Exit Fullscreen" : "Enter Fullscreen"} + </Tooltip.Content> + </Tooltip.Root> + ); +} diff --git a/components/watch/new-player/components/chapter-title.tsx b/components/watch/new-player/components/chapter-title.tsx new file mode 100644 index 0000000..779f826 --- /dev/null +++ b/components/watch/new-player/components/chapter-title.tsx @@ -0,0 +1,11 @@ +import { ChapterTitle, type ChapterTitleProps } from "@vidstack/react"; +import { ChevronLeftIcon, ChevronRightIcon } from "@vidstack/react/icons"; + +export function ChapterTitleComponent() { + return ( + <span className="inline-block flex-1 overflow-hidden text-ellipsis whitespace-nowrap px-2 text-sm font-medium text-white"> + <span className="mr-1 text-txt">•</span> + <ChapterTitle className="ml-1" /> + </span> + ); +} diff --git a/components/watch/new-player/components/layouts/captions.module.css b/components/watch/new-player/components/layouts/captions.module.css new file mode 100644 index 0000000..338b96e --- /dev/null +++ b/components/watch/new-player/components/layouts/captions.module.css @@ -0,0 +1,80 @@ +.captions { + @apply font-roboto font-medium; + /* Recommended settings in the WebVTT spec (https://www.w3.org/TR/webvtt1). */ + /* --cue-color: var(--media-cue-color, white); */ + /* --cue-color: white; */ + /* z-index: 20; */ + /* --cue-bg-color: var(--media-cue-bg, rgba(0, 0, 0, 0.7)); */ + + /* bg color white */ + --cue-bg-color: rgba(255, 255, 255, 0.9); + --cue-font-size: calc(var(--overlay-height) / 100 * 5); + --cue-line-height: calc(var(--cue-font-size) * 1.2); + --cue-padding-x: 0.5em; + --cue-padding-y: 0.1em; + + /* remove background blur */ + + /* --cue-text-shadow: 0 0 5px black; */ + + font-size: var(--cue-font-size); + word-spacing: normal; + text-shadow: 0px 2px 8px rgba(0, 0, 0, 1); + /* contain: layout style; */ +} + +.captions[data-dir="rtl"] :global([data-part="cue-display"]) { + direction: rtl; +} + +.captions[aria-hidden="true"] { + display: none; +} + +/************************************************************************************************* + * Cue Display + *************************************************************************************************/ + +/* +* Most of the cue styles are set automatically by our [media-captions](https://github.com/vidstack/media-captions) +* library via CSS variables. They are inferred from the VTT, SRT, or SSA file cue settings. You're +* free to ignore them and style the captions as desired, but we don't recommend it unless the +* captions file contains no cue settings. Otherwise, you might be breaking accessibility. +*/ +.captions :global([data-part="cue-display"]) { + position: absolute; + direction: ltr; + overflow: visible; + contain: content; + top: var(--cue-top); + left: var(--cue-left); + right: var(--cue-right); + bottom: var(--cue-bottom); + width: var(--cue-width, auto); + height: var(--cue-height, auto); + transform: var(--cue-transform); + text-align: var(--cue-text-align); + writing-mode: var(--cue-writing-mode, unset); + white-space: pre-line; + unicode-bidi: plaintext; + min-width: min-content; + min-height: min-content; +} + +.captions :global([data-part="cue"]) { + display: inline-block; + contain: content; + /* border-radius: 2px; */ + /* backdrop-filter: unset; */ + padding: var(--cue-padding-y) var(--cue-padding-x); + line-height: var(--cue-line-height); + /* background-color: var(--cue-bg-color); */ + color: var(--cue-color); + white-space: pre-wrap; + outline: var(--cue-outline); + text-shadow: var(--cue-text-shadow); +} + +.captions :global([data-part="cue-display"][data-vertical] [data-part="cue"]) { + padding: var(--cue-padding-x) var(--cue-padding-y); +} diff --git a/components/watch/new-player/components/layouts/video-layout.module.css b/components/watch/new-player/components/layouts/video-layout.module.css new file mode 100644 index 0000000..14540f6 --- /dev/null +++ b/components/watch/new-player/components/layouts/video-layout.module.css @@ -0,0 +1,13 @@ +.controls { + /* + * These CSS variables are supported out of the box to easily apply offsets to all popups. + * You can also offset via props on `Tooltip.Content`, `Menu.Content`, and slider previews. + */ + --media-tooltip-y-offset: 30px; + --media-menu-y-offset: 30px; +} + +.controls :global(.volume-slider) { + --media-slider-preview-offset: 30px; + margin-left: 1.5px; +} diff --git a/components/watch/new-player/components/layouts/video-layout.tsx b/components/watch/new-player/components/layouts/video-layout.tsx new file mode 100644 index 0000000..fa1f6c3 --- /dev/null +++ b/components/watch/new-player/components/layouts/video-layout.tsx @@ -0,0 +1,173 @@ +import captionStyles from "./captions.module.css"; +import styles from "./video-layout.module.css"; + +import { + Captions, + Controls, + Gesture, + Spinner, + useMediaState, +} from "@vidstack/react"; + +import * as Buttons from "../buttons"; +import * as Menus from "../menus"; +import * as Sliders from "../sliders"; +import { TimeGroup } from "../time-group"; +import { Title } from "../title"; +import { ChapterTitleComponent } from "../chapter-title"; +import { useWatchProvider } from "@/lib/context/watchPageProvider"; +import { Navigation } from "../../player"; +import BufferingIndicator from "../bufferingIndicator"; +import { useEffect, useState } from "react"; + +export interface VideoLayoutProps { + thumbnails?: string; + navigation?: Navigation; + host?: boolean; +} + +function isMobileDevice() { + if (typeof window !== "undefined") { + return ( + typeof window.orientation !== "undefined" || + navigator.userAgent.indexOf("IEMobile") !== -1 + ); + } + return false; +} + +export function VideoLayout({ + thumbnails, + navigation, + host = true, +}: VideoLayoutProps) { + const [isMobile, setIsMobile] = useState(false); + + const { track } = useWatchProvider(); + const isFullscreen = useMediaState("fullscreen"); + + useEffect(() => { + setIsMobile(isMobileDevice()); + }, []); + + return ( + <> + <Gestures host={host} /> + <Captions + className={`${captionStyles.captions} media-preview:opacity-0 media-controls:bottom-[85px] media-captions:opacity-100 absolute inset-0 bottom-2 z-10 select-none break-words opacity-0 transition-[opacity,bottom] duration-300`} + /> + <Controls.Root + className={`${styles.controls} media-paused:bg-black/10 duration-200 media-controls:opacity-100 absolute inset-0 z-10 flex h-full w-full flex-col bg-gradient-to-t from-black/30 via-transparent to-black/30 opacity-0 transition-opacity`} + > + <Controls.Group className="flex justify-between items-center w-full px-2 pt-2"> + <Title navigation={navigation} /> + <div className="flex-1" /> + {/* <Menus.Episodes placement="left start" /> */} + </Controls.Group> + <div className="flex-1" /> + + {/* {isPaused && ( */} + <Controls.Group + className={`media-paused:opacity-100 media-paused:scale-100 backdrop-blur-sm scale-[160%] opacity-0 duration-200 ease-out flex shadow bg-white/10 rounded-full absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2`} + > + <Buttons.MobilePlayButton tooltipPlacement="top center" host={host} /> + </Controls.Group> + {/* )} */} + + <div className="pointer-events-none absolute inset-0 z-50 flex h-full w-full items-center justify-center"> + <Spinner.Root + className="text-white opacity-0 transition-opacity duration-200 ease-linear media-buffering:animate-spin media-buffering:opacity-100" + size={84} + > + <Spinner.Track className="opacity-25" width={8} /> + <Spinner.TrackFill className="opacity-75" width={8} /> + </Spinner.Root> + </div> + {/* </Controls.Group> */} + + <Controls.Group className="flex px-4"> + <div className="flex-1" /> + {host && ( + <> + <Buttons.SkipOpButton tooltipPlacement="top end" /> + <Buttons.SkipEdButton tooltipPlacement="top end" /> + <Buttons.PlayNextButton + navigation={navigation} + tooltipPlacement="top end" + /> + </> + )} + </Controls.Group> + + <Controls.Group className="flex w-full items-center px-2"> + <Sliders.Time thumbnails={thumbnails} host={host} /> + </Controls.Group> + <Controls.Group className="-mt-0.5 flex w-full items-center px-2 pb-2"> + <Buttons.Play tooltipPlacement="top start" /> + <Buttons.Mute tooltipPlacement="top" /> + <Sliders.Volume /> + <TimeGroup /> + <ChapterTitleComponent /> + <div className="flex-1" /> + {track?.subtitles && <Buttons.Caption tooltipPlacement="top" />} + <Menus.Settings placement="top end" tooltipPlacement="top" /> + {!isMobile && !isFullscreen && ( + <Buttons.TheaterButton tooltipPlacement="top" /> + )} + <Buttons.PIP tooltipPlacement="top" /> + <Buttons.Fullscreen tooltipPlacement="top end" /> + </Controls.Group> + </Controls.Root> + </> + ); +} + +function Gestures({ host }: { host?: boolean }) { + const isMobile = isMobileDevice(); + return ( + <> + {isMobile ? ( + <> + {host && ( + <Gesture + className="absolute inset-0 z-10" + event="dblpointerup" + action="toggle:paused" + /> + )} + <Gesture + className="absolute inset-0" + event="pointerup" + action="toggle:controls" + /> + </> + ) : ( + <> + {host && ( + <Gesture + className="absolute inset-0" + event="pointerup" + action="toggle:paused" + /> + )} + <Gesture + className="absolute inset-0 z-10" + event="dblpointerup" + action="toggle:fullscreen" + /> + </> + )} + + <Gesture + className="absolute top-0 left-0 w-1/5 h-full z-20" + event="dblpointerup" + action="seek:-10" + /> + <Gesture + className="absolute top-0 right-0 w-1/5 h-full z-20" + event="dblpointerup" + action="seek:10" + /> + </> + ); +} diff --git a/components/watch/new-player/components/menus.tsx b/components/watch/new-player/components/menus.tsx new file mode 100644 index 0000000..de2b302 --- /dev/null +++ b/components/watch/new-player/components/menus.tsx @@ -0,0 +1,387 @@ +// @ts-nocheck + +import type { ReactElement } from "react"; + +// import EpiDataDummy from "@/components/test/episodeDummy.json"; + +import { + Menu, + Tooltip, + useCaptionOptions, + type MenuPlacement, + type TooltipPlacement, + useVideoQualityOptions, + useMediaState, + usePlaybackRateOptions, +} from "@vidstack/react"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ClosedCaptionsIcon, + SettingsMenuIcon, + RadioButtonIcon, + RadioButtonSelectedIcon, + SettingsIcon, + // EpisodesIcon, + SettingsSwitchIcon, + // PlaybackSpeedCircleIcon, + OdometerIcon, +} from "@vidstack/react/icons"; + +import { buttonClass, tooltipClass } from "./buttons"; +import { useWatchProvider } from "@/lib/context/watchPageProvider"; +import React from "react"; + +export interface SettingsProps { + placement: MenuPlacement; + tooltipPlacement: TooltipPlacement; +} + +export const menuClass = + "fixed bottom-0 animate-out fade-out slide-out-to-bottom-2 data-[open]:animate-in data-[open]:fade-in data-[open]:slide-in-from-bottom-4 flex h-[var(--menu-height)] max-h-[200px] lg:max-h-[400px] min-w-[260px] flex-col overflow-y-auto overscroll-y-contain rounded-md border border-white/10 bg-black/95 p-2.5 font-sans text-[15px] font-medium outline-none backdrop-blur-sm transition-[height] duration-300 will-change-[height] data-[resizing]:overflow-hidden"; + +export const submenuClass = + "hidden w-full flex-col items-start justify-center outline-none data-[keyboard]:mt-[3px] data-[open]:inline-block"; + +export const contentMenuClass = + "flex cust-scroll h-[var(--menu-height)] max-h-[180px] lg:max-h-[400px] min-w-[260px] flex-col overflow-y-auto overscroll-y-contain rounded-md border border-white/10 bg-secondary p-2 font-sans text-[15px] font-medium outline-none backdrop-blur-sm transition-[height] duration-300 will-change-[height] data-[resizing]:overflow-hidden"; + +export function Settings({ placement, tooltipPlacement }: SettingsProps) { + const { track } = useWatchProvider(); + const isSubtitleAvailable = track?.epiData?.subtitles?.length > 0; + + return ( + <Menu.Root className="parent"> + <Tooltip.Root> + <Tooltip.Trigger asChild> + <Menu.Button className={buttonClass}> + <SettingsIcon className="h-8 w-8 transform transition-transform duration-200 ease-out group-data-[open]:rotate-90" /> + </Menu.Button> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + Settings + </Tooltip.Content> + </Tooltip.Root> + {/* <Menu.Content className={menuClass} placement={placement}> + {isSubtitleAvailable && <CaptionSubmenu />} + <QualitySubmenu /> + </Menu.Content> */} + <Menu.Content className={contentMenuClass} placement={placement}> + <AutoPlay /> + <AutoNext /> + <SpeedSubmenu /> + {isSubtitleAvailable && <CaptionSubmenu />} + <QualitySubmenu /> + </Menu.Content> + </Menu.Root> + ); +} + +// export function Episodes({ placement }: { placement: MenuPlacement }) { +// return ( +// <Menu.Root className="parent"> +// <Tooltip.Root> +// <Tooltip.Trigger asChild> +// <Menu.Button className={buttonClass}> +// <EpisodesIcon className="w-10 h-10" /> +// </Menu.Button> +// </Tooltip.Trigger> +// </Tooltip.Root> +// <Menu.Content +// className={`bg-secondary/95 border border-white/10 max-h-[240px] overflow-y-scroll cust-scroll rounded overflow-hidden z-30 -translate-y-5 -translate-x-2`} +// placement={placement} +// > +// <EpisodeSubmenu /> +// </Menu.Content> +// </Menu.Root> +// ); +// } + +function SpeedSubmenu() { + const options = usePlaybackRateOptions(), + hint = + options.selectedValue === "1" ? "Normal" : options.selectedValue + "x"; + return ( + <Menu.Root> + <SubmenuButton + label="Playback Rate" + hint={hint} + icon={OdometerIcon} + disabled={options.disabled} + > + Speed ({hint}) + </SubmenuButton> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup + className="w-full flex flex-col" + value={options.selectedValue} + > + {options.map(({ label, value, select }) => ( + <Radio value={value} onSelect={select} key={value}> + {label} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ); +} + +function CaptionSubmenu() { + const options = useCaptionOptions(), + hint = options.selectedTrack?.label ?? "Off"; + return ( + <Menu.Root> + <SubmenuButton + label="Captions" + hint={hint} + disabled={options.disabled} + icon={ClosedCaptionsIcon} + /> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup + className="w-full flex flex-col" + value={options.selectedValue} + > + {options.map(({ label, value, select }) => ( + <Radio value={value} onSelect={select} key={value}> + {label} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ); +} + +// function EpisodeSubmenu() { +// return ( +// // <div className="h-full w-[320px]"> +// <div className="flex flex-col h-full w-[360px] font-karla"> +// {/* {EpiDataDummy.map((epi, index) => ( */} +// <div +// key={index} +// className={`flex gap-1 hover:bg-secondary px-3 py-2 ${ +// index === 0 +// ? "pt-4" +// // : index === EpiDataDummy.length - 1 +// ? "pb-4" +// : "" +// }`} +// > +// <Image +// src={epi.img} +// alt="thumbnail" +// width={100} +// height={100} +// className="object-cover w-[120px] h-[64px] rounded-md" +// /> +// <div className="flex flex-col pl-2"> +// <h1 className="font-semibold">{epi.title}</h1> +// <p className="line-clamp-2 text-sm font-light"> +// {epi?.description} +// </p> +// </div> +// </div> +// ))} +// </div> +// // </div> +// ); +// } + +function AutoPlay() { + const [options, setOptions] = React.useState([ + { + label: "On", + value: "on", + selected: false, + }, + { + label: "Off", + value: "off", + selected: true, + }, + ]); + + const { autoplay, setAutoPlay } = useWatchProvider(); + + // console.log({ autoplay }); + + return ( + <Menu.Root> + <SubmenuButton + label="Autoplay Video" + hint={ + autoplay + ? options.find((option) => option.value === autoplay)?.value + : options.find((option) => option.selected)?.value + } + icon={SettingsSwitchIcon} + /> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup + className="w-full flex flex-col" + value={ + autoplay + ? options.find((option) => option.value === autoplay)?.value + : options.find((option) => option.selected)?.value + } + onChange={(value) => { + setOptions((options) => + options.map((option) => + option.value === value + ? { ...option, selected: true } + : { ...option, selected: false } + ) + ); + setAutoPlay(value); + localStorage.setItem("autoplay", value); + }} + > + {options.map((option) => ( + <Radio key={option.value} value={option.value}> + {option.label} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ); +} + +function AutoNext() { + const [options, setOptions] = React.useState([ + { + label: "On", + value: "on", + selected: false, + }, + { + label: "Off", + value: "off", + selected: true, + }, + ]); + + const { autoNext, setAutoNext } = useWatchProvider(); + + return ( + <Menu.Root> + <SubmenuButton + label="Autoplay Next" + hint={ + autoNext + ? options.find((option) => option.value === autoNext)?.value + : options.find((option) => option.selected)?.value + } + icon={SettingsSwitchIcon} + /> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup + className="w-full flex flex-col" + value={ + autoNext + ? options.find((option) => option.value === autoNext)?.value + : options.find((option) => option.selected)?.value + } + onChange={(value) => { + setOptions((options) => + options.map((option) => + option.value === value + ? { ...option, selected: true } + : { ...option, selected: false } + ) + ); + setAutoNext(value); + localStorage.setItem("autoNext", value); + }} + > + {options.map((option) => ( + <Radio key={option.value} value={option.value}> + {option.label} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ); +} + +function QualitySubmenu() { + const options = useVideoQualityOptions({ sort: "descending" }), + autoQuality = useMediaState("autoQuality"), + currentQualityText = options.selectedQuality?.height + "p" ?? "", + hint = !autoQuality ? currentQualityText : `Auto (${currentQualityText})`; + + // console.log({ options }); + + return ( + <Menu.Root> + <SubmenuButton + label="Quality" + hint={hint} + disabled={options.disabled} + icon={SettingsMenuIcon} + /> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup + className="w-full flex flex-col" + value={options.selectedValue} + > + {options.map(({ label, value, bitrateText, select }) => ( + <Radio value={value} onSelect={select} key={value}> + {label} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ); +} + +export interface RadioProps extends Menu.RadioProps {} + +function Radio({ children, ...props }: RadioProps) { + return ( + <Menu.Radio + className="ring-media-focus group relative flex w-full cursor-pointer select-none items-center justify-start rounded-sm p-2.5 outline-none data-[hocus]:bg-white/10 data-[focus]:ring-[3px]" + {...props} + > + <RadioButtonIcon className="h-4 w-4 text-white group-data-[checked]:hidden" /> + <RadioButtonSelectedIcon + className="text-media-brand hidden h-4 w-4 group-data-[checked]:block" + type="radio-button-selected" + /> + <span className="ml-2">{children}</span> + </Menu.Radio> + ); +} + +export interface SubmenuButtonProps { + label: string; + hint: string; + disabled?: boolean; + icon: ReactElement; +} + +function SubmenuButton({ + label, + hint, + icon: Icon, + disabled, +}: SubmenuButtonProps) { + return ( + <Menu.Button + className="ring-media-focus data-[open]:bg-secondary parent left-0 z-10 flex w-full cursor-pointer select-none items-center justify-start rounded-sm p-2.5 outline-none ring-inset data-[open]:sticky data-[open]:-top-2.5 data-[hocus]:bg-white/10 data-[focus]:ring-[3px]" + disabled={disabled} + > + <ChevronLeftIcon className="parent-data-[open]:block -ml-0.5 mr-1.5 hidden h-[18px] w-[18px]" /> + <div className="contents parent-data-[open]:hidden"> + <Icon className="w-5 h-5" /> + </div> + <span className="ml-1.5 parent-data-[open]:ml-0">{label}</span> + <span className="ml-auto text-sm text-white/50">{hint}</span> + <ChevronRightIcon className="parent-data-[open]:hidden ml-0.5 h-[18px] w-[18px] text-sm text-white/50" /> + </Menu.Button> + ); +} diff --git a/components/watch/new-player/components/sliders.tsx b/components/watch/new-player/components/sliders.tsx new file mode 100644 index 0000000..f31e28a --- /dev/null +++ b/components/watch/new-player/components/sliders.tsx @@ -0,0 +1,73 @@ +import { TimeSlider, VolumeSlider } from "@vidstack/react"; + +export function Volume() { + return ( + <VolumeSlider.Root className="volume-slider group relative mx-[7.5px] inline-flex h-10 w-full max-w-[80px] cursor-pointer touch-none select-none items-center outline-none aria-hidden:hidden"> + <VolumeSlider.Track className="relative ring-media-focus z-0 h-[5px] w-full rounded-sm bg-white/30 group-data-[focus]:ring-[3px]"> + <VolumeSlider.TrackFill className="bg-white absolute h-full w-[var(--slider-fill)] rounded-sm will-change-[width]" /> + </VolumeSlider.Track> + + <VolumeSlider.Preview + className="flex flex-col items-center opacity-0 transition-opacity duration-200 data-[visible]:opacity-100" + noClamp + > + <VolumeSlider.Value className="rounded-sm bg-black px-2 py-px text-[13px] font-medium" /> + </VolumeSlider.Preview> + <VolumeSlider.Thumb className="absolute left-[var(--slider-fill)] top-1/2 z-20 h-[15px] w-[15px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-[#cacaca] bg-white opacity-0 ring-white/40 transition-opacity group-data-[active]:opacity-100 group-data-[dragging]:ring-4 will-change-[left]" /> + </VolumeSlider.Root> + ); +} + +export interface TimeSliderProps { + thumbnails?: string; + host?: boolean; +} + +export function Time({ thumbnails, host }: TimeSliderProps) { + return ( + <TimeSlider.Root + className={`${ + host ? "" : "pointer-events-none" + } time-slider group relative mx-[7.5px] inline-flex h-10 w-full cursor-pointer touch-none select-none items-center outline-none`} + > + <TimeSlider.Chapters className="relative flex h-full w-full items-center rounded-[1px]"> + {(cues, forwardRef) => + cues.map((cue) => ( + <div + className="last-child:mr-0 group/slider relative mr-0.5 flex h-full w-full items-center rounded-[1px]" + style={{ contain: "layout style" }} + key={cue.startTime} + ref={forwardRef} + > + <TimeSlider.Track className="relative ring-media-focus z-0 h-[5px] group-hover/slider:h-[10px] transition-all duration-100 w-full rounded-sm bg-white/30 group-data-[focus]:ring-[3px]"> + <TimeSlider.TrackFill className="bg-white absolute h-full w-[var(--chapter-fill)] rounded-sm will-change-[width]" /> + <TimeSlider.Progress className="absolute z-10 h-full w-[var(--chapter-progress)] rounded-sm bg-white/50 will-change-[width]" /> + </TimeSlider.Track> + </div> + )) + } + </TimeSlider.Chapters> + {/* <TimeSlider.Track className="relative ring-media-focus z-0 h-[5px] w-full rounded-sm bg-white/30 group-data-[focus]:ring-[3px]"> + <TimeSlider.TrackFill className="bg-white absolute h-full w-[var(--slider-fill)] rounded-sm will-change-[width]" /> + <TimeSlider.Progress className="absolute z-10 h-full w-[var(--slider-progress)] rounded-sm bg-white/40 will-change-[width]" /> + </TimeSlider.Track> */} + + <TimeSlider.Thumb className="absolute left-[var(--slider-fill)] top-1/2 z-20 h-[15px] w-[15px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-[#cacaca] bg-white opacity-0 ring-white/40 transition-opacity group-data-[active]:opacity-100 group-data-[dragging]:ring-4 will-change-[left]" /> + + <TimeSlider.Preview className="flex flex-col items-center opacity-0 transition-opacity duration-200 data-[visible]:opacity-100 pointer-events-none"> + {thumbnails ? ( + <TimeSlider.Thumbnail.Root + src={thumbnails} + className="block h-[var(--thumbnail-height)] max-h-[160px] min-h-[80px] w-[var(--thumbnail-width)] min-w-[120px] max-w-[180px] overflow-hidden border border-white bg-black" + > + <TimeSlider.Thumbnail.Img /> + </TimeSlider.Thumbnail.Root> + ) : null} + + <TimeSlider.ChapterTitle className="mt-2 text-sm" /> + + <TimeSlider.Value className="text-[13px]" /> + </TimeSlider.Preview> + </TimeSlider.Root> + ); +} diff --git a/components/watch/new-player/components/time-group.tsx b/components/watch/new-player/components/time-group.tsx new file mode 100644 index 0000000..45fc795 --- /dev/null +++ b/components/watch/new-player/components/time-group.tsx @@ -0,0 +1,11 @@ +import { Time } from "@vidstack/react"; + +export function TimeGroup() { + return ( + <div className="ml-1.5 flex items-center text-sm font-medium"> + <Time className="time" type="current" /> + <div className="mx-1 text-white/80">/</div> + <Time className="time" type="duration" /> + </div> + ); +} diff --git a/components/watch/new-player/components/title.tsx b/components/watch/new-player/components/title.tsx new file mode 100644 index 0000000..6233061 --- /dev/null +++ b/components/watch/new-player/components/title.tsx @@ -0,0 +1,35 @@ +import { useWatchProvider } from "@/lib/context/watchPageProvider"; +import { useMediaRemote } from "@vidstack/react"; +import { ChevronLeftIcon } from "@vidstack/react/icons"; +import { Navigation } from "../player"; + +type TitleProps = { + navigation?: Navigation; +}; + +export function Title({ navigation }: TitleProps) { + const { dataMedia } = useWatchProvider(); + const remote = useMediaRemote(); + + return ( + <div className="media-fullscreen:flex hidden text-start flex-1 text-sm font-medium text-white"> + {/* <p className="pt-4 h-full"> + </p> */} + <button + type="button" + className="flex items-center gap-2 text-sm font-karla w-full" + onClick={() => remote.toggleFullscreen()} + > + <ChevronLeftIcon className="font-extrabold w-7 h-7" /> + <span className="max-w-[75%] text-base xl:text-2xl font-semibold whitespace-nowrap overflow-hidden text-ellipsis"> + {dataMedia?.title?.romaji} + </span> + <span className="text-base xl:text-2xl font-normal">/</span> + <span className="text-base xl:text-2xl font-normal"> + Episode {navigation?.playing.number} + </span> + {/* <span className="absolute top-5 left-[1s0%] w-[24%] h-[1px] bg-white" /> */} + </button> + </div> + ); +} diff --git a/components/watch/new-player/player.module.css b/components/watch/new-player/player.module.css new file mode 100644 index 0000000..f2f5b39 --- /dev/null +++ b/components/watch/new-player/player.module.css @@ -0,0 +1,50 @@ +.player { + --media-brand: #f5f5f5; + --media-focus-ring-color: #4e9cf6; + --media-focus-ring: 0 0 0 3px var(--media-focus-ring-color); + + --media-tooltip-y-offset: 30px; + --media-menu-y-offset: 30px; + + background-color: black; + border-radius: var(--media-border-radius); + color: #f5f5f5; + contain: layout; + font-family: sans-serif; + overflow: hidden; +} + +.player[data-focus]:not([data-playing]) { + box-shadow: var(--media-focus-ring); +} + +.player video { + height: 100%; + object-fit: contain; + display: block; +} + +.player video, +.poster { + border-radius: var(--media-border-radius); +} + +.poster { + display: block; + position: absolute; + top: 0; + left: 0; + opacity: 0; + width: 100%; + height: 100%; +} + +.poster[data-visible] { + opacity: 1; +} + +.poster img { + width: 100%; + height: 100%; + object-fit: cover; +} diff --git a/components/watch/new-player/player.tsx b/components/watch/new-player/player.tsx new file mode 100644 index 0000000..b98ff79 --- /dev/null +++ b/components/watch/new-player/player.tsx @@ -0,0 +1,471 @@ +import "@vidstack/react/player/styles/base.css"; + +import { useEffect, useRef, useState } from "react"; + +import style from "./player.module.css"; + +import { + MediaPlayer, + MediaProvider, + useMediaStore, + useMediaRemote, + type MediaPlayerInstance, + Track, + MediaTimeUpdateEventDetail, + MediaTimeUpdateEvent, +} from "@vidstack/react"; +import { VideoLayout } from "./components/layouts/video-layout"; +import { useWatchProvider } from "@/lib/context/watchPageProvider"; +import { useRouter } from "next/router"; +import { Subtitle } from "types/episodes/TrackData"; +import useWatchStorage from "@/lib/hooks/useWatchStorage"; +import { Sessions } from "types/episodes/Sessions"; +import { useAniList } from "@/lib/anilist/useAnilist"; + +export interface Navigation { + prev: Prev; + playing: Playing; + next: Next; +} + +export interface Prev { + id: string; + title: string; + img: string; + number: number; + description: string; +} + +export interface Playing { + id: string; + title: string; + description: string; + img: string; + number: number; +} + +export interface Next { + id: string; + title: string; + description: string; + img: string; + number: number; +} + +type VidStackProps = { + id: string; + navigation: Navigation; + userData: UserData; + sessions: Sessions; +}; + +export type UserData = { + id?: string; + userProfileId?: string; + aniId: string; + watchId: string; + title: string; + aniTitle: string; + image: string; + episode: number; + duration: number; + timeWatched: number; + provider: string; + nextId: string; + nextNumber: number; + dub: boolean; + createdAt: string; +}; + +type SkipData = { + startTime: number; + endTime: number; + text: string; +}; + +export default function VidStack({ + id, + navigation, + userData, + sessions, +}: VidStackProps) { + let player = useRef<MediaPlayerInstance>(null); + + const { + aspectRatio, + setAspectRatio, + track, + playerState, + dataMedia, + autoNext, + } = useWatchProvider(); + + const { qualities, duration } = useMediaStore(player); + + const [getSettings, updateSettings] = useWatchStorage(); + const { marked, setMarked } = useWatchProvider(); + + const { markProgress } = useAniList(sessions); + + const remote = useMediaRemote(player); + + const { defaultQuality = null } = track ?? {}; + + const [chapters, setChapters] = useState<string>(""); + + const router = useRouter(); + + useEffect(() => { + if (qualities.length > 0) { + const sourceQuality = qualities.reduce( + (max, obj) => (obj.height > max.height ? obj : max), + qualities[0] + ); + const aspectRatio = calculateAspectRatio( + sourceQuality.width, + sourceQuality.height + ); + + setAspectRatio(aspectRatio); + } + }, [qualities]); + + const [isPlaying, setIsPlaying] = useState(false); + let interval: any; + + useEffect(() => { + const plyr = player.current; + + function handlePlay() { + // console.log("Player is playing"); + setIsPlaying(true); + } + + function handlePause() { + // console.log("Player is paused"); + setIsPlaying(false); + } + + function handleEnd() { + // console.log("Player ended"); + setIsPlaying(false); + } + + plyr?.addEventListener("play", handlePlay); + plyr?.addEventListener("pause", handlePause); + plyr?.addEventListener("ended", handleEnd); + + return () => { + plyr?.removeEventListener("play", handlePlay); + plyr?.removeEventListener("pause", handlePause); + plyr?.removeEventListener("ended", handleEnd); + }; + }, [id, duration]); + + useEffect(() => { + if (isPlaying) { + interval = setInterval(async () => { + const currentTime = player.current?.currentTime + ? Math.round(player.current?.currentTime) + : 0; + + const parsedImage = navigation?.playing?.img?.includes("null") + ? dataMedia?.coverImage?.extraLarge + : navigation?.playing?.img; + + if (sessions?.user?.name) { + // console.log("updating user data"); + await fetch("/api/user/update/episode", { + method: "PUT", + body: JSON.stringify({ + name: sessions?.user?.name, + id: String(dataMedia?.id), + watchId: navigation?.playing?.id, + title: + navigation.playing?.title || + dataMedia.title?.romaji || + dataMedia.title?.english, + aniTitle: dataMedia.title?.romaji || dataMedia.title?.english, + image: parsedImage, + number: Number(navigation.playing?.number), + duration: duration, + timeWatched: currentTime, + provider: track?.provider, + nextId: navigation?.next?.id, + nextNumber: Number(navigation?.next?.number), + dub: track?.isDub ? true : false, + }), + }); + } + + updateSettings(navigation?.playing?.id, { + aniId: String(dataMedia.id), + watchId: navigation?.playing?.id, + title: + navigation.playing?.title || + dataMedia.title?.romaji || + dataMedia.title?.english, + aniTitle: dataMedia.title?.romaji || dataMedia.title?.english, + image: parsedImage, + episode: Number(navigation.playing?.number), + duration: duration, + timeWatched: currentTime, // update timeWatched with currentTime + provider: track?.provider, + nextId: navigation?.next?.id, + nextNumber: navigation?.next?.number, + dub: track?.isDub ? true : false, + createdAt: new Date().toISOString(), + }); + // console.log("update"); + }, 5000); + } else { + clearInterval(interval); + } + + return () => { + clearInterval(interval); + }; + }, [isPlaying, sessions?.user?.name, track?.isDub, duration]); + + useEffect(() => { + const autoplay = localStorage.getItem("autoplay") || "off"; + + return player.current!.subscribe(({ canPlay }) => { + // console.log("can play?", "->", canPlay); + if (canPlay) { + if (autoplay === "on") { + if (playerState?.currentTime === 0) { + remote.play(); + } else { + if (playerState?.isPlaying) { + remote.play(); + } else { + remote.pause(); + } + } + } else { + if (playerState?.isPlaying) { + remote.play(); + } else { + remote.pause(); + } + } + remote.seek(playerState?.currentTime); + } + }); + }, [playerState?.currentTime, playerState?.isPlaying]); + + useEffect(() => { + const chapter = track?.skip, + videoDuration = Math.round(duration); + + let vtt = "WEBVTT\n\n"; + + let lastEndTime = 0; + + if (chapter && chapter?.length > 0) { + chapter.forEach((item: SkipData) => { + let startMinutes = Math.floor(item.startTime / 60); + let startSeconds = item.startTime % 60; + let endMinutes = Math.floor(item.endTime / 60); + let endSeconds = item.endTime % 60; + + let start = `${startMinutes.toString().padStart(2, "0")}:${startSeconds + .toString() + .padStart(2, "0")}`; + let end = `${endMinutes.toString().padStart(2, "0")}:${endSeconds + .toString() + .padStart(2, "0")}`; + + vtt += `${start} --> ${end}\n${item.text}\n\n`; + if (item.endTime > lastEndTime) { + lastEndTime = item.endTime; + } + }); + + if (lastEndTime < videoDuration) { + let startMinutes = Math.floor(lastEndTime / 60); + let startSeconds = lastEndTime % 60; + let endMinutes = Math.floor(videoDuration / 60); + let endSeconds = videoDuration % 60; + + let start = `${startMinutes.toString().padStart(2, "0")}:${startSeconds + .toString() + .padStart(2, "0")}`; + let end = `${endMinutes.toString().padStart(2, "0")}:${endSeconds + .toString() + .padStart(2, "0")}`; + + vtt += `${start} --> ${end}\n\n\n`; + } + + const vttBlob = new Blob([vtt], { type: "text/vtt" }); + const vttUrl = URL.createObjectURL(vttBlob); + + setChapters(vttUrl); + } + return () => { + setChapters(""); + }; + }, [track?.skip, duration]); + + useEffect(() => { + return () => { + if (player.current) { + player.current.destroy(); + } + }; + }, []); + + function onEnded() { + if (!navigation?.next?.id) return; + if (autoNext === "on") { + const nextButton = document.querySelector(".next-button"); + + let timeoutId: ReturnType<typeof setTimeout>; + + const stopTimeout = () => { + clearTimeout(timeoutId); + nextButton?.classList.remove("progress"); + }; + + nextButton?.classList.remove("hidden"); + nextButton?.classList.add("progress"); + + timeoutId = setTimeout(() => { + console.log("time is up!"); + if (navigation?.next) { + router.push( + `/en/anime/watch/${dataMedia.id}/${track.provider}?id=${ + navigation?.next?.id + }&num=${navigation?.next?.number}${ + track?.isDub ? `&dub=${track?.isDub}` : "" + }` + ); + } + }, 7000); + + nextButton?.addEventListener("mouseover", stopTimeout); + } + } + + function onLoadedMetadata() { + const seek: any = getSettings(navigation?.playing?.id); + if (playerState?.currentTime !== 0) return; + const seekTime = seek?.timeWatched; + const percentage = duration !== 0 ? seekTime / Math.round(duration) : 0; + const percentagedb = + duration !== 0 ? userData?.timeWatched / Math.round(duration) : 0; + + if (percentage >= 0.9 || percentagedb >= 0.9) { + remote.seek(0); + console.log("Video started from the beginning"); + } else if (userData?.timeWatched) { + remote.seek(userData?.timeWatched); + } else { + remote.seek(seekTime); + } + } + + let mark = 0; + function onTimeUpdate(detail: MediaTimeUpdateEventDetail) { + if (sessions) { + let currentTime = detail.currentTime; + const percentage = currentTime / duration; + + if (percentage >= 0.9) { + // use >= instead of > + if (mark < 1 && marked < 1) { + mark = 1; + setMarked(1); + console.log("marking progress"); + markProgress(dataMedia.id, navigation.playing.number); + } + } + } + + const opButton = document.querySelector(".op-button"); + const edButton = document.querySelector(".ed-button"); + + const op: SkipData = track?.skip.find( + (item: SkipData) => item.text === "Opening" + ), + ed = track?.skip.find((item: SkipData) => item.text === "Ending"); + + if ( + op && + detail.currentTime > op.startTime && + detail.currentTime < op.endTime + ) { + opButton?.classList.remove("hidden"); + } else { + opButton?.classList.add("hidden"); + } + + if ( + ed && + detail.currentTime > ed.startTime && + detail.currentTime < ed.endTime + ) { + edButton?.classList.remove("hidden"); + } else { + edButton?.classList.add("hidden"); + } + } + + function onSeeked(currentTime: number) { + const nextButton = document.querySelector(".next-button"); + // console.log({ currentTime, duration }); + if (currentTime !== duration) { + nextButton?.classList.add("hidden"); + } + } + + return ( + <MediaPlayer + key={id} + className={`${style.player} player`} + title={ + navigation?.playing?.title || + `Episode ${navigation?.playing?.number}` || + "Loading..." + } + load="idle" + crossorigin="anonymous" + src={{ + src: defaultQuality?.url, + type: "application/vnd.apple.mpegurl", + }} + onTimeUpdate={onTimeUpdate} + playsinline + aspectRatio={aspectRatio} + onEnd={onEnded} + onSeeked={onSeeked} + onLoadedMetadata={onLoadedMetadata} + ref={player} + > + <MediaProvider> + {track && + track?.subtitles && + track?.subtitles?.map((track: Subtitle) => ( + <Track {...track} key={track.src} /> + ))} + {chapters?.length > 0 && ( + <Track key={chapters} src={chapters} kind="chapters" default={true} /> + )} + </MediaProvider> + <VideoLayout thumbnails={track?.thumbnails} navigation={navigation} /> + </MediaPlayer> + ); +} + +export function calculateAspectRatio(width: number, height: number) { + if (width === 0 && height === 0) { + return "16/9"; + } + + const gcd = (a: number, b: number): any => (b === 0 ? a : gcd(b, a % b)); + const divisor = gcd(width, height); + const aspectRatio = `${width / divisor}/${height / divisor}`; + return aspectRatio; +} diff --git a/components/watch/new-player/tracks.tsx b/components/watch/new-player/tracks.tsx new file mode 100644 index 0000000..abc1fb5 --- /dev/null +++ b/components/watch/new-player/tracks.tsx @@ -0,0 +1,184 @@ +export const textTracks = [ + // Subtitles + // { + // src: "https://media-files.vidstack.io/sprite-fight/subs/english.vtt", + // label: "English", + // language: "en-US", + // kind: "subtitles", + // default: true, + // }, + // { + // src: "https://media-files.vidstack.io/sprite-fight/subs/spanish.vtt", + // label: "Spanish", + // language: "es-ES", + // kind: "subtitles", + // }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/ara-3.vtt", + label: "Arabic", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/chi-4.vtt", + label: "Chinese - Chinese Simplified", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/chi-5.vtt", + label: "Chinese - Chinese Traditional", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/hrv-6.vtt", + label: "Croatian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/cze-7.vtt", + label: "Czech", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/dan-8.vtt", + label: "Danish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/dut-9.vtt", + label: "Dutch", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/32/6d/326d5416033fe39a1540b11908f191fe/326d5416033fe39a1540b11908f191fe.vtt", + label: "English", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/fin-10.vtt", + label: "Finnish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/fre-11.vtt", + label: "French", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/ger-12.vtt", + label: "German", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/gre-13.vtt", + label: "Greek", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/heb-14.vtt", + label: "Hebrew", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/hun-15.vtt", + label: "Hungarian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/ind-16.vtt", + label: "Indonesian", + kind: "subtitles", + default: true, + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/ita-17.vtt", + label: "Italian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/jpn-18.vtt", + label: "Japanese", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/kor-19.vtt", + label: "Korean", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/may-20.vtt", + label: "Malay", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/nob-21.vtt", + label: "Norwegian BokmÃ¥l - Norwegian Bokmal", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/pol-22.vtt", + label: "Polish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/por-23.vtt", + label: "Portuguese", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/por-24.vtt", + label: "Portuguese - Brazilian Portuguese", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/rum-25.vtt", + label: "Romanian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/rus-26.vtt", + label: "Russian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/spa-27.vtt", + label: "Spanish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/spa-28.vtt", + label: "Spanish - European Spanish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/swe-29.vtt", + label: "Swedish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/tha-30.vtt", + label: "Thai", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/tur-31.vtt", + label: "Turkish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/ukr-32.vtt", + label: "Ukrainian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/vie-33.vtt", + label: "Vietnamese", + kind: "subtitles", + }, + // // Chapters + // { + // src: "https://media-files.vidstack.io/sprite-fight/chapters.vtt", + // kind: "chapters", + // language: "en-US", + // default: true, + // }, +] as const; |