mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-22 12:13:43 +08:00
feat: Add initial support for Android TV and Apple TV platforms, introduce a dedicated premium mode settings store, and implement personalized video recommendations.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* TVNavigationInitializer
|
||||
* Adds tv-mode class to body and activates spatial navigation when TV is detected.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useIsTV } from '@/lib/contexts/TVContext';
|
||||
import { useSpatialNavigation } from '@/lib/hooks/useSpatialNavigation';
|
||||
|
||||
export function TVNavigationInitializer() {
|
||||
const isTV = useIsTV();
|
||||
|
||||
useEffect(() => {
|
||||
if (isTV) {
|
||||
document.body.classList.add('tv-mode');
|
||||
} else {
|
||||
document.body.classList.remove('tv-mode');
|
||||
}
|
||||
return () => {
|
||||
document.body.classList.remove('tv-mode');
|
||||
};
|
||||
}, [isTV]);
|
||||
|
||||
useSpatialNavigation(isTV);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export const MovieCard = memo(function MovieCard({ movie, onMovieClick }: MovieC
|
||||
e.preventDefault();
|
||||
onMovieClick(movie);
|
||||
}}
|
||||
data-focusable
|
||||
className="group cursor-pointer hover:translate-y-[-2px] transition-transform duration-200 ease-out"
|
||||
style={{
|
||||
position: 'relative',
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
/**
|
||||
* PopularFeatures - Main component for popular movies section
|
||||
* Displays Douban movie recommendations with tag filtering and infinite scroll
|
||||
* Displays Douban movie recommendations with tag filtering and infinite scroll.
|
||||
* Includes personalized "为你推荐" tag when user has 2+ watched items.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { TagManager } from './TagManager';
|
||||
import { MovieGrid } from './MovieGrid';
|
||||
import { useTagManager } from './hooks/useTagManager';
|
||||
import { usePopularMovies } from './hooks/usePopularMovies';
|
||||
import { usePersonalizedRecommendations } from './hooks/usePersonalizedRecommendations';
|
||||
|
||||
interface PopularFeaturesProps {
|
||||
onSearch?: (query: string) => void;
|
||||
@@ -34,13 +37,33 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
isLoadingTags,
|
||||
} = useTagManager();
|
||||
|
||||
const {
|
||||
movies: recommendMovies,
|
||||
loading: recommendLoading,
|
||||
hasMore: recommendHasMore,
|
||||
hasHistory,
|
||||
prefetchRef: recommendPrefetchRef,
|
||||
loadMoreRef: recommendLoadMoreRef,
|
||||
} = usePersonalizedRecommendations(false);
|
||||
|
||||
// Track whether the recommendation tab is active
|
||||
const [isRecommendSelected, setIsRecommendSelected] = useState(hasHistory);
|
||||
|
||||
// Sync default selection when hasHistory changes
|
||||
// (on first render, if hasHistory is true, recommendation tab is pre-selected)
|
||||
const effectiveRecommendSelected = hasHistory && isRecommendSelected;
|
||||
|
||||
const {
|
||||
movies,
|
||||
loading,
|
||||
hasMore,
|
||||
prefetchRef,
|
||||
loadMoreRef,
|
||||
} = usePopularMovies(selectedTag, tags, contentType);
|
||||
} = usePopularMovies(
|
||||
effectiveRecommendSelected ? '' : selectedTag,
|
||||
tags,
|
||||
contentType
|
||||
);
|
||||
|
||||
const handleMovieClick = (movie: any) => {
|
||||
if (onSearch) {
|
||||
@@ -48,48 +71,58 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecommendSelect = () => {
|
||||
setIsRecommendSelected(true);
|
||||
};
|
||||
|
||||
const handleRegularTagSelect = (tagId: string) => {
|
||||
if (tagId === 'custom_高级' || tags.find(t => t.id === tagId)?.label === '高级') {
|
||||
window.location.href = '/premium';
|
||||
return;
|
||||
}
|
||||
setIsRecommendSelected(false);
|
||||
setSelectedTag(tagId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */}
|
||||
<div className="mb-10 flex justify-center">
|
||||
<div className="relative w-80 p-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-full grid grid-cols-2 backdrop-blur-2xl shadow-lg ring-1 ring-white/10 overflow-hidden">
|
||||
{/* Sliding Indicator */}
|
||||
<div
|
||||
className="absolute top-1 bottom-1 w-[calc(50%-4px)] bg-[var(--accent-color)] rounded-full transition-transform duration-400 cubic-bezier(0.4, 0, 0.2, 1) shadow-[0_0_15px_rgba(0,122,255,0.4)]"
|
||||
style={{
|
||||
transform: `translateX(${contentType === 'movie' ? '4px' : 'calc(100% + 4px)'})`,
|
||||
}}
|
||||
/>
|
||||
{!effectiveRecommendSelected && (
|
||||
<div className="mb-10 flex justify-center">
|
||||
<div className="relative w-80 p-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-full grid grid-cols-2 backdrop-blur-2xl shadow-lg ring-1 ring-white/10 overflow-hidden">
|
||||
{/* Sliding Indicator */}
|
||||
<div
|
||||
className="absolute top-1 bottom-1 w-[calc(50%-4px)] bg-[var(--accent-color)] rounded-full transition-transform duration-400 cubic-bezier(0.4, 0, 0.2, 1) shadow-[0_0_15px_rgba(0,122,255,0.4)]"
|
||||
style={{
|
||||
transform: `translateX(${contentType === 'movie' ? '4px' : 'calc(100% + 4px)'})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setContentType('movie')}
|
||||
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'movie' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
||||
}`}
|
||||
>
|
||||
电影
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setContentType('tv')}
|
||||
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'tv' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
||||
}`}
|
||||
>
|
||||
电视剧
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setContentType('movie')}
|
||||
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'movie' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
||||
}`}
|
||||
>
|
||||
电影
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setContentType('tv')}
|
||||
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'tv' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
||||
}`}
|
||||
>
|
||||
电视剧
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TagManager
|
||||
tags={tags}
|
||||
selectedTag={selectedTag}
|
||||
selectedTag={effectiveRecommendSelected ? '' : selectedTag}
|
||||
showTagManager={showTagManager}
|
||||
newTagInput={newTagInput}
|
||||
justAddedTag={justAddedTag}
|
||||
onTagSelect={(tagId) => {
|
||||
if (tagId === 'custom_高级' || tags.find(t => t.id === tagId)?.label === '高级') {
|
||||
window.location.href = '/premium';
|
||||
return;
|
||||
}
|
||||
setSelectedTag(tagId);
|
||||
}}
|
||||
onTagSelect={handleRegularTagSelect}
|
||||
onTagDelete={handleDeleteTag}
|
||||
onToggleManager={() => setShowTagManager(!showTagManager)}
|
||||
onRestoreDefaults={handleRestoreDefaults}
|
||||
@@ -98,16 +131,32 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
onDragEnd={handleDragEnd}
|
||||
onJustAddedTagHandled={() => setJustAddedTag(false)}
|
||||
isLoadingTags={isLoadingTags}
|
||||
recommendTag={hasHistory ? {
|
||||
label: '为你推荐',
|
||||
isSelected: effectiveRecommendSelected,
|
||||
onSelect: handleRecommendSelect,
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
<MovieGrid
|
||||
movies={movies}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onMovieClick={handleMovieClick}
|
||||
prefetchRef={prefetchRef}
|
||||
loadMoreRef={loadMoreRef}
|
||||
/>
|
||||
{effectiveRecommendSelected ? (
|
||||
<MovieGrid
|
||||
movies={recommendMovies}
|
||||
loading={recommendLoading}
|
||||
hasMore={recommendHasMore}
|
||||
onMovieClick={handleMovieClick}
|
||||
prefetchRef={recommendPrefetchRef}
|
||||
loadMoreRef={recommendLoadMoreRef}
|
||||
/>
|
||||
) : (
|
||||
<MovieGrid
|
||||
movies={movies}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onMovieClick={handleMovieClick}
|
||||
prefetchRef={prefetchRef}
|
||||
loadMoreRef={loadMoreRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { SortableTag, Tag } from './SortableTag';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface RecommendTagConfig {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
interface TagListProps {
|
||||
tags: Tag[];
|
||||
@@ -28,6 +35,7 @@ interface TagListProps {
|
||||
onTagDelete: (tagId: string) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onJustAddedTagHandled: () => void;
|
||||
recommendTag?: RecommendTagConfig;
|
||||
}
|
||||
|
||||
export function TagList({
|
||||
@@ -39,6 +47,7 @@ export function TagList({
|
||||
onTagDelete,
|
||||
onDragEnd,
|
||||
onJustAddedTagHandled,
|
||||
recommendTag,
|
||||
}: TagListProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
@@ -108,6 +117,24 @@ export function TagList({
|
||||
ref={scrollContainerRef}
|
||||
className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide"
|
||||
>
|
||||
{/* Recommendation Tag — non-draggable, rendered before sortable tags */}
|
||||
{recommendTag && (
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={recommendTag.onSelect}
|
||||
className={`
|
||||
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none flex items-center gap-1.5
|
||||
${recommendTag.isSelected
|
||||
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
|
||||
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icons.Sparkles size={14} />
|
||||
{recommendTag.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<SortableContext
|
||||
items={tags.map((t) => t.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
|
||||
@@ -4,6 +4,12 @@ import { TagInput } from './TagInput';
|
||||
import { TagList } from './TagList';
|
||||
import { Tag } from './SortableTag';
|
||||
|
||||
interface RecommendTagConfig {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
interface TagManagerProps {
|
||||
tags: Tag[];
|
||||
selectedTag: string;
|
||||
@@ -19,6 +25,7 @@ interface TagManagerProps {
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onJustAddedTagHandled: () => void;
|
||||
isLoadingTags?: boolean;
|
||||
recommendTag?: RecommendTagConfig;
|
||||
}
|
||||
|
||||
export function TagManager({
|
||||
@@ -36,6 +43,7 @@ export function TagManager({
|
||||
onDragEnd,
|
||||
onJustAddedTagHandled,
|
||||
isLoadingTags,
|
||||
recommendTag,
|
||||
}: TagManagerProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -84,6 +92,7 @@ export function TagManager({
|
||||
onTagDelete={onTagDelete}
|
||||
onDragEnd={onDragEnd}
|
||||
onJustAddedTagHandled={onJustAddedTagHandled}
|
||||
recommendTag={recommendTag}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* usePersonalizedRecommendations
|
||||
*
|
||||
* Fetches personalized content based on viewing history patterns.
|
||||
* Designed to integrate into the tag system — returns the same shape
|
||||
* as usePopularMovies (movies, loading, hasMore, prefetchRef, loadMoreRef).
|
||||
*
|
||||
* Features:
|
||||
* - Interleaves results from multiple recommendation queries into a single mixed feed
|
||||
* - Randomizes Douban API offsets so each page load shows different content
|
||||
* - Excludes already-watched titles
|
||||
* - Auto-infinite-scroll via useInfiniteScroll (no "load more" button)
|
||||
* - Caches results for 30 minutes
|
||||
* - hasHistory = true when viewingHistory.length >= 2
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useHistoryStore, usePremiumHistoryStore } from '@/lib/store/history-store';
|
||||
import { useInfiniteScroll } from '@/lib/hooks/useInfiniteScroll';
|
||||
import {
|
||||
generateRecommendations,
|
||||
getWatchedTitles,
|
||||
interleaveResults,
|
||||
type RecommendationQuery,
|
||||
} from '@/lib/utils/recommendation-engine';
|
||||
|
||||
interface DoubanMovie {
|
||||
id: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface InterleavedMovie extends DoubanMovie {
|
||||
sourceLabel: string;
|
||||
}
|
||||
|
||||
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
|
||||
const ITEMS_PER_PAGE = 18; // How many to fetch per query per page
|
||||
|
||||
export function usePersonalizedRecommendations(isPremium = false) {
|
||||
const normalHistory = useHistoryStore();
|
||||
const premiumHistory = usePremiumHistoryStore();
|
||||
const { viewingHistory } = isPremium ? premiumHistory : normalHistory;
|
||||
|
||||
const [movies, setMovies] = useState<InterleavedMovie[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const queriesRef = useRef<RecommendationQuery[]>([]);
|
||||
const cacheRef = useRef<{
|
||||
key: string;
|
||||
movies: InterleavedMovie[];
|
||||
timestamp: number;
|
||||
} | null>(null);
|
||||
|
||||
const hasHistory = viewingHistory.length >= 2;
|
||||
|
||||
// Fetch a page of results from all queries
|
||||
const fetchPage = useCallback(async (
|
||||
queries: RecommendationQuery[],
|
||||
pageNum: number,
|
||||
watchedTitles: Set<string>,
|
||||
): Promise<InterleavedMovie[]> => {
|
||||
const results = await Promise.all(
|
||||
queries.map(async (query) => {
|
||||
try {
|
||||
const offset = query.pageStart + pageNum * ITEMS_PER_PAGE;
|
||||
const res = await fetch(
|
||||
`/api/douban/recommend?tag=${encodeURIComponent(query.tag)}&type=${query.type}&page_limit=${ITEMS_PER_PAGE}&page_start=${offset}`
|
||||
);
|
||||
if (!res.ok) return { label: query.label, movies: [] as DoubanMovie[] };
|
||||
const data = await res.json();
|
||||
const movies: DoubanMovie[] = (data.subjects || []).map((s: any) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
cover: s.cover,
|
||||
rate: s.rate,
|
||||
url: s.url,
|
||||
}));
|
||||
return { label: query.label, movies };
|
||||
} catch {
|
||||
return { label: query.label, movies: [] as DoubanMovie[] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return interleaveResults(results, watchedTitles);
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (viewingHistory.length < 2) {
|
||||
setMovies([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const queries = generateRecommendations(viewingHistory);
|
||||
queriesRef.current = queries;
|
||||
|
||||
if (queries.length === 0) {
|
||||
setMovies([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache key based on query tags (not pageStart, since that's randomized)
|
||||
const cacheKey = queries.map(q => `${q.tag}:${q.type}`).join('|');
|
||||
|
||||
if (
|
||||
cacheRef.current &&
|
||||
cacheRef.current.key === cacheKey &&
|
||||
Date.now() - cacheRef.current.timestamp < CACHE_DURATION
|
||||
) {
|
||||
setMovies(cacheRef.current.movies);
|
||||
setHasMore(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setPage(0);
|
||||
setHasMore(true);
|
||||
|
||||
const watchedTitles = getWatchedTitles(viewingHistory);
|
||||
|
||||
fetchPage(queries, 0, watchedTitles).then((interleaved) => {
|
||||
if (cancelled) return;
|
||||
setMovies(interleaved);
|
||||
setHasMore(interleaved.length >= queries.length * 2);
|
||||
cacheRef.current = {
|
||||
key: cacheKey,
|
||||
movies: interleaved,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [viewingHistory.length, fetchPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Load more via infinite scroll
|
||||
const handleLoadMore = useCallback(async (nextPage: number) => {
|
||||
const queries = queriesRef.current;
|
||||
if (queries.length === 0 || loading) return;
|
||||
|
||||
setLoading(true);
|
||||
const watchedTitles = getWatchedTitles(viewingHistory);
|
||||
|
||||
try {
|
||||
const newMovies = await fetchPage(queries, nextPage, watchedTitles);
|
||||
|
||||
// Deduplicate against existing movies
|
||||
const existingTitles = new Set(movies.map(m => m.title.toLowerCase().trim()));
|
||||
const uniqueNew = newMovies.filter(
|
||||
m => !existingTitles.has(m.title.toLowerCase().trim())
|
||||
);
|
||||
|
||||
if (uniqueNew.length === 0) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
setMovies((prev) => [...prev, ...uniqueNew]);
|
||||
setPage(nextPage);
|
||||
// Update cache
|
||||
if (cacheRef.current) {
|
||||
cacheRef.current.movies = [...cacheRef.current.movies, ...uniqueNew];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading, viewingHistory, movies, fetchPage]);
|
||||
|
||||
const { prefetchRef, loadMoreRef } = useInfiniteScroll({
|
||||
hasMore,
|
||||
loading,
|
||||
page,
|
||||
onLoadMore: handleLoadMore,
|
||||
});
|
||||
|
||||
return { movies, loading, hasMore, hasHistory, prefetchRef, loadMoreRef };
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
href={isPremiumMode ? '/premium' : '/'}
|
||||
className="flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity cursor-pointer min-w-0"
|
||||
onClick={onReset}
|
||||
data-focusable
|
||||
>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 relative flex items-center justify-center flex-shrink-0">
|
||||
<Image
|
||||
@@ -95,6 +96,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
href={settingsHref}
|
||||
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
|
||||
aria-label="设置"
|
||||
data-focusable
|
||||
>
|
||||
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 -960 960 960" fill="currentColor">
|
||||
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { getSourceName } from '@/lib/utils/source-names';
|
||||
|
||||
/**
|
||||
* Split person names by common delimiters (comma, Chinese comma, slash).
|
||||
* Does NOT split by space — Chinese names contain no spaces, and splitting
|
||||
* by space would break English names like "Tom Hanks".
|
||||
*/
|
||||
function splitPersonNames(str: string): string[] {
|
||||
return str.split(/[,,/]/).map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
interface VideoMetadataProps {
|
||||
videoData: any;
|
||||
source: string | null;
|
||||
@@ -61,16 +71,38 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps)
|
||||
</p>
|
||||
)}
|
||||
{videoData?.vod_actor && (
|
||||
<p className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-2">
|
||||
<div className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-2">
|
||||
<span className="font-semibold">主演:</span>
|
||||
{videoData.vod_actor}
|
||||
</p>
|
||||
<span className="inline-flex flex-wrap gap-1">
|
||||
{splitPersonNames(videoData.vod_actor).map((name) => (
|
||||
<Link
|
||||
key={name}
|
||||
href={`/?q=${encodeURIComponent(name)}`}
|
||||
data-focusable
|
||||
className="inline-block px-2 py-0.5 rounded-full bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] hover:border-[var(--accent-color)] hover:text-[var(--accent-color)] transition-all duration-200"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{videoData?.vod_director && (
|
||||
<p className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-1">
|
||||
<div className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-1">
|
||||
<span className="font-semibold">导演:</span>
|
||||
{videoData.vod_director}
|
||||
</p>
|
||||
<span className="inline-flex flex-wrap gap-1">
|
||||
{splitPersonNames(videoData.vod_director).map((name) => (
|
||||
<Link
|
||||
key={name}
|
||||
href={`/?q=${encodeURIComponent(name)}`}
|
||||
data-focusable
|
||||
className="inline-block px-2 py-0.5 rounded-full bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] hover:border-[var(--accent-color)] hover:text-[var(--accent-color)] transition-all duration-200"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useHistory } from '@/lib/store/history-store';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
|
||||
import { CustomVideoPlayer } from './CustomVideoPlayer';
|
||||
import { VideoPlayerError } from './VideoPlayerError';
|
||||
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
|
||||
@@ -52,14 +53,15 @@ export function VideoPlayer({
|
||||
const [proxyMode, setProxyMode] = useState<'retry' | 'none' | 'always'>('retry');
|
||||
|
||||
useEffect(() => {
|
||||
// Initial value
|
||||
const settings = settingsStore.getSettings();
|
||||
// Initial value - use mode-specific store
|
||||
const store = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||
const settings = store.getSettings();
|
||||
setShowModeIndicator(settings.showModeIndicator);
|
||||
setProxyMode(settings.proxyMode);
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = settingsStore.subscribe(() => {
|
||||
const newSettings = settingsStore.getSettings();
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
const newSettings = store.getSettings();
|
||||
setShowModeIndicator(newSettings.showModeIndicator);
|
||||
setProxyMode(newSettings.proxyMode);
|
||||
});
|
||||
@@ -200,6 +202,7 @@ export function VideoPlayer({
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-no-spatial>
|
||||
<Card hover={false} className="p-0 relative">
|
||||
{/* Mode Indicator Badge - controlled by settings */}
|
||||
{showModeIndicator && (
|
||||
@@ -237,5 +240,6 @@ export function VideoPlayer({
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { TagManager } from '@/components/home/TagManager';
|
||||
import { MovieGrid } from '@/components/home/MovieGrid';
|
||||
import { PremiumContentGrid } from './PremiumContentGrid';
|
||||
import { usePremiumTagManager } from '@/lib/hooks/usePremiumTagManager';
|
||||
import { usePremiumContent } from '@/lib/hooks/usePremiumContent';
|
||||
import { usePersonalizedRecommendations } from '@/components/home/hooks/usePersonalizedRecommendations';
|
||||
|
||||
interface PremiumContentProps {
|
||||
onSearch?: (query: string) => void;
|
||||
@@ -26,6 +29,19 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
|
||||
handleDragEnd,
|
||||
} = usePremiumTagManager();
|
||||
|
||||
const {
|
||||
movies: recommendMovies,
|
||||
loading: recommendLoading,
|
||||
hasMore: recommendHasMore,
|
||||
hasHistory,
|
||||
prefetchRef: recommendPrefetchRef,
|
||||
loadMoreRef: recommendLoadMoreRef,
|
||||
} = usePersonalizedRecommendations(true);
|
||||
|
||||
// Track whether the recommendation tab is active
|
||||
const [isRecommendSelected, setIsRecommendSelected] = useState(hasHistory);
|
||||
const effectiveRecommendSelected = hasHistory && isRecommendSelected;
|
||||
|
||||
// Get the category value from selected tag
|
||||
const categoryValue = tags.find(t => t.id === selectedTag)?.value || '';
|
||||
|
||||
@@ -35,25 +51,32 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
|
||||
hasMore,
|
||||
prefetchRef,
|
||||
loadMoreRef,
|
||||
} = usePremiumContent(categoryValue);
|
||||
} = usePremiumContent(effectiveRecommendSelected ? '' : categoryValue);
|
||||
|
||||
const handleVideoClick = (video: any) => {
|
||||
if (onSearch) {
|
||||
onSearch(video.vod_name);
|
||||
onSearch(video.vod_name || video.title);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecommendSelect = () => {
|
||||
setIsRecommendSelected(true);
|
||||
};
|
||||
|
||||
const handleRegularTagSelect = (tagId: string) => {
|
||||
setIsRecommendSelected(false);
|
||||
setSelectedTag(tagId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<TagManager
|
||||
tags={tags}
|
||||
selectedTag={selectedTag}
|
||||
selectedTag={effectiveRecommendSelected ? '' : selectedTag}
|
||||
showTagManager={showTagManager}
|
||||
newTagInput={newTagInput}
|
||||
justAddedTag={justAddedTag}
|
||||
onTagSelect={(tagId) => {
|
||||
setSelectedTag(tagId);
|
||||
}}
|
||||
onTagSelect={handleRegularTagSelect}
|
||||
onTagDelete={handleDeleteTag}
|
||||
onToggleManager={() => setShowTagManager(!showTagManager)}
|
||||
onRestoreDefaults={handleRestoreDefaults}
|
||||
@@ -61,16 +84,32 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
|
||||
onAddTag={handleAddTag}
|
||||
onDragEnd={handleDragEnd}
|
||||
onJustAddedTagHandled={() => setJustAddedTag(false)}
|
||||
recommendTag={hasHistory ? {
|
||||
label: '为你推荐',
|
||||
isSelected: effectiveRecommendSelected,
|
||||
onSelect: handleRecommendSelect,
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
<PremiumContentGrid
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onVideoClick={handleVideoClick}
|
||||
prefetchRef={prefetchRef}
|
||||
loadMoreRef={loadMoreRef}
|
||||
/>
|
||||
{effectiveRecommendSelected ? (
|
||||
<MovieGrid
|
||||
movies={recommendMovies}
|
||||
loading={recommendLoading}
|
||||
hasMore={recommendHasMore}
|
||||
onMovieClick={handleVideoClick}
|
||||
prefetchRef={recommendPrefetchRef}
|
||||
loadMoreRef={recommendLoadMoreRef}
|
||||
/>
|
||||
) : (
|
||||
<PremiumContentGrid
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onVideoClick={handleVideoClick}
|
||||
prefetchRef={prefetchRef}
|
||||
loadMoreRef={loadMoreRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export function SearchBox({ onSearch, onClear, initialQuery = '', placeholder =
|
||||
aria-expanded={isDropdownOpen}
|
||||
aria-controls="search-history-dropdown"
|
||||
aria-autocomplete="list"
|
||||
data-focusable
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 z-10">
|
||||
|
||||
Reference in New Issue
Block a user