feat: add hide video for visitor mode feature

This commit is contained in:
Peifan Li
2025-12-23 23:20:51 -05:00
parent d3a32b834e
commit 0d0de62b1f
24 changed files with 1057 additions and 79 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `videos` ADD `visibility` integer DEFAULT 1;

View File

@@ -0,0 +1,826 @@
{
"version": "6",
"dialect": "sqlite",
"id": "107caef6-bda3-4836-b79d-ba3e0107a989",
"prevId": "c86dfb86-c8e7-4f13-8523-35b73541e6f0",
"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": {}
},
"continuous_download_tasks": {
"name": "continuous_download_tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_url": {
"name": "author_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"total_videos": {
"name": "total_videos",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"downloaded_count": {
"name": "downloaded_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"skipped_count": {
"name": "skipped_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"failed_count": {
"name": "failed_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"current_video_index": {
"name": "current_video_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"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
},
"visibility": {
"name": "visibility",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -50,6 +50,13 @@
"when": 1766528513707,
"tag": "0006_bright_swordsman",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1766548244908,
"tag": "0007_broad_jasper_sitwell",
"breakpoints": true
}
]
}

View File

@@ -203,6 +203,7 @@ export const updateVideoDetails = async (
const allowedUpdates: any = {};
if (updates.title !== undefined) allowedUpdates.title = updates.title;
if (updates.tags !== undefined) allowedUpdates.tags = updates.tags;
if (updates.visibility !== undefined) allowedUpdates.visibility = updates.visibility;
// Add other allowed fields here if needed in the future
if (Object.keys(allowedUpdates).length === 0) {

View File

@@ -30,6 +30,7 @@ export const videos = sqliteTable('videos', {
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
visibility: integer('visibility').default(1), // 1 = visible, 0 = hidden
});
export const collections = sqliteTable('collections', {

View File

@@ -231,15 +231,20 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
</>
)}
<FormControlLabel
control={
<Switch
checked={visitorMode ?? false}
onChange={(e) => handleVisitorModeChange(e.target.checked)}
/>
}
label={t('visitorMode') || "Visitor Mode (Read-only)"}
/>
<Box>
<FormControlLabel
control={
<Switch
checked={visitorMode ?? false}
onChange={(e) => handleVisitorModeChange(e.target.checked)}
/>
}
label={t('visitorMode') || "Visitor Mode (Read-only)"}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, ml: 4.5 }}>
{t('visitorModeDescription') || "Read-only mode. Hidden videos will not be visible to visitors."}
</Typography>
</Box>
</Box>
<PasswordModal

View File

@@ -19,6 +19,7 @@ import { useNavigate } from 'react-router-dom';
import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext'; // Added
import { useVideo } from '../contexts/VideoContext';
import { useCloudStorageUrl } from '../hooks/useCloudStorageUrl';
import { useShareVideo } from '../hooks/useShareVideo'; // Added
import { Collection, Video } from '../types';
@@ -60,6 +61,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
// Hooks for share and snackbar
const { handleShare } = useShareVideo(video);
const { showSnackbar } = useSnackbar();
const { updateVideo } = useVideo();
@@ -299,6 +301,18 @@ const VideoCard: React.FC<VideoCardProps> = ({
await removeFromCollection(video.id);
};
// Handle visibility toggle
const handleToggleVisibility = async () => {
if (!video.id) return;
const newVisibility = (video.visibility ?? 1) === 0 ? 1 : 0;
const result = await updateVideo(video.id, { visibility: newVisibility });
if (result.success) {
showSnackbar(newVisibility === 1 ? t('showVideo') : t('hideVideo'), 'success');
} else {
showSnackbar(t('error'), 'error');
}
};
// Calculate collections that contain THIS video
const currentVideoCollections = allCollections.filter(c => c.videos.includes(video.id));
@@ -544,6 +558,8 @@ const VideoCard: React.FC<VideoCardProps> = ({
onAddToCollection={() => setShowCollectionModal(true)}
onDelete={(showDeleteButton && onDeleteVideo) ? () => setShowDeleteModal(true) : undefined}
isDeleting={isDeleting}
onToggleVisibility={handleToggleVisibility}
video={video}
sx={{
color: 'white',
bgcolor: 'rgba(0,0,0,0.6)',

View File

@@ -29,6 +29,7 @@ interface VideoInfoProps {
isSubscribed?: boolean;
onSubscribe?: () => void;
onUnsubscribe?: () => void;
onToggleVisibility?: () => void;
}
const VideoInfo: React.FC<VideoInfoProps> = ({
@@ -46,7 +47,8 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
onTagsUpdate,
isSubscribed,
onSubscribe,
onUnsubscribe
onUnsubscribe,
onToggleVisibility
}) => {
const { videoRef, videoResolution } = useVideoResolution(video);
const videoUrl = useCloudStorageUrl(video.videoPath, 'video');
@@ -108,6 +110,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
onAddToCollection={onAddToCollection}
onDelete={onDelete}
isDeleting={isDeleting}
onToggleVisibility={onToggleVisibility}
/>
</Stack>

View File

@@ -1,4 +1,4 @@
import { Add, Cast, Delete, Share } from '@mui/icons-material';
import { Add, Cast, Delete, Share, Visibility, VisibilityOff } from '@mui/icons-material';
import { Button, Menu, MenuItem, Stack, Tooltip, useMediaQuery, useTheme } from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
@@ -14,13 +14,15 @@ interface VideoActionButtonsProps {
onAddToCollection: () => void;
onDelete: () => void;
isDeleting: boolean;
onToggleVisibility?: () => void;
}
const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
video,
onAddToCollection,
onDelete,
isDeleting
isDeleting,
onToggleVisibility
}) => {
const { t } = useLanguage();
const { handleShare } = useShareVideo(video);
@@ -195,6 +197,18 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
</Tooltip>
{!visitorMode && (
<>
{onToggleVisibility && (
<Tooltip title={video.visibility === 0 ? t('showVideo') : t('hideVideo')} disableHoverListener={isTouch}>
<Button
variant="outlined"
color="inherit"
onClick={onToggleVisibility}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
{video.visibility === 0 ? <Visibility /> : <VisibilityOff />}
</Button>
</Tooltip>
)}
<Tooltip title={t('addToCollection')} disableHoverListener={isTouch}>
<Button
variant="outlined"
@@ -230,6 +244,8 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
onAddToCollection={onAddToCollection}
onDelete={onDelete}
isDeleting={isDeleting}
onToggleVisibility={onToggleVisibility}
video={video}
/>
<Menu
anchorEl={playerMenuAnchor}

View File

@@ -1,4 +1,4 @@
import { Add, Cast, Delete, MoreVert, Share } from '@mui/icons-material';
import { Add, Cast, Delete, MoreVert, Share, Visibility, VisibilityOff } from '@mui/icons-material';
import { Button, IconButton, Menu, Stack, Tooltip, useMediaQuery } from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
@@ -10,6 +10,8 @@ interface VideoKebabMenuButtonsProps {
onAddToCollection: () => void;
onDelete?: () => void;
isDeleting?: boolean;
onToggleVisibility?: () => void;
video?: { visibility?: number };
sx?: any;
}
@@ -19,6 +21,8 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
onAddToCollection,
onDelete,
isDeleting = false,
onToggleVisibility,
video,
sx
}) => {
const { t } = useLanguage();
@@ -59,6 +63,11 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
if (onDelete) onDelete();
};
const handleToggleVisibility = () => {
handleKebabMenuClose();
if (onToggleVisibility) onToggleVisibility();
};
// Close menu on scroll
React.useEffect(() => {
if (Boolean(kebabMenuAnchor)) {
@@ -133,6 +142,18 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
</Tooltip>
{!visitorMode && (
<>
{onToggleVisibility && (
<Tooltip title={video?.visibility === 0 ? t('showVideo') : t('hideVideo')} disableHoverListener={isTouch}>
<Button
variant="outlined"
color="inherit"
onClick={handleToggleVisibility}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
{video?.visibility === 0 ? <Visibility /> : <VisibilityOff />}
</Button>
</Tooltip>
)}
<Tooltip title={t('addToCollection')} disableHoverListener={isTouch}>
<Button
variant="outlined"

View File

@@ -1,9 +1,10 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Video } from '../types';
import { useLanguage } from './LanguageContext';
import { useSnackbar } from './SnackbarContext';
import { useVisitorMode } from './VisitorModeContext';
const API_URL = import.meta.env.VITE_API_URL;
@@ -48,9 +49,10 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const { showSnackbar } = useSnackbar();
const { t } = useLanguage();
const queryClient = useQueryClient();
const { visitorMode } = useVisitorMode();
// Videos Query
const { data: videos = [], isLoading: videosLoading, error: videosError, refetch: refetchVideos } = useQuery({
const { data: videosRaw = [], isLoading: videosLoading, error: videosError, refetch: refetchVideos } = useQuery({
queryKey: ['videos'],
queryFn: async () => {
console.log('Fetching videos from:', `${API_URL}/videos`);
@@ -67,6 +69,14 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
retryDelay: 1000,
});
// Filter invisible videos when in visitor mode
const videos = useMemo(() => {
if (visitorMode) {
return videosRaw.filter(video => (video.visibility ?? 1) === 1);
}
return videosRaw;
}, [videosRaw, visitorMode]);
// Settings Query (tags and showYoutubeSearch)
const { data: settingsData } = useQuery({
queryKey: ['settings'],

View File

@@ -78,25 +78,8 @@ const AuthorVideos: React.FC = () => {
);
}
// Filter videos to only show the first video from each collection
const filteredVideos = authorVideos.filter(video => {
// If the video is not in any collection, show it
const videoCollections = collections.filter(collection =>
collection.videos.includes(video.id)
);
if (videoCollections.length === 0) {
return true;
}
// For each collection this video is in, check if it's the first video
return videoCollections.some(collection => {
// Get the first video ID in this collection
const firstVideoId = collection.videos[0];
// Show this video if it's the first in at least one collection
return video.id === firstVideoId;
});
});
// Show all videos for the author (no collection filtering)
const filteredVideos = authorVideos;
return (
<Container maxWidth="xl" sx={{ py: 4 }}>

View File

@@ -21,6 +21,7 @@ import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
import { useVideo } from '../contexts/VideoContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
import { useCloudStorageUrl } from '../hooks/useCloudStorageUrl';
import { Collection, Video } from '../types';
import { getRecommendations } from '../utils/recommendations';
@@ -36,6 +37,7 @@ const VideoPlayer: React.FC = () => {
const queryClient = useQueryClient();
const { videos, deleteVideo } = useVideo();
const { visitorMode } = useVisitorMode();
const {
collections,
addToCollection,
@@ -83,7 +85,7 @@ const VideoPlayer: React.FC = () => {
retry: false
});
// Handle error redirect
// Handle error redirect and invisible videos in visitor mode
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
@@ -91,7 +93,14 @@ const VideoPlayer: React.FC = () => {
}, 3000);
return () => clearTimeout(timer);
}
}, [error, navigate]);
// In visitor mode, redirect if video is invisible
if (visitorMode && video && (video.visibility ?? 1) === 0) {
const timer = setTimeout(() => {
navigate('/');
}, 3000);
return () => clearTimeout(timer);
}
}, [error, navigate, visitorMode, video]);
// Fetch settings
const { data: settings } = useQuery({
@@ -454,6 +463,32 @@ const VideoPlayer: React.FC = () => {
await tagsMutation.mutateAsync(newTags);
};
// Visibility mutation
const visibilityMutation = useMutation({
mutationFn: async (visibility: number) => {
const response = await axios.put(`${API_URL}/videos/${id}`, { visibility });
return response.data;
},
onSuccess: (data, visibility) => {
if (data.success) {
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, visibility } : old);
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
old ? old.map(v => v.id === id ? { ...v, visibility } : v) : []
);
showSnackbar(visibility === 1 ? t('showVideo') : t('hideVideo'), 'success');
}
},
onError: () => {
showSnackbar(t('error'), 'error');
}
});
const handleToggleVisibility = async () => {
if (!id || !video) return;
const newVisibility = video.visibility === 0 ? 1 : 0;
await visibilityMutation.mutateAsync(newVisibility);
};
// Subtitle preference mutation
const subtitlePreferenceMutation = useMutation({
mutationFn: async (enabled: boolean) => {
@@ -613,6 +648,7 @@ const VideoPlayer: React.FC = () => {
isSubscribed={isSubscribed}
onSubscribe={handleSubscribe}
onUnsubscribe={handleUnsubscribe}
onToggleVisibility={handleToggleVisibility}
/>
{(video.source === 'youtube' || video.source === 'bilibili') && (

View File

@@ -23,6 +23,7 @@ export interface Video {
lastPlayedAt?: number;
subtitles?: Array<{ language: string; filename: string; path: string }>;
description?: string;
visibility?: number; // 1 = visible, 0 = hidden
[key: string]: any;
}

View File

@@ -119,6 +119,7 @@ export const ar = {
showYoutubeSearch: "عرض نتائج بحث YouTube",
visitorMode: "وضع الزائر (للقراءة فقط)",
visitorModeReadOnly: "وضع الزائر: للقراءة فقط",
visitorModeDescription: "وضع القراءة فقط. لن تكون مقاطع الفيديو المخفية مرئية للزوار.",
visitorModePasswordPrompt: "يرجى إدخال كلمة مرور الموقع لتغيير إعدادات وضع الزائر.",
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",
@@ -243,6 +244,9 @@ export const ar = {
exitFullscreen: "خروج من ملء الشاشة",
share: "مشاركة",
editTitle: "تعديل العنوان",
hideVideo: "جعل الفيديو مخفيًا في وضع الزائر",
showVideo: "جعل الفيديو مرئيًا في وضع الزائر",
toggleVisibility: "تبديل الرؤية",
titleUpdated: "تم تحديث العنوان بنجاح",
titleUpdateFailed: "فشل تحديث العنوان",
refreshThumbnail: "تحديث الصورة المصغرة",
@@ -436,8 +440,6 @@ export const ar = {
deleteTask: "حذف المهمة",
confirmDeleteTask: "هل أنت متأكد أنك تريد حذف سجل المهمة لـ {author}؟ لا يمكن التراجع عن هذا الإجراء.",
taskDeleted: "تم حذف المهمة بنجاح",
minutes: "دقائق",
never: "أبداً",
// Instruction Page
instructionSection1Title: "1. التنزيل وإدارة المهام",
instructionSection1Desc:

View File

@@ -115,6 +115,7 @@ export const de = {
showYoutubeSearch: "YouTube-Suchergebnisse anzeigen",
visitorMode: "Besuchermodus (Nur-Lesen)",
visitorModeReadOnly: "Besuchermodus: Nur-Lesen",
visitorModeDescription: "Nur-Lese-Modus. Ausgeblendete Videos sind für Besucher nicht sichtbar.",
visitorModePasswordPrompt: "Bitte geben Sie das Website-Passwort ein, um die Besuchermodus-Einstellungen zu ändern.",
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",
@@ -238,6 +239,9 @@ export const de = {
exitFullscreen: "Vollbild Verlassen",
share: "Teilen",
editTitle: "Titel Bearbeiten",
hideVideo: "Video für Besuchermodus ausblenden",
showVideo: "Video für Besuchermodus sichtbar machen",
toggleVisibility: "Sichtbarkeit Umschalten",
titleUpdated: "Titel erfolgreich aktualisiert",
titleUpdateFailed: "Fehler beim Aktualisieren des Titels",
refreshThumbnail: "Vorschaubild aktualisieren",
@@ -409,8 +413,6 @@ export const de = {
deleteTask: "Aufgabe löschen",
confirmDeleteTask: "Sind Sie sicher, dass Sie den Aufgaben-Datensatz für {author} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
taskDeleted: "Aufgabe erfolgreich gelöscht",
minutes: "Minuten",
never: "Nie",
// Instruction Page
instructionSection1Title: "1. Download & Aufgabenverwaltung",
instructionSection1Desc:

View File

@@ -116,7 +116,10 @@ export const en = {
showYoutubeSearch: "Show YouTube Search Results",
visitorMode: "Visitor Mode (Read-only)",
visitorModeReadOnly: "Visitor mode: Read-only",
visitorModePasswordPrompt: "Please enter the website password to change Visitor Mode settings.",
visitorModeDescription:
"Read-only mode. Hidden videos will not be visible to visitors.",
visitorModePasswordPrompt:
"Please enter the website password to change Visitor Mode settings.",
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
cleanupTempFilesFailed: "Failed to clean up temporary files",
@@ -145,18 +148,21 @@ export const en = {
apiUrlHelper: "e.g. https://your-alist-instance.com/api/fs/put",
token: "Token",
publicUrl: "Public URL",
publicUrlHelper: "Public domain for accessing files (e.g., https://your-cloudflare-tunnel-domain.com). If set, this will be used instead of the API URL for file access.",
publicUrlHelper:
"Public domain for accessing files (e.g., https://your-cloudflare-tunnel-domain.com). If set, this will be used instead of the API URL for file access.",
uploadPath: "Upload Path",
cloudDrivePathHelper: "Directory path in cloud drive, e.g. /mytube-uploads",
scanPaths: "Scan Paths",
scanPathsHelper: "One path per line. Videos will be scanned from these paths. If empty, will use upload path. Example:\n/a/Movies\n/b/Documentaries",
scanPathsHelper:
"One path per line. Videos will be scanned from these paths. If empty, will use upload path. Example:\n/a/Movies\n/b/Documentaries",
cloudDriveNote:
"After enabling this feature, newly downloaded videos will be automatically uploaded to cloud storage and local files will be deleted. Videos will be played from cloud storage via proxy.",
testing: "Testing...",
testConnection: "Test Connection",
sync: "Sync",
syncToCloud: "Two-way Sync",
syncWarning: "This operation will upload local videos to cloud and scan cloud storage for new files. Local files will be deleted after upload.",
syncWarning:
"This operation will upload local videos to cloud and scan cloud storage for new files. Local files will be deleted after upload.",
syncing: "Syncing...",
syncCompleted: "Sync Completed",
syncFailed: "Sync failed",
@@ -241,6 +247,9 @@ export const en = {
exitFullscreen: "Exit Fullscreen",
share: "Share",
editTitle: "Edit Title",
hideVideo: "Make Video Hidden for Visitor Mode",
showVideo: "Make Video Visible for Visitor Mode",
toggleVisibility: "Toggle Visibility",
titleUpdated: "Title updated successfully",
titleUpdateFailed: "Failed to update title",
thumbnailRefreshed: "Thumbnail refreshed successfully",
@@ -256,7 +265,8 @@ export const en = {
openInExternalPlayer: "Open in external player",
playWith: "Play with...",
deleteAllFilteredVideos: "Delete All Filtered Videos",
confirmDeleteFilteredVideos: "Are you sure you want to delete {count} videos filtered by the selected tags?",
confirmDeleteFilteredVideos:
"Are you sure you want to delete {count} videos filtered by the selected tags?",
deleteFilteredVideosSuccess: "Successfully deleted {count} videos.",
deletingVideos: "Deleting videos...",
@@ -295,7 +305,8 @@ export const en = {
unknownAuthor: "Unknown",
noVideosForAuthor: "No videos found for this author.",
deleteAuthor: "Delete Author",
deleteAuthorConfirmation: "Are you sure you want to delete author {author}? This will delete all videos associated with this author.",
deleteAuthorConfirmation:
"Are you sure you want to delete author {author}? This will delete all videos associated with this author.",
authorDeletedSuccessfully: "Author deleted successfully",
failedToDeleteAuthor: "Failed to delete author",
@@ -423,10 +434,12 @@ export const en = {
taskStatusCancelled: "Cancelled",
downloaded: "Downloaded",
cancelTask: "Cancel Task",
confirmCancelTask: "Are you sure you want to cancel the download task for {author}?",
confirmCancelTask:
"Are you sure you want to cancel the download task for {author}?",
taskCancelled: "Task cancelled successfully",
deleteTask: "Delete Task",
confirmDeleteTask: "Are you sure you want to delete the task record for {author}? This action cannot be undone.",
confirmDeleteTask:
"Are you sure you want to delete the task record for {author}? This action cannot be undone.",
taskDeleted: "Task deleted successfully",
// Instruction Page
instructionSection1Title: "1. Download & Task Management",

View File

@@ -129,6 +129,7 @@ export const es = {
showYoutubeSearch: "Mostrar resultados de búsqueda de YouTube",
visitorMode: "Modo Visitante (Solo lectura)",
visitorModeReadOnly: "Modo visitante: Solo lectura",
visitorModeDescription: "Modo de solo lectura. Los videos ocultos no serán visibles para los visitantes.",
visitorModePasswordPrompt: "Por favor, introduzca la contraseña del sitio web para cambiar la configuración del modo visitante.",
cleanupTempFilesSuccess:
"Se eliminaron exitosamente {count} archivo(s) temporal(es).",
@@ -261,6 +262,9 @@ export const es = {
exitFullscreen: "Salir de Pantalla Completa",
share: "Compartir",
editTitle: "Editar Título",
hideVideo: "Hacer Video Oculto para Modo Visitante",
showVideo: "Hacer Video Visible para Modo Visitante",
toggleVisibility: "Alternar Visibilidad",
titleUpdated: "Título actualizado exitosamente",
titleUpdateFailed: "Error al actualizar el título",
refreshThumbnail: "Actualizar miniatura",
@@ -424,8 +428,6 @@ export const es = {
deleteTask: "Eliminar tarea",
confirmDeleteTask: "¿Estás seguro de que quieres eliminar el registro de tarea para {author}? Esta acción no se puede deshacer.",
taskDeleted: "Tarea eliminada exitosamente",
minutes: "minutos",
never: "Nunca",
// Instruction Page
instructionSection1Title: "1. Descarga y Gestión de Tareas",
instructionSection1Desc:

View File

@@ -128,7 +128,10 @@ export const fr = {
showYoutubeSearch: "Afficher les résultats de recherche YouTube",
visitorMode: "Mode Visiteur (Lecture seule)",
visitorModeReadOnly: "Mode visiteur : Lecture seule",
visitorModePasswordPrompt: "Veuillez entrer le mot de passe du site web pour modifier les paramètres du mode visiteur.",
visitorModeDescription:
"Mode lecture seule. Les vidéos masquées ne seront pas visibles pour les visiteurs.",
visitorModePasswordPrompt:
"Veuillez entrer le mot de passe du site web pour modifier les paramètres du mode visiteur.",
cleanupTempFilesSuccess:
"{count} fichier(s) temporaire(s) supprimé(s) avec succès.",
cleanupTempFilesFailed: "Échec du nettoyage des fichiers temporaires",
@@ -158,32 +161,39 @@ export const fr = {
apiUrlHelper: "ex. https://your-alist-instance.com/api/fs/put",
token: "Jeton (Token)",
publicUrl: "URL Publique",
publicUrlHelper: "Domaine public pour accéder aux fichiers (ex. https://your-cloudflare-tunnel-domain.com). S'il est défini, il sera utilisé à la place de l'URL de l'API pour accéder aux fichiers.",
publicUrlHelper:
"Domaine public pour accéder aux fichiers (ex. https://your-cloudflare-tunnel-domain.com). S'il est défini, il sera utilisé à la place de l'URL de l'API pour accéder aux fichiers.",
uploadPath: "Chemin de téléchargement",
cloudDrivePathHelper:
"Chemin du répertoire dans le cloud, ex. /mytube-uploads",
scanPaths: "Chemins d'analyse",
scanPathsHelper: "Un chemin par ligne. Les vidéos seront analysées à partir de ces chemins. Si vide, le chemin de téléchargement sera utilisé. Exemple :\n/a/Films\n/b/Documentaires",
scanPathsHelper:
"Un chemin par ligne. Les vidéos seront analysées à partir de ces chemins. Si vide, le chemin de téléchargement sera utilisé. Exemple :\n/a/Films\n/b/Documentaires",
cloudDriveNote:
"Après avoir activé cette fonctionnalité, les vidéos nouvellement téléchargées seront automatiquement téléchargées vers le stockage cloud et les fichiers locaux seront supprimés. Les vidéos seront lues depuis le stockage cloud via un proxy.",
testing: "Test en cours...",
testConnection: "Tester la connexion",
sync: "Synchroniser",
syncToCloud: "Synchronisation bidirectionnelle",
syncWarning: "Cette opération téléchargera les vidéos locales vers le cloud et recherchera les nouveaux fichiers dans le stockage cloud. Les fichiers locaux seront supprimés après le téléchargement.",
syncWarning:
"Cette opération téléchargera les vidéos locales vers le cloud et recherchera les nouveaux fichiers dans le stockage cloud. Les fichiers locaux seront supprimés après le téléchargement.",
syncing: "Synchronisation...",
syncCompleted: "Synchronisation Terminée",
syncFailed: "Échec de la Synchronisation",
syncReport: "Total : {total} | Téléchargés : {uploaded} | Échoués : {failed}",
syncErrors: "Erreurs :",
fillApiUrlToken: "Veuillez d'abord remplir l'URL de l'API et le jeton",
connectionTestSuccess: "Test de connexion réussi ! Les paramètres sont valides.",
connectionFailedStatus: "Échec de la connexion : Le serveur a renvoyé le statut {status}",
connectionFailedUrl: "Impossible de se connecter au serveur. Veuillez vérifier l'URL de l'API.",
connectionTestSuccess:
"Test de connexion réussi ! Les paramètres sont valides.",
connectionFailedStatus:
"Échec de la connexion : Le serveur a renvoyé le statut {status}",
connectionFailedUrl:
"Impossible de se connecter au serveur. Veuillez vérifier l'URL de l'API.",
authFailed: "Échec de l'authentification. Veuillez vérifier votre jeton.",
connectionTestFailed: "Échec du test de connexion : {error}",
syncFailedMessage: "Échec de la synchronisation. Veuillez réessayer.",
foundVideosToSync: "{count} vidéos avec des fichiers locaux à synchroniser trouvées",
foundVideosToSync:
"{count} vidéos avec des fichiers locaux à synchroniser trouvées",
uploadingVideo: "Téléversement : {title}",
// Manage
@@ -263,6 +273,9 @@ export const fr = {
exitFullscreen: "Quitter le plein écran",
share: "Partager",
editTitle: "Modifier le titre",
hideVideo: "Rendre la vidéo cachée pour le mode visiteur",
showVideo: "Rendre la vidéo visible pour le mode visiteur",
toggleVisibility: "Basculer la visibilité",
titleUpdated: "Titre mis à jour avec succès",
titleUpdateFailed: "Échec de la mise à jour du titre",
thumbnailRefreshed: "Miniature actualisée avec succès",
@@ -279,7 +292,8 @@ export const fr = {
openInExternalPlayer: "Ouvrir dans un lecteur externe",
playWith: "Lire avec...",
deleteAllFilteredVideos: "Supprimer toutes les vidéos filtrées",
confirmDeleteFilteredVideos: "Êtes-vous sûr de vouloir supprimer {count} vidéos filtrées par les tags sélectionnés ?",
confirmDeleteFilteredVideos:
"Êtes-vous sûr de vouloir supprimer {count} vidéos filtrées par les tags sélectionnés ?",
deleteFilteredVideosSuccess: "{count} vidéos supprimées avec succès.",
deletingVideos: "Suppression des vidéos...",
@@ -318,7 +332,8 @@ export const fr = {
unknownAuthor: "Inconnu",
noVideosForAuthor: "Aucune vidéo trouvée pour cet auteur.",
deleteAuthor: "Supprimer l'auteur",
deleteAuthorConfirmation: "Êtes-vous sûr de vouloir supprimer l'auteur {author} ? Cela supprimera toutes les vidéos associées à cet auteur.",
deleteAuthorConfirmation:
"Êtes-vous sûr de vouloir supprimer l'auteur {author} ? Cela supprimera toutes les vidéos associées à cet auteur.",
authorDeletedSuccessfully: "Auteur supprimé avec succès",
failedToDeleteAuthor: "Échec de la suppression de l'auteur",
@@ -429,7 +444,8 @@ export const fr = {
subscriptionAlreadyExists: "Vous êtes déjà abonné à cet auteur.",
minutes: "minutes",
never: "Jamais",
downloadAllPreviousVideos: "Télécharger toutes les vidéos précédentes de cet auteur",
downloadAllPreviousVideos:
"Télécharger toutes les vidéos précédentes de cet auteur",
downloadAllPreviousWarning:
"Avertissement : Cela téléchargera toutes les vidéos précédentes de cet auteur. Cela peut consommer un espace de stockage important et pourrait déclencher des mécanismes de détection de bots qui peuvent entraîner des interdictions temporaires ou permanentes de la plateforme. Utilisez à vos propres risques.",
continuousDownloadTasks: "Tâches de téléchargement continu",
@@ -439,14 +455,13 @@ export const fr = {
taskStatusCancelled: "Annulé",
downloaded: "Téléchargé",
cancelTask: "Annuler la tâche",
confirmCancelTask: "Êtes-vous sûr de vouloir annuler la tâche de téléchargement pour {author} ?",
confirmCancelTask:
"Êtes-vous sûr de vouloir annuler la tâche de téléchargement pour {author} ?",
taskCancelled: "Tâche annulée avec succès",
deleteTask: "Supprimer la tâche",
confirmDeleteTask: "Êtes-vous sûr de vouloir supprimer l'enregistrement de tâche pour {author} ? Cette action ne peut pas être annulée.",
confirmDeleteTask:
"Êtes-vous sûr de vouloir supprimer l'enregistrement de tâche pour {author} ? Cette action ne peut pas être annulée.",
taskDeleted: "Tâche supprimée avec succès",
subscriptionAlreadyExists: "Vous êtes déjà abonné à cet auteur.",
minutes: "minutes",
never: "Jamais",
// Instruction Page
instructionSection1Title: "1. Téléchargement et Gestion des Tâches",
instructionSection1Desc:

View File

@@ -124,6 +124,7 @@ export const ja = {
showYoutubeSearch: "YouTube検索結果を表示",
visitorMode: "ビジターモード(読み取り専用)",
visitorModeReadOnly: "ビジターモード:読み取り専用",
visitorModeDescription: "読み取り専用モード。非表示の動画は訪問者には表示されません。",
visitorModePasswordPrompt: "ビジターモードの設定を変更するには、ウェブサイトのパスワードを入力してください。",
cleanupTempFilesSuccess: "{count}個の一時ファイルを正常に削除しました。",
cleanupTempFilesFailed: "一時ファイルのクリーンアップに失敗しました",
@@ -248,6 +249,9 @@ export const ja = {
exitFullscreen: "全画面表示を終了",
share: "共有",
editTitle: "タイトルを編集",
hideVideo: "ビジターモードで動画を非表示にする",
showVideo: "ビジターモードで動画を表示する",
toggleVisibility: "表示/非表示を切り替え",
titleUpdated: "タイトルが正常に更新されました",
titleUpdateFailed: "タイトルの更新に失敗しました",
refreshThumbnail: "サムネイルを更新",
@@ -430,8 +434,6 @@ export const ja = {
deleteTask: "タスクを削除",
confirmDeleteTask: "{author} のタスクレコードを削除してもよろしいですか?この操作は元に戻せません。",
taskDeleted: "タスクが正常に削除されました",
minutes: "分",
never: "なし",
// Instruction Page
instructionSection1Title: "1. ダウンロードとタスク管理",
instructionSection1Desc:

View File

@@ -121,6 +121,7 @@ export const ko = {
showYoutubeSearch: "YouTube 검색 결과 표시",
visitorMode: "방문자 모드 (읽기 전용)",
visitorModeReadOnly: "방문자 모드: 읽기 전용",
visitorModeDescription: "읽기 전용 모드. 숨겨진 동영상은 방문자에게 표시되지 않습니다.",
visitorModePasswordPrompt: "방문자 모드 설정을 변경하려면 웹사이트 비밀번호를 입력하세요.",
cleanupTempFilesSuccess: "{count}개의 임시 파일을 성공적으로 삭제했습니다.",
cleanupTempFilesFailed: "임시 파일 정리 실패",
@@ -244,6 +245,9 @@ export const ko = {
exitFullscreen: "전체 화면 종료",
share: "공유",
editTitle: "제목 편집",
hideVideo: "방문자 모드에서 동영상 숨기기",
showVideo: "방문자 모드에서 동영상 표시",
toggleVisibility: "표시 여부 전환",
titleUpdated: "제목이 성공적으로 업데이트됨",
titleUpdateFailed: "제목 업데이트 실패",
refreshThumbnail: "썸네일 새로고침",
@@ -426,8 +430,6 @@ export const ko = {
deleteTask: "작업 삭제",
confirmDeleteTask: "{author}님의 작업 기록을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
taskDeleted: "작업이 성공적으로 삭제되었습니다",
minutes: "분",
never: "없음",
// Instruction Page
instructionSection1Title: "1. 다운로드 및 작업 관리",
instructionSection1Desc:

View File

@@ -124,6 +124,7 @@ export const pt = {
showYoutubeSearch: "Mostrar resultados de pesquisa do YouTube",
visitorMode: "Modo Visitante (Somente leitura)",
visitorModeReadOnly: "Modo visitante: Somente leitura",
visitorModeDescription: "Modo somente leitura. Vídeos ocultos não serão visíveis para visitantes.",
visitorModePasswordPrompt: "Por favor, digite a senha do site para alterar as configurações do modo visitante.",
cleanupTempFilesSuccess:
"{count} arquivo(s) temporário(s) excluído(s) com sucesso.",
@@ -249,6 +250,9 @@ export const pt = {
exitFullscreen: "Sair da Tela Cheia",
share: "Compartilhar",
editTitle: "Editar Título",
hideVideo: "Tornar Vídeo Oculto para Modo Visitante",
showVideo: "Tornar Vídeo Visível para Modo Visitante",
toggleVisibility: "Alternar Visibilidade",
titleUpdated: "Título atualizado com sucesso",
titleUpdateFailed: "Falha ao atualizar título",
refreshThumbnail: "Atualizar miniatura",
@@ -439,8 +443,6 @@ export const pt = {
deleteTask: "Excluir tarefa",
confirmDeleteTask: "Tem certeza de que deseja excluir o registro da tarefa para {author}? Esta ação não pode ser desfeita.",
taskDeleted: "Tarefa excluída com sucesso",
minutes: "minutos",
never: "Nunca",
// Instruction Page
instructionSection1Title: "1. Download e Gerenciamento de Tarefas",
instructionSection1Desc:

View File

@@ -133,6 +133,7 @@ export const ru = {
showYoutubeSearch: "Показать результаты поиска YouTube",
visitorMode: "Режим посетителя (Только чтение)",
visitorModeReadOnly: "Режим посетителя: Только чтение",
visitorModeDescription: "Режим только чтения. Скрытые видео не будут видны посетителям.",
visitorModePasswordPrompt: "Пожалуйста, введите пароль веб-сайта для изменения настроек режима посетителя.",
cleanupTempFilesSuccess: "Успешно удалено {count} временных файлов.",
cleanupTempFilesFailed: "Не удалось очистить временные файлы",
@@ -259,6 +260,9 @@ export const ru = {
exitFullscreen: "Выйти из полноэкранного режима",
share: "Поделиться",
editTitle: "Редактировать название",
hideVideo: "Скрыть видео для режима посетителя",
showVideo: "Сделать видео видимым для режима посетителя",
toggleVisibility: "Переключить видимость",
titleUpdated: "Название успешно обновлено",
titleUpdateFailed: "Не удалось обновить название",
refreshThumbnail: "Обновить миниатюру",
@@ -444,8 +448,6 @@ export const ru = {
deleteTask: "Удалить задачу",
confirmDeleteTask: "Вы уверены, что хотите удалить запись задачи для {author}? Это действие нельзя отменить.",
taskDeleted: "Задача успешно удалена",
minutes: "минуты",
never: "Никогда",
// Instruction Page
instructionSection1Title: "1. Загрузка и управление задачами",
instructionSection1Desc:

View File

@@ -116,6 +116,7 @@ export const zh = {
showYoutubeSearch: "显示 YouTube 搜索结果",
visitorMode: "访客模式(只读)",
visitorModeReadOnly: "访客模式:只读",
visitorModeDescription: "只读模式。隐藏的视频对访客不可见。",
visitorModePasswordPrompt: "请输入网站密码以更改访客模式设置。",
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
cleanupTempFilesFailed: "清理临时文件失败",
@@ -145,18 +146,21 @@ export const zh = {
apiUrlHelper: "例如https://your-alist-instance.com/api/fs/put",
token: "Token",
publicUrl: "公开访问域名",
publicUrlHelper: "用于访问文件的公开域名例如https://your-cloudflare-tunnel-domain.com。如果设置将使用此域名而不是 API 地址来访问文件。",
publicUrlHelper:
"用于访问文件的公开域名例如https://your-cloudflare-tunnel-domain.com。如果设置将使用此域名而不是 API 地址来访问文件。",
uploadPath: "上传路径",
cloudDrivePathHelper: "云端存储中的目录路径,例如:/mytube-uploads",
scanPaths: "扫描路径",
scanPathsHelper: "每行一个路径。系统将扫描这些路径下的视频。留空则使用默认上传路径。示例:\n/a/电影\n/b/纪录片",
scanPathsHelper:
"每行一个路径。系统将扫描这些路径下的视频。留空则使用默认上传路径。示例:\n/a/电影\n/b/纪录片",
cloudDriveNote:
"启用此功能后,新下载的视频将自动上传到云端存储,本地文件将被删除。视频将通过代理从云端存储播放。",
testing: "测试中...",
testConnection: "测试连接",
sync: "同步",
syncToCloud: "双向同步",
syncWarning: "此操作将上传本地视频到云端并扫描云端新文件。上传后,本地文件将被删除。",
syncWarning:
"此操作将上传本地视频到云端并扫描云端新文件。上传后,本地文件将被删除。",
syncing: "正在同步...",
syncCompleted: "同步完成",
syncFailed: "同步失败",
@@ -238,6 +242,9 @@ export const zh = {
exitFullscreen: "退出全屏",
share: "分享",
editTitle: "编辑标题",
hideVideo: "使视频在访客模式下隐藏",
showVideo: "使视频在访客模式下可见",
toggleVisibility: "切换可见性",
titleUpdated: "标题更新成功",
titleUpdateFailed: "更新标题失败",
refreshThumbnail: "刷新缩略图",
@@ -254,7 +261,8 @@ export const zh = {
openInExternalPlayer: "在外部播放器中打开",
playWith: "使用此应用播放...",
deleteAllFilteredVideos: "删除所有过滤后的视频",
confirmDeleteFilteredVideos: "您确定要删除通过选定标签过滤的 {count} 个视频吗?",
confirmDeleteFilteredVideos:
"您确定要删除通过选定标签过滤的 {count} 个视频吗?",
deleteFilteredVideosSuccess: "成功删除 {count} 个视频。",
deletingVideos: "正在删除视频...",
@@ -300,7 +308,8 @@ export const zh = {
unknownAuthor: "未知",
noVideosForAuthor: "未找到该作者的视频。",
deleteAuthor: "删除作者",
deleteAuthorConfirmation: "您确定要删除作者 {author} 吗?这将删除该作者的所有视频。",
deleteAuthorConfirmation:
"您确定要删除作者 {author} 吗?这将删除该作者的所有视频。",
authorDeletedSuccessfully: "作者删除成功",
failedToDeleteAuthor: "删除作者失败",