7 Commits

Author SHA1 Message Date
Peifan Li
81dc0b08a5 chore(release): v1.3.9 2025-12-02 16:06:38 -05:00
Peifan Li
a6920ef4c1 feat: Add subtitles support and rescan for existing subtitles 2025-12-02 15:29:51 -05:00
Peifan Li
12858c503d fix: Update backend and frontend package versions to 1.3.8 2025-12-02 13:35:46 -05:00
Peifan Li
b74b6578af chore(release): v1.3.8 2025-12-02 13:33:05 -05:00
Peifan Li
75b6f89066 refactor: Update download history logic to exclude cancelled tasks 2025-12-02 13:33:00 -05:00
Peifan Li
0cf2947c23 fix: Update route path for collection in App component 2025-12-02 13:27:39 -05:00
Peifan Li
9c48b5c007 fix: Update backend and frontend versions to 1.3.7 2025-12-02 13:18:48 -05:00
20 changed files with 401 additions and 47 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "backend",
"version": "1.3.5",
"version": "1.3.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.3.5",
"version": "1.3.8",
"license": "ISC",
"dependencies": {
"axios": "^1.8.1",

View File

@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.3.7",
"version": "1.3.9",
"main": "server.js",
"scripts": {
"start": "ts-node src/server.ts",
@@ -9,7 +9,7 @@
"generate": "drizzle-kit generate",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"postinstall": "cd bgutil-ytdlp-pot-provider/server && npm install && npx tsc"
"postinstall": "node -e \"const fs = require('fs'); const cp = require('child_process'); const p = 'bgutil-ytdlp-pot-provider/server'; if (fs.existsSync(p)) { console.log('Building provider...'); cp.execSync('npm install && npx tsc', { cwd: p, stdio: 'inherit' }); } else { console.log('Skipping provider build: ' + p + ' not found'); }\""
},
"keywords": [],
"author": "",

View File

@@ -6,6 +6,7 @@ export const ROOT_DIR: string = process.cwd();
export const UPLOADS_DIR: string = path.join(ROOT_DIR, "uploads");
export const VIDEOS_DIR: string = path.join(UPLOADS_DIR, "videos");
export const IMAGES_DIR: string = path.join(UPLOADS_DIR, "images");
export const SUBTITLES_DIR: string = path.join(UPLOADS_DIR, "subtitles");
export const DATA_DIR: string = path.join(ROOT_DIR, "data");
export const VIDEOS_DATA_PATH: string = path.join(DATA_DIR, "videos.json");

View File

@@ -19,6 +19,7 @@ interface Settings {
openListToken?: string;
cloudDrivePath?: string;
homeSidebarOpen?: boolean;
subtitlesEnabled?: boolean;
}
const defaultSettings: Settings = {
@@ -32,7 +33,8 @@ const defaultSettings: Settings = {
openListApiUrl: '',
openListToken: '',
cloudDrivePath: '',
homeSidebarOpen: true
homeSidebarOpen: true,
subtitlesEnabled: true
};
export const getSettings = async (_req: Request, res: Response) => {

View File

@@ -28,6 +28,7 @@ export const videos = sqliteTable('videos', {
progress: integer('progress'), // Playback progress in seconds
fileSize: text('file_size'),
lastPlayedAt: integer('last_played_at'), // Timestamp when video was last played
subtitles: text('subtitles'), // JSON stringified array of subtitle objects
});
export const collections = sqliteTable('collections', {

View File

@@ -0,0 +1,55 @@
import fs from "fs-extra";
import path from "path";
import { SUBTITLES_DIR } from "../config/paths";
/**
* Clean existing VTT files by removing alignment tags that force left-alignment
*/
async function cleanVttFiles() {
console.log("Starting VTT file cleanup...");
try {
if (!fs.existsSync(SUBTITLES_DIR)) {
console.log("Subtitles directory doesn't exist");
return;
}
const vttFiles = fs.readdirSync(SUBTITLES_DIR).filter((file) => file.endsWith(".vtt"));
console.log(`Found ${vttFiles.length} VTT files to clean`);
let cleanedCount = 0;
for (const vttFile of vttFiles) {
const filePath = path.join(SUBTITLES_DIR, vttFile);
// Read VTT file
let vttContent = fs.readFileSync(filePath, 'utf-8');
// Check if it has alignment tags
if (vttContent.includes('align:start') || vttContent.includes('position:0%')) {
// Replace align:start with align:middle for centered subtitles (Safari needs this)
// Remove position:0% which forces left positioning
vttContent = vttContent.replace(/ align:start/g, ' align:middle');
vttContent = vttContent.replace(/ position:0%/g, '');
// Write cleaned content back
fs.writeFileSync(filePath, vttContent, 'utf-8');
console.log(`Cleaned: ${vttFile}`);
cleanedCount++;
}
}
console.log(`VTT cleanup complete. Cleaned ${cleanedCount} files.`);
} catch (error) {
console.error("Error during VTT cleanup:", error);
}
}
// Run the script
cleanVttFiles().then(() => {
console.log("Done");
process.exit(0);
}).catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@@ -0,0 +1,72 @@
import fs from "fs-extra";
import { SUBTITLES_DIR } from "../config/paths";
import * as storageService from "../services/storageService";
/**
* Scan subtitle directory and update video records with subtitle metadata
*/
async function rescanSubtitles() {
console.log("Starting subtitle rescan...");
try {
// Get all videos
const videos = storageService.getVideos();
console.log(`Found ${videos.length} videos to check`);
// Get all subtitle files
if (!fs.existsSync(SUBTITLES_DIR)) {
console.log("Subtitles directory doesn't exist");
return;
}
const subtitleFiles = fs.readdirSync(SUBTITLES_DIR).filter((file) => file.endsWith(".vtt"));
console.log(`Found ${subtitleFiles.length} subtitle files`);
let updatedCount = 0;
for (const video of videos) {
// Skip if video already has subtitles
if (video.subtitles && video.subtitles.length > 0) {
continue;
}
// Look for subtitle files matching this video
const videoTimestamp = video.id;
const matchingSubtitles = subtitleFiles.filter((file) => file.includes(videoTimestamp));
if (matchingSubtitles.length > 0) {
console.log(`Found ${matchingSubtitles.length} subtitles for video: ${video.title}`);
const subtitles = matchingSubtitles.map((filename) => {
// Parse language from filename (e.g., video_123.en.vtt -> en)
const match = filename.match(/\.([a-z]{2}(?:-[A-Z]{2})?)\.vtt$/);
const language = match ? match[1] : "unknown";
return {
language,
filename,
path: `/subtitles/${filename}`,
};
});
// Update video record
storageService.updateVideo(video.id, { subtitles });
console.log(`Updated video ${video.id} with ${subtitles.length} subtitles`);
updatedCount++;
}
}
console.log(`Subtitle rescan complete. Updated ${updatedCount} videos.`);
} catch (error) {
console.error("Error during subtitle rescan:", error);
}
}
// Run the script
rescanSubtitles().then(() => {
console.log("Done");
process.exit(0);
}).catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@@ -4,7 +4,7 @@ dotenv.config();
import cors from "cors";
import express from "express";
import { IMAGES_DIR, VIDEOS_DIR } from "./config/paths";
import { IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "./config/paths";
import apiRoutes from "./routes/api";
import settingsRoutes from './routes/settingsRoutes';
import downloadManager from "./services/downloadManager";
@@ -35,6 +35,7 @@ downloadManager.initialize();
// Serve static files
app.use("/videos", express.static(VIDEOS_DIR));
app.use("/images", express.static(IMAGES_DIR));
app.use("/subtitles", express.static(SUBTITLES_DIR));
// API Routes
app.use("/api", apiRoutes);

View File

@@ -11,6 +11,7 @@ interface DownloadTask {
cancelFn?: () => void;
sourceUrl?: string;
type?: string;
cancelled?: boolean;
}
class DownloadManager {
@@ -141,6 +142,7 @@ class DownloadManager {
const task = this.activeTasks.get(id);
if (task) {
console.log(`Cancelling active download: ${task.title} (${id})`);
task.cancelled = true;
// Call the cancel function if available
if (task.cancelFn) {
@@ -269,16 +271,18 @@ class DownloadManager {
}
// Add to history
storageService.addDownloadHistoryItem({
id: task.id,
title: finalTitle || task.title,
finishedAt: Date.now(),
status: 'success',
videoPath: videoData.videoPath,
thumbnailPath: videoData.thumbnailPath,
sourceUrl: videoData.sourceUrl || task.sourceUrl,
author: videoData.author,
});
if (!task.cancelled) {
storageService.addDownloadHistoryItem({
id: task.id,
title: finalTitle || task.title,
finishedAt: Date.now(),
status: 'success',
videoPath: videoData.videoPath,
thumbnailPath: videoData.thumbnailPath,
sourceUrl: videoData.sourceUrl || task.sourceUrl,
author: videoData.author,
});
}
// Trigger Cloud Upload (Async, don't await to block queue processing?)
// Actually, we might want to await it if we want to ensure it's done before resolving,
@@ -298,14 +302,16 @@ class DownloadManager {
storageService.removeActiveDownload(task.id);
// Add to history
storageService.addDownloadHistoryItem({
id: task.id,
title: task.title,
finishedAt: Date.now(),
status: 'failed',
error: error instanceof Error ? error.message : String(error),
sourceUrl: task.sourceUrl,
});
if (!task.cancelled) {
storageService.addDownloadHistoryItem({
id: task.id,
title: task.title,
finishedAt: Date.now(),
status: 'failed',
error: error instanceof Error ? error.message : String(error),
sourceUrl: task.sourceUrl,
});
}
task.reject(error);
} finally {

View File

@@ -2,7 +2,7 @@ import axios from "axios";
import fs from "fs-extra";
import path from "path";
import youtubedl from "youtube-dl-exec";
import { IMAGES_DIR, VIDEOS_DIR } from "../../config/paths";
import { IMAGES_DIR, SUBTITLES_DIR, VIDEOS_DIR } from "../../config/paths";
import { sanitizeFilename } from "../../utils/helpers";
import * as storageService from "../storageService";
import { Video } from "../storageService";
@@ -177,6 +177,7 @@ export class YtDlpDownloader {
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved, source;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
let subtitles: Array<{ language: string; filename: string; path: string }> = [];
try {
// Get video info first
@@ -240,6 +241,9 @@ export class YtDlpDownloader {
output: newVideoPath,
format: "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
mergeOutputFormat: "mp4",
writeSubs: true,
writeAutoSubs: true,
convertSubs: "vtt",
};
// Add YouTube specific flags if it's a YouTube URL
@@ -343,6 +347,50 @@ export class YtDlpDownloader {
// Continue even if thumbnail download fails
}
}
// Scan for subtitle files
try {
const baseFilename = newSafeBaseFilename;
const subtitleFiles = fs.readdirSync(VIDEOS_DIR).filter((file: string) =>
file.startsWith(baseFilename) && file.endsWith(".vtt")
);
console.log(`Found ${subtitleFiles.length} subtitle files`);
for (const subtitleFile of subtitleFiles) {
// Parse language from filename (e.g., video_123.en.vtt -> en)
const match = subtitleFile.match(/\.([a-z]{2}(?:-[A-Z]{2})?)(?:\..*?)?\.vtt$/);
const language = match ? match[1] : "unknown";
// Move subtitle to subtitles directory
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
const destSubFilename = `${baseFilename}.${language}.vtt`;
const destSubPath = path.join(SUBTITLES_DIR, destSubFilename);
// Read VTT file and fix alignment for centering
let vttContent = fs.readFileSync(sourceSubPath, 'utf-8');
// Replace align:start with align:middle for centered subtitles
// Also remove position:0% which forces left positioning
vttContent = vttContent.replace(/ align:start/g, ' align:middle');
vttContent = vttContent.replace(/ position:0%/g, '');
// Write cleaned VTT to destination
fs.writeFileSync(destSubPath, vttContent, 'utf-8');
// Remove original file
fs.unlinkSync(sourceSubPath);
console.log(`Processed and moved subtitle ${subtitleFile} to ${destSubPath}`);
subtitles.push({
language,
filename: destSubFilename,
path: `/subtitles/${destSubFilename}`,
});
}
} catch (subtitleError) {
console.error("Error processing subtitle files:", subtitleError);
}
} catch (error) {
console.error("Error in download process:", error);
throw error;
@@ -364,6 +412,7 @@ export class YtDlpDownloader {
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: null,
subtitles: subtitles.length > 0 ? subtitles : undefined,
duration: undefined, // Will be populated below
addedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),

View File

@@ -5,6 +5,7 @@ import {
DATA_DIR,
IMAGES_DIR,
STATUS_DATA_PATH,
SUBTITLES_DIR,
UPLOADS_DIR,
VIDEOS_DIR,
} from "../config/paths";
@@ -17,6 +18,7 @@ export interface Video {
sourceUrl: string;
videoFilename?: string;
thumbnailFilename?: string;
subtitles?: Array<{ language: string; filename: string; path: string }>;
createdAt: string;
tags?: string[];
viewCount?: number;
@@ -70,6 +72,7 @@ export function initializeStorage(): void {
fs.ensureDirSync(UPLOADS_DIR);
fs.ensureDirSync(VIDEOS_DIR);
fs.ensureDirSync(IMAGES_DIR);
fs.ensureDirSync(SUBTITLES_DIR);
fs.ensureDirSync(DATA_DIR);
// Initialize status.json if it doesn't exist
@@ -151,6 +154,12 @@ export function initializeStorage(): void {
console.log("Migration successful: last_played_at added.");
}
if (!columns.includes('subtitles')) {
console.log("Migrating database: Adding subtitles column to videos table...");
sqlite.prepare("ALTER TABLE videos ADD COLUMN subtitles TEXT").run();
console.log("Migration successful: subtitles added.");
}
// Check downloads table columns
const downloadsTableInfo = sqlite.prepare("PRAGMA table_info(downloads)").all();
const downloadsColumns = (downloadsTableInfo as any[]).map((col: any) => col.name);
@@ -429,6 +438,7 @@ export function getVideos(): Video[] {
return allVideos.map(v => ({
...v,
tags: v.tags ? JSON.parse(v.tags) : [],
subtitles: v.subtitles ? JSON.parse(v.subtitles) : undefined,
})) as Video[];
} catch (error) {
console.error("Error getting videos:", error);
@@ -443,6 +453,7 @@ export function getVideoById(id: string): Video | undefined {
return {
...video,
tags: video.tags ? JSON.parse(video.tags) : [],
subtitles: video.subtitles ? JSON.parse(video.subtitles) : undefined,
} as Video;
}
return undefined;
@@ -457,6 +468,7 @@ export function saveVideo(videoData: Video): Video {
const videoToSave = {
...videoData,
tags: videoData.tags ? JSON.stringify(videoData.tags) : undefined,
subtitles: videoData.subtitles ? JSON.stringify(videoData.subtitles) : undefined,
};
db.insert(videos).values(videoToSave as any).onConflictDoUpdate({
target: videos.id,
@@ -474,6 +486,7 @@ export function updateVideo(id: string, updates: Partial<Video>): Video | null {
const updatesToSave = {
...updates,
tags: updates.tags ? JSON.stringify(updates.tags) : undefined,
subtitles: updates.subtitles ? JSON.stringify(updates.subtitles) : undefined,
};
// If tags is explicitly empty array, we might want to save it as '[]' or null.
// JSON.stringify([]) is '[]', which is fine.
@@ -484,6 +497,7 @@ export function updateVideo(id: string, updates: Partial<Video>): Video | null {
return {
...result,
tags: result.tags ? JSON.parse(result.tags) : [],
subtitles: result.subtitles ? JSON.parse(result.subtitles) : undefined,
} as Video;
}
return null;
@@ -498,7 +512,7 @@ export function deleteVideo(id: string): boolean {
const videoToDelete = getVideoById(id);
if (!videoToDelete) return false;
// Remove files
// Remove video file
if (videoToDelete.videoFilename) {
const actualPath = findVideoFile(videoToDelete.videoFilename);
if (actualPath && fs.existsSync(actualPath)) {
@@ -506,6 +520,7 @@ export function deleteVideo(id: string): boolean {
}
}
// Remove thumbnail file
if (videoToDelete.thumbnailFilename) {
const actualPath = findImageFile(videoToDelete.thumbnailFilename);
if (actualPath && fs.existsSync(actualPath)) {
@@ -513,6 +528,17 @@ export function deleteVideo(id: string): boolean {
}
}
// Remove subtitle files
if (videoToDelete.subtitles && videoToDelete.subtitles.length > 0) {
for (const subtitle of videoToDelete.subtitles) {
const subtitlePath = path.join(SUBTITLES_DIR, subtitle.filename);
if (fs.existsSync(subtitlePath)) {
fs.unlinkSync(subtitlePath);
console.log(`Deleted subtitle file: ${subtitle.filename}`);
}
}
}
// Delete from DB
db.delete(videos).where(eq(videos.id, id)).run();
return true;

View File

@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "1.3.5",
"version": "1.3.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.3.5",
"version": "1.3.8",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.3.7",
"version": "1.3.9",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -110,8 +110,8 @@ function AppContent() {
<Route path="/manage" element={<ManagePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/downloads" element={<DownloadPage />} />
<Route path="/collections/:id" element={<CollectionPage />} />
<Route path="/author/:name" element={<AuthorVideos />} />
<Route path="/collection/:id" element={<CollectionPage />} />
<Route path="/author/:authorName" element={<AuthorVideos />} />
<Route path="/video/:id" element={<VideoPlayer />} />
<Route path="/subscriptions" element={<SubscriptionsPage />} />
<Route path="/instruction" element={<InstructionPage />} />

View File

@@ -9,7 +9,9 @@ import {
Loop,
Pause,
PlayArrow,
Replay10
Replay10,
Subtitles,
SubtitlesOff
} from '@mui/icons-material';
import {
Box,
@@ -29,6 +31,10 @@ interface VideoControlsProps {
onTimeUpdate?: (currentTime: number) => void;
onLoadedMetadata?: (duration: number) => void;
startTime?: number;
subtitles?: Array<{ language: string; filename: string; path: string }>;
subtitlesEnabled?: boolean;
onSubtitlesToggle?: (enabled: boolean) => void;
onLoopToggle?: (enabled: boolean) => void;
}
const VideoControls: React.FC<VideoControlsProps> = ({
@@ -37,7 +43,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
autoLoop = false,
onTimeUpdate,
onLoadedMetadata,
startTime = 0
startTime = 0,
subtitles = [],
subtitlesEnabled: initialSubtitlesEnabled = true,
onSubtitlesToggle,
onLoopToggle
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@@ -47,6 +57,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isLooping, setIsLooping] = useState<boolean>(autoLoop);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [subtitlesEnabled, setSubtitlesEnabled] = useState<boolean>(initialSubtitlesEnabled && subtitles.length > 0);
useEffect(() => {
if (videoRef.current) {
@@ -112,6 +123,20 @@ const VideoControls: React.FC<VideoControlsProps> = ({
};
}, []);
// Sync subtitle tracks when preference changes or subtitles become available
useEffect(() => {
if (videoRef.current && subtitles.length > 0) {
const tracks = videoRef.current.textTracks;
const newState = initialSubtitlesEnabled && subtitles.length > 0;
for (let i = 0; i < tracks.length; i++) {
tracks[i].mode = newState ? 'showing' : 'hidden';
}
setSubtitlesEnabled(newState);
}
}, [initialSubtitlesEnabled, subtitles]);
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
@@ -125,8 +150,14 @@ const VideoControls: React.FC<VideoControlsProps> = ({
const handleToggleLoop = () => {
if (videoRef.current) {
videoRef.current.loop = !isLooping;
setIsLooping(!isLooping);
const newState = !isLooping;
videoRef.current.loop = newState;
setIsLooping(newState);
// Call the callback to save preference to database
if (onLoopToggle) {
onLoopToggle(newState);
}
}
};
@@ -160,8 +191,52 @@ const VideoControls: React.FC<VideoControlsProps> = ({
}
};
const handleToggleSubtitles = () => {
if (videoRef.current) {
const tracks = videoRef.current.textTracks;
const newState = !subtitlesEnabled;
for (let i = 0; i < tracks.length; i++) {
tracks[i].mode = newState ? 'showing' : 'hidden';
}
setSubtitlesEnabled(newState);
// Call the callback to save preference to database
if (onSubtitlesToggle) {
onSubtitlesToggle(newState);
}
}
};
return (
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: { xs: 0, sm: 2 }, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
{/* Global style for centering subtitles */}
<style>
{`
video::cue {
text-align: center !important;
line-height: 1.5;
background-color: rgba(0, 0, 0, 0.8);
}
video::-webkit-media-text-track-display {
text-align: center !important;
}
video::-webkit-media-text-track-container {
text-align: center !important;
display: flex;
justify-content: center;
align-items: flex-end;
}
video::cue-region {
text-align: center !important;
}
`}
</style>
<video
ref={videoRef}
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
@@ -181,9 +256,26 @@ const VideoControls: React.FC<VideoControlsProps> = ({
if (onLoadedMetadata) {
onLoadedMetadata(e.currentTarget.duration);
}
// Initialize subtitle tracks based on preference
const tracks = e.currentTarget.textTracks;
const shouldShow = initialSubtitlesEnabled && subtitles.length > 0;
for (let i = 0; i < tracks.length; i++) {
tracks[i].mode = shouldShow ? 'showing' : 'hidden';
}
}}
playsInline
crossOrigin="anonymous"
>
{subtitles && subtitles.map((subtitle) => (
<track
key={subtitle.language}
kind="subtitles"
src={`${import.meta.env.VITE_BACKEND_URL}${subtitle.path}`}
srcLang={subtitle.language}
label={subtitle.language.toUpperCase()}
/>
))}
Your browser does not support the video tag.
</video>
@@ -234,6 +326,18 @@ const VideoControls: React.FC<VideoControlsProps> = ({
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</Button>
</Tooltip>
{subtitles && subtitles.length > 0 && (
<Tooltip title={subtitlesEnabled ? 'Hide Subtitles' : 'Show Subtitles'}>
<Button
variant={subtitlesEnabled ? "contained" : "outlined"}
onClick={handleToggleSubtitles}
fullWidth={isMobile}
>
{subtitlesEnabled ? <Subtitles /> : <SubtitlesOff />}
</Button>
</Tooltip>
)}
</Stack>
{/* Row 2 on Mobile: Seek Controls */}

View File

@@ -365,15 +365,6 @@ const SettingsPage: React.FC = () => {
}
label={t('autoPlay')}
/>
<FormControlLabel
control={
<Switch
checked={settings.defaultAutoLoop}
onChange={(e) => handleChange('defaultAutoLoop', e.target.checked)}
/>
}
label={t('autoLoop')}
/>
</Box>
</Grid>

View File

@@ -96,6 +96,7 @@ const VideoPlayer: React.FC = () => {
const autoPlay = settings?.defaultAutoPlay || false;
const autoLoop = settings?.defaultAutoLoop || false;
const availableTags = settings?.tags || [];
const subtitlesEnabled = settings?.subtitlesEnabled ?? true;
// Fetch comments
const { data: comments = [], isLoading: loadingComments } = useQuery({
@@ -283,6 +284,46 @@ const VideoPlayer: React.FC = () => {
await tagsMutation.mutateAsync(newTags);
};
// Subtitle preference mutation
const subtitlePreferenceMutation = useMutation({
mutationFn: async (enabled: boolean) => {
const response = await axios.post(`${API_URL}/settings`, { ...settings, subtitlesEnabled: enabled });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
queryClient.setQueryData(['settings'], (old: any) => old ? { ...old, subtitlesEnabled: data.settings.subtitlesEnabled } : old);
}
},
onError: () => {
showSnackbar(t('error'), 'error');
}
});
const handleSubtitlesToggle = async (enabled: boolean) => {
await subtitlePreferenceMutation.mutateAsync(enabled);
};
// Loop preference mutation
const loopPreferenceMutation = useMutation({
mutationFn: async (enabled: boolean) => {
const response = await axios.post(`${API_URL}/settings`, { ...settings, defaultAutoLoop: enabled });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
queryClient.setQueryData(['settings'], (old: any) => old ? { ...old, defaultAutoLoop: data.settings.defaultAutoLoop } : old);
}
},
onError: () => {
showSnackbar(t('error'), 'error');
}
});
const handleLoopToggle = async (enabled: boolean) => {
await loopPreferenceMutation.mutateAsync(enabled);
};
const [hasViewed, setHasViewed] = useState<boolean>(false);
const lastProgressSave = useRef<number>(0);
const currentTimeRef = useRef<number>(0);
@@ -370,6 +411,10 @@ const VideoPlayer: React.FC = () => {
autoLoop={autoLoop}
onTimeUpdate={handleTimeUpdate}
startTime={video.progress || 0}
subtitles={video.subtitles}
subtitlesEnabled={subtitlesEnabled}
onSubtitlesToggle={handleSubtitlesToggle}
onLoopToggle={handleLoopToggle}
/>
<Box sx={{ px: { xs: 2, md: 0 } }}>

View File

@@ -21,6 +21,7 @@ export interface Video {
duration?: string;
fileSize?: string; // Size in bytes as string
lastPlayedAt?: number;
subtitles?: Array<{ language: string; filename: string; path: string }>;
[key: string]: any;
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "mytube",
"version": "1.3.5",
"version": "1.3.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mytube",
"version": "1.3.5",
"version": "1.3.8",
"license": "MIT",
"dependencies": {
"concurrently": "^8.2.2"

View File

@@ -1,6 +1,6 @@
{
"name": "mytube",
"version": "1.3.7",
"version": "1.3.9",
"description": "YouTube video downloader and player application",
"main": "index.js",
"scripts": {