From dc1462d744301d58be59be3dfb066d172c2e8cc1 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 15 Dec 2025 00:16:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=89=A7=E9=9B=86=E5=B1=8F?= =?UTF-8?q?=E8=94=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/play/page.tsx | 109 +++++- src/components/EpisodeFilterSettings.tsx | 452 +++++++++++++++++++++++ src/components/EpisodeSelector.tsx | 128 +++++-- src/lib/db.client.ts | 46 +++ src/lib/types.ts | 13 + 5 files changed, 709 insertions(+), 39 deletions(-) create mode 100644 src/components/EpisodeFilterSettings.tsx diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index f673ae3..283d830 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -24,6 +24,7 @@ import { saveSkipConfig, subscribeToDataUpdates, getDanmakuFilterConfig, + getEpisodeFilterConfig, } from '@/lib/db.client'; import { convertDanmakuFormat, @@ -37,7 +38,7 @@ import { initDanmakuModule, } from '@/lib/danmaku/api'; import type { DanmakuAnime, DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types'; -import { SearchResult, DanmakuFilterConfig } from '@/lib/types'; +import { SearchResult, DanmakuFilterConfig, EpisodeFilterConfig } from '@/lib/types'; import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import EpisodeSelector from '@/components/EpisodeSelector'; @@ -276,6 +277,8 @@ function PlayPageClient() { ); const [danmakuFilterConfig, setDanmakuFilterConfig] = useState(null); const danmakuFilterConfigRef = useRef(null); + const [episodeFilterConfig, setEpisodeFilterConfig] = useState(null); + const episodeFilterConfigRef = useRef(null); const [currentDanmakuSelection, setCurrentDanmakuSelection] = useState(null); const [danmakuEpisodesList, setDanmakuEpisodesList] = useState< @@ -316,8 +319,19 @@ function PlayPageClient() { setDanmakuFilterConfig(defaultConfig); danmakuFilterConfigRef.current = defaultConfig; } + + // 加载集数过滤配置 + const episodeConfig = await getEpisodeFilterConfig(); + if (episodeConfig) { + setEpisodeFilterConfig(episodeConfig); + episodeFilterConfigRef.current = episodeConfig; + } else { + const defaultEpisodeConfig: EpisodeFilterConfig = { rules: [] }; + setEpisodeFilterConfig(defaultEpisodeConfig); + episodeFilterConfigRef.current = defaultEpisodeConfig; + } } catch (error) { - console.error('加载弹幕过滤配置失败:', error); + console.error('加载过滤配置失败:', error); } }; loadFilterConfig(); @@ -328,6 +342,11 @@ function PlayPageClient() { danmakuFilterConfigRef.current = danmakuFilterConfig; }, [danmakuFilterConfig]); + // 同步集数过滤配置到ref + useEffect(() => { + episodeFilterConfigRef.current = episodeFilterConfig; + }, [episodeFilterConfig]); + // 视频基本信息 const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || ''); const [videoYear, setVideoYear] = useState(searchParams.get('year') || ''); @@ -2024,14 +2043,60 @@ function PlayPageClient() { } }; + // 检查集数是否被过滤 + const isEpisodeFilteredByTitle = (title: string): boolean => { + const filterConfig = episodeFilterConfigRef.current; + if (!filterConfig || filterConfig.rules.length === 0) { + return false; + } + + for (const rule of filterConfig.rules) { + if (!rule.enabled) continue; + + try { + if (rule.type === 'normal' && title.includes(rule.keyword)) { + return true; + } + if (rule.type === 'regex' && new RegExp(rule.keyword).test(title)) { + return true; + } + } catch (e) { + console.error('集数过滤规则错误:', e); + } + } + return false; + }; + const handleNextEpisode = () => { const d = detailRef.current; const idx = currentEpisodeIndexRef.current; - if (d && d.episodes && idx < d.episodes.length - 1) { - if (artPlayerRef.current && !artPlayerRef.current.paused) { - saveCurrentPlayProgress(); + + if (!d || !d.episodes || idx >= d.episodes.length - 1) { + return; + } + + // 保存当前进度 + if (artPlayerRef.current && !artPlayerRef.current.paused) { + saveCurrentPlayProgress(); + } + + // 查找下一个未被过滤的集数 + let nextIdx = idx + 1; + while (nextIdx < d.episodes.length) { + const episodeTitle = d.episodes_titles?.[nextIdx]; + const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle); + + if (!isFiltered) { + setCurrentEpisodeIndex(nextIdx); + return; } - setCurrentEpisodeIndex(idx + 1); + nextIdx++; + } + + // 所有后续集数都被屏蔽 + if (artPlayerRef.current) { + artPlayerRef.current.notice.show = '后续集数均已屏蔽'; + artPlayerRef.current.pause(); } }; @@ -3331,10 +3396,29 @@ function PlayPageClient() { const d = detailRef.current; const idx = currentEpisodeIndexRef.current; - if (d && d.episodes && idx < d.episodes.length - 1) { - setTimeout(() => { - setCurrentEpisodeIndex(idx + 1); - }, 1000); + + if (!d || !d.episodes || idx >= d.episodes.length - 1) { + return; + } + + // 查找下一个未被过滤的集数 + let nextIdx = idx + 1; + while (nextIdx < d.episodes.length) { + const episodeTitle = d.episodes_titles?.[nextIdx]; + const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle); + + if (!isFiltered) { + setTimeout(() => { + setCurrentEpisodeIndex(nextIdx); + }, 1000); + return; + } + nextIdx++; + } + + // 所有后续集数都被屏蔽 + if (artPlayerRef.current) { + artPlayerRef.current.notice.show = '后续集数均已屏蔽,已自动停止'; } }); @@ -4097,6 +4181,11 @@ function PlayPageClient() { precomputedVideoInfo={precomputedVideoInfo} onDanmakuSelect={handleDanmakuSelect} currentDanmakuSelection={currentDanmakuSelection} + episodeFilterConfig={episodeFilterConfig} + onFilterConfigUpdate={setEpisodeFilterConfig} + onShowToast={(message, type) => { + setToast({ message, type, onClose: () => setToast(null) }); + }} /> diff --git a/src/components/EpisodeFilterSettings.tsx b/src/components/EpisodeFilterSettings.tsx new file mode 100644 index 0000000..0f7efb8 --- /dev/null +++ b/src/components/EpisodeFilterSettings.tsx @@ -0,0 +1,452 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { X, Plus, Trash2, ToggleLeft, ToggleRight } from 'lucide-react'; +import { EpisodeFilterConfig, EpisodeFilterRule } from '@/lib/types'; +import { getEpisodeFilterConfig, saveEpisodeFilterConfig } from '@/lib/db.client'; + +interface EpisodeFilterSettingsProps { + isOpen: boolean; + onClose: () => void; + onConfigUpdate?: (config: EpisodeFilterConfig) => void; + onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; +} + +export default function EpisodeFilterSettings({ + isOpen, + onClose, + onConfigUpdate, + onShowToast, +}: EpisodeFilterSettingsProps) { + const [config, setConfig] = useState({ rules: [] }); + const [newKeyword, setNewKeyword] = useState(''); + const [newType, setNewType] = useState<'normal' | 'regex'>('normal'); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [isVisible, setIsVisible] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const [inputKey, setInputKey] = useState(0); // 用于强制重新渲染输入框 + const inputRef = useRef(null); // 用于直接操作输入框 DOM + + // 控制动画状态 + useEffect(() => { + let animationId: number; + let timer: NodeJS.Timeout; + + if (isOpen) { + setIsVisible(true); + // 使用双重 requestAnimationFrame 确保DOM完全渲染 + animationId = requestAnimationFrame(() => { + animationId = requestAnimationFrame(() => { + setIsAnimating(true); + }); + }); + } else { + setIsAnimating(false); + // 等待动画完成后隐藏组件 + timer = setTimeout(() => { + setIsVisible(false); + }, 300); + } + + return () => { + if (animationId) { + cancelAnimationFrame(animationId); + } + if (timer) { + clearTimeout(timer); + } + }; + }, [isOpen]); + + // 阻止背景滚动 + useEffect(() => { + if (isVisible) { + // 保存当前滚动位置 + const scrollY = window.scrollY; + const scrollX = window.scrollX; + const body = document.body; + const html = document.documentElement; + + // 获取滚动条宽度 + const scrollBarWidth = window.innerWidth - html.clientWidth; + + // 保存原始样式 + const originalBodyStyle = { + position: body.style.position, + top: body.style.top, + left: body.style.left, + right: body.style.right, + width: body.style.width, + paddingRight: body.style.paddingRight, + overflow: body.style.overflow, + }; + + // 设置body样式来阻止滚动,但保持原位置 + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.left = `-${scrollX}px`; + body.style.right = '0'; + body.style.width = '100%'; + body.style.overflow = 'hidden'; + body.style.paddingRight = `${scrollBarWidth}px`; + + return () => { + // 恢复所有原始样式 + body.style.position = originalBodyStyle.position; + body.style.top = originalBodyStyle.top; + body.style.left = originalBodyStyle.left; + body.style.right = originalBodyStyle.right; + body.style.width = originalBodyStyle.width; + body.style.paddingRight = originalBodyStyle.paddingRight; + body.style.overflow = originalBodyStyle.overflow; + + // 使用 requestAnimationFrame 确保样式恢复后再滚动 + requestAnimationFrame(() => { + window.scrollTo(scrollX, scrollY); + }); + }; + } + }, [isVisible]); + + // 加载配置 + useEffect(() => { + if (isOpen) { + loadConfig(); + } + }, [isOpen]); + + const loadConfig = async () => { + setLoading(true); + try { + const loadedConfig = await getEpisodeFilterConfig(); + if (loadedConfig) { + setConfig(loadedConfig); + } else { + setConfig({ rules: [] }); + } + } catch (error) { + console.error('加载集数过滤配置失败:', error); + } finally { + setLoading(false); + } + }; + + // 保存配置 + const handleSave = async () => { + setSaving(true); + try { + await saveEpisodeFilterConfig(config); + if (onConfigUpdate) { + onConfigUpdate(config); + } + if (onShowToast) { + onShowToast('保存成功!', 'success'); + } + // 延迟关闭面板,让用户看到toast + setTimeout(() => { + onClose(); + }, 300); + } catch (error) { + console.error('保存集数过滤配置失败:', error); + if (onShowToast) { + onShowToast('保存失败,请重试', 'error'); + } + } finally { + setSaving(false); + } + }; + + // 添加规则 + const handleAddRule = () => { + if (!newKeyword.trim()) { + if (onShowToast) { + onShowToast('请输入关键字', 'info'); + } + return; + } + + const newRule: EpisodeFilterRule = { + keyword: newKeyword.trim(), + type: newType, + enabled: true, + id: Date.now().toString(), + }; + + setConfig((prev) => ({ + rules: [...prev.rules, newRule], + })); + + // 清空输入框并强制重新渲染 + setNewKeyword(''); + + // 使用 setTimeout 确保在状态更新后操作 DOM + setTimeout(() => { + if (inputRef.current) { + inputRef.current.value = ''; // 直接清空 DOM 值 + inputRef.current.blur(); // 失去焦点,阻止自动填充 + } + setInputKey(prev => prev + 1); // 强制重新渲染输入框 + }, 0); + }; + + // 删除规则 + const handleDeleteRule = (id: string | undefined) => { + if (!id) return; + setConfig((prev) => ({ + rules: prev.rules.filter((rule) => rule.id !== id), + })); + }; + + // 切换规则启用状态 + const handleToggleRule = (id: string | undefined) => { + if (!id) return; + setConfig((prev) => ({ + rules: prev.rules.map((rule) => + rule.id === id ? { ...rule, enabled: !rule.enabled } : rule + ), + })); + }; + + if (!isVisible) return null; + + return ( +
{ + // 阻止最外层容器的触摸移动,防止背景滚动 + e.preventDefault(); + e.stopPropagation(); + }} + style={{ + touchAction: 'none', // 禁用所有触摸操作 + }} + > + {/* 背景遮罩 */} +
{ + // 只阻止滚动,允许其他触摸事件(包括点击) + e.preventDefault(); + }} + onWheel={(e) => { + // 阻止滚轮滚动 + e.preventDefault(); + }} + style={{ + backdropFilter: 'blur(4px)', + willChange: 'opacity', + touchAction: 'none', // 禁用所有触摸操作 + }} + /> + + {/* 弹窗主体 */} +
{ + // 允许弹窗内部滚动,阻止事件冒泡到外层 + e.stopPropagation(); + }} + style={{ + marginBottom: 'calc(0rem + env(safe-area-inset-bottom))', + willChange: 'transform, opacity', + backfaceVisibility: 'hidden', // 避免闪烁 + transform: isAnimating + ? 'translateY(0) translateZ(0)' + : 'translateY(100%) translateZ(0)', // 组合变换保持滑入效果和硬件加速 + opacity: isAnimating ? 1 : 0, + touchAction: 'auto', // 允许弹窗内的正常触摸操作 + }} + > + {/* 顶部拖拽指示器 */} +
+
+
+
+
+ + {/* 头部 */} +
+

+ 集数屏蔽设置 +

+ +
+ + {/* 内容区域 */} +
+ {/* 添加规则 */} +
+

+ 添加屏蔽规则 +

+
+ setNewKeyword(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleAddRule()} + placeholder="输入要屏蔽的集数关键字(如:预告、花絮)" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + data-form-type="other" + data-lpignore="true" + className="w-full px-4 py-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg border border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200" + /> +
+ + +
+
+

+ 💡 普通模式:集数标题包含关键字即屏蔽
+ 🔧 正则模式:支持正则表达式匹配(如:^预告.*匹配以"预告"开头的集数) +

+
+ + {/* 规则列表 */} +
+
+

+ 当前规则 +

+ + {config.rules.length} + +
+ + {loading ? ( +
+
+
+ 加载中... +
+
+ ) : config.rules.length === 0 ? ( +
+
+
+ +
+
+

暂无屏蔽规则

+

点击上方添加关键字

+
+
+
+ ) : ( +
+ {config.rules.map((rule) => ( +
+ {/* 启用/禁用按钮 */} + + + {/* 关键字 */} +
+
+ + {rule.keyword} + + + {rule.type === 'regex' ? '🔧 正则' : '💬 普通'} + +
+
+ + {/* 删除按钮 */} + +
+ ))} +
+ )} +
+
+ + {/* 底部按钮 */} +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 88a6a12..182ae2b 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -8,10 +8,12 @@ import React, { useRef, useState, } from 'react'; +import { Settings } from 'lucide-react'; import DanmakuPanel from '@/components/DanmakuPanel'; +import EpisodeFilterSettings from '@/components/EpisodeFilterSettings'; import type { DanmakuSelection } from '@/lib/danmaku/types'; -import { SearchResult } from '@/lib/types'; +import { SearchResult, EpisodeFilterConfig } from '@/lib/types'; import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; // 定义视频信息类型 @@ -49,6 +51,10 @@ interface EpisodeSelectorProps { currentDanmakuSelection?: DanmakuSelection | null; /** 观影室房员状态 - 禁用选集和换源,但保留弹幕 */ isRoomMember?: boolean; + /** 集数过滤配置 */ + episodeFilterConfig?: EpisodeFilterConfig | null; + onFilterConfigUpdate?: (config: EpisodeFilterConfig) => void; + onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; } /** @@ -71,6 +77,9 @@ const EpisodeSelector: React.FC = ({ onDanmakuSelect, currentDanmakuSelection, isRoomMember = false, + episodeFilterConfig = null, + onFilterConfigUpdate, + onShowToast, }) => { const router = useRouter(); const pageCount = Math.ceil(totalEpisodes / episodesPerPage); @@ -122,6 +131,46 @@ const EpisodeSelector: React.FC = ({ // 是否倒序显示 const [descending, setDescending] = useState(false); + // 集数过滤设置弹窗状态 + const [showFilterSettings, setShowFilterSettings] = useState(false); + + // 集数过滤逻辑 + const isEpisodeFiltered = useCallback( + (episodeNumber: number): boolean => { + if (!episodeFilterConfig || episodeFilterConfig.rules.length === 0) { + return false; + } + + // 获取集数标题 + const title = episodes_titles?.[episodeNumber - 1]; + if (!title) return false; + + // 检查每个启用的规则 + for (const rule of episodeFilterConfig.rules) { + if (!rule.enabled) continue; + + try { + if (rule.type === 'normal') { + // 普通模式:字符串包含匹配 + if (title.includes(rule.keyword)) { + return true; + } + } else if (rule.type === 'regex') { + // 正则模式:正则表达式匹配 + if (new RegExp(rule.keyword).test(title)) { + return true; + } + } + } catch (e) { + console.error('集数过滤规则错误:', e); + } + } + + return false; + }, + [episodeFilterConfig, episodes_titles] + ); + // 根据 descending 状态计算实际显示的分页索引 const displayPage = useMemo(() => { if (descending) { @@ -549,6 +598,14 @@ const EpisodeSelector: React.FC = ({ /> + {/* 集数屏蔽配置按钮 */} +
{/* 集数网格 */} @@ -558,34 +615,37 @@ const EpisodeSelector: React.FC = ({ const episodes = Array.from({ length: len }, (_, i) => descending ? currentEnd - i : currentStart + i ); - return episodes; - })().map((episodeNumber) => { - const isActive = episodeNumber === value; - return ( - - ); - })} + // 过滤掉被屏蔽的集数,但保持原有索引 + return episodes + .filter(episodeNumber => !isEpisodeFiltered(episodeNumber)) + .map((episodeNumber) => { + const isActive = episodeNumber === value; + return ( + + ); + }); + })()} )} @@ -833,6 +893,16 @@ const EpisodeSelector: React.FC = ({ )} )} + + {/* 集数过滤设置弹窗 */} + setShowFilterSettings(false)} + onConfigUpdate={(config) => { + onFilterConfigUpdate?.(config); + }} + onShowToast={onShowToast} + /> ); }; diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 0325a2c..b0f1296 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -1833,3 +1833,49 @@ export async function saveDanmakuFilterConfig( throw err; } } + +// ---------------- 集数过滤配置相关 API ---------------- + +/** + * 获取集数过滤配置(纯 localStorage 存储) + */ +export async function getEpisodeFilterConfig(): Promise { + if (typeof window === 'undefined') { + return null; + } + + try { + const raw = localStorage.getItem('moontv_episode_filter_config'); + if (!raw) return null; + return JSON.parse(raw) as EpisodeFilterConfig; + } catch (err) { + console.error('读取集数过滤配置失败:', err); + return null; + } +} + +/** + * 保存集数过滤配置(纯 localStorage 存储) + */ +export async function saveEpisodeFilterConfig( + config: EpisodeFilterConfig +): Promise { + if (typeof window === 'undefined') { + console.warn('无法在服务端保存集数过滤配置'); + return; + } + + try { + localStorage.setItem('moontv_episode_filter_config', JSON.stringify(config)); + window.dispatchEvent( + new CustomEvent('episodeFilterConfigUpdated', { + detail: config, + }) + ); + } catch (err) { + console.error('保存集数过滤配置失败:', err); + throw err; + } +} + + diff --git a/src/lib/types.ts b/src/lib/types.ts index 1d206a1..b040c7e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -143,3 +143,16 @@ export interface DanmakuFilterRule { export interface DanmakuFilterConfig { rules: DanmakuFilterRule[]; // 过滤规则列表 } + +// 集数过滤规则数据结构 +export interface EpisodeFilterRule { + keyword: string; // 关键字 + type: 'normal' | 'regex'; // 普通模式或正则模式 + enabled: boolean; // 是否启用 + id?: string; // 规则ID(用于前端管理) +} + +// 集数过滤配置数据结构 +export interface EpisodeFilterConfig { + rules: EpisodeFilterRule[]; // 过滤规则列表 +}