DEV Community

Joan Roucoux
Joan Roucoux

Posted on

Easily Sync Your Subtitles with React, RHF and shadcn/ui

Introduction

As a big fan of movies and TV shows, I often run into subtitle syncing issues that can be frustrating. This usually happens when the .srt file comes from a different source, causing delays with the actual dialogue. Adjusting the subtitles manually on a TV isn’t always an option, as the settings might not allow precise changes or may be outside of range.

To solve this problem, let's create a simple tool that allows you to shift the timecodes of your subtitles to perfectly sync them with your video.

Ten seconds delay

We'll use React with Vite, shadcn/ui (a collection of reusable components I have been wanting to try for a while), React Hook Form for form management, and Zod for schema-based form validation.

Here is what our application will look like:

SRT Shifter App

So let's jump into it 🚀

1. Setting Up the Project

We'll start by setting up a new React project with TypeScript preconfigured powered by Vite. Run the following to create our sub-shifter-app:

pnpm create vite@latest sub-shifter-app --template react-ts # Create the app
cd ./sub-shifter-app # Navigate to the project directory
pnpm install # Install dependencies
Enter fullscreen mode Exit fullscreen mode

Next, to install shadcn/ui and its configuration, simply follow the installation steps here from step 2 to step 7, which are very well explained. ⚠️ But be careful ⚠️.

🚨 TailwindCSS 4.0.0 will not work with shadcn/ui if you follow the documentation (as it has not been updated yet - 22/01/2025).

To avoid compatibility issues, please install TailwindCSS version 3.4.17 by running the following commands:

pnpm install -D tailwindcss@3.4.17 postcss autoprefixer
pnpm dlx tailwindcss@3.4.17 init -p
Enter fullscreen mode Exit fullscreen mode

This ensures everything works properly with the current setup.

...

After that, install the UI components needed for building our form interface:

pnpm dlx shadcn@latest add button form input label
Enter fullscreen mode Exit fullscreen mode

When installing the form component using the command above, it will automatically add the required dependencies react-hook-form and zod.

About theming and colors, feel free to explore the hand-picked themes available here, which you can easily copy and paste into the application. For this tutorial, I chose the red theme (only copy/paste the .dark class in src/index.css like below):

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  .dark {
    --background: 0 0% 3.9%;
    --foreground: 0 0% 98%;
    --card: 0 0% 3.9%;
    --card-foreground: 0 0% 98%;
    --popover: 0 0% 3.9%;
    --popover-foreground: 0 0% 98%;
    --primary: 0 72.2% 50.6%;
    --primary-foreground: 0 85.7% 97.3%;
    --secondary: 0 0% 14.9%;
    --secondary-foreground: 0 0% 98%;
    --muted: 0 0% 14.9%;
    --muted-foreground: 0 0% 63.9%;
    --accent: 0 0% 14.9%;
    --accent-foreground: 0 0% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 0 0% 98%;
    --border: 0 0% 14.9%;
    --input: 0 0% 14.9%;
    --ring: 0 72.2% 50.6%;
    --chart-1: 220 70% 50%;
    --chart-2: 160 60% 45%;
    --chart-3: 30 80% 55%;
    --chart-4: 280 65% 60%;
    --chart-5: 340 75% 55%;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}
Enter fullscreen mode Exit fullscreen mode

Lastly, let’s enable the dark mode (because, as viewers, we would prefer to avoid being blinded by a bright background 😆). Update the code in index.html as follows:

...
<body class="dark">
...
Enter fullscreen mode Exit fullscreen mode

Great, we can now move on and start building the form of our application.

2. Building the SRT Shifter Form

In this section, we'll create the SRTShifterForm component, allowing users to upload an SRT subtitle file and apply a time offset to resync the subtitles like below.

SRT shifter form component

We'll divide this into two parts: designing the UI and implementing the underlying logic, ensuring a clear separation of concerns.

2.1 Designing the UI

Below is the complete code for the component. We'll break it down in detail to understand each part:

import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { zodResolver } from '@hookform/resolvers/zod';
import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormControl,
  FormMessage
} from './ui/form';
import {
  type FormSchemaType,
  FORM_INPUT_NAMES,
  ACCEPTED_FILE_TYPE,
  FormSchema,
  defaultValues,
  handleFormSubmit,
} from '../config/SRTShifterFormConfig';

const SRTShifterForm = () => {
  const form = useForm<FormSchemaType>({
    resolver: zodResolver(FormSchema),
    defaultValues,
  });

  const onSubmit = async (data: FormSchemaType): Promise<void> =>
    await handleFormSubmit(data, form);

  return (
    <>
      <section className="flex-1">
        <h2 className="text-2xl font-semibold mb-4">
          How to sync an SRT file with a video?
        </h2>
        <p className="leading-7 [&:not(:first-child)]:mt-4">
          Choose the SRT file you want to sync.
        </p>
        <p className="leading-7 [&:not(:first-child)]:mt-4">
          Enter the offset you want to apply to your subtitles, starting with "+" to add time or "-" to subtract it.
          For example, <strong>+1.20</strong> adds 1 second and 200 milliseconds to each timecode.
        </p>
        <p className="leading-7 [&:not(:first-child)]:mt-4">
          Click <strong>"Shift SRT Subtitles"</strong> to apply the offset and sync your subtitles.
        </p>
        <p className="leading-7 [&:not(:first-child)]:mt-4">
          Your resynced file will be automatically downloaded once ready 🍿
        </p>
      </section>
      <Form {...form}>
        <form
          onSubmit={form.handleSubmit(onSubmit)}
          className="flex-1 space-y-6 rounded-xl border bg-card text-card-foreground shadow p-6"
        >
          <FormField
            control={form.control}
            name={FORM_INPUT_NAMES.FILE}
            render={({ field: { onChange } }) => (
              <FormItem>
                <FormLabel>
                  Upload SRT File (.srt extension)
                </FormLabel>
                <FormControl>
                  <Input
                    id={FORM_INPUT_NAMES.FILE}
                    type="file"
                    accept={ACCEPTED_FILE_TYPE}
                    onChange={async (event) =>
                      onChange(event.target.files && event.target.files[0])}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name={FORM_INPUT_NAMES.OFFSET}
            render={({ field }) => (
              <FormItem>
                <FormLabel>
                  Time Offset
                </FormLabel>
                <FormControl>
                  <Input
                    id={FORM_INPUT_NAMES.OFFSET}
                    type="text"
                    placeholder="+1.20"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button type="submit" className="w-full">
            Shift SRT Subtitles
          </Button>
        </form>
      </Form>
    </>
  );
};

export default SRTShifterForm;
Enter fullscreen mode Exit fullscreen mode

You might have noticed that we are importing several methods from SRTShifterFormConfig.ts. We'll dive deeper into these in the next part.

So let's break it down!

First we used the custom hook useForm from React Hook Form for managing the form and incorporate Zod for validation:

const form = useForm<FormSchemaType>({
  resolver: zodResolver(FormSchema),
  defaultValues,
});
Enter fullscreen mode Exit fullscreen mode

Then, we defined an onSubmit handler to process the form data when the user clicks the submit button:

const onSubmit = async (data: FormSchemaType): Promise<void> =>
  await handleFormSubmit(data, form);
Enter fullscreen mode Exit fullscreen mode

We added a section in the render to provide clear instructions on how to use the application:

<section className="flex-1">
  <h2 className="text-2xl font-semibold mb-4">
    How to sync an SRT file with a video?
  </h2>
  ...
</section>
Enter fullscreen mode Exit fullscreen mode

We added a <Form /> with two fields:

  • A file input which allows users to upload their .srt file:
<FormField
  control={form.control}
  name={FORM_INPUT_NAMES.FILE}
  render={({ field: { onChange } }) => (
    <FormItem>
      <FormLabel>
        Upload SRT File (.srt extension)
      </FormLabel>
      <FormControl>
        <Input
          id={FORM_INPUT_NAMES.FILE}
          type="file"
          accept={ACCEPTED_FILE_TYPE}
          onChange={async (event) =>
            onChange(event.target.files && event.target.files[0])}
        />
      </FormControl>
      <FormMessage />
    </FormItem>
  )}
/>
Enter fullscreen mode Exit fullscreen mode
  • A text input where users specify the offset in the format +value or -value (e.g. +1.20):
<FormField
  control={form.control}
  name={FORM_INPUT_NAMES.OFFSET}
  render={({ field }) => (
    <FormItem>
      <FormLabel>
        Time Offset
      </FormLabel>
      <FormControl>
        <Input
          id={FORM_INPUT_NAMES.OFFSET}
          type="text"
          placeholder="+1.20"
          {...field}
        />
      </FormControl>
      <FormMessage />
    </FormItem>
  )}
/>
Enter fullscreen mode Exit fullscreen mode

And finally, we defined the submit button, which triggers the onSubmit handler mentioned above:

<Button type="submit" className="w-full">
  Shift SRT Subtitles
</Button>
Enter fullscreen mode Exit fullscreen mode

Great, we can proceed with implementing the underlying logic.

2.2 Implementing the underlying logic

In this section, we'll set up the logic for the SRTShifterForm component, including form validation, file handling, and timestamp resynchronization.

As mentioned earlier, I like to separate the logic into a different file to maintain a clean and simple component (which should primarily handle UI rendering and event management). This also makes it easier to test the methods defined in the config file.

To begin, let's create a new file: src/config/SRTShifterFormConfig.ts.

We'll start by defining constants to store the names of form inputs and error messages, ensuring consistent use across our form schema and error handling:

export const FORM_INPUT_NAMES = {
  FILE: 'file',
  OFFSET: 'offset',
} as const;

const FORM_ERROR_MESSAGES = {
  EMPTY_FILE_CONTENT: 'File content is empty.',
  WRONG_FILE_TYPE: 'Please upload a valid .srt file.',
  WRONG_FILE_SIZE: 'File size must be less than 2MB.',
  EMPTY_OFFSET: 'Offset is required.',
  WRONG_OFFSET_FORMAT: 'Offset must be a number, starting with \'+\' or \'-\'.',
  OFFSET_OUT_OF_RANGE: 'Offset must be between -3600 and +3600 seconds.',
} as const;

export const ACCEPTED_FILE_TYPE = '.srt';

const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const OFFSET_REGEX = /^[+-][0-9]+\.?[0-9]*$/; // e.g. +1.20
const MAX_OFFSET_SIZE = 3600; // 1 hour in seconds
Enter fullscreen mode Exit fullscreen mode

Then, we can define the form schema using Zod and the constants we just created, which handles validation for both the SRT file upload and the offset input:

import { z } from 'zod';
...
export const FormSchema = z.object({
  [FORM_INPUT_NAMES.FILE]: z
    .instanceof(File)
    .refine((file) => file.name.endsWith(ACCEPTED_FILE_TYPE), {
      message: FORM_ERROR_MESSAGES.WRONG_FILE_TYPE,
    })
    .refine((file) => file.size <= MAX_FILE_SIZE, {
      message: FORM_ERROR_MESSAGES.WRONG_FILE_SIZE,
    }),
  [FORM_INPUT_NAMES.OFFSET]: z
    .string()
    .min(1, {
      message: FORM_ERROR_MESSAGES.EMPTY_OFFSET,
    })
    .regex(OFFSET_REGEX, {
      message: FORM_ERROR_MESSAGES.WRONG_OFFSET_FORMAT,
    })
    .refine(
      (offset) => {
        const value = parseFloat(offset);
        return (
          !isNaN(value) && value >= -MAX_OFFSET_SIZE && value <= MAX_OFFSET_SIZE
        );
      },
      {
        message: FORM_ERROR_MESSAGES.OFFSET_OUT_OF_RANGE,
      }
    ),
});
Enter fullscreen mode Exit fullscreen mode

The Zod schema validates the following:

  • The file must be of the valid type (.srt) and not exceed 2MB.
  • The offset must be a string that matches the specified format and is within a valid range (between -3600 and 3600 seconds).

Next, let's add the form's default values and specify the type based on the schema:

export type FormSchemaType = z.infer<typeof FormSchema>;

export const defaultValues: FormSchemaType = {
  file: new File([], ''),
  offset: '',
};
Enter fullscreen mode Exit fullscreen mode

Now, to manage the uploaded file and handle the resync process, we can define three helper methods:

  • downloadResyncedFile: This method creates a Blob from the resynced content and triggers the file download.
export const downloadResyncedFile = (resyncedContent: string, fileName: string): void => {
  // Create a Blob from the resynced content
  const blob = new Blob([resyncedContent], { type: 'text/plain' });
  const url = URL.createObjectURL(blob);

  // Create an anchor element and trigger the download
  const link = document.createElement('a');
  link.href = url;
  link.download = fileName.replace(/\.srt$/, '-resynced.srt');
  link.click();

  // Clean up the object URL
  URL.revokeObjectURL(url);
};
Enter fullscreen mode Exit fullscreen mode
  • adjustTimestamp: This method adjusts a given timestamp by adding or subtracting the offset in seconds.
const adjustTimestamp = (timestamp: string, offset: number): string => {
  const [hours, minutes, seconds] = timestamp.split(':');
  const [secs, millis] = seconds.split(',');

  // Convert the timestamp to milliseconds
  let totalMillis =
    parseInt(hours) * 3600000 +
    parseInt(minutes) * 60000 +
    parseInt(secs) * 1000 +
    parseInt(millis);

  // Apply the offset (convert seconds to milliseconds)
  totalMillis += offset * 1000;

  // Handle negative timestamps by clamping to zero
  if (totalMillis < 0) {
    totalMillis = 0;
  }

  // Convert back to "hh:mm:ss,SSS" format
  const newHours = Math.floor(totalMillis / 3600000);
  const newMinutes = Math.floor((totalMillis % 3600000) / 60000);
  const newSeconds = Math.floor((totalMillis % 60000) / 1000);
  const newMillis = totalMillis % 1000;

  // Return new timestamp
  return `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(
    2,
    '0'
  )}:${String(newSeconds).padStart(2, '0')},${String(newMillis).padStart(
    3,
    '0'
  )}`;
};
Enter fullscreen mode Exit fullscreen mode
  • resyncTimestamps: This method adjusts the timestamps of the subtitle file by applying the specified offset.
const resyncTimestamps = (content: string, offset: string): string => {
  // Parse the offset to a number
  const parsedOffset = parseFloat(offset);

  // Process each line of the file
  const lines = content.split('\n');
  const resyncedLines = lines.map((line) => {
    const timecodeMatch = line.match(
      /(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})/
    );

    // Resync timestamps
    if (timecodeMatch) {
      const [_, startTime, endTime] = timecodeMatch;
      const adjustedStartTime = adjustTimestamp(startTime, parsedOffset);
      const adjustedEndTime = adjustTimestamp(endTime, parsedOffset);
      return `${adjustedStartTime} --> ${adjustedEndTime}`;
    }

    return line;
  });

  // Join the adjusted lines back into a single string
  return resyncedLines.join('\n');
};
Enter fullscreen mode Exit fullscreen mode

Finally, we can create the handleFormSubmit method to tie everything together: read the file content, process it with the specified offset, and trigger the download of the resynced file.

import { UseFormReturn, } from 'react-hook-form';
...
export const handleFormSubmit = async (
  data: FormSchemaType,
  form: UseFormReturn<FormSchemaType>
): Promise<void> => {
  const { file, offset } = data;

  // Read the file content
  const reader = new FileReader();
  reader.onload = (event) => {
    // Get the file content
    const fileContent = event?.target?.result as string;
    if (!fileContent) {
      form.setError(FORM_INPUT_NAMES.FILE, {
        type: 'manual',
        message: FORM_ERROR_MESSAGES.EMPTY_FILE_CONTENT,
      });
      return;
    }

    // Resync timestamps
    const resyncedContent = resyncTimestamps(fileContent, offset);

    // Download the resynced file
    downloadResyncedFile(resyncedContent, file.name);
  };

  reader.readAsText(file);
};
Enter fullscreen mode Exit fullscreen mode

Now with the form's UI and logic in place, we can move on and build the rest of the UI.

3. Building the Rest of the UI

In this section, we'll create a simple Header component and update our App component to integrate the SRTShifterForm we just built.

Our Header component is very basic and includes a title and a subtitle. Add the following code in src/components/Header.tsx:

const Header = () => (
  <header className="text-center mb-8">
    <h1 className="text-4xl font-bold leading-tight tracking-tighter">
      <span className="text-primary">SRT</span> Sub Shifter
    </h1>
    <p className="text-base text-muted-foreground">
      Sync the SRT file with your video by shifting the timecodes of your subtitles.
    </p>
  </header>
);

export default Header;
Enter fullscreen mode Exit fullscreen mode

Then, change the src/App.tsx with:

import Header from './components/Header';
import SRTShifterForm from './components/SRTShifterForm';

const App = () => (
  <div className="container mx-auto p-8">
    <Header />
    <div className="flex flex-col lg:flex-row gap-8">
      <SRTShifterForm />
    </div>
  </div>
);

export default App;
Enter fullscreen mode Exit fullscreen mode

We are now done and ready to test our application!

4. Testing

Simply run pnpm dev and navigate to http://localhost:5173/ to see the result. Feel free to upload an SRT file and add an offset to see if the timestamps change 🙂 You’ll see in the code repo that I’ve added a resources folder with a few SRT files for you to try.

You can also check if the form is blocking you when submitting it without any data:

Empty values SRT shifter form

Or with an empty file and an incorrect offset format:

Incorrect values SRT shifter form

And that's how we wrap up this tutorial!

Conclusion

With this very simple tool, you can easily sync your subtitles by adjusting the timecodes, ensuring they match the dialogue perfectly. I hope you enjoyed!

You can find the code here.

Happy watching 🍿

Top comments (0)