feat: Add channel_url column to videos table
This commit is contained in:
4
backend/drizzle/0005_tired_demogoblin.sql
Normal file
4
backend/drizzle/0005_tired_demogoblin.sql
Normal 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;
|
||||
697
backend/drizzle/meta/0005_snapshot.json
Normal file
697
backend/drizzle/meta/0005_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1733644800000,
|
||||
"tag": "0004_video_downloads",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1766096471960,
|
||||
"tag": "0005_tired_demogoblin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user