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) => {