diff --git a/src/app/(feed)/feed/TopRightHeaderContent.tsx b/src/app/(feed)/feed/TopRightHeaderContent.tsx index 6dfe389..8202766 100644 --- a/src/app/(feed)/feed/TopRightHeaderContent.tsx +++ b/src/app/(feed)/feed/TopRightHeaderContent.tsx @@ -1,8 +1,47 @@ +"use client"; + +import { MinusIcon, PlusIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { ButtonWithShortcut } from "~/components/ButtonWithShortcut"; import { CustomVideoButton } from "~/components/CustomVideoButton"; +import { MAX_ZOOM, MIN_ZOOM, useKeyboard } from "~/components/KeyboardProvider"; import { OpenRightSidebarButton } from "./OpenRightSidebarButton"; import { RefetchItemsButton } from "./RefetchItemsButton"; +import { useSidebar } from "~/components/ui/sidebar"; export function TopRightHeaderContent() { + const pathname = usePathname(); + + const { isMobile } = useSidebar(); + const { zoom, zoomIn, zoomOut } = useKeyboard(); + + if (pathname.includes("/feed/watch/")) { + if (isMobile) return null; + + return ( +
+ + + + + + +
+ ); + } + return (
diff --git a/src/app/(feed)/feed/watch/[videoID]/page.tsx b/src/app/(feed)/feed/watch/[videoID]/page.tsx index bdd5eff..1cc5dcc 100644 --- a/src/app/(feed)/feed/watch/[videoID]/page.tsx +++ b/src/app/(feed)/feed/watch/[videoID]/page.tsx @@ -36,6 +36,7 @@ export default function WatchVideoPage(props: { "max-w-4xl": view === "windowed" && zoom === 3, "max-w-5xl": view === "windowed" && zoom === 4, "max-w-6xl": view === "windowed" && zoom === 5, + "max-w-7xl": view === "windowed" && zoom === 6, })} >
(null); - const [playerState, setPlayerState] = useState( - YOUTUBE_PLAYER_STATES.BUFFERING, - ); - const [manualPlayerState, setManualPlayerState] = useState(playerState); - const [playbackSpeed, setPlaybackSpeed] = useState(1); - const [videoProgress, setVideoProgress] = useState(0); - const [videoDuration, setVideoDuration] = useState(0); - const [isSeeking, setIsSeeking] = useState(false); - - const changeVideoPlaybackSpeed = useCallback((speed: number) => { - if (!playerRef?.current) return; - const player = playerRef?.current as YouTube | null; - - setPlaybackSpeed(speed); - void player?.internalPlayer?.setPlaybackRate(speed); - }, []); - - const toggleVideoPlayback = useCallback(() => { - if (!playerRef?.current) return; - const player = playerRef?.current as YouTube | null; - - if ( - playerState === YOUTUBE_PLAYER_STATES.BUFFERING || - playerState === YOUTUBE_PLAYER_STATES.CUED || - playerState === YOUTUBE_PLAYER_STATES.PAUSED || - playerState === YOUTUBE_PLAYER_STATES.ENDED - ) { - void player?.internalPlayer?.playVideo(); - return; - } - - if (playerState === YOUTUBE_PLAYER_STATES.PLAYING) { - setManualPlayerState(YOUTUBE_PLAYER_STATES.PAUSED); - - setTimeout(() => { - void player?.internalPlayer?.pauseVideo(); - }, 100); - return; - } - }, [playerState, setManualPlayerState]); - - const seekToSecond = useCallback( - (seconds: number) => { - if (!playerRef?.current) return; - const player = playerRef?.current as YouTube | null; - void player?.internalPlayer.seekTo(seconds); - setVideoProgress(seconds); - setIsSeeking(true); - if (playerState !== YOUTUBE_PLAYER_STATES.PLAYING) { - toggleVideoPlayback(); - } - }, - [playerState], - ); - - useEffect(() => { - const processKey = async (event: KeyboardEvent) => { - if (event.key === " ") { - event.preventDefault(); - toggleVideoPlayback(); - return; - } - if (event.key === "ArrowLeft") { - event.preventDefault(); - seekToSecond(videoProgress - 5 * playbackSpeed); - return; - } - if (event.key === "ArrowRight") { - event.preventDefault(); - seekToSecond(videoProgress + 5 * playbackSpeed); - toggleVideoPlayback(); - return; - } - if (SEEK_KEYS.includes(event.key)) { - event.preventDefault(); - const chunks = videoDuration / 10; - seekToSecond(chunks * parseInt(event.key)); - return; - } - if (event.key === "<" && event.shiftKey) { - event.preventDefault(); - const currentSpeedIndex = PLAYBACK_SPEEDS.findIndex( - (speed) => speed.value === playbackSpeed, - ); - if (currentSpeedIndex <= 0) return; - changeVideoPlaybackSpeed(PLAYBACK_SPEEDS[currentSpeedIndex - 1]!.value); - return; - } - if (event.key === ">" && event.shiftKey) { - event.preventDefault(); - const currentSpeedIndex = PLAYBACK_SPEEDS.findIndex( - (speed) => speed.value === playbackSpeed, - ); - if (playbackSpeed >= FASTEST_SPEED) return; - changeVideoPlaybackSpeed(PLAYBACK_SPEEDS[currentSpeedIndex + 1]!.value); - return; - } - }; - window.addEventListener("keydown", processKey); - - return () => { - window.removeEventListener("keydown", processKey); - }; - }, [playerState, toggleVideoPlayback, playbackSpeed, videoProgress]); - - return { - playerRef, - toggleVideoPlayback, - manualPlayerState, - setManualPlayerState, - playerState, - setPlayerState, - playbackSpeed, - changeVideoPlaybackSpeed, - videoDuration, - setVideoDuration, - isSeeking, - setIsSeeking, - videoProgress, - setVideoProgress, - }; -} - -interface IResponsiveVideoProps { - videoID?: string; - videoSrc?: string; - isInactive: boolean; -} - -export default function CustomVideoPlayer(props: IResponsiveVideoProps) { - const { - playerRef, - toggleVideoPlayback, - manualPlayerState, - setManualPlayerState, - playerState, - setPlayerState, - playbackSpeed, - changeVideoPlaybackSpeed, - videoDuration, - setVideoDuration, - isSeeking, - setIsSeeking, - videoProgress, - setVideoProgress, - } = useVideoShortcuts(); - - const videoProgressIntervalRef = useRef(null); - useEffect(() => { - const player = playerRef?.current as YouTube | null; - - const updateTime = async () => { - const time = await player?.internalPlayer?.getCurrentTime(); - setVideoProgress(time || 0); - }; - - if (playerState === YOUTUBE_PLAYER_STATES.PLAYING) { - videoProgressIntervalRef.current = setInterval(updateTime, 250); - } else if (videoProgressIntervalRef.current) { - clearInterval(videoProgressIntervalRef.current); - } - }, [playerState]); - - const player = playerRef?.current as YouTube | null; - - return ( -
- {props.videoID && ( - <> - { - setVideoDuration(event.target.getDuration()); - setVideoProgress(event.target.getCurrentTime()); - setPlayerState(event.data); - - if (event.data === YOUTUBE_PLAYER_STATES.PLAYING) { - setTimeout(() => { - setManualPlayerState(event.data); - setIsSeeking(false); - }, 50); - } else { - setManualPlayerState(event.data); - } - }} - /> - -
-
-
- -
- -
-
- { - if (!value) return; - const numberValue = parseFloat(value); - - changeVideoPlaybackSpeed(numberValue); - }} - size="xs" - className="flex flex-col items-center justify-center font-mono" - > - {PLAYBACK_SPEEDS.map((speed) => ( - - {speed.label} - - ))} - -
-
- { - setIsSeeking(true); - setVideoProgress(value[0]!); - void player?.internalPlayer.seekTo(value[0]!); - if (playerState !== YOUTUBE_PLAYER_STATES.PLAYING) { - toggleVideoPlayback(); - } - }} - className="mr-4" - /> -
-
- {transformSecondsToFormattedTime(videoProgress)} /{" "} - {transformSecondsToFormattedTime(videoDuration)} -
-
- {/* */} -
-
- - )} - - {props.videoSrc && ( - - )} -
- ); -} diff --git a/src/components/CustomVideoPlayer/CustomVideoPlayerProvider.tsx b/src/components/CustomVideoPlayer/CustomVideoPlayerProvider.tsx new file mode 100644 index 0000000..94bc45f --- /dev/null +++ b/src/components/CustomVideoPlayer/CustomVideoPlayerProvider.tsx @@ -0,0 +1,178 @@ +/* eslint-disable */ +import { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import YouTube, { YouTubeEvent } from "react-youtube"; +import { YouTubeVideoType, YOUTUBE_PLAYER_STATES } from "./constants"; + +type CustomVideoPlayerContext = { + playerRef: React.RefObject; + onStateChange: (event: YouTubeEvent) => void; + toggleVideoPlayback: () => void; + manualPlayerState: number; + playerState: number; + playbackSpeed: number; + changeVideoPlaybackSpeed: (speed: number) => void; + videoDuration: number; + isSeeking: boolean; + seekToSecond: (second: number) => void; + videoProgress: number; + videoType: YouTubeVideoType; +}; + +const CustomVideoPlayerContext = createContext( + null, +); + +export function CustomVideoPlayerProvider({ children }: PropsWithChildren) { + const playerRef = useRef(null); + const [playerState, setPlayerState] = useState( + YOUTUBE_PLAYER_STATES.BUFFERING, + ); + const [manualPlayerState, setManualPlayerState] = useState(playerState); + + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [videoProgress, setVideoProgress] = useState(0); + const [videoDuration, setVideoDuration] = useState(0); + const [isSeeking, setIsSeeking] = useState(false); + + const [videoType, setVideoType] = useState("video"); + + const changeVideoPlaybackSpeed = useCallback((speed: number) => { + if (!playerRef?.current) return; + const player = playerRef?.current as YouTube | null; + + setPlaybackSpeed(speed); + void player?.internalPlayer?.setPlaybackRate(speed); + }, []); + + const firstPlayTimestampRef = useRef(null); + const toggleVideoPlayback = useCallback(() => { + if (!playerRef?.current) return; + if (!firstPlayTimestampRef.current) { + firstPlayTimestampRef.current = Date.now(); + } + + const player = playerRef?.current as YouTube | null; + + if ( + playerState === YOUTUBE_PLAYER_STATES.BUFFERING || + playerState === YOUTUBE_PLAYER_STATES.CUED || + playerState === YOUTUBE_PLAYER_STATES.PAUSED || + playerState === YOUTUBE_PLAYER_STATES.ENDED + ) { + void player?.internalPlayer?.playVideo(); + return; + } + + if (playerState === YOUTUBE_PLAYER_STATES.PLAYING) { + setManualPlayerState(YOUTUBE_PLAYER_STATES.PAUSED); + + setTimeout(() => { + void player?.internalPlayer?.pauseVideo(); + }, 100); + return; + } + }, [playerState, setManualPlayerState]); + + const hasSeekedRef = useRef(false); + const seekToSecond = useCallback( + (seconds: number) => { + if (!playerRef?.current) return; + if (!hasSeekedRef.current) { + hasSeekedRef.current = true; + } + + const player = playerRef?.current as YouTube | null; + void player?.internalPlayer.seekTo(seconds); + setVideoProgress(seconds); + setIsSeeking(true); + if (playerState !== YOUTUBE_PLAYER_STATES.PLAYING) { + toggleVideoPlayback(); + } + }, + [playerState], + ); + + const videoProgressIntervalRef = useRef(null); + useEffect(() => { + const player = playerRef?.current; + if (!player) return; + + const updateTime = async () => { + const time = await player?.internalPlayer?.getCurrentTime(); + setVideoProgress(time || 0); + + const isUnderFiveSecondsFromPlay = + firstPlayTimestampRef.current && + Date.now() - firstPlayTimestampRef.current < 5000; + + const isLive = + time > 120 && isUnderFiveSecondsFromPlay && !hasSeekedRef.current; + + if (time > videoDuration || isLive) { + setVideoType("live"); + } + }; + + if (playerState === YOUTUBE_PLAYER_STATES.PLAYING) { + videoProgressIntervalRef.current = setInterval(updateTime, 250); + } else if (videoProgressIntervalRef.current) { + clearInterval(videoProgressIntervalRef.current); + } + }, [playerState]); + + const onStateChange = useCallback((event: YouTubeEvent) => { + setVideoDuration(event.target.getDuration()); + setVideoProgress(event.target.getCurrentTime()); + setPlayerState(event.data); + + if (event.data === YOUTUBE_PLAYER_STATES.PLAYING) { + setTimeout(() => { + setManualPlayerState(event.data); + setIsSeeking(false); + }, 50); + } else { + setManualPlayerState(event.data); + } + }, []); + + return ( + + {children} + + ); +} + +export function useCustomVideoPlayerContext() { + const context = useContext(CustomVideoPlayerContext); + + if (!context) { + throw new Error( + "useCustomVideoPlayer must be used within a CustomVideoPlayerProvider", + ); + } + + return context; +} diff --git a/src/components/CustomVideoPlayer/constants.ts b/src/components/CustomVideoPlayer/constants.ts new file mode 100644 index 0000000..6828d81 --- /dev/null +++ b/src/components/CustomVideoPlayer/constants.ts @@ -0,0 +1,43 @@ +export const YOUTUBE_PLAYER_STATES = { + ENDED: 0, + PLAYING: 1, + PAUSED: 2, + BUFFERING: 3, + CUED: 5, +} as const; + +export const YOUTUBE_PLAYBACK_SPEEDS = [ + { + label: "0.50x", + value: 0.5, + }, + { + label: "0.75x", + value: 0.75, + }, + { + label: "1.00x", + value: 1, + }, + { + label: "1.25x", + value: 1.25, + }, + { + label: "1.50x", + value: 1.5, + }, + { + label: "1.75x", + value: 1.75, + }, + { + label: "2.00x", + value: 2, + }, +]; +export const YOUTUBE_FASTEST_SPEED = + YOUTUBE_PLAYBACK_SPEEDS[YOUTUBE_PLAYBACK_SPEEDS.length - 1]!.value; + +export const YOUTUBE_VIDEO_TYPES = ["video", "live"] as const; +export type YouTubeVideoType = (typeof YOUTUBE_VIDEO_TYPES)[number]; diff --git a/src/components/CustomVideoPlayer/index.tsx b/src/components/CustomVideoPlayer/index.tsx new file mode 100644 index 0000000..d97c701 --- /dev/null +++ b/src/components/CustomVideoPlayer/index.tsx @@ -0,0 +1,219 @@ +/* eslint-disable */ +"use client"; + +import clsx from "clsx"; +import { + FullscreenIcon, + PlayIcon, + MaximizeIcon, + MinimizeIcon, + PlusIcon, + MinusIcon, +} from "lucide-react"; +import YouTube from "react-youtube"; +import { transformSecondsToFormattedTime } from "~/lib/transformSecondsToFormattedTime"; +import { useKeyboard } from "../KeyboardProvider"; +import { Button } from "../ui/button"; +import { Slider } from "../ui/slider"; +import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"; +import { + CustomVideoPlayerProvider, + useCustomVideoPlayerContext, +} from "./CustomVideoPlayerProvider"; +import { YOUTUBE_PLAYBACK_SPEEDS, YOUTUBE_PLAYER_STATES } from "./constants"; +import { useVideoShortcuts } from "./useYouTubeVideoShortcuts"; +import { ButtonWithShortcut } from "../ButtonWithShortcut"; +import { useFlagState } from "~/lib/hooks/useFlagState"; + +interface IResponsiveVideoProps { + videoID?: string; + videoSrc?: string; + isInactive: boolean; +} + +function CustomVideoPlayerContent(props: IResponsiveVideoProps) { + const { + playerRef, + onStateChange, + toggleVideoPlayback, + manualPlayerState, + playbackSpeed, + changeVideoPlaybackSpeed, + videoDuration, + isSeeking, + videoProgress, + seekToSecond, + videoType, + } = useCustomVideoPlayerContext(); + useVideoShortcuts(); + + const { view, toggleView } = useKeyboard(); + const [hasInlineShortcutsVisible] = useFlagState("INLINE_SHORTCUTS"); + + const player = playerRef?.current; + + return ( +
+ {props.videoID && ( + <> + +
+
+
+ +
+ +
+
+ { + if (!value) return; + const numberValue = parseFloat(value); + + changeVideoPlaybackSpeed(numberValue); + }} + size="xs" + className="flex flex-col items-center justify-center font-mono" + > + {YOUTUBE_PLAYBACK_SPEEDS.map((speed) => ( + + {speed.label} + + ))} + +
+
+
+
+ {videoType === "live" && ( + + )} + {videoType === "video" && ( +
+ {transformSecondsToFormattedTime(videoProgress)} /{" "} + {transformSecondsToFormattedTime(videoDuration)} +
+ )} +
+
+ + {view === "fullscreen" ? ( + + ) : ( + + )} + +
+
+ { + seekToSecond(value[0]!); + }} + className="mt-2 mr-4" + /> +
+
+ + )} + + {props.videoSrc && ( + + )} +
+ ); +} + +export function CustomVideoPlayer(props: IResponsiveVideoProps) { + return ( + + + + ); +} diff --git a/src/components/CustomVideoPlayer/useYouTubeVideoShortcuts.tsx b/src/components/CustomVideoPlayer/useYouTubeVideoShortcuts.tsx new file mode 100644 index 0000000..be8c916 --- /dev/null +++ b/src/components/CustomVideoPlayer/useYouTubeVideoShortcuts.tsx @@ -0,0 +1,81 @@ +const SEEK_KEYS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + +import { useEffect } from "react"; +import { + YOUTUBE_FASTEST_SPEED, + YOUTUBE_PLAYBACK_SPEEDS, + YOUTUBE_PLAYER_STATES, +} from "./constants"; +import { useCustomVideoPlayerContext } from "./CustomVideoPlayerProvider"; +import { doesAnyFormElementHaveFocus } from "~/lib/doesAnyFormElementHaveFocus"; + +export function useVideoShortcuts() { + const { + toggleVideoPlayback, + playerState, + playbackSpeed, + changeVideoPlaybackSpeed, + videoDuration, + seekToSecond, + videoProgress, + } = useCustomVideoPlayerContext(); + + useEffect(() => { + const processKey = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + if (doesAnyFormElementHaveFocus()) return; + + if (event.key === " ") { + event.preventDefault(); + toggleVideoPlayback(); + return; + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + seekToSecond(videoProgress - 5 * playbackSpeed); + return; + } + if (event.key === "ArrowRight") { + event.preventDefault(); + seekToSecond(videoProgress + 5 * playbackSpeed); + toggleVideoPlayback(); + return; + } + if (SEEK_KEYS.includes(event.key)) { + event.preventDefault(); + const chunks = videoDuration / 10; + seekToSecond(chunks * parseInt(event.key)); + return; + } + if (event.key === "<" && event.shiftKey) { + event.preventDefault(); + const currentSpeedIndex = YOUTUBE_PLAYBACK_SPEEDS.findIndex( + (speed) => speed.value === playbackSpeed, + ); + if (currentSpeedIndex <= 0) return; + changeVideoPlaybackSpeed( + YOUTUBE_PLAYBACK_SPEEDS[currentSpeedIndex - 1]!.value, + ); + return; + } + if (event.key === ">" && event.shiftKey) { + event.preventDefault(); + const currentSpeedIndex = YOUTUBE_PLAYBACK_SPEEDS.findIndex( + (speed) => speed.value === playbackSpeed, + ); + if (playbackSpeed >= YOUTUBE_FASTEST_SPEED) return; + changeVideoPlaybackSpeed( + YOUTUBE_PLAYBACK_SPEEDS[currentSpeedIndex + 1]!.value, + ); + return; + } + }; + window.addEventListener("keydown", processKey); + + return () => { + window.removeEventListener("keydown", processKey); + }; + }, [playerState, toggleVideoPlayback, playbackSpeed, videoProgress]); +} diff --git a/src/components/KeyboardProvider.tsx b/src/components/KeyboardProvider.tsx index 029e85f..464bc0a 100644 --- a/src/components/KeyboardProvider.tsx +++ b/src/components/KeyboardProvider.tsx @@ -3,6 +3,7 @@ import { createContext, Ref, + useCallback, useContext, useEffect, useRef, @@ -17,22 +18,19 @@ import { } from "~/lib/data/feed-items/mutations"; import { useFeedItemsMap } from "~/lib/data/atoms"; import { useSidebar } from "./ui/sidebar"; +import { doesAnyFormElementHaveFocus } from "~/lib/doesAnyFormElementHaveFocus"; -function doesAnyInputElementHaveFocus() { - const elements = document.querySelectorAll("input, textarea, select"); - for (const element of elements) { - if (element === document.activeElement) { - return true; - } - } - return false; -} +export const MIN_ZOOM = 0; +export const MAX_ZOOM = 6; export type FeedContext = { view: "windowed" | "fullscreen"; + toggleView: () => void; zoom: number; isCategoriesOpen: boolean; setIsCategoriesOpen: (value: boolean) => void; + zoomIn: () => void; + zoomOut: () => void; }; const FeedContext = createContext(null); @@ -46,7 +44,7 @@ export function KeyboardProvider({ children }: KeyboardProviderProps) { const router = useRouter(); const pathname = usePathname(); - const [zoom, setZoom] = useState(4); + const [zoom, setZoom] = useState(3); const { toggleSidebar } = useSidebar(); const feedItemsMap = useFeedItemsMap(); @@ -65,11 +63,29 @@ export function KeyboardProvider({ children }: KeyboardProviderProps) { const launchDialog = useDialogStore((store) => store.launchDialog); const closeDialog = useDialogStore((store) => store.closeDialog); + const zoomIn = useCallback(() => { + setZoom((z) => { + if (z >= MAX_ZOOM) { + return z; + } + return z + 1; + }); + }, []); + + const zoomOut = useCallback(() => { + setZoom((z) => { + if (z <= MIN_ZOOM) { + return z; + } + return z - 1; + }); + }, []); + useEffect(() => { const processKey = (event: KeyboardEvent) => { const videoID = params.videoID as string; - if (doesAnyInputElementHaveFocus()) return; + if (doesAnyFormElementHaveFocus()) return; if (event.metaKey || event.shiftKey || event.ctrlKey || event.altKey) { return; } @@ -164,21 +180,11 @@ export function KeyboardProvider({ children }: KeyboardProviderProps) { return; } if (event.key === "=") { - setZoom((z) => { - if (z >= 5) { - return z; - } - return z + 1; - }); + zoomIn(); return; } if (event.key === "-") { - setZoom((z) => { - if (z <= 0) { - return z; - } - return z - 1; - }); + zoomOut(); return; } if (event.key === "\\") { @@ -202,11 +208,25 @@ export function KeyboardProvider({ children }: KeyboardProviderProps) { setZoom, pathname, toggleSidebar, + zoomIn, + zoomOut, ]); + const toggleView = useCallback(() => { + setView((v) => (v === "fullscreen" ? "windowed" : "fullscreen")); + }, []); + return ( {children} diff --git a/src/components/ResponsiveVideo.tsx b/src/components/ResponsiveVideo.tsx index e1b726c..8f8eec8 100644 --- a/src/components/ResponsiveVideo.tsx +++ b/src/components/ResponsiveVideo.tsx @@ -2,9 +2,9 @@ import { useFlagState } from "~/lib/hooks/useFlagState"; import classes from "./ResponsiveVideo.module.css"; -import CustomVideoPlayer from "./CustomVideoPlayer"; import clsx from "clsx"; import { useRef } from "react"; +import { CustomVideoPlayer } from "./CustomVideoPlayer"; interface IResponsiveVideoProps { videoID?: string; diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 80684ba..2feba33 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; -import { VariantProps, cva } from "class-variance-authority"; +import { type VariantProps, cva } from "class-variance-authority"; import { PanelLeftIcon } from "lucide-react"; import { Button } from "~/components/ui/button"; diff --git a/src/components/youtube.ts b/src/components/youtube.ts deleted file mode 100644 index 3dcddd6..0000000 --- a/src/components/youtube.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const YOUTUBE_PLAYER_STATES = { - ENDED: 0, - PLAYING: 1, - PAUSED: 2, - BUFFERING: 3, - CUED: 5, -} as const; diff --git a/src/content/releases/2025-04-16.md b/src/content/releases/2025-04-16.md new file mode 100644 index 0000000..a03d6fe --- /dev/null +++ b/src/content/releases/2025-04-16.md @@ -0,0 +1,22 @@ +--- +title: "Livelier Livestreams" +description: "Serial now differentiates between standard videos and livestreamss, adding some nice UI and UX flair." +publish_date: "2025-04-16" +public: true +--- + +#### Features + +- Adds better livestream support + - Adds "Go Live" button that will bring you to the most recent part of a livestream + - Adds "Live" indicator for when you are caught up to a livestream + +#### Improvements + +- Adds a button to toggle windowed fullscreen + - Alternatively, toggle fullscreen with ` or f +- Adds buttons to zoom in and out the active video + - Alternatively, zoom in with + and out with - + - Minor adjustments to zoom, adding one more zoom in step +- Cleaner logic for custom video player +- Better shortcut logic when on video page diff --git a/src/lib/data/feed-items/index.ts b/src/lib/data/feed-items/index.ts index 72e256c..5e53dd4 100644 --- a/src/lib/data/feed-items/index.ts +++ b/src/lib/data/feed-items/index.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - DatabaseFeed, + type DatabaseFeed, type DatabaseFeedCategory, type DatabaseFeedItem, } from "~/server/db/schema"; diff --git a/src/lib/doesAnyFormElementHaveFocus.ts b/src/lib/doesAnyFormElementHaveFocus.ts new file mode 100644 index 0000000..e5f803b --- /dev/null +++ b/src/lib/doesAnyFormElementHaveFocus.ts @@ -0,0 +1,9 @@ +export function doesAnyFormElementHaveFocus() { + const elements = document.querySelectorAll("input, textarea, select"); + for (const element of elements) { + if (element === document.activeElement) { + return true; + } + } + return false; +} diff --git a/src/lib/transformSecondsToFormattedTime.ts b/src/lib/transformSecondsToFormattedTime.ts index 5806841..5a69198 100644 --- a/src/lib/transformSecondsToFormattedTime.ts +++ b/src/lib/transformSecondsToFormattedTime.ts @@ -1,13 +1,15 @@ export function transformSecondsToFormattedTime(seconds: number): string { - const hours = Math.floor(seconds / 3600); + const days = Math.floor(seconds / (3600 * 24)); + const hours = Math.floor((seconds % (3600 * 24)) / 3600); const minutes = Math.floor((seconds % 3600) / 60); const remainingSeconds = seconds % 60; + const formattedDays = days > 0 ? `${days.toString().padStart(2, "0")}:` : ""; const formattedHours = hours > 0 ? `${hours.toString().padStart(2, "0")}:` : ""; const formattedMinutes = `${minutes.toString().padStart(2, "0")}:`; let formattedSeconds = remainingSeconds.toString().split(".")?.[0] ?? ""; formattedSeconds = formattedSeconds.padStart(2, "0"); - return `${formattedHours}${formattedMinutes}${formattedSeconds}`; + return `${formattedDays}${formattedHours}${formattedMinutes}${formattedSeconds}`; } diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index a2e4d7f..f1be08a 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -78,7 +78,7 @@ export const createTRPCRouter = t.router; */ export const publicProcedure = t.procedure; -import { auth } from "../auth"; +import { type auth } from "../auth"; // // TODO: protected procedures export const isAuthed = t.middleware(async (opts) => {