feat: Add channel_url column to videos table

This commit is contained in:
Peifan Li
2025-12-18 17:57:14 -05:00
parent 90f85955b8
commit 9c0f3abcc2
9 changed files with 1386 additions and 119 deletions

View File

@@ -0,0 +1,4 @@
-- Add channel_url column to videos table
-- Note: SQLite doesn't support IF NOT EXISTS for ALTER TABLE ADD COLUMN
-- This migration assumes the column doesn't exist yet
ALTER TABLE `videos` ADD `channel_url` text;

View File

@@ -0,0 +1,697 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1d19e2bb-a70b-4c9f-bfb0-913f62951823",
"prevId": "e34144d1-add0-4bb0-b9d3-852c5fa0384e",
"tables": {
"collection_videos": {
"name": "collection_videos",
"columns": {
"collection_id": {
"name": "collection_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"video_id": {
"name": "video_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"collection_videos_collection_id_collections_id_fk": {
"name": "collection_videos_collection_id_collections_id_fk",
"tableFrom": "collection_videos",
"tableTo": "collections",
"columnsFrom": [
"collection_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"collection_videos_video_id_videos_id_fk": {
"name": "collection_videos_video_id_videos_id_fk",
"tableFrom": "collection_videos",
"tableTo": "videos",
"columnsFrom": [
"video_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"collection_videos_collection_id_video_id_pk": {
"columns": [
"collection_id",
"video_id"
],
"name": "collection_videos_collection_id_video_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collections": {
"name": "collections",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"download_history": {
"name": "download_history",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"finished_at": {
"name": "finished_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_path": {
"name": "video_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_size": {
"name": "total_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_id": {
"name": "video_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_at": {
"name": "downloaded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"downloads": {
"name": "downloads",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_size": {
"name": "total_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_size": {
"name": "downloaded_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"speed": {
"name": "speed",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscriptions": {
"name": "subscriptions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author_url": {
"name": "author_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"interval": {
"name": "interval",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_video_link": {
"name": "last_video_link",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_check": {
"name": "last_check",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"download_count": {
"name": "download_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'YouTube'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"video_downloads": {
"name": "video_downloads",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_video_id": {
"name": "source_video_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"video_id": {
"name": "video_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'exists'"
},
"downloaded_at": {
"name": "downloaded_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"videos": {
"name": "videos",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_filename": {
"name": "video_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_filename": {
"name": "thumbnail_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_path": {
"name": "video_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"part_number": {
"name": "part_number",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_parts": {
"name": "total_parts",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"series_title": {
"name": "series_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view_count": {
"name": "view_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_played_at": {
"name": "last_played_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subtitles": {
"name": "subtitles",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"channel_url": {
"name": "channel_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1733644800000,
"tag": "0004_video_downloads",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1766096471960,
"tag": "0005_tired_demogoblin",
"breakpoints": true
}
]
}

View File

@@ -223,7 +223,14 @@ export const getAuthorChannelUrl = async (
}
try {
// Check if it's a YouTube URL
// First, check if we have the video in the database with a stored channelUrl
const existingVideo = storageService.getVideoBySourceUrl(sourceUrl);
if (existingVideo && existingVideo.channelUrl) {
res.status(200).json({ success: true, channelUrl: existingVideo.channelUrl });
return;
}
// If not in database, fetch it (for YouTube)
if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) {
const {
executeYtDlpJson,
@@ -240,6 +247,10 @@ export const getAuthorChannelUrl = async (
const channelUrl = info.channel_url || info.uploader_url || null;
if (channelUrl) {
// If we have the video in database, update it with the channelUrl
if (existingVideo) {
storageService.updateVideo(existingVideo.id, { channelUrl });
}
res.status(200).json({ success: true, channelUrl });
return;
}
@@ -247,6 +258,13 @@ export const getAuthorChannelUrl = async (
// Check if it's a Bilibili URL
if (sourceUrl.includes("bilibili.com") || sourceUrl.includes("b23.tv")) {
// If we have the video in database, try to get channelUrl from there first
// (already checked above, but this is for clarity)
if (existingVideo && existingVideo.channelUrl) {
res.status(200).json({ success: true, channelUrl: existingVideo.channelUrl });
return;
}
const axios = (await import("axios")).default;
const { extractBilibiliVideoId } = await import("../utils/helpers");
@@ -277,6 +295,12 @@ export const getAuthorChannelUrl = async (
) {
const mid = response.data.data.owner.mid;
const spaceUrl = `https://space.bilibili.com/${mid}`;
// If we have the video in database, update it with the channelUrl
if (existingVideo) {
storageService.updateVideo(existingVideo.id, { channelUrl: spaceUrl });
}
res.status(200).json({ success: true, channelUrl: spaceUrl });
return;
}

View File

@@ -29,6 +29,7 @@ export const videos = sqliteTable('videos', {
fileSize: text('file_size'),
lastPlayedAt: integer('last_played_at'), // Timestamp when video was last played
subtitles: text('subtitles'), // JSON stringified array of subtitle objects
channelUrl: text('channel_url'), // Author channel URL for subscriptions
});
export const collections = sqliteTable('collections', {

View File

@@ -429,6 +429,35 @@ export async function downloadSinglePart(
videoDescription = bilibiliInfo.description || "";
thumbnailUrl = bilibiliInfo.thumbnailUrl;
thumbnailSaved = bilibiliInfo.thumbnailSaved;
// Extract channel URL for Bilibili
let channelUrl: string | undefined;
try {
const { extractBilibiliVideoId } = await import("../../../utils/helpers");
const videoId = extractBilibiliVideoId(url);
if (videoId) {
const axios = (await import("axios")).default;
const isBvId = videoId.startsWith("BV");
const apiUrl = isBvId
? `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`
: `https://api.bilibili.com/x/web-interface/view?aid=${videoId.replace("av", "")}`;
const response = await axios.get(apiUrl, {
headers: {
Referer: "https://www.bilibili.com",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
},
});
if (response.data?.data?.owner?.mid) {
const mid = response.data.data.owner.mid;
channelUrl = `https://space.bilibili.com/${mid}`;
}
}
} catch (error) {
logger.error("Error extracting Bilibili channel URL:", error);
// Continue without channel URL
}
// Update the safe base filename with the actual title
// Update the safe base filename with the new format
@@ -560,6 +589,7 @@ export async function downloadSinglePart(
: null,
duration: duration,
fileSize: fileSize,
channelUrl: channelUrl || undefined,
addedAt: new Date().toISOString(),
partNumber: partNumber,
totalParts: totalParts,

View File

@@ -74,7 +74,8 @@ export async function downloadVideo(
videoDescription: string,
thumbnailUrl: string | null,
thumbnailSaved: boolean,
source: string;
source: string,
channelUrl: string | null = null;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
let subtitles: Array<{ language: string; filename: string; path: string }> =
@@ -111,6 +112,9 @@ export async function downloadVideo(
videoDescription = metadata.videoDescription;
thumbnailUrl = metadata.thumbnailUrl;
source = metadata.source;
// Extract channel URL from info if available
channelUrl = info.channel_url || info.uploader_url || null;
// Update the safe base filename with the actual title
const newSafeBaseFilename = formatVideoFilename(
@@ -292,6 +296,7 @@ export async function downloadVideo(
: null,
subtitles: subtitles.length > 0 ? subtitles : undefined,
duration: undefined, // Will be populated below
channelUrl: channelUrl || undefined,
addedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};

View File

@@ -11,16 +11,20 @@ import {
PlayArrow,
Replay10,
Subtitles,
SubtitlesOff
SubtitlesOff,
VolumeDown,
VolumeOff,
VolumeUp
} from '@mui/icons-material';
import {
Box,
Button,
IconButton,
Menu,
MenuItem,
Slider,
Stack,
Tooltip,
useMediaQuery,
Typography,
useTheme
} from '@mui/material';
import React, { useEffect, useRef, useState } from 'react';
@@ -54,7 +58,6 @@ const VideoControls: React.FC<VideoControlsProps> = ({
onEnded
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { t } = useLanguage();
const videoRef = useRef<HTMLVideoElement>(null);
@@ -62,6 +65,17 @@ const VideoControls: React.FC<VideoControlsProps> = ({
const [isLooping, setIsLooping] = useState<boolean>(autoLoop);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [subtitlesEnabled, setSubtitlesEnabled] = useState<boolean>(initialSubtitlesEnabled && subtitles.length > 0);
const [currentTime, setCurrentTime] = useState<number>(0);
const [duration, setDuration] = useState<number>(0);
const [volume, setVolume] = useState<number>(1);
const [previousVolume, setPreviousVolume] = useState<number>(1);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [showVolumeSlider, setShowVolumeSlider] = useState<boolean>(false);
const volumeSliderRef = useRef<HTMLDivElement>(null);
const volumeSliderHideTimerRef = useRef<NodeJS.Timeout | null>(null);
const [controlsVisible, setControlsVisible] = useState<boolean>(true);
const hideControlsTimerRef = useRef<NodeJS.Timeout | null>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const [subtitleMenuAnchor, setSubtitleMenuAnchor] = useState<null | HTMLElement>(null);
@@ -76,8 +90,27 @@ const VideoControls: React.FC<VideoControlsProps> = ({
videoRef.current.loop = true;
setIsLooping(true);
}
// Initialize volume
videoRef.current.volume = volume;
}
}, [autoPlay, autoLoop]);
}, [autoPlay, autoLoop, volume]);
// Listen for duration changes (in case duration becomes available after metadata load)
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement) return;
const handleDurationChange = () => {
const videoDuration = videoElement.duration;
// Update duration for display
setDuration(videoDuration);
};
videoElement.addEventListener('durationchange', handleDurationChange);
return () => {
videoElement.removeEventListener('durationchange', handleDurationChange);
};
}, []);
useEffect(() => {
const handleFullscreenChange = () => {
@@ -109,6 +142,66 @@ const VideoControls: React.FC<VideoControlsProps> = ({
};
}, []);
// Handle controls visibility in fullscreen mode
useEffect(() => {
const startHideTimer = () => {
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
if (isFullscreen) {
// Show controls first
setControlsVisible(true);
// After 5 seconds, hide completely
hideControlsTimerRef.current = setTimeout(() => {
setControlsVisible(false);
}, 5000);
} else {
// Always show controls when not in fullscreen
setControlsVisible(true);
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
}
};
startHideTimer();
return () => {
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
};
}, [isFullscreen]);
// Handle mouse movement to show controls in fullscreen
useEffect(() => {
if (!isFullscreen) return;
const handleMouseMove = () => {
setControlsVisible(true);
// Reset timer on mouse move
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
// Hide again after 5 seconds of no movement
hideControlsTimerRef.current = setTimeout(() => {
setControlsVisible(false);
}, 5000);
};
const container = videoContainerRef.current;
if (container) {
container.addEventListener('mousemove', handleMouseMove);
return () => {
container.removeEventListener('mousemove', handleMouseMove);
};
}
}, [isFullscreen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input or textarea
@@ -207,6 +300,126 @@ const VideoControls: React.FC<VideoControlsProps> = ({
}
};
const handleProgressChange = (_event: Event, newValue: number | number[]) => {
if (!videoRef.current || duration <= 0 || !isFinite(duration)) return;
const value = Array.isArray(newValue) ? newValue[0] : newValue;
const newTime = (value / 100) * duration;
setCurrentTime(newTime);
};
const handleProgressChangeCommitted = (_event: Event | React.SyntheticEvent, newValue: number | number[]) => {
if (videoRef.current && duration > 0 && isFinite(duration)) {
const value = Array.isArray(newValue) ? newValue[0] : newValue;
const newTime = (value / 100) * duration;
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
setIsDragging(false);
}
};
const handleProgressMouseDown = () => {
setIsDragging(true);
};
const handleVolumeChange = (_event: Event, newValue: number | number[]) => {
if (videoRef.current) {
const value = Array.isArray(newValue) ? newValue[0] : newValue;
const volumeValue = value / 100;
videoRef.current.volume = volumeValue;
setVolume(volumeValue);
}
};
const handleVolumeClick = () => {
if (videoRef.current) {
if (volume > 0) {
// Mute: save current volume and set to 0
setPreviousVolume(volume);
videoRef.current.volume = 0;
setVolume(0);
} else {
// Unmute: restore previous volume
const volumeToRestore = previousVolume > 0 ? previousVolume : 1;
videoRef.current.volume = volumeToRestore;
setVolume(volumeToRestore);
}
}
};
const formatTime = (seconds: number): string => {
if (isNaN(seconds) || !isFinite(seconds)) return '0:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
const getVolumeIcon = () => {
if (volume === 0) return <VolumeOff />;
if (volume < 0.5) return <VolumeDown />;
return <VolumeUp />;
};
// Close volume slider when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (volumeSliderRef.current && !volumeSliderRef.current.contains(event.target as Node)) {
if (volumeSliderHideTimerRef.current) {
clearTimeout(volumeSliderHideTimerRef.current);
}
setShowVolumeSlider(false);
}
};
if (showVolumeSlider) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [showVolumeSlider]);
// Handle wheel event on volume control with native listener to properly prevent default
useEffect(() => {
const handleWheel = (event: WheelEvent) => {
if (volumeSliderRef.current && volumeSliderRef.current.contains(event.target as Node)) {
event.preventDefault();
event.stopPropagation();
if (videoRef.current) {
const delta = event.deltaY > 0 ? 0.05 : -0.05; // Scroll down decreases, scroll up increases
const newVolume = Math.max(0, Math.min(1, volume + delta));
videoRef.current.volume = newVolume;
setVolume(newVolume);
// Update previousVolume if not muted
if (newVolume > 0) {
setPreviousVolume(newVolume);
}
}
}
};
const container = volumeSliderRef.current;
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false });
return () => {
container.removeEventListener('wheel', handleWheel);
};
}
}, [volume, videoRef]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (volumeSliderHideTimerRef.current) {
clearTimeout(volumeSliderHideTimerRef.current);
}
};
}, []);
const handleSubtitleClick = (event: React.MouseEvent<HTMLElement>) => {
setSubtitleMenuAnchor(event.currentTarget);
};
@@ -237,7 +450,10 @@ const VideoControls: React.FC<VideoControlsProps> = ({
};
return (
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: { xs: 0, sm: 2 }, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
<Box
ref={videoContainerRef}
sx={{ width: '100%', bgcolor: 'black', borderRadius: { xs: 0, sm: 2 }, overflow: 'hidden', boxShadow: 4, position: 'relative' }}
>
{/* Global style for centering subtitles */}
<style>
{`
@@ -266,23 +482,32 @@ const VideoControls: React.FC<VideoControlsProps> = ({
<video
ref={videoRef}
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
controls={true} // Enable native controls as requested
// The original code had `controls` attribute on the video tag, which enables native controls.
// But it also rendered custom controls below it.
// Let's keep it consistent with original: native controls enabled.
style={{ width: '100%', aspectRatio: '16/9', display: 'block', cursor: 'pointer' }}
controls={false}
src={src}
onClick={handlePlayPause}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={onEnded}
onTimeUpdate={(e) => onTimeUpdate && onTimeUpdate(e.currentTarget.currentTime)}
onTimeUpdate={(e) => {
const time = e.currentTarget.currentTime;
if (!isDragging) {
setCurrentTime(time);
}
if (onTimeUpdate) {
onTimeUpdate(time);
}
}}
onLoadedMetadata={(e) => {
const videoDuration = e.currentTarget.duration;
// Set duration for display (even if 0 or NaN, formatTime will handle it)
setDuration(videoDuration);
if (startTime > 0) {
e.currentTarget.currentTime = startTime;
setCurrentTime(startTime);
}
if (onLoadedMetadata) {
onLoadedMetadata(e.currentTarget.duration);
onLoadedMetadata(videoDuration);
}
// Initialize subtitle tracks based on preference
@@ -297,6 +522,9 @@ const VideoControls: React.FC<VideoControlsProps> = ({
tracks[0].mode = 'showing';
}
}}
onVolumeChange={(e) => {
setVolume(e.currentTarget.volume);
}}
playsInline
crossOrigin="anonymous"
>
@@ -313,116 +541,348 @@ const VideoControls: React.FC<VideoControlsProps> = ({
</video>
{/* Custom Controls Area */}
<Box sx={{
p: 1,
bgcolor: theme.palette.mode === 'dark' ? '#1a1a1a' : '#f5f5f5',
opacity: isFullscreen ? 0.3 : 1,
transition: 'opacity 0.3s, background-color 0.3s',
'&:hover': { opacity: 1 }
}}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems="center"
justifyContent="center"
spacing={{ xs: 2, sm: 2 }}
>
{/* Row 1 on Mobile: Play/Pause and Loop */}
<Stack direction="row" spacing={2} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
<Tooltip title={isPlaying ? t('paused') : t('playing')}>
<Button
variant={isPlaying ? "outlined" : "contained"}
color={isPlaying ? "secondary" : "primary"}
onClick={handlePlayPause}
fullWidth={isMobile}
<Box
sx={{
p: 1,
bgcolor: theme.palette.mode === 'dark' ? '#1a1a1a' : '#f5f5f5',
opacity: isFullscreen
? (controlsVisible ? 0.3 : 0)
: 1,
visibility: isFullscreen && !controlsVisible ? 'hidden' : 'visible',
transition: 'opacity 0.3s, visibility 0.3s, background-color 0.3s',
pointerEvents: isFullscreen && !controlsVisible ? 'none' : 'auto',
'&:hover': {
opacity: isFullscreen && controlsVisible ? 1 : (isFullscreen ? 0 : 1)
}
}}
onMouseEnter={() => {
if (isFullscreen) {
setControlsVisible(true);
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
hideControlsTimerRef.current = setTimeout(() => {
setControlsVisible(false);
}, 5000);
}
}}
>
{/* Progress Bar */}
<Box sx={{ px: { xs: 1, sm: 2 }, mb: 1 }}>
<Stack direction="row" spacing={1} alignItems="center">
{/* Left Side: Volume and Play */}
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mr: 1 }}>
{/* Volume Control (Hidden on mobile/tablet, shown on desktop) */}
<Box
ref={volumeSliderRef}
sx={{
position: 'relative',
display: { xs: 'none', md: 'flex' },
alignItems: 'center'
}}
onMouseEnter={() => {
if (volumeSliderHideTimerRef.current) {
clearTimeout(volumeSliderHideTimerRef.current);
}
setShowVolumeSlider(true);
}}
onMouseLeave={() => {
// Add a small delay to allow moving cursor to slider
volumeSliderHideTimerRef.current = setTimeout(() => {
setShowVolumeSlider(false);
}, 200);
}}
>
{isPlaying ? <Pause /> : <PlayArrow />}
</Button>
</Tooltip>
<Tooltip title={`${t('loop')} ${isLooping ? t('on') : t('off')}`}>
<Button
variant={isLooping ? "contained" : "outlined"}
color="secondary"
onClick={handleToggleLoop}
fullWidth={isMobile}
>
<Loop />
</Button>
</Tooltip>
<Tooltip title={isFullscreen ? t('exitFullscreen') : t('enterFullscreen')}>
<Button
variant="outlined"
onClick={handleToggleFullscreen}
fullWidth={isMobile}
>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</Button>
</Tooltip>
{subtitles && subtitles.length > 0 && (
<>
<Tooltip title={subtitlesEnabled ? 'Subtitles' : 'Subtitles Off'}>
<Button
variant={subtitlesEnabled ? "contained" : "outlined"}
onClick={handleSubtitleClick}
fullWidth={isMobile}
<Tooltip title={volume === 0 ? 'Unmute' : 'Mute'}>
<IconButton
onClick={handleVolumeClick}
size="small"
>
{subtitlesEnabled ? <Subtitles /> : <SubtitlesOff />}
</Button>
{getVolumeIcon()}
</IconButton>
</Tooltip>
<Menu
anchorEl={subtitleMenuAnchor}
open={Boolean(subtitleMenuAnchor)}
onClose={handleCloseSubtitleMenu}
>
<MenuItem onClick={() => handleSelectSubtitle(-1)}>
{t('off') || 'Off'}
</MenuItem>
{subtitles.map((subtitle, index) => (
<MenuItem key={subtitle.language} onClick={() => handleSelectSubtitle(index)}>
{subtitle.language.toUpperCase()}
</MenuItem>
))}
</Menu>
</>
)}
</Stack>
{showVolumeSlider && (
<Box
sx={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
mb: 0.5,
width: '40px',
bgcolor: theme.palette.mode === 'dark' ? '#2a2a2a' : '#fff',
p: 1,
borderRadius: 1,
boxShadow: 2,
zIndex: 1000,
display: 'flex',
justifyContent: 'center',
pointerEvents: 'auto'
}}
onMouseEnter={() => {
if (volumeSliderHideTimerRef.current) {
clearTimeout(volumeSliderHideTimerRef.current);
}
}}
onMouseLeave={() => {
volumeSliderHideTimerRef.current = setTimeout(() => {
setShowVolumeSlider(false);
}, 200);
}}
>
<Slider
orientation="vertical"
value={volume * 100}
onChange={handleVolumeChange}
min={0}
max={100}
size="small"
sx={{
height: '80px',
'& .MuiSlider-thumb': {
width: 12,
height: 12,
},
'& .MuiSlider-track': {
width: 4,
},
'& .MuiSlider-rail': {
width: 4,
}
}}
/>
</Box>
)}
</Box>
{/* Row 2 on Mobile: Seek Controls */}
<Stack direction="row" spacing={0.4} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
{/* Play/Pause */}
<Tooltip title={isPlaying ? t('paused') : t('playing')}>
<IconButton
color={isPlaying ? "secondary" : "primary"}
onClick={handlePlayPause}
size="small"
>
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
</Tooltip>
</Stack>
<Typography variant="caption" sx={{ minWidth: '45px', textAlign: 'right', fontSize: '0.75rem' }}>
{formatTime(currentTime)}
</Typography>
<Slider
value={duration > 0 && isFinite(duration) ? (currentTime / duration) * 100 : 0}
onChange={handleProgressChange}
onChangeCommitted={handleProgressChangeCommitted}
onMouseDown={handleProgressMouseDown}
disabled={duration <= 0 || !isFinite(duration)}
size="small"
sx={{
flex: 1,
color: theme.palette.primary.main,
transition: 'all 0.2s ease',
'& .MuiSlider-thumb': {
width: 12,
height: 12,
transition: 'width 0.2s, height 0.2s',
'&:hover': {
width: 16,
height: 16,
}
},
'& .MuiSlider-track': {
height: 4,
transition: 'height 0.2s ease',
},
'& .MuiSlider-rail': {
height: 4,
transition: 'height 0.2s ease',
},
'&:hover': {
'& .MuiSlider-track': {
height: 8,
},
'& .MuiSlider-rail': {
height: 8,
}
}
}}
/>
<Typography variant="caption" sx={{ minWidth: '45px', textAlign: 'left', fontSize: '0.75rem' }}>
{formatTime(duration)}
</Typography>
{/* Right Side: Fullscreen, Subtitle, Loop (Desktop only) */}
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ ml: 1, display: { xs: 'none', sm: 'flex' } }}>
{/* Fullscreen */}
<Tooltip title={isFullscreen ? t('exitFullscreen') : t('enterFullscreen')}>
<IconButton
onClick={handleToggleFullscreen}
size="small"
>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</IconButton>
</Tooltip>
{/* Subtitle */}
{subtitles && subtitles.length > 0 && (
<>
<Tooltip title={subtitlesEnabled ? 'Subtitles' : 'Subtitles Off'}>
<IconButton
color={subtitlesEnabled ? "primary" : "default"}
onClick={handleSubtitleClick}
size="small"
>
{subtitlesEnabled ? <Subtitles /> : <SubtitlesOff />}
</IconButton>
</Tooltip>
<Menu
anchorEl={subtitleMenuAnchor}
open={Boolean(subtitleMenuAnchor)}
onClose={handleCloseSubtitleMenu}
>
<MenuItem onClick={() => handleSelectSubtitle(-1)}>
{t('off') || 'Off'}
</MenuItem>
{subtitles.map((subtitle, index) => (
<MenuItem key={subtitle.language} onClick={() => handleSelectSubtitle(index)}>
{subtitle.language.toUpperCase()}
</MenuItem>
))}
</Menu>
</>
)}
{/* Loop */}
<Tooltip title={`${t('loop')} ${isLooping ? t('on') : t('off')}`}>
<IconButton
color={isLooping ? "primary" : "default"}
onClick={handleToggleLoop}
size="small"
>
<Loop />
</IconButton>
</Tooltip>
</Stack>
</Stack>
</Box>
{/* Seek Controls */}
<Stack
direction="row"
spacing={0.5}
justifyContent="center"
alignItems="center"
sx={{ width: '100%', flexWrap: 'wrap' }}
>
<Tooltip title="-10m">
<Button variant="outlined" onClick={() => handleSeek(-600)}>
<IconButton
onClick={() => handleSeek(-600)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<KeyboardDoubleArrowLeft />
</Button>
</IconButton>
</Tooltip>
<Tooltip title="-1m">
<Button variant="outlined" onClick={() => handleSeek(-60)}>
<IconButton
onClick={() => handleSeek(-60)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<FastRewind />
</Button>
</IconButton>
</Tooltip>
<Tooltip title="-10s">
<Button variant="outlined" onClick={() => handleSeek(-10)}>
<IconButton
onClick={() => handleSeek(-10)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<Replay10 />
</Button>
</IconButton>
</Tooltip>
<Tooltip title="+10s">
<Button variant="outlined" onClick={() => handleSeek(10)}>
<IconButton
onClick={() => handleSeek(10)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<Forward10 />
</Button>
</IconButton>
</Tooltip>
<Tooltip title="+1m">
<Button variant="outlined" onClick={() => handleSeek(60)}>
<IconButton
onClick={() => handleSeek(60)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<FastForward />
</Button>
</IconButton>
</Tooltip>
<Tooltip title="+10m">
<Button variant="outlined" onClick={() => handleSeek(600)}>
<IconButton
onClick={() => handleSeek(600)}
size="small"
sx={{ padding: { xs: '10px', sm: '8px' } }}
>
<KeyboardDoubleArrowRight />
</Button>
</IconButton>
</Tooltip>
{/* Mobile: Fullscreen, Subtitle, Loop */}
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ display: { xs: 'flex', sm: 'none' }, ml: 1 }}>
{/* Fullscreen */}
<Tooltip title={isFullscreen ? t('exitFullscreen') : t('enterFullscreen')}>
<IconButton
onClick={handleToggleFullscreen}
size="small"
>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</IconButton>
</Tooltip>
{/* Subtitle */}
{subtitles && subtitles.length > 0 && (
<>
<Tooltip title={subtitlesEnabled ? 'Subtitles' : 'Subtitles Off'}>
<IconButton
color={subtitlesEnabled ? "primary" : "default"}
onClick={handleSubtitleClick}
size="small"
>
{subtitlesEnabled ? <Subtitles /> : <SubtitlesOff />}
</IconButton>
</Tooltip>
<Menu
anchorEl={subtitleMenuAnchor}
open={Boolean(subtitleMenuAnchor)}
onClose={handleCloseSubtitleMenu}
>
<MenuItem onClick={() => handleSelectSubtitle(-1)}>
{t('off') || 'Off'}
</MenuItem>
{subtitles.map((subtitle, index) => (
<MenuItem key={subtitle.language} onClick={() => handleSelectSubtitle(index)}>
{subtitle.language.toUpperCase()}
</MenuItem>
))}
</Menu>
</>
)}
{/* Loop */}
<Tooltip title={`${t('loop')} ${isLooping ? t('on') : t('off')}`}>
<IconButton
color={isLooping ? "primary" : "default"}
onClick={handleToggleLoop}
size="small"
>
<Loop />
</IconButton>
</Tooltip>
</Stack>
</Stack>
</Stack>
</Box>
</Box>
);

View File

@@ -236,6 +236,61 @@ const Home: React.FC = () => {
}
}, [filteredVideos, sortOption, shuffleSeed]);
// Pagination logic
const totalPages = Math.ceil(sortedVideos.length / itemsPerPage);
const displayedVideos = sortedVideos.slice(
(page - 1) * itemsPerPage,
page * itemsPerPage
);
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
setSearchParams((prev: URLSearchParams) => {
const newParams = new URLSearchParams(prev);
newParams.set('page', value.toString());
return newParams;
});
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Keyboard navigation for pagination
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Don't handle keyboard navigation if user is typing in an input field
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Only handle if there are multiple pages
if (totalPages <= 1) {
return;
}
if (event.key === 'ArrowLeft' && page > 1) {
event.preventDefault();
setSearchParams((prev: URLSearchParams) => {
const newParams = new URLSearchParams(prev);
newParams.set('page', (page - 1).toString());
return newParams;
});
window.scrollTo({ top: 0, behavior: 'smooth' });
} else if (event.key === 'ArrowRight' && page < totalPages) {
event.preventDefault();
setSearchParams((prev: URLSearchParams) => {
const newParams = new URLSearchParams(prev);
newParams.set('page', (page + 1).toString());
return newParams;
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [page, totalPages, setSearchParams]);
if (!settingsLoaded || (loading && videoArray.length === 0)) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
@@ -292,22 +347,6 @@ const Home: React.FC = () => {
setSortAnchorEl(null);
};
// Pagination logic
const totalPages = Math.ceil(sortedVideos.length / itemsPerPage);
const displayedVideos = sortedVideos.slice(
(page - 1) * itemsPerPage,
page * itemsPerPage
);
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
setSearchParams((prev: URLSearchParams) => {
const newParams = new URLSearchParams(prev);
newParams.set('page', value.toString());
return newParams;
});
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Regular home view (not in search mode)
return (
<Container maxWidth="xl" sx={{ py: 4 }}>