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.
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:
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
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
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
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;
}
}
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">
...
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.
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;
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,
});
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);
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>
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>
)}
/>
- 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>
)}
/>
And finally, we defined the submit button, which triggers the onSubmit
handler mentioned above:
<Button type="submit" className="w-full">
Shift SRT Subtitles
</Button>
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
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,
}
),
});
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: '',
};
Now, to manage the uploaded file and handle the resync process, we can define three helper methods:
-
downloadResyncedFile
: This method creates aBlob
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);
};
-
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'
)}`;
};
-
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');
};
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);
};
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;
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;
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:
Or with an empty file and an incorrect offset format:
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)