feat: Add subscribe functionality to VideoPlayer page
This commit is contained in:
@@ -12,8 +12,8 @@ export const createSubscription = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { url, interval } = req.body;
|
||||
logger.info("Creating subscription:", { url, interval });
|
||||
const { url, interval, authorName } = req.body;
|
||||
logger.info("Creating subscription:", { url, interval, authorName });
|
||||
|
||||
if (!url || !interval) {
|
||||
throw new ValidationError("URL and interval are required", "body");
|
||||
@@ -21,7 +21,8 @@ export const createSubscription = async (
|
||||
|
||||
const subscription = await subscriptionService.subscribe(
|
||||
url,
|
||||
parseInt(interval)
|
||||
parseInt(interval),
|
||||
authorName
|
||||
);
|
||||
// Return subscription object directly for backward compatibility
|
||||
res.status(201).json(subscription);
|
||||
|
||||
@@ -225,7 +225,11 @@ export const getAuthorChannelUrl = async (
|
||||
try {
|
||||
// Check if it's a YouTube URL
|
||||
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 networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
|
||||
@@ -245,7 +249,7 @@ export const getAuthorChannelUrl = async (
|
||||
if (sourceUrl.includes("bilibili.com") || sourceUrl.includes("b23.tv")) {
|
||||
const axios = (await import("axios")).default;
|
||||
const { extractBilibiliVideoId } = await import("../utils/helpers");
|
||||
|
||||
|
||||
const videoId = extractBilibiliVideoId(sourceUrl);
|
||||
if (videoId) {
|
||||
try {
|
||||
@@ -253,16 +257,24 @@ export const getAuthorChannelUrl = async (
|
||||
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", "")}`;
|
||||
|
||||
: `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",
|
||||
"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 spaceUrl = `https://space.bilibili.com/${mid}`;
|
||||
res.status(200).json({ success: true, channelUrl: spaceUrl });
|
||||
|
||||
@@ -39,42 +39,148 @@ export class SubscriptionService {
|
||||
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
|
||||
let platform: string;
|
||||
let authorName = "Unknown Author";
|
||||
let authorName = providedAuthorName || "Unknown Author";
|
||||
|
||||
if (isBilibiliSpaceUrl(authorUrl)) {
|
||||
platform = "Bilibili";
|
||||
|
||||
// Extract mid from the space URL
|
||||
const mid = extractBilibiliMid(authorUrl);
|
||||
if (!mid) {
|
||||
throw ValidationError.invalidBilibiliSpaceUrl(authorUrl);
|
||||
}
|
||||
// If author name not provided, try to get it from Bilibili API
|
||||
if (!providedAuthorName) {
|
||||
// Extract mid from the space URL
|
||||
const mid = extractBilibiliMid(authorUrl);
|
||||
if (!mid) {
|
||||
throw ValidationError.invalidBilibiliSpaceUrl(authorUrl);
|
||||
}
|
||||
|
||||
// Try to get author name from Bilibili API
|
||||
try {
|
||||
const authorInfo = await BilibiliDownloader.getAuthorInfo(mid);
|
||||
authorName = authorInfo.name;
|
||||
} catch (error) {
|
||||
logger.error("Error fetching Bilibili author info:", error);
|
||||
// Use mid as fallback author name
|
||||
authorName = `Bilibili User ${mid}`;
|
||||
// Try to get author name from Bilibili API
|
||||
try {
|
||||
const authorInfo = await BilibiliDownloader.getAuthorInfo(mid);
|
||||
authorName = authorInfo.name;
|
||||
} catch (error) {
|
||||
logger.error("Error fetching Bilibili author info:", error);
|
||||
// Use mid as fallback author name
|
||||
authorName = `Bilibili User ${mid}`;
|
||||
}
|
||||
}
|
||||
} else if (authorUrl.includes("youtube.com")) {
|
||||
platform = "YouTube";
|
||||
|
||||
// Extract author from YouTube URL if possible
|
||||
const match = authorUrl.match(/youtube\.com\/(@[^\/]+)/);
|
||||
if (match && match[1]) {
|
||||
authorName = match[1];
|
||||
} else {
|
||||
// Fallback: try to extract from other URL formats
|
||||
const parts = authorUrl.split("/");
|
||||
if (parts.length > 0) {
|
||||
const lastPart = parts[parts.length - 1];
|
||||
if (lastPart) authorName = lastPart;
|
||||
// If author name not provided, try to get it from channel URL using yt-dlp
|
||||
if (!providedAuthorName) {
|
||||
try {
|
||||
const {
|
||||
executeYtDlpJson,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} = await import("../utils/ytDlpUtils");
|
||||
const userConfig = getUserYtDlpConfig(authorUrl);
|
||||
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 {
|
||||
|
||||
@@ -25,6 +25,9 @@ interface VideoInfoProps {
|
||||
onCollectionClick: (id: string) => void;
|
||||
availableTags: string[];
|
||||
onTagsUpdate: (tags: string[]) => Promise<void>;
|
||||
isSubscribed?: boolean;
|
||||
onSubscribe?: () => void;
|
||||
onUnsubscribe?: () => void;
|
||||
}
|
||||
|
||||
const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
@@ -39,7 +42,10 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
videoCollections,
|
||||
onCollectionClick,
|
||||
availableTags,
|
||||
onTagsUpdate
|
||||
onTagsUpdate,
|
||||
isSubscribed,
|
||||
onSubscribe,
|
||||
onUnsubscribe
|
||||
}) => {
|
||||
const { videoRef, videoResolution } = useVideoResolution(video);
|
||||
|
||||
@@ -89,6 +95,10 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
author={video.author}
|
||||
date={video.date}
|
||||
onAuthorClick={onAuthorClick}
|
||||
source={video.source}
|
||||
isSubscribed={isSubscribed}
|
||||
onSubscribe={onSubscribe}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
/>
|
||||
|
||||
<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 { useLanguage } from '../../../contexts/LanguageContext';
|
||||
|
||||
interface VideoAuthorInfoProps {
|
||||
author: string;
|
||||
date: string | undefined;
|
||||
onAuthorClick: () => void;
|
||||
source?: 'youtube' | 'bilibili' | 'local' | 'missav';
|
||||
isSubscribed?: boolean;
|
||||
onSubscribe?: () => void;
|
||||
onUnsubscribe?: () => void;
|
||||
}
|
||||
|
||||
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
||||
@@ -20,18 +26,53 @@ const formatDate = (dateString?: string) => {
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main', mr: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
mr: { xs: 1, sm: 2 },
|
||||
cursor: 'pointer',
|
||||
'&:hover': { opacity: 0.8 }
|
||||
}}
|
||||
onClick={onAuthorClick}
|
||||
>
|
||||
{author ? author.charAt(0).toUpperCase() : 'A'}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight="bold"
|
||||
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}
|
||||
</Typography>
|
||||
@@ -39,6 +80,18 @@ const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({ author, date, onAutho
|
||||
{formatDate(date)}
|
||||
</Typography>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LocalOffer } from '@mui/icons-material';
|
||||
import { Autocomplete, Box, Chip, TextField } from '@mui/material';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
|
||||
interface VideoTagsProps {
|
||||
@@ -11,6 +11,7 @@ interface VideoTagsProps {
|
||||
|
||||
const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate }) => {
|
||||
const { t } = useLanguage();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Ensure tags and availableTags are always arrays
|
||||
const tagsArray = Array.isArray(tags) ? tags : [];
|
||||
@@ -21,6 +22,10 @@ const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate
|
||||
<LocalOffer color="action" fontSize="small" />
|
||||
<Autocomplete
|
||||
multiple
|
||||
open={open}
|
||||
onOpen={() => setOpen(true)}
|
||||
onClose={() => setOpen(false)}
|
||||
disableCloseOnSelect
|
||||
options={availableTagsArray}
|
||||
value={tagsArray}
|
||||
isOptionEqualToValue={(option, value) => option === value}
|
||||
@@ -57,7 +62,11 @@ const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate
|
||||
placeholder={tagsArray.length === 0 ? (t('tags') || 'Tags') : ''}
|
||||
sx={{ minWidth: 200 }}
|
||||
slotProps={{
|
||||
input: { ...params.InputProps, disableUnderline: true }
|
||||
input: {
|
||||
...params.InputProps,
|
||||
disableUnderline: true,
|
||||
readOnly: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -39,7 +39,7 @@ html {
|
||||
|
||||
/* Smooth theme transition */
|
||||
*, *::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-timing-function: ease-out;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import CollectionModal from '../components/CollectionModal';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import SubscribeModal from '../components/SubscribeModal';
|
||||
import CommentsSection from '../components/VideoPlayer/CommentsSection';
|
||||
import UpNextSidebar from '../components/VideoPlayer/UpNextSidebar';
|
||||
import VideoControls from '../components/VideoPlayer/VideoControls';
|
||||
@@ -44,6 +45,8 @@ const VideoPlayer: React.FC = () => {
|
||||
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
|
||||
const [activeCollectionVideoId, setActiveCollectionVideoId] = useState<string | null>(null);
|
||||
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 saved = localStorage.getItem('autoPlayNext');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
@@ -112,6 +115,15 @@ const VideoPlayer: React.FC = () => {
|
||||
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 = () => {
|
||||
setShowComments(!showComments);
|
||||
};
|
||||
@@ -138,25 +150,60 @@ const VideoPlayer: React.FC = () => {
|
||||
return [];
|
||||
}, [collections, activeCollectionVideoId]);
|
||||
|
||||
// Handle navigation to author videos page or external channel
|
||||
const handleAuthorClick = async () => {
|
||||
if (!video) return;
|
||||
// Get author channel URL and check subscription status
|
||||
useEffect(() => {
|
||||
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 {
|
||||
const response = await axios.get(`${API_URL}/videos/author-channel-url`, {
|
||||
params: { sourceUrl: video.sourceUrl }
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.channelUrl) {
|
||||
// Open the channel URL in a new tab
|
||||
window.open(response.data.channelUrl, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
setAuthorChannelUrl(response.data.channelUrl);
|
||||
} else {
|
||||
setAuthorChannelUrl(null);
|
||||
}
|
||||
} catch (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)}`);
|
||||
};
|
||||
|
||||
// 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) => {
|
||||
navigate(`/collection/${collectionId}`);
|
||||
};
|
||||
@@ -462,6 +565,9 @@ const VideoPlayer: React.FC = () => {
|
||||
onCollectionClick={handleCollectionClick}
|
||||
availableTags={availableTags}
|
||||
onTagsUpdate={handleUpdateTags}
|
||||
isSubscribed={isSubscribed}
|
||||
onSubscribe={handleSubscribe}
|
||||
onUnsubscribe={handleUnsubscribe}
|
||||
/>
|
||||
|
||||
{(video.source === 'youtube' || video.source === 'bilibili') && (
|
||||
@@ -507,6 +613,14 @@ const VideoPlayer: React.FC = () => {
|
||||
cancelText={confirmationModal.cancelText}
|
||||
isDanger={confirmationModal.isDanger}
|
||||
/>
|
||||
|
||||
<SubscribeModal
|
||||
open={showSubscribeModal}
|
||||
onClose={() => setShowSubscribeModal(false)}
|
||||
onConfirm={handleSubscribeConfirm}
|
||||
authorName={video?.author}
|
||||
url={authorChannelUrl || ''}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user