feat: Add subscribe functionality to VideoPlayer page

This commit is contained in:
Peifan Li
2025-12-16 14:42:19 -05:00
parent a7a4eae713
commit 4624d121b7
8 changed files with 358 additions and 53 deletions

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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 {

View File

@@ -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

View File

@@ -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>
); );
}; };

View File

@@ -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
}
}} }}
/> />
)} )}

View File

@@ -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;
} }

View File

@@ -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>
); );
}; };