Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/app/(feed)/feed/RefetchItemsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import clsx from "clsx";
import { RefreshCwIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { ButtonWithShortcut } from "~/components/ButtonWithShortcut";
import { Button } from "~/components/ui/button";
import { FETCH_NEW_FEED_ITEMS_KEY } from "~/lib/data/feed-items";
import { useFetchNewFeedItemsMutation } from "~/lib/data/feed-items/mutations";
Expand Down Expand Up @@ -33,13 +34,14 @@ export function RefetchItemsButton() {
if (pathname !== "/feed") return null;

return (
<Button
<ButtonWithShortcut
size="icon md:default"
variant="outline"
onClick={async () => {
await fetchNewFeedItems();
}}
disabled={isPending}
shortcut="r"
>
<RefreshCwIcon
size={16}
Expand All @@ -48,6 +50,6 @@ export function RefetchItemsButton() {
})}
/>
<span className="hidden pl-1.5 md:block">Refresh</span>
</Button>
</ButtonWithShortcut>
);
}
96 changes: 84 additions & 12 deletions src/components/CustomVideoPlayer/CustomVideoPlayerProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type CustomVideoPlayerContext = {
seekToSecond: (second: number) => void;
videoProgress: number;
videoType: YouTubeVideoType;
startVideoHold: () => void;
stopVideoHold: () => void;
};

const CustomVideoPlayerContext = createContext<CustomVideoPlayerContext | null>(
Expand Down Expand Up @@ -52,6 +54,67 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {
void player?.internalPlayer?.setPlaybackRate(speed);
}, []);

// In an effort to prevent YouTube suggestions from playing in the embed,
// we "pause" the video manually
const videoHoldLocationRef = useRef<number | null>(null);
const videoHoldSpeedRef = useRef<number | null>(null);
const videoHoldTimeoutRef = useRef<NodeJS.Timeout | null>(null);

const setHoldTimeout = () => {
if (!playerRef?.current) return null;
const player = playerRef.current;

return setTimeout(async () => {
await player?.internalPlayer?.seekTo(videoHoldLocationRef.current);
videoHoldTimeoutRef.current = setHoldTimeout();
}, 0);
};

// in order to "hold" the video, we want to
// - mute the video
// - drop the playback speed super low
// - rewind the video every X period of time back to the hold location
const startVideoHold = useCallback(async () => {
if (!playerRef?.current) return;
const player = playerRef.current;

setManualPlayerState(YOUTUBE_PLAYER_STATES.HELD);

videoHoldLocationRef.current =
await player?.internalPlayer?.getCurrentTime();

videoHoldSpeedRef.current = await player?.internalPlayer?.getPlaybackRate();
void player?.internalPlayer?.setPlaybackRate(0);

player.internalPlayer.mute();

videoHoldTimeoutRef.current = setHoldTimeout();
}, []);

const stopVideoHold = useCallback(() => {
if (!playerRef?.current) return;
const player = playerRef.current;

player.internalPlayer.unMute();

if (videoHoldSpeedRef.current) {
void player?.internalPlayer?.setPlaybackRate(videoHoldSpeedRef.current);
videoHoldSpeedRef.current = null;
}

if (videoHoldTimeoutRef.current) {
clearTimeout(videoHoldTimeoutRef.current);
videoHoldTimeoutRef.current = null;
}

if (videoHoldLocationRef.current) {
void player?.internalPlayer?.seekTo(videoHoldLocationRef.current);
videoHoldLocationRef.current = null;
}

setManualPlayerState(YOUTUBE_PLAYER_STATES.PLAYING);
}, []);

const firstPlayTimestampRef = useRef<number | null>(null);
const toggleVideoPlayback = useCallback(() => {
if (!playerRef?.current) return;
Expand Down Expand Up @@ -128,20 +191,27 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {
}
}, [playerState]);

const onStateChange = useCallback((event: YouTubeEvent) => {
setVideoDuration(event.target.getDuration());
setVideoProgress(event.target.getCurrentTime());
setPlayerState(event.data);
const onStateChange = useCallback(
(event: YouTubeEvent) => {
setVideoDuration(event.target.getDuration());
setVideoProgress(event.target.getCurrentTime());
setPlayerState(event.data);

if (event.data === YOUTUBE_PLAYER_STATES.PLAYING) {
setTimeout(() => {
if (manualPlayerState === YOUTUBE_PLAYER_STATES.HELD) {
return;
}

if (event.data === YOUTUBE_PLAYER_STATES.PLAYING) {
setTimeout(() => {
setManualPlayerState(event.data);
setIsSeeking(false);
}, 50);
} else {
setManualPlayerState(event.data);
setIsSeeking(false);
}, 50);
} else {
setManualPlayerState(event.data);
}
}, []);
}
},
[manualPlayerState],
);

return (
<CustomVideoPlayerContext.Provider
Expand All @@ -158,6 +228,8 @@ export function CustomVideoPlayerProvider({ children }: PropsWithChildren) {
seekToSecond,
videoProgress,
videoType,
startVideoHold,
stopVideoHold,
}}
>
{children}
Expand Down
4 changes: 4 additions & 0 deletions src/components/CustomVideoPlayer/constants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
export const YOUTUBE_PLAYER_STATES = {
// youtube states
ENDED: 0,
PLAYING: 1,
PAUSED: 2,
BUFFERING: 3,
CUED: 5,

// custom states
HELD: 6,
} as const;

export const YOUTUBE_PLAYBACK_SPEEDS = [
Expand Down
1 change: 1 addition & 0 deletions src/components/CustomVideoPlayer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function CustomVideoPlayerContent(props: IResponsiveVideoProps) {
className={clsx("transition-all", {
"opacity-0":
manualPlayerState === YOUTUBE_PLAYER_STATES.PLAYING ||
manualPlayerState === YOUTUBE_PLAYER_STATES.HELD ||
isSeeking,
})}
>
Expand Down
41 changes: 36 additions & 5 deletions src/components/CustomVideoPlayer/useYouTubeVideoShortcuts.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
const SEEK_KEYS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];

import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { doesAnyFormElementHaveFocus } from "~/lib/doesAnyFormElementHaveFocus";
import { YOUTUBE_FASTEST_SPEED, YOUTUBE_PLAYBACK_SPEEDS } from "./constants";
import {
YOUTUBE_FASTEST_SPEED,
YOUTUBE_PLAYBACK_SPEEDS,
YOUTUBE_PLAYER_STATES,
} from "./constants";
import { useCustomVideoPlayerContext } from "./CustomVideoPlayerProvider";

export function useVideoShortcuts() {
const {
toggleVideoPlayback,
startVideoHold,
stopVideoHold,
playerState,
playbackSpeed,
changeVideoPlaybackSpeed,
Expand All @@ -16,14 +22,36 @@ export function useVideoShortcuts() {
videoProgress,
} = useCustomVideoPlayerContext();

const keypressTimeRef = useRef<Record<string, number | null>>({});

useEffect(() => {
const processKey = (event: KeyboardEvent) => {
const processKeyDown = (event: KeyboardEvent) => {
if (typeof keypressTimeRef.current[event.key] === "number") {
return;
}

keypressTimeRef.current[event.key] = Date.now();

if (playerState === YOUTUBE_PLAYER_STATES.PLAYING && event.key === " ") {
startVideoHold();
}
};

const processKeyUp = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey) {
return;
}
if (doesAnyFormElementHaveFocus()) return;

let timePressed = 0;
const keypressStartTime = keypressTimeRef.current[event.key];
if (keypressStartTime) {
timePressed = Date.now() - keypressStartTime;
}
keypressTimeRef.current[event.key] = null;

if (event.key === " ") {
stopVideoHold();
event.preventDefault();
toggleVideoPlayback();
return;
Expand Down Expand Up @@ -68,10 +96,13 @@ export function useVideoShortcuts() {
return;
}
};
window.addEventListener("keydown", processKey);

window.addEventListener("keydown", processKeyDown);
window.addEventListener("keyup", processKeyUp);

return () => {
window.removeEventListener("keydown", processKey);
window.removeEventListener("keydown", processKeyDown);
window.removeEventListener("keyup", processKeyUp);
};
}, [
playerState,
Expand Down
21 changes: 21 additions & 0 deletions src/content/releases/2025-11-07.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
title: "Unified Importing"
description: "Serial now supports one unified import UI, making it much easier and consistent to import data from YouTube and other RSS readers."
publish_date: "2025-11-07"
public: true
---

## Features

- Update import view to be one, unified UI
- Upload `subscriptions.csv` and `*.opml` files in one interface
- More consistent & lenient imports
- See post-import success and error states on a per-feed basis

## Improvements

- Added inline shortcut display for feed refreshing (which is <kbd>r</kbd>)

## Experimental

- Added the ability to "hold" a video by holding <kbd>Space</kbd>, which is useful when you want to pause on a part of a video to read long text or look closer. Doing a full pause will cause the YouTube suggestions to pop up, so this is an attempt at a workaround. If you have feedback on this, don't hesitate to reach out!
6 changes: 5 additions & 1 deletion src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,11 @@
}

.prose kbd {
@apply bg-muted rounded px-1.5 py-0.5 text-sm;
@apply bg-muted border-muted-foreground/20 rounded border border-b-2 border-solid px-1.5 py-0.5 text-sm;
}

.prose code {
@apply bg-muted rounded px-px py-px text-sm;
}

.prose img {
Expand Down