feat: Add subscribe functionality to VideoPlayer page
This commit is contained in:
@@ -12,8 +12,8 @@ export const createSubscription = async (
|
|||||||
req: Request,
|
req: Request,
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const { url, interval } = req.body;
|
const { url, interval, authorName } = req.body;
|
||||||
logger.info("Creating subscription:", { url, interval });
|
logger.info("Creating subscription:", { url, interval, authorName });
|
||||||
|
|
||||||
if (!url || !interval) {
|
if (!url || !interval) {
|
||||||
throw new ValidationError("URL and interval are required", "body");
|
throw new ValidationError("URL and interval are required", "body");
|
||||||
@@ -21,7 +21,8 @@ export const createSubscription = async (
|
|||||||
|
|
||||||
const subscription = await subscriptionService.subscribe(
|
const subscription = await subscriptionService.subscribe(
|
||||||
url,
|
url,
|
||||||
parseInt(interval)
|
parseInt(interval),
|
||||||
|
authorName
|
||||||
);
|
);
|
||||||
// Return subscription object directly for backward compatibility
|
// Return subscription object directly for backward compatibility
|
||||||
res.status(201).json(subscription);
|
res.status(201).json(subscription);
|
||||||
|
|||||||
@@ -225,7 +225,11 @@ export const getAuthorChannelUrl = async (
|
|||||||
try {
|
try {
|
||||||
// Check if it's a YouTube URL
|
// Check if it's a YouTube URL
|
||||||
if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) {
|
if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) {
|
||||||
const { executeYtDlpJson, getNetworkConfigFromUserConfig, getUserYtDlpConfig } = await import("../utils/ytDlpUtils");
|
const {
|
||||||
|
executeYtDlpJson,
|
||||||
|
getNetworkConfigFromUserConfig,
|
||||||
|
getUserYtDlpConfig,
|
||||||
|
} = await import("../utils/ytDlpUtils");
|
||||||
const userConfig = getUserYtDlpConfig(sourceUrl);
|
const userConfig = getUserYtDlpConfig(sourceUrl);
|
||||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||||
|
|
||||||
@@ -245,7 +249,7 @@ export const getAuthorChannelUrl = async (
|
|||||||
if (sourceUrl.includes("bilibili.com") || sourceUrl.includes("b23.tv")) {
|
if (sourceUrl.includes("bilibili.com") || sourceUrl.includes("b23.tv")) {
|
||||||
const axios = (await import("axios")).default;
|
const axios = (await import("axios")).default;
|
||||||
const { extractBilibiliVideoId } = await import("../utils/helpers");
|
const { extractBilibiliVideoId } = await import("../utils/helpers");
|
||||||
|
|
||||||
const videoId = extractBilibiliVideoId(sourceUrl);
|
const videoId = extractBilibiliVideoId(sourceUrl);
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
try {
|
try {
|
||||||
@@ -253,16 +257,24 @@ export const getAuthorChannelUrl = async (
|
|||||||
const isBvId = videoId.startsWith("BV");
|
const isBvId = videoId.startsWith("BV");
|
||||||
const apiUrl = isBvId
|
const apiUrl = isBvId
|
||||||
? `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`
|
? `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`
|
||||||
: `https://api.bilibili.com/x/web-interface/view?aid=${videoId.replace("av", "")}`;
|
: `https://api.bilibili.com/x/web-interface/view?aid=${videoId.replace(
|
||||||
|
"av",
|
||||||
|
""
|
||||||
|
)}`;
|
||||||
|
|
||||||
const response = await axios.get(apiUrl, {
|
const response = await axios.get(apiUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Referer: "https://www.bilibili.com",
|
Referer: "https://www.bilibili.com",
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data && response.data.data && response.data.data.owner?.mid) {
|
if (
|
||||||
|
response.data &&
|
||||||
|
response.data.data &&
|
||||||
|
response.data.data.owner?.mid
|
||||||
|
) {
|
||||||
const mid = response.data.data.owner.mid;
|
const mid = response.data.data.owner.mid;
|
||||||
const spaceUrl = `https://space.bilibili.com/${mid}`;
|
const spaceUrl = `https://space.bilibili.com/${mid}`;
|
||||||
res.status(200).json({ success: true, channelUrl: spaceUrl });
|
res.status(200).json({ success: true, channelUrl: spaceUrl });
|
||||||
|
|||||||
@@ -39,42 +39,148 @@ export class SubscriptionService {
|
|||||||
return SubscriptionService.instance;
|
return SubscriptionService.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
async subscribe(authorUrl: string, interval: number): Promise<Subscription> {
|
async subscribe(
|
||||||
|
authorUrl: string,
|
||||||
|
interval: number,
|
||||||
|
providedAuthorName?: string
|
||||||
|
): Promise<Subscription> {
|
||||||
// Detect platform and validate URL
|
// Detect platform and validate URL
|
||||||
let platform: string;
|
let platform: string;
|
||||||
let authorName = "Unknown Author";
|
let authorName = providedAuthorName || "Unknown Author";
|
||||||
|
|
||||||
if (isBilibiliSpaceUrl(authorUrl)) {
|
if (isBilibiliSpaceUrl(authorUrl)) {
|
||||||
platform = "Bilibili";
|
platform = "Bilibili";
|
||||||
|
|
||||||
// Extract mid from the space URL
|
// If author name not provided, try to get it from Bilibili API
|
||||||
const mid = extractBilibiliMid(authorUrl);
|
if (!providedAuthorName) {
|
||||||
if (!mid) {
|
// Extract mid from the space URL
|
||||||
throw ValidationError.invalidBilibiliSpaceUrl(authorUrl);
|
const mid = extractBilibiliMid(authorUrl);
|
||||||
}
|
if (!mid) {
|
||||||
|
throw ValidationError.invalidBilibiliSpaceUrl(authorUrl);
|
||||||
|
}
|
||||||
|
|
||||||
// Try to get author name from Bilibili API
|
// Try to get author name from Bilibili API
|
||||||
try {
|
try {
|
||||||
const authorInfo = await BilibiliDownloader.getAuthorInfo(mid);
|
const authorInfo = await BilibiliDownloader.getAuthorInfo(mid);
|
||||||
authorName = authorInfo.name;
|
authorName = authorInfo.name;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error fetching Bilibili author info:", error);
|
logger.error("Error fetching Bilibili author info:", error);
|
||||||
// Use mid as fallback author name
|
// Use mid as fallback author name
|
||||||
authorName = `Bilibili User ${mid}`;
|
authorName = `Bilibili User ${mid}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (authorUrl.includes("youtube.com")) {
|
} else if (authorUrl.includes("youtube.com")) {
|
||||||
platform = "YouTube";
|
platform = "YouTube";
|
||||||
|
|
||||||
// Extract author from YouTube URL if possible
|
// If author name not provided, try to get it from channel URL using yt-dlp
|
||||||
const match = authorUrl.match(/youtube\.com\/(@[^\/]+)/);
|
if (!providedAuthorName) {
|
||||||
if (match && match[1]) {
|
try {
|
||||||
authorName = match[1];
|
const {
|
||||||
} else {
|
executeYtDlpJson,
|
||||||
// Fallback: try to extract from other URL formats
|
getNetworkConfigFromUserConfig,
|
||||||
const parts = authorUrl.split("/");
|
getUserYtDlpConfig,
|
||||||
if (parts.length > 0) {
|
} = await import("../utils/ytDlpUtils");
|
||||||
const lastPart = parts[parts.length - 1];
|
const userConfig = getUserYtDlpConfig(authorUrl);
|
||||||
if (lastPart) authorName = lastPart;
|
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||||
|
|
||||||
|
// Construct URL to get videos from the channel
|
||||||
|
let targetUrl = authorUrl;
|
||||||
|
if (
|
||||||
|
!targetUrl.includes("/videos") &&
|
||||||
|
!targetUrl.includes("/shorts") &&
|
||||||
|
!targetUrl.includes("/streams")
|
||||||
|
) {
|
||||||
|
// Append /videos to get the videos playlist
|
||||||
|
if (targetUrl.endsWith("/")) {
|
||||||
|
targetUrl = `${targetUrl}videos`;
|
||||||
|
} else {
|
||||||
|
targetUrl = `${targetUrl}/videos`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get channel info from the channel URL
|
||||||
|
const info = await executeYtDlpJson(targetUrl, {
|
||||||
|
...networkConfig,
|
||||||
|
noWarnings: true,
|
||||||
|
flatPlaylist: true,
|
||||||
|
playlistEnd: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to get uploader/channel name from the first video or channel info
|
||||||
|
if (info.uploader) {
|
||||||
|
authorName = info.uploader;
|
||||||
|
} else if (info.channel) {
|
||||||
|
authorName = info.channel;
|
||||||
|
} else if (
|
||||||
|
info.channel_id &&
|
||||||
|
info.entries &&
|
||||||
|
info.entries.length > 0
|
||||||
|
) {
|
||||||
|
// If we have entries, try to get info from the first video
|
||||||
|
const firstVideo = info.entries[0];
|
||||||
|
if (firstVideo && firstVideo.url) {
|
||||||
|
try {
|
||||||
|
const videoInfo = await executeYtDlpJson(firstVideo.url, {
|
||||||
|
...networkConfig,
|
||||||
|
noWarnings: true,
|
||||||
|
});
|
||||||
|
if (videoInfo.uploader) {
|
||||||
|
authorName = videoInfo.uploader;
|
||||||
|
} else if (videoInfo.channel) {
|
||||||
|
authorName = videoInfo.channel;
|
||||||
|
}
|
||||||
|
} catch (videoError) {
|
||||||
|
logger.error(
|
||||||
|
"Error fetching video info for channel name:",
|
||||||
|
videoError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to extract from URL if still not found
|
||||||
|
if (
|
||||||
|
authorName === "Unknown Author" ||
|
||||||
|
authorName === providedAuthorName
|
||||||
|
) {
|
||||||
|
const match = authorUrl.match(/youtube\.com\/(@[^\/]+)/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
authorName = match[1];
|
||||||
|
} else {
|
||||||
|
const parts = authorUrl.split("/");
|
||||||
|
if (parts.length > 0) {
|
||||||
|
const lastPart = parts[parts.length - 1];
|
||||||
|
if (
|
||||||
|
lastPart &&
|
||||||
|
lastPart !== "videos" &&
|
||||||
|
lastPart !== "about" &&
|
||||||
|
lastPart !== "channel"
|
||||||
|
) {
|
||||||
|
authorName = lastPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching YouTube channel info:", error);
|
||||||
|
// Fallback: try to extract from URL
|
||||||
|
const match = authorUrl.match(/youtube\.com\/(@[^\/]+)/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
authorName = match[1];
|
||||||
|
} else {
|
||||||
|
const parts = authorUrl.split("/");
|
||||||
|
if (parts.length > 0) {
|
||||||
|
const lastPart = parts[parts.length - 1];
|
||||||
|
if (
|
||||||
|
lastPart &&
|
||||||
|
lastPart !== "videos" &&
|
||||||
|
lastPart !== "about" &&
|
||||||
|
lastPart !== "channel"
|
||||||
|
) {
|
||||||
|
authorName = lastPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ interface VideoInfoProps {
|
|||||||
onCollectionClick: (id: string) => void;
|
onCollectionClick: (id: string) => void;
|
||||||
availableTags: string[];
|
availableTags: string[];
|
||||||
onTagsUpdate: (tags: string[]) => Promise<void>;
|
onTagsUpdate: (tags: string[]) => Promise<void>;
|
||||||
|
isSubscribed?: boolean;
|
||||||
|
onSubscribe?: () => void;
|
||||||
|
onUnsubscribe?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoInfo: React.FC<VideoInfoProps> = ({
|
const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||||
@@ -39,7 +42,10 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
|||||||
videoCollections,
|
videoCollections,
|
||||||
onCollectionClick,
|
onCollectionClick,
|
||||||
availableTags,
|
availableTags,
|
||||||
onTagsUpdate
|
onTagsUpdate,
|
||||||
|
isSubscribed,
|
||||||
|
onSubscribe,
|
||||||
|
onUnsubscribe
|
||||||
}) => {
|
}) => {
|
||||||
const { videoRef, videoResolution } = useVideoResolution(video);
|
const { videoRef, videoResolution } = useVideoResolution(video);
|
||||||
|
|
||||||
@@ -89,6 +95,10 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
|||||||
author={video.author}
|
author={video.author}
|
||||||
date={video.date}
|
date={video.date}
|
||||||
onAuthorClick={onAuthorClick}
|
onAuthorClick={onAuthorClick}
|
||||||
|
source={video.source}
|
||||||
|
isSubscribed={isSubscribed}
|
||||||
|
onSubscribe={onSubscribe}
|
||||||
|
onUnsubscribe={onUnsubscribe}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VideoActionButtons
|
<VideoActionButtons
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Avatar, Box, Typography } from '@mui/material';
|
import { Notifications, NotificationsActive } from '@mui/icons-material';
|
||||||
|
import { Avatar, Box, IconButton, Tooltip, Typography } from '@mui/material';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||||
|
|
||||||
interface VideoAuthorInfoProps {
|
interface VideoAuthorInfoProps {
|
||||||
author: string;
|
author: string;
|
||||||
date: string | undefined;
|
date: string | undefined;
|
||||||
onAuthorClick: () => void;
|
onAuthorClick: () => void;
|
||||||
|
source?: 'youtube' | 'bilibili' | 'local' | 'missav';
|
||||||
|
isSubscribed?: boolean;
|
||||||
|
onSubscribe?: () => void;
|
||||||
|
onUnsubscribe?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
||||||
@@ -20,18 +26,53 @@ const formatDate = (dateString?: string) => {
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({ author, date, onAuthorClick }) => {
|
const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({
|
||||||
|
author,
|
||||||
|
date,
|
||||||
|
onAuthorClick,
|
||||||
|
source,
|
||||||
|
isSubscribed,
|
||||||
|
onSubscribe,
|
||||||
|
onUnsubscribe
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const showSubscribeButton = source === 'youtube' || source === 'bilibili';
|
||||||
|
|
||||||
|
const handleSubscribeClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isSubscribed && onUnsubscribe) {
|
||||||
|
onUnsubscribe();
|
||||||
|
} else if (!isSubscribed && onSubscribe) {
|
||||||
|
onSubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Avatar sx={{ bgcolor: 'primary.main', mr: 2 }}>
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
mr: { xs: 1, sm: 2 },
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { opacity: 0.8 }
|
||||||
|
}}
|
||||||
|
onClick={onAuthorClick}
|
||||||
|
>
|
||||||
{author ? author.charAt(0).toUpperCase() : 'A'}
|
{author ? author.charAt(0).toUpperCase() : 'A'}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle1"
|
variant="subtitle1"
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
onClick={onAuthorClick}
|
onClick={onAuthorClick}
|
||||||
sx={{ cursor: 'pointer', '&:hover': { color: 'primary.main' } }}
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { color: 'primary.main' },
|
||||||
|
maxWidth: { xs: '120px', sm: 'none' },
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{author}
|
{author}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -39,6 +80,18 @@ const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({ author, date, onAutho
|
|||||||
{formatDate(date)}
|
{formatDate(date)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
{showSubscribeButton && (
|
||||||
|
<Tooltip title={isSubscribed ? t('unsubscribe') : t('subscribe')}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={handleSubscribeClick}
|
||||||
|
color={isSubscribed ? 'primary' : 'default'}
|
||||||
|
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||||
|
>
|
||||||
|
{isSubscribed ? <NotificationsActive /> : <Notifications />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LocalOffer } from '@mui/icons-material';
|
import { LocalOffer } from '@mui/icons-material';
|
||||||
import { Autocomplete, Box, Chip, TextField } from '@mui/material';
|
import { Autocomplete, Box, Chip, TextField } from '@mui/material';
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||||
|
|
||||||
interface VideoTagsProps {
|
interface VideoTagsProps {
|
||||||
@@ -11,6 +11,7 @@ interface VideoTagsProps {
|
|||||||
|
|
||||||
const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate }) => {
|
const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Ensure tags and availableTags are always arrays
|
// Ensure tags and availableTags are always arrays
|
||||||
const tagsArray = Array.isArray(tags) ? tags : [];
|
const tagsArray = Array.isArray(tags) ? tags : [];
|
||||||
@@ -21,6 +22,10 @@ const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate
|
|||||||
<LocalOffer color="action" fontSize="small" />
|
<LocalOffer color="action" fontSize="small" />
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
multiple
|
multiple
|
||||||
|
open={open}
|
||||||
|
onOpen={() => setOpen(true)}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
disableCloseOnSelect
|
||||||
options={availableTagsArray}
|
options={availableTagsArray}
|
||||||
value={tagsArray}
|
value={tagsArray}
|
||||||
isOptionEqualToValue={(option, value) => option === value}
|
isOptionEqualToValue={(option, value) => option === value}
|
||||||
@@ -57,7 +62,11 @@ const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate
|
|||||||
placeholder={tagsArray.length === 0 ? (t('tags') || 'Tags') : ''}
|
placeholder={tagsArray.length === 0 ? (t('tags') || 'Tags') : ''}
|
||||||
sx={{ minWidth: 200 }}
|
sx={{ minWidth: 200 }}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: { ...params.InputProps, disableUnderline: true }
|
input: {
|
||||||
|
...params.InputProps,
|
||||||
|
disableUnderline: true,
|
||||||
|
readOnly: true
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ html {
|
|||||||
|
|
||||||
/* Smooth theme transition */
|
/* Smooth theme transition */
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
transition-property: background-color, color, border-color, fill, stroke, box-shadow;
|
transition-property: background-color, border-color, fill, stroke, box-shadow;
|
||||||
transition-duration: 0.3s;
|
transition-duration: 0.3s;
|
||||||
transition-timing-function: ease-out;
|
transition-timing-function: ease-out;
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import CollectionModal from '../components/CollectionModal';
|
import CollectionModal from '../components/CollectionModal';
|
||||||
import ConfirmationModal from '../components/ConfirmationModal';
|
import ConfirmationModal from '../components/ConfirmationModal';
|
||||||
|
import SubscribeModal from '../components/SubscribeModal';
|
||||||
import CommentsSection from '../components/VideoPlayer/CommentsSection';
|
import CommentsSection from '../components/VideoPlayer/CommentsSection';
|
||||||
import UpNextSidebar from '../components/VideoPlayer/UpNextSidebar';
|
import UpNextSidebar from '../components/VideoPlayer/UpNextSidebar';
|
||||||
import VideoControls from '../components/VideoPlayer/VideoControls';
|
import VideoControls from '../components/VideoPlayer/VideoControls';
|
||||||
@@ -44,6 +45,8 @@ const VideoPlayer: React.FC = () => {
|
|||||||
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
|
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
|
||||||
const [activeCollectionVideoId, setActiveCollectionVideoId] = useState<string | null>(null);
|
const [activeCollectionVideoId, setActiveCollectionVideoId] = useState<string | null>(null);
|
||||||
const [showComments, setShowComments] = useState<boolean>(false);
|
const [showComments, setShowComments] = useState<boolean>(false);
|
||||||
|
const [showSubscribeModal, setShowSubscribeModal] = useState<boolean>(false);
|
||||||
|
const [authorChannelUrl, setAuthorChannelUrl] = useState<string | null>(null);
|
||||||
const [autoPlayNext, setAutoPlayNext] = useState<boolean>(() => {
|
const [autoPlayNext, setAutoPlayNext] = useState<boolean>(() => {
|
||||||
const saved = localStorage.getItem('autoPlayNext');
|
const saved = localStorage.getItem('autoPlayNext');
|
||||||
return saved !== null ? JSON.parse(saved) : false;
|
return saved !== null ? JSON.parse(saved) : false;
|
||||||
@@ -112,6 +115,15 @@ const VideoPlayer: React.FC = () => {
|
|||||||
enabled: showComments && !!id
|
enabled: showComments && !!id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch subscriptions
|
||||||
|
const { data: subscriptions = [] } = useQuery({
|
||||||
|
queryKey: ['subscriptions'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get(`${API_URL}/subscriptions`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleToggleComments = () => {
|
const handleToggleComments = () => {
|
||||||
setShowComments(!showComments);
|
setShowComments(!showComments);
|
||||||
};
|
};
|
||||||
@@ -138,25 +150,60 @@ const VideoPlayer: React.FC = () => {
|
|||||||
return [];
|
return [];
|
||||||
}, [collections, activeCollectionVideoId]);
|
}, [collections, activeCollectionVideoId]);
|
||||||
|
|
||||||
// Handle navigation to author videos page or external channel
|
// Get author channel URL and check subscription status
|
||||||
const handleAuthorClick = async () => {
|
useEffect(() => {
|
||||||
if (!video) return;
|
const fetchChannelUrl = async () => {
|
||||||
|
if (!video || (video.source !== 'youtube' && video.source !== 'bilibili')) {
|
||||||
|
setAuthorChannelUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If it's a YouTube or Bilibili video, try to get the channel URL
|
|
||||||
if (video.source === 'youtube' || video.source === 'bilibili') {
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_URL}/videos/author-channel-url`, {
|
const response = await axios.get(`${API_URL}/videos/author-channel-url`, {
|
||||||
params: { sourceUrl: video.sourceUrl }
|
params: { sourceUrl: video.sourceUrl }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.success && response.data.channelUrl) {
|
if (response.data.success && response.data.channelUrl) {
|
||||||
// Open the channel URL in a new tab
|
setAuthorChannelUrl(response.data.channelUrl);
|
||||||
window.open(response.data.channelUrl, '_blank', 'noopener,noreferrer');
|
} else {
|
||||||
return;
|
setAuthorChannelUrl(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching author channel URL:', error);
|
console.error('Error fetching author channel URL:', error);
|
||||||
// Fall through to default behavior
|
setAuthorChannelUrl(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchChannelUrl();
|
||||||
|
}, [video]);
|
||||||
|
|
||||||
|
// Check if author is subscribed
|
||||||
|
const isSubscribed = useMemo(() => {
|
||||||
|
if (!authorChannelUrl || !subscriptions || subscriptions.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return subscriptions.some((sub: any) => sub.authorUrl === authorChannelUrl);
|
||||||
|
}, [authorChannelUrl, subscriptions]);
|
||||||
|
|
||||||
|
// Get subscription ID if subscribed
|
||||||
|
const subscriptionId = useMemo(() => {
|
||||||
|
if (!authorChannelUrl || !subscriptions || subscriptions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const subscription = subscriptions.find((sub: any) => sub.authorUrl === authorChannelUrl);
|
||||||
|
return subscription?.id || null;
|
||||||
|
}, [authorChannelUrl, subscriptions]);
|
||||||
|
|
||||||
|
// Handle navigation to author videos page or external channel
|
||||||
|
const handleAuthorClick = async () => {
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
// If it's a YouTube or Bilibili video, try to get the channel URL
|
||||||
|
if (video.source === 'youtube' || video.source === 'bilibili') {
|
||||||
|
if (authorChannelUrl) {
|
||||||
|
// Open the channel URL in a new tab
|
||||||
|
window.open(authorChannelUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +211,62 @@ const VideoPlayer: React.FC = () => {
|
|||||||
navigate(`/author/${encodeURIComponent(video.author)}`);
|
navigate(`/author/${encodeURIComponent(video.author)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle subscribe
|
||||||
|
const handleSubscribe = () => {
|
||||||
|
if (!authorChannelUrl) return;
|
||||||
|
setShowSubscribeModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle subscribe confirmation
|
||||||
|
const handleSubscribeConfirm = async (interval: number) => {
|
||||||
|
if (!authorChannelUrl || !video) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/subscriptions`, {
|
||||||
|
url: authorChannelUrl,
|
||||||
|
interval,
|
||||||
|
authorName: video.author // Pass the author name from the video
|
||||||
|
});
|
||||||
|
showSnackbar(t('subscribedSuccessfully'));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
|
setShowSubscribeModal(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error subscribing:', error);
|
||||||
|
if (error.response && error.response.status === 409) {
|
||||||
|
showSnackbar(t('subscriptionAlreadyExists'), 'warning');
|
||||||
|
} else {
|
||||||
|
showSnackbar(t('error'), 'error');
|
||||||
|
}
|
||||||
|
setShowSubscribeModal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle unsubscribe
|
||||||
|
const handleUnsubscribe = () => {
|
||||||
|
if (!subscriptionId) return;
|
||||||
|
|
||||||
|
setConfirmationModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: t('unsubscribe'),
|
||||||
|
message: t('confirmUnsubscribe', { author: video?.author || '' }),
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_URL}/subscriptions/${subscriptionId}`);
|
||||||
|
showSnackbar(t('unsubscribedSuccessfully'));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
|
setConfirmationModal({ ...confirmationModal, isOpen: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error unsubscribing:', error);
|
||||||
|
showSnackbar(t('error'), 'error');
|
||||||
|
setConfirmationModal({ ...confirmationModal, isOpen: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmText: t('unsubscribe'),
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
isDanger: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleCollectionClick = (collectionId: string) => {
|
const handleCollectionClick = (collectionId: string) => {
|
||||||
navigate(`/collection/${collectionId}`);
|
navigate(`/collection/${collectionId}`);
|
||||||
};
|
};
|
||||||
@@ -462,6 +565,9 @@ const VideoPlayer: React.FC = () => {
|
|||||||
onCollectionClick={handleCollectionClick}
|
onCollectionClick={handleCollectionClick}
|
||||||
availableTags={availableTags}
|
availableTags={availableTags}
|
||||||
onTagsUpdate={handleUpdateTags}
|
onTagsUpdate={handleUpdateTags}
|
||||||
|
isSubscribed={isSubscribed}
|
||||||
|
onSubscribe={handleSubscribe}
|
||||||
|
onUnsubscribe={handleUnsubscribe}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(video.source === 'youtube' || video.source === 'bilibili') && (
|
{(video.source === 'youtube' || video.source === 'bilibili') && (
|
||||||
@@ -507,6 +613,14 @@ const VideoPlayer: React.FC = () => {
|
|||||||
cancelText={confirmationModal.cancelText}
|
cancelText={confirmationModal.cancelText}
|
||||||
isDanger={confirmationModal.isDanger}
|
isDanger={confirmationModal.isDanger}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SubscribeModal
|
||||||
|
open={showSubscribeModal}
|
||||||
|
onClose={() => setShowSubscribeModal(false)}
|
||||||
|
onConfirm={handleSubscribeConfirm}
|
||||||
|
authorName={video?.author}
|
||||||
|
url={authorChannelUrl || ''}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user