剧集更新提示

This commit is contained in:
mtvpls
2026-02-19 22:03:05 +08:00
parent b914133dd7
commit 84fec2eada
11 changed files with 175 additions and 49 deletions

View File

@@ -0,0 +1,8 @@
-- ============================================
-- 添加 new_episodes 字段到播放记录表
-- 版本: 1.0.1
-- 创建时间: 2026-02-19
-- ============================================
-- 为播放记录表添加 new_episodes 字段(用于显示新增剧集提示)
ALTER TABLE play_records ADD COLUMN new_episodes INTEGER;

View File

@@ -0,0 +1,8 @@
-- ============================================
-- 添加 new_episodes 字段到播放记录表
-- 版本: 1.0.1
-- 创建时间: 2026-02-19
-- ============================================
-- 为播放记录表添加 new_episodes 字段(用于显示新增剧集提示)
ALTER TABLE play_records ADD COLUMN new_episodes BIGINT;

View File

@@ -216,6 +216,14 @@ async function refreshRecordAndFavorites() {
const episodeCount = detail.episodes?.length || 0;
if (episodeCount > 0 && episodeCount !== record.total_episodes) {
// 计算新增的剧集数量
const newEpisodesCount = episodeCount > record.total_episodes
? episodeCount - record.total_episodes
: 0;
// 如果有新增剧集,累加到现有的 new_episodes 字段
const updatedNewEpisodes = (record.new_episodes || 0) + newEpisodesCount;
await db.savePlayRecord(user, source, id, {
title: detail.title || record.title,
source_name: record.source_name,
@@ -227,9 +235,10 @@ async function refreshRecordAndFavorites() {
total_time: record.total_time,
save_time: record.save_time,
search_title: record.search_title,
new_episodes: updatedNewEpisodes > 0 ? updatedNewEpisodes : undefined,
});
console.log(
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount})`
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount}, 新增 ${newEpisodesCount})`
);
}

View File

@@ -211,3 +211,37 @@ div[data-media-provider] video {
display: none !important;
}
}
/* 水波纹缩放动画 - 第一层 (快速扩散) */
@keyframes ping-scale {
0% {
transform: scale(1);
opacity: 0.6;
}
75%, 100% {
transform: scale(2);
opacity: 0;
}
}
/* 水波纹缩放动画 - 第二层 (慢速脉冲) */
@keyframes pulse-scale {
0%, 100% {
transform: scale(1);
opacity: 0.4;
}
50% {
transform: scale(1.15);
opacity: 0.2;
}
}
/* 主体徽章缩放动画 */
@keyframes badge-scale {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}

View File

@@ -124,7 +124,7 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
</div>
{loading ? (
// 加载状态显示灰色占位数据(使用原始 ScrollableRow
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
<div className="flex gap-2 overflow-x-auto scrollbar-hide pt-2 pb-2">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
@@ -140,41 +140,98 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
</div>
) : (
// 使用虚拟滚动显示真实数据
<VirtualScrollableRow>
{playRecords.map((record) => {
const { source, id } = parseKey(record.key);
return (
<div
key={record.key}
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
>
<VideoCard
id={id}
title={record.title}
poster={record.cover}
year={record.year}
source={source}
source_name={record.source_name}
progress={getProgress(record)}
episodes={record.total_episodes}
currentEpisode={record.index}
query={record.search_title}
from='playrecord'
onDelete={() =>
setPlayRecords((prev) =>
prev.filter((r) => r.key !== record.key)
)
}
type={record.total_episodes > 1 ? 'tv' : ''}
origin={record.origin}
orientation='horizontal'
playTime={record.play_time}
totalTime={record.total_time}
/>
</div>
);
})}
</VirtualScrollableRow>
<div>
<VirtualScrollableRow>
{playRecords.map((record) => {
const { source, id } = parseKey(record.key);
return (
<div
key={record.key}
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
style={{ position: 'relative' }}
>
<VideoCard
id={id}
title={record.title}
poster={record.cover}
year={record.year}
source={source}
source_name={record.source_name}
progress={getProgress(record)}
episodes={record.total_episodes}
currentEpisode={record.index}
query={record.search_title}
from='playrecord'
onDelete={() =>
setPlayRecords((prev) =>
prev.filter((r) => r.key !== record.key)
)
}
type={record.total_episodes > 1 ? 'tv' : ''}
origin={record.origin}
orientation='horizontal'
playTime={record.play_time}
totalTime={record.total_time}
/>
{/* 新增剧集提示 - 完全独立于 VideoCard */}
{record.new_episodes && record.new_episodes > 0 && (
<div
style={{
position: 'absolute',
top: '-6px',
right: '-6px',
zIndex: 100,
pointerEvents: 'none',
width: '28px',
height: '28px',
}}
>
{/* 水波纹动画 - 第一层 */}
<div
style={{
position: 'absolute',
inset: '0',
borderRadius: '9999px',
backgroundColor: 'rgb(14 165 233)',
animation: 'ping-scale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite',
}}
/>
{/* 水波纹动画 - 第二层 */}
<div
style={{
position: 'absolute',
inset: '0',
borderRadius: '9999px',
backgroundColor: 'rgb(14 165 233)',
animation: 'pulse-scale 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
}}
/>
{/* 主体徽章 */}
<div
style={{
position: 'absolute',
inset: '0',
borderRadius: '9999px',
background: 'linear-gradient(to bottom right, rgb(14 165 233), rgb(2 132 199))',
color: 'white',
fontSize: '11px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
animation: 'badge-scale 2s ease-in-out infinite',
}}
>
+{record.new_episodes}
</div>
</div>
)}
</div>
);
})}
</VirtualScrollableRow>
</div>
)}
</section>

View File

@@ -1050,10 +1050,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{actor.profile_path ? (
<div
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path, 'w185')))}
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
>
<Image
src={processImageUrl(getTMDBImageUrl(actor.profile_path, 'w185'))}
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
alt={actor.name}
fill
className="object-cover"

View File

@@ -6,11 +6,13 @@ import { useEffect, useRef, useState } from 'react';
interface VirtualScrollableRowProps {
children: React.ReactNode[];
maxVisible?: number; // 最大可见数量
className?: string; // 额外的 CSS 类名
}
export default function VirtualScrollableRow({
children,
maxVisible = 30, // 默认最多显示 30 个项目
className = '',
}: VirtualScrollableRowProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [showLeftScroll, setShowLeftScroll] = useState(false);
@@ -94,8 +96,8 @@ export default function VirtualScrollableRow({
{/* 滚动容器 */}
<div
ref={containerRef}
className="flex gap-2 overflow-x-auto scrollbar-hide scroll-smooth"
style={{ scrollBehavior: 'smooth' }}
className={`flex gap-2 overflow-x-auto scrollbar-hide scroll-smooth ${className}`}
style={{ scrollBehavior: 'smooth', paddingTop: '20px', paddingBottom: '20px', marginTop: '-20px', marginBottom: '-20px' }}
>
{/* 左侧占位符(用于保持滚动位置) */}
{visibleRange.start > 0 && (

View File

@@ -66,9 +66,9 @@ export class D1Storage implements IStorage {
INSERT INTO play_records (
username, key, title, source_name, cover, year,
episode_index, total_episodes, play_time, total_time,
save_time, search_title
save_time, search_title, new_episodes
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username, key) DO UPDATE SET
title = excluded.title,
source_name = excluded.source_name,
@@ -79,7 +79,8 @@ export class D1Storage implements IStorage {
play_time = excluded.play_time,
total_time = excluded.total_time,
save_time = excluded.save_time,
search_title = excluded.search_title
search_title = excluded.search_title,
new_episodes = excluded.new_episodes
`)
.bind(
userName,
@@ -93,7 +94,8 @@ export class D1Storage implements IStorage {
record.play_time,
record.total_time,
record.save_time,
record.search_title || ''
record.search_title || '',
record.new_episodes || null
)
.run();
} catch (err) {
@@ -727,6 +729,7 @@ export class D1Storage implements IStorage {
total_time: row.total_time,
save_time: row.save_time,
search_title: row.search_title || '',
new_episodes: row.new_episodes || undefined,
};
}

View File

@@ -41,6 +41,7 @@ export interface PlayRecord {
save_time: number; // 记录保存时间(时间戳)
search_title?: string; // 搜索时使用的标题
origin?: 'vod' | 'live'; // 来源类型
new_episodes?: number; // 新增的剧集数量(用于显示更新提示)
}
// ---- 收藏类型 ----

View File

@@ -67,9 +67,9 @@ export class PostgresStorage implements IStorage {
INSERT INTO play_records (
username, key, title, source_name, cover, year,
episode_index, total_episodes, play_time, total_time,
save_time, search_title
save_time, search_title, new_episodes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (username, key) DO UPDATE SET
title = EXCLUDED.title,
source_name = EXCLUDED.source_name,
@@ -80,7 +80,8 @@ export class PostgresStorage implements IStorage {
play_time = EXCLUDED.play_time,
total_time = EXCLUDED.total_time,
save_time = EXCLUDED.save_time,
search_title = EXCLUDED.search_title
search_title = EXCLUDED.search_title,
new_episodes = EXCLUDED.new_episodes
`)
.bind(
userName,
@@ -94,7 +95,8 @@ export class PostgresStorage implements IStorage {
record.play_time,
record.total_time,
record.save_time,
record.search_title || ''
record.search_title || '',
record.new_episodes || null
)
.run();
} catch (err) {
@@ -301,6 +303,7 @@ export class PostgresStorage implements IStorage {
total_time: row.total_time,
save_time: row.save_time,
search_title: row.search_title || '',
new_episodes: row.new_episodes || undefined,
};
}

View File

@@ -12,6 +12,7 @@ export interface PlayRecord {
total_time: number; // 总进度(秒)
save_time: number; // 记录保存时间(时间戳)
search_title: string; // 搜索时使用的标题
new_episodes?: number; // 新增的剧集数量(用于显示更新提示)
}
// 收藏数据结构