From 75109ce45c75190bb3632b5265fb288582687a9b Mon Sep 17 00:00:00 2001 From: mrjvs Date: Fri, 20 Oct 2023 15:54:10 +0200 Subject: [PATCH] refactored context menu links, + next episode button styling + mobile UI Co-authored-by: Jip Frijlink --- src/components/Icon.tsx | 2 + src/components/player/atoms/Episodes.tsx | 45 ++--- .../player/atoms/NextEpisodeButton.tsx | 84 +++++++++ src/components/player/atoms/ProgressBar.tsx | 2 +- src/components/player/atoms/Settings.tsx | 26 +-- src/components/player/atoms/Time.tsx | 28 ++- src/components/player/atoms/index.ts | 1 + .../atoms/settings/CaptionSettingsView.tsx | 14 +- .../player/atoms/settings/CaptionsView.tsx | 39 ++-- .../atoms/settings/PlaybackSettingsView.tsx | 12 +- .../player/atoms/settings/QualityView.tsx | 61 ++---- .../player/atoms/settings/SettingsMenu.tsx | 88 +++++---- .../atoms/settings/SourceSelectingView.tsx | 52 ++---- .../player/internals/ContextMenu/Cards.tsx | 17 ++ .../player/internals/ContextMenu/Items.tsx | 1 + .../player/internals/ContextMenu/Links.tsx | 133 +++++++++++++ .../player/internals/ContextMenu/Misc.tsx | 50 +++++ .../player/internals/ContextMenu/Sections.tsx | 20 ++ .../player/internals/ContextMenu/index.ts | 11 ++ .../player/internals/ContextUtils.tsx | 176 ------------------ src/pages/parts/player/PlayerPart.tsx | 29 ++- src/stores/player/slices/interface.ts | 8 + src/stores/player/slices/source.ts | 1 + tailwind.config.js | 9 + 24 files changed, 519 insertions(+), 390 deletions(-) create mode 100644 src/components/player/atoms/NextEpisodeButton.tsx create mode 100644 src/components/player/internals/ContextMenu/Cards.tsx create mode 100644 src/components/player/internals/ContextMenu/Items.tsx create mode 100644 src/components/player/internals/ContextMenu/Links.tsx create mode 100644 src/components/player/internals/ContextMenu/Misc.tsx create mode 100644 src/components/player/internals/ContextMenu/Sections.tsx create mode 100644 src/components/player/internals/ContextMenu/index.ts delete mode 100644 src/components/player/internals/ContextUtils.tsx diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 2bdb6261..f19e8919 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -43,6 +43,7 @@ export enum Icons { TACHOMETER = "tachometer", MAIL = "mail", CIRCLE_CHECK = "circle_check", + SKIP_EPISODE = "skip_episode", } export interface IconProps { @@ -93,6 +94,7 @@ const iconList: Record = { tachometer: ``, mail: ``, circle_check: ``, + skip_episode: ``, }; function ChromeCastButton() { diff --git a/src/components/player/atoms/Episodes.tsx b/src/components/player/atoms/Episodes.tsx index a483ff79..5706ce98 100644 --- a/src/components/player/atoms/Episodes.tsx +++ b/src/components/player/atoms/Episodes.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { ReactNode, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAsync } from "react-use"; @@ -11,7 +11,7 @@ import { OverlayPage } from "@/components/overlays/OverlayPage"; import { OverlayRouter } from "@/components/overlays/OverlayRouter"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { VideoPlayerButton } from "@/components/player/internals/Button"; -import { Context } from "@/components/player/internals/ContextUtils"; +import { Menu } from "@/components/player/internals/ContextMenu"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { PlayerMeta } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; @@ -56,16 +56,18 @@ function SeasonsView({ let content: ReactNode = null; if (seasons) { content = ( - + {seasons?.map((season) => { return ( - setSeason(season.id)}> - {season.title} - - + setSeason(season.id)} + > + {season.title} + ); })} - + ); } else if (loadingState.error) content = Error loading season; @@ -73,10 +75,10 @@ function SeasonsView({ content = Loading...; return ( - - {meta?.title} + + {meta?.title} {content} - + ); } @@ -115,37 +117,36 @@ function EpisodesView({ content = Loading...; else if (loadingState.value) { content = ( - + {loadingState.value.season.episodes.map((ep) => { return ( - playEpisode(ep.id)} active={ep.id === meta?.episode?.tmdbId} > - +
E{ep.number} {ep.title}
-
- -
+ + ); })} -
+ ); } return ( - - + + {loadingState?.value?.season.title || t("videoPlayer.loading")} - + {content} - + ); } diff --git a/src/components/player/atoms/NextEpisodeButton.tsx b/src/components/player/atoms/NextEpisodeButton.tsx new file mode 100644 index 00000000..23fda5a3 --- /dev/null +++ b/src/components/player/atoms/NextEpisodeButton.tsx @@ -0,0 +1,84 @@ +import classNames from "classnames"; + +import { Icon, Icons } from "@/components/Icon"; +import { Transition } from "@/components/Transition"; +import { usePlayerStore } from "@/stores/player/store"; + +function shouldShowNextEpisodeButton( + time: number, + duration: number +): "always" | "hover" | "none" { + const percentage = time / duration; + const secondsFromEnd = duration - time; + if (secondsFromEnd <= 30) return "always"; + if (percentage >= 0.9) return "hover"; + return "none"; +} + +function Button(props: { + className: string; + onClick?: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +// TODO check if has next episode +export function NextEpisodeButton(props: { controlsShowing: boolean }) { + const duration = usePlayerStore((s) => s.progress.duration); + const isHidden = usePlayerStore((s) => s.interface.hideNextEpisodeBtn); + const hideNextEpisodeButton = usePlayerStore((s) => s.hideNextEpisodeButton); + const metaType = usePlayerStore((s) => s.meta?.type); + const time = usePlayerStore((s) => s.progress.time); + const showingState = shouldShowNextEpisodeButton(time, duration); + const status = usePlayerStore((s) => s.status); + + let show = false; + if (showingState === "always") show = true; + else if (showingState === "hover" && props.controlsShowing) show = true; + if (isHidden || status !== "playing" || duration === 0) show = false; + + const animation = showingState === "hover" ? "slide-up" : "fade"; + let bottom = "bottom-24"; + if (showingState === "always") + bottom = props.controlsShowing ? "bottom-24" : "bottom-12"; + + if (metaType !== "show") return null; + + return ( + +
+ + +
+
+ ); +} diff --git a/src/components/player/atoms/ProgressBar.tsx b/src/components/player/atoms/ProgressBar.tsx index 47ac1a2d..ff4a8f43 100644 --- a/src/components/player/atoms/ProgressBar.tsx +++ b/src/components/player/atoms/ProgressBar.tsx @@ -32,7 +32,7 @@ export function ProgressBar() { }, [setDraggingTime, duration, dragPercentage]); return ( -
+
- + - + - + - + - + - + - + - + - + - + - + - + diff --git a/src/components/player/atoms/Time.tsx b/src/components/player/atoms/Time.tsx index 4870aacc..5d4fc935 100644 --- a/src/components/player/atoms/Time.tsx +++ b/src/components/player/atoms/Time.tsx @@ -9,7 +9,7 @@ function durationExceedsHour(secs: number): boolean { return secs > 60 * 60; } -export function Time() { +export function Time(props: { short?: boolean }) { const timeFormat = usePlayerStore((s) => s.interface.timeFormat); const setTimeFormat = usePlayerStore((s) => s.setTimeFormat); @@ -40,16 +40,26 @@ export function Time() { }, }); - const timeString = `${formatSeconds(currentTime, hasHours)} / ${formatSeconds( - duration, - hasHours - )}`; - const timeFinishedString = `${t("videoPlayer.timeLeft", { - timeLeft: formatSeconds( + let timeString; + let timeFinishedString; + if (props.short) { + timeString = formatSeconds(currentTime, hasHours); + timeFinishedString = `-${formatSeconds( secondsRemaining, durationExceedsHour(secondsRemaining) - ), - })} • ${formattedTimeFinished}`; + )}`; + } else { + timeString = `${formatSeconds(currentTime, hasHours)} / ${formatSeconds( + duration, + hasHours + )}`; + timeFinishedString = `${t("videoPlayer.timeLeft", { + timeLeft: formatSeconds( + secondsRemaining, + durationExceedsHour(secondsRemaining) + ), + })} • ${formattedTimeFinished}`; + } const child = timeFormat === VideoPlayerTimeFormat.REGULAR ? ( diff --git a/src/components/player/atoms/index.ts b/src/components/player/atoms/index.ts index 53076cb3..8ccadc89 100644 --- a/src/components/player/atoms/index.ts +++ b/src/components/player/atoms/index.ts @@ -12,3 +12,4 @@ export * from "./Settings"; export * from "./Episodes"; export * from "./Airplay"; export * from "./VolumeChangedPopout"; +export * from "./NextEpisodeButton"; diff --git a/src/components/player/atoms/settings/CaptionSettingsView.tsx b/src/components/player/atoms/settings/CaptionSettingsView.tsx index 3fcaa847..2818d608 100644 --- a/src/components/player/atoms/settings/CaptionSettingsView.tsx +++ b/src/components/player/atoms/settings/CaptionSettingsView.tsx @@ -2,7 +2,7 @@ import classNames from "classnames"; import { useCallback, useEffect, useRef, useState } from "react"; import { Icon, Icons } from "@/components/Icon"; -import { Context } from "@/components/player/internals/ContextUtils"; +import { Menu } from "@/components/player/internals/ContextMenu"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useProgressBar } from "@/hooks/useProgressBar"; import { useSubtitleStore } from "@/stores/subtitles"; @@ -80,7 +80,7 @@ function CaptionSetting(props: { return (
- {props.label} + {props.label}
- router.navigate("/captions")}> + router.navigate("/captions")}> Custom captions - - + + `${s}%`} />
- Color + Color
{colors.map((v) => (
- + ); } diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index ec0a0f97..562f9b09 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -1,8 +1,6 @@ -import classNames from "classnames"; - import { FlagIcon } from "@/components/FlagIcon"; -import { Icon, Icons } from "@/components/Icon"; -import { Context } from "@/components/player/internals/ContextUtils"; +import { Menu } from "@/components/player/internals/ContextMenu"; +import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { Caption } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; @@ -26,25 +24,14 @@ export function CaptionOption(props: { onClick?: () => void; }) { return ( -
-
- -
- - {props.children} + + + + + + {props.children} - {props.selected ? ( - - ) : null} -
+ ); } @@ -75,7 +62,7 @@ export function CaptionsView({ id }: { id: string }) { return ( <> - router.navigate("/")} rightSide={ + ); +} + +export function ChevronLink(props: { + rightText?: string; + onClick?: () => void; + children?: ReactNode; + active?: boolean; +}) { + const rightContent = {props.rightText}; + return ( + + {props.children} + + ); +} + +export function SelectableLink(props: { + selected?: boolean; + onClick?: () => void; + children?: ReactNode; + disabled?: boolean; +}) { + const rightContent = ( + + ); + return ( + + + {props.children} + + + ); +} diff --git a/src/components/player/internals/ContextMenu/Misc.tsx b/src/components/player/internals/ContextMenu/Misc.tsx new file mode 100644 index 00000000..8ca0ea18 --- /dev/null +++ b/src/components/player/internals/ContextMenu/Misc.tsx @@ -0,0 +1,50 @@ +import { Icon, Icons } from "@/components/Icon"; + +export function Title(props: { + children: React.ReactNode; + rightSide?: React.ReactNode; +}) { + return ( +
+

+
{props.children}
+
{props.rightSide}
+

+
+ ); +} + +export function IconButton(props: { icon: Icons; onClick?: () => void }) { + return ( + + ); +} + +export function Divider() { + return
; +} + +export function SmallText(props: { children: React.ReactNode }) { + return

{props.children}

; +} + +export function Anchor(props: { + children: React.ReactNode; + onClick: () => void; +}) { + return ( + + {props.children} + + ); +} + +export function FieldTitle(props: { children: React.ReactNode }) { + return

{props.children}

; +} diff --git a/src/components/player/internals/ContextMenu/Sections.tsx b/src/components/player/internals/ContextMenu/Sections.tsx new file mode 100644 index 00000000..7631e155 --- /dev/null +++ b/src/components/player/internals/ContextMenu/Sections.tsx @@ -0,0 +1,20 @@ +import classNames from "classnames"; + +export function SectionTitle(props: { children: React.ReactNode }) { + return ( +

+ {props.children} +

+ ); +} + +export function Section(props: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {props.children} +
+ ); +} diff --git a/src/components/player/internals/ContextMenu/index.ts b/src/components/player/internals/ContextMenu/index.ts new file mode 100644 index 00000000..0a3d4a92 --- /dev/null +++ b/src/components/player/internals/ContextMenu/index.ts @@ -0,0 +1,11 @@ +import * as Cards from "./Cards"; +import * as Links from "./Links"; +import * as Misc from "./Misc"; +import * as Sections from "./Sections"; + +export const Menu = { + ...Cards, + ...Links, + ...Sections, + ...Misc, +}; diff --git a/src/components/player/internals/ContextUtils.tsx b/src/components/player/internals/ContextUtils.tsx deleted file mode 100644 index 6e8c2025..00000000 --- a/src/components/player/internals/ContextUtils.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import classNames from "classnames"; - -import { Icon, Icons } from "@/components/Icon"; - -function Card(props: { children: React.ReactNode }) { - return ( -
-
- {props.children} -
-
- ); -} - -function CardWithScrollable(props: { children: React.ReactNode }) { - return ( -
- {props.children} -
- ); -} - -function SectionTitle(props: { children: React.ReactNode }) { - return ( -

- {props.children} -

- ); -} - -function LinkTitle(props: { children: React.ReactNode; textClass?: string }) { - return ( - -
{props.children}
-
- ); -} - -function Section(props: { children: React.ReactNode; className?: string }) { - return ( -
- {props.children} -
- ); -} - -function Link(props: { - onClick?: () => void; - children: React.ReactNode; - active?: boolean; -}) { - const classes = classNames( - "flex justify-between items-center py-2 pl-3 pr-3 -ml-3 rounded w-full", - { - "cursor-default": !props.onClick, - "hover:bg-video-context-border": !!props.onClick, - "bg-video-context-border": props.active, - } - ); - const styles = { width: "calc(100% + 1.5rem)" }; - - if (!props.onClick) { - return ( -
- {props.children} -
- ); - } - - return ( - - ); -} - -function Title(props: { - children: React.ReactNode; - rightSide?: React.ReactNode; -}) { - return ( -
-

-
{props.children}
-
{props.rightSide}
-

-
- ); -} - -function BackLink(props: { - onClick?: () => void; - children: React.ReactNode; - rightSide?: React.ReactNode; -}) { - return ( - - <button - type="button" - className="-ml-2 p-2 rounded hover:bg-video-context-light hover:bg-opacity-10" - onClick={props.onClick} - > - <Icon className="text-xl" icon={Icons.ARROW_LEFT} /> - </button> - <span className="line-clamp-1 break-all">{props.children}</span> - - ); -} - -function LinkChevron(props: { children?: React.ReactNode }) { - return ( - - {props.children} - - - ); -} - -function IconButton(props: { icon: Icons; onClick?: () => void }) { - return ( - - ); -} - -function Divider() { - return
; -} - -function SmallText(props: { children: React.ReactNode }) { - return

{props.children}

; -} - -function Anchor(props: { children: React.ReactNode; onClick: () => void }) { - return ( - - {props.children} - - ); -} - -function FieldTitle(props: { children: React.ReactNode }) { - return

{props.children}

; -} - -export const Context = { - CardWithScrollable, - SectionTitle, - LinkChevron, - IconButton, - FieldTitle, - SmallText, - BackLink, - LinkTitle, - Section, - Divider, - Anchor, - Title, - Link, - Card, -}; diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 5f18c2c7..f8dc40ff 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -3,6 +3,7 @@ import { ReactNode } from "react"; import { BrandPill } from "@/components/layout/BrandPill"; import { Player } from "@/components/player"; import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; +import { useIsMobile } from "@/hooks/useIsMobile"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; @@ -16,6 +17,7 @@ export interface PlayerPartProps { export function PlayerPart(props: PlayerPartProps) { const { showTargets, showTouchTargets } = useShouldShowControls(); const status = usePlayerStore((s) => s.status); + const { isMobile } = useIsMobile(); return ( @@ -53,23 +55,25 @@ export function PlayerPart(props: PlayerPartProps) {
+
+ +
- -
- +
+ {isMobile ? : null} + +
+
+ - - {/* Do mobile controls here :) */} - -
@@ -77,9 +81,20 @@ export function PlayerPart(props: PlayerPartProps) {
+
+
+
+ + +
+
+ +
+
+ ); } diff --git a/src/stores/player/slices/interface.ts b/src/stores/player/slices/interface.ts index d5faa461..a3e6449c 100644 --- a/src/stores/player/slices/interface.ts +++ b/src/stores/player/slices/interface.ts @@ -20,6 +20,7 @@ export interface InterfaceSlice { hovering: PlayerHoverState; lastHoveringState: PlayerHoverState; canAirplay: boolean; + hideNextEpisodeBtn: boolean; volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently? volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig" @@ -33,6 +34,7 @@ export interface InterfaceSlice { setHoveringLeftControls(state: boolean): void; setHasOpenOverlay(state: boolean): void; setLastVolume(state: number): void; + hideNextEpisodeButton(): void; } export const createInterfaceSlice: MakeSlice = (set, get) => ({ @@ -48,6 +50,7 @@ export const createInterfaceSlice: MakeSlice = (set, get) => ({ volumeChangedWithKeybindDebounce: null, timeFormat: VideoPlayerTimeFormat.REGULAR, canAirplay: false, + hideNextEpisodeBtn: false, }, setLastVolume(state) { @@ -84,4 +87,9 @@ export const createInterfaceSlice: MakeSlice = (set, get) => ({ s.interface.leftControlHovering = state; }); }, + hideNextEpisodeButton() { + set((s) => { + s.interface.hideNextEpisodeBtn = true; + }); + }, }); diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index ddba0d8e..430a2a31 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -109,6 +109,7 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ setMeta(meta) { set((s) => { s.meta = meta; + s.interface.hideNextEpisodeBtn = false; }); }, setCaption(caption) { diff --git a/tailwind.config.js b/tailwind.config.js index b9045b08..a840f76a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -131,6 +131,15 @@ module.exports = { set: "#A75FC9" }, + buttons: { + secondary: "#161F25", + secondaryText: "#8EA3B0", + secondaryHover: "#1B262E", + primary: "#fff", + primaryText: "#000", + primaryHover: "#dedede" + }, + context: { background: "#0C1216", light: "#4D79A8",