Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81dc0b08a5 | ||
|
|
a6920ef4c1 | ||
|
|
12858c503d | ||
|
|
b74b6578af | ||
|
|
75b6f89066 | ||
|
|
0cf2947c23 | ||
|
|
9c48b5c007 |
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
55
backend/src/scripts/cleanVttFiles.ts
Normal file
55
backend/src/scripts/cleanVttFiles.ts
Normal 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);
|
||||
});
|
||||
72
backend/src/scripts/rescanSubtitles.ts
Normal file
72
backend/src/scripts/rescanSubtitles.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.3.7",
|
||||
"version": "1.3.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 } }}>
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user