2640 lines
78 KiB
Markdown
2640 lines
78 KiB
Markdown
# TikTok Video Player - Implementation Guide
|
|
|
|
## Overview
|
|
|
|
This document provides comprehensive TypeScript types, React components, and implementation details for replicating TikTok's video player and infinite scroll mechanism based on deobfuscated source code.
|
|
|
|
---
|
|
|
|
## Core TypeScript Types
|
|
|
|
### Video Player State
|
|
|
|
```typescript
|
|
// Core video detail state management
|
|
interface VideoDetailState {
|
|
currentIndex: number; // Current video index in the list
|
|
itemListKey: ItemListKey; // Key for the item list type
|
|
subtitleContent: SubtitleCue[]; // Parsed subtitle/caption content
|
|
ifShowSubtitle: boolean; // Whether subtitles are visible
|
|
subtitleStruct: SubtitleStruct | null; // Structured subtitle data
|
|
seekType: SeekType; // Type of seek operation
|
|
playMode: PlayMode; // Current play mode
|
|
isScrollGuideVisible: boolean; // Scroll guide visibility
|
|
isYmlRightPanelVisible: boolean; // Right panel visibility
|
|
}
|
|
|
|
// Video item data structure
|
|
interface VideoItem {
|
|
id: string;
|
|
author: {
|
|
nickname?: string;
|
|
uniqueId?: string;
|
|
id?: string;
|
|
secUid?: string;
|
|
avatarThumb?: string;
|
|
};
|
|
video: {
|
|
width?: number;
|
|
height?: number;
|
|
duration?: number;
|
|
ratio?: string;
|
|
playAddr?: string;
|
|
};
|
|
stats: {
|
|
diggCount?: number;
|
|
playCount?: number;
|
|
shareCount?: number;
|
|
commentCount?: number;
|
|
collectCount?: number;
|
|
};
|
|
createTime?: number;
|
|
isPinnedItem?: boolean;
|
|
imagePost?: {
|
|
images: Array<{ url: string }>;
|
|
};
|
|
ad_info?: AdInfo;
|
|
backendSourceEventTracking?: string;
|
|
}
|
|
|
|
// Subtitle/Caption types
|
|
interface SubtitleStruct {
|
|
url: string;
|
|
language: string;
|
|
expire?: number;
|
|
}
|
|
|
|
interface SubtitleCue {
|
|
start: number; // Start time in seconds
|
|
end: number; // End time in seconds
|
|
text: string; // Subtitle text content
|
|
startStr: string; // Formatted start time string
|
|
}
|
|
|
|
interface SubtitleInfo {
|
|
Version: string;
|
|
Format: SubtitleFormat;
|
|
Url: string;
|
|
LanguageCodeName: string;
|
|
UrlExpire?: number;
|
|
}
|
|
```
|
|
|
|
### Enums and Constants
|
|
|
|
```typescript
|
|
// Play modes for different viewing contexts
|
|
enum PlayMode {
|
|
VideoDetail = "video_detail",
|
|
OneColumn = "one_column",
|
|
MiniPlayer = "mini_player"
|
|
}
|
|
|
|
// Seek operation types
|
|
enum SeekType {
|
|
None = "none",
|
|
Forward = "forward",
|
|
Backward = "backward",
|
|
Scrub = "scrub"
|
|
}
|
|
|
|
// Enter methods for analytics tracking
|
|
enum EnterMethod {
|
|
VideoDetailPage = "video_detail_page",
|
|
VideoCoverClick = "video_cover_click",
|
|
VideoCoverClickAIGCDesc = "video_cover_click_aigc_desc",
|
|
VideoErrorAutoReload = "video_error_auto_reload",
|
|
CreatorCard = "creator_card",
|
|
ClickButton = "click_button"
|
|
}
|
|
|
|
// Subtitle formats
|
|
enum SubtitleFormat {
|
|
WebVTT = "webvtt",
|
|
CreatorCaption = "creator_caption"
|
|
}
|
|
|
|
// Item list keys for different content types
|
|
enum ItemListKey {
|
|
Video = "video",
|
|
SearchTop = "search_top",
|
|
SearchVideo = "search_video",
|
|
SearchPhoto = "search_photo"
|
|
}
|
|
|
|
// Status codes for API responses
|
|
enum StatusCode {
|
|
Ok = 0,
|
|
UnknownError = -1,
|
|
NetworkError = -2
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## React Components with Tailwind CSS
|
|
|
|
### Main Video Feed Container
|
|
|
|
```tsx
|
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { useVideoDetailService } from './hooks/useVideoDetail';
|
|
|
|
interface VideoFeedProps {
|
|
videos: VideoItem[];
|
|
onLoadMore: () => void;
|
|
hasMore: boolean;
|
|
loading: boolean;
|
|
}
|
|
|
|
export const VideoFeedContainer: React.FC<VideoFeedProps> = ({
|
|
videos,
|
|
onLoadMore,
|
|
hasMore,
|
|
loading
|
|
}) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const videoDetailService = useVideoDetailService();
|
|
|
|
// Infinite scroll handler
|
|
const handleScroll = useCallback(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
const container = containerRef.current;
|
|
const scrollTop = container.scrollTop;
|
|
const containerHeight = container.clientHeight;
|
|
const scrollHeight = container.scrollHeight;
|
|
|
|
// Calculate current video index based on scroll position
|
|
const videoHeight = containerHeight; // Assuming full-height videos
|
|
const newIndex = Math.round(scrollTop / videoHeight);
|
|
|
|
if (newIndex !== currentIndex && newIndex >= 0 && newIndex < videos.length) {
|
|
setCurrentIndex(newIndex);
|
|
videoDetailService.handleSwitchVideo({
|
|
newIndex,
|
|
enterMethod: EnterMethod.VideoDetailPage,
|
|
playStatusUpdate: true
|
|
});
|
|
}
|
|
|
|
// Load more content when near bottom
|
|
const threshold = 0.8;
|
|
if (scrollTop + containerHeight >= scrollHeight * threshold && hasMore && !loading) {
|
|
onLoadMore();
|
|
}
|
|
}, [currentIndex, videos.length, hasMore, loading, onLoadMore, videoDetailService]);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
container.addEventListener('scroll', handleScroll, { passive: true });
|
|
return () => container.removeEventListener('scroll', handleScroll);
|
|
}, [handleScroll]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
id="column-list-container"
|
|
className="h-screen overflow-y-auto snap-y snap-mandatory"
|
|
style={{ scrollBehavior: 'smooth' }}
|
|
>
|
|
{videos.map((video, index) => (
|
|
<VideoItemContainer
|
|
key={video.id}
|
|
video={video}
|
|
index={index}
|
|
isActive={index === currentIndex}
|
|
onSelect={() => videoDetailService.handleSelectVideo({
|
|
newIndex: index,
|
|
isAIGCDesc: false
|
|
})}
|
|
/>
|
|
))}
|
|
|
|
{/* Loading placeholder items */}
|
|
{loading && Array.from({ length: 5 }).map((_, index) => (
|
|
<VideoItemPlaceholder key={`placeholder-${index}`} index={videos.length + index} />
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Individual Video Item Component
|
|
|
|
```tsx
|
|
interface VideoItemContainerProps {
|
|
video: VideoItem;
|
|
index: number;
|
|
isActive: boolean;
|
|
onSelect: () => void;
|
|
}
|
|
|
|
export const VideoItemContainer: React.FC<VideoItemContainerProps> = ({
|
|
video,
|
|
index,
|
|
isActive,
|
|
onSelect
|
|
}) => {
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
|
|
return (
|
|
<article
|
|
data-scroll-index={index}
|
|
shape="vertical"
|
|
data-e2e="recommend-list-item-container"
|
|
className={`
|
|
h-screen snap-start flex flex-col transition-all duration-300
|
|
${isActive ? 'opacity-100' : 'opacity-70'}
|
|
${isLoaded ? '' : 'animate-pulse'}
|
|
`}
|
|
style={{ transitionDuration: '0ms' }}
|
|
>
|
|
<div className="flex-1 flex">
|
|
<VideoMediaCard
|
|
video={video}
|
|
index={index}
|
|
isActive={isActive}
|
|
onSelect={onSelect}
|
|
onLoad={() => setIsLoaded(true)}
|
|
/>
|
|
<VideoActionBar video={video} />
|
|
</div>
|
|
</article>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Video Media Card Component
|
|
|
|
```tsx
|
|
interface VideoMediaCardProps {
|
|
video: VideoItem;
|
|
index: number;
|
|
isActive: boolean;
|
|
onSelect: () => void;
|
|
onLoad: () => void;
|
|
}
|
|
|
|
export const VideoMediaCard: React.FC<VideoMediaCardProps> = ({
|
|
video,
|
|
index,
|
|
isActive,
|
|
onSelect,
|
|
onLoad
|
|
}) => {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [progress, setProgress] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
// Auto-play when video becomes active
|
|
useEffect(() => {
|
|
if (videoRef.current && isActive) {
|
|
videoRef.current.play().catch(console.error);
|
|
setIsPlaying(true);
|
|
} else if (videoRef.current && !isActive) {
|
|
videoRef.current.pause();
|
|
setIsPlaying(false);
|
|
}
|
|
}, [isActive]);
|
|
|
|
const handleTimeUpdate = useCallback(() => {
|
|
if (videoRef.current) {
|
|
const current = videoRef.current.currentTime;
|
|
const total = videoRef.current.duration;
|
|
setProgress(current / total);
|
|
}
|
|
}, []);
|
|
|
|
const handleLoadedMetadata = useCallback(() => {
|
|
if (videoRef.current) {
|
|
setDuration(videoRef.current.duration);
|
|
onLoad();
|
|
}
|
|
}, [onLoad]);
|
|
|
|
return (
|
|
<section
|
|
id={`one-column-item-${index}`}
|
|
shape="vertical"
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-label="Watch in full screen"
|
|
data-e2e="feed-video"
|
|
className="flex-1 relative cursor-pointer focus:outline-none focus:ring-2 focus:ring-white/20 rounded-2xl overflow-hidden"
|
|
onClick={onSelect}
|
|
>
|
|
{/* Video placeholder canvas */}
|
|
<canvas
|
|
width="56.25"
|
|
height="100"
|
|
className="absolute inset-0 w-full h-full bg-gray-900"
|
|
/>
|
|
|
|
{/* Main video container */}
|
|
<div className="relative w-full h-full bg-black rounded-2xl overflow-hidden">
|
|
{/* Video element */}
|
|
<div className="relative w-full h-full">
|
|
<video
|
|
ref={videoRef}
|
|
className="w-full h-full object-cover"
|
|
crossOrigin="use-credentials"
|
|
preload="metadata"
|
|
playsInline
|
|
muted={!isActive}
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onLoadedMetadata={handleLoadedMetadata}
|
|
src={video.video.playAddr}
|
|
/>
|
|
</div>
|
|
|
|
{/* Video controls overlay */}
|
|
<VideoControlsOverlay
|
|
video={video}
|
|
isPlaying={isPlaying}
|
|
progress={progress}
|
|
duration={duration}
|
|
onPlayPause={() => {
|
|
if (videoRef.current) {
|
|
if (isPlaying) {
|
|
videoRef.current.pause();
|
|
} else {
|
|
videoRef.current.play();
|
|
}
|
|
setIsPlaying(!isPlaying);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Video metadata overlay */}
|
|
<VideoMetadataOverlay video={video} />
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Video Controls Overlay
|
|
|
|
```tsx
|
|
interface VideoControlsOverlayProps {
|
|
video: VideoItem;
|
|
isPlaying: boolean;
|
|
progress: number;
|
|
duration: number;
|
|
onPlayPause: () => void;
|
|
}
|
|
|
|
export const VideoControlsOverlay: React.FC<VideoControlsOverlayProps> = ({
|
|
video,
|
|
isPlaying,
|
|
progress,
|
|
duration,
|
|
onPlayPause
|
|
}) => {
|
|
const [showControls, setShowControls] = useState(false);
|
|
const [volume, setVolume] = useState(1);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
|
|
const formatTime = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Top controls */}
|
|
<div className="absolute top-4 left-4 right-4 flex justify-between items-center z-10">
|
|
<div className="flex items-center space-x-2">
|
|
{/* Volume control */}
|
|
<div className="flex items-center space-x-2 bg-black/20 backdrop-blur-sm rounded-full px-3 py-2">
|
|
<button
|
|
onClick={() => setIsMuted(!isMuted)}
|
|
className="text-white hover:text-gray-300 transition-colors"
|
|
aria-label="Volume"
|
|
>
|
|
<svg width="24" height="24" viewBox="0 0 48 48" fill="currentColor">
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M20.3359 8.37236C22.3296 7.04325 25 8.47242 25 10.8685V37.1315C25 39.5276 22.3296 40.9567 20.3359 39.6276L10.3944 33H6C4.34314 33 3 31.6568 3 30V18C3 16.3431 4.34315 15 6 15H10.3944L20.3359 8.37236ZM21 12.737L12.1094 18.6641C11.7809 18.8831 11.3948 19 11 19H7V29H11C11.3948 29 11.7809 29.1169 12.1094 29.3359L21 35.263V12.737Z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
{/* More options menu */}
|
|
<button
|
|
className="bg-black/20 backdrop-blur-sm rounded-full p-2 text-white hover:text-gray-300 transition-colors"
|
|
aria-label="More options"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 48 48" fill="currentColor">
|
|
<path d="M5 24a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm15 0a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm15 0a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Center play/pause button */}
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<button
|
|
onClick={onPlayPause}
|
|
className={`
|
|
bg-black/30 backdrop-blur-sm rounded-full p-4 text-white
|
|
transition-all duration-200 hover:bg-black/40 hover:scale-110
|
|
${showControls ? 'opacity-100' : 'opacity-0'}
|
|
`}
|
|
aria-label={isPlaying ? "Pause" : "Play"}
|
|
>
|
|
{isPlaying ? (
|
|
<svg width="24" height="24" viewBox="0 0 48 48" fill="currentColor">
|
|
<path d="M16 10a2 2 0 0 0-2 2v24a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V12a2 2 0 0 0-2-2h-4ZM28 10a2 2 0 0 0-2 2v24a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V12a2 2 0 0 0-2-2h-4Z"/>
|
|
</svg>
|
|
) : (
|
|
<svg width="24" height="24" viewBox="0 0 48 48" fill="currentColor">
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M12 8.77702C12 6.43812 14.5577 4.99881 16.5569 6.21266L41.6301 21.4356C43.5542 22.6038 43.5542 25.3962 41.6301 26.5644L16.5569 41.7873C14.5577 43.0012 12 41.5619 12 39.223V8.77702Z"/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Bottom progress bar and time display */}
|
|
<div className="absolute bottom-4 left-4 right-4 z-10">
|
|
<div className="mb-2">
|
|
<p className="text-white text-2xl font-bold text-center">
|
|
{formatTime(duration * progress)} / {formatTime(duration)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<div className="w-full h-1 bg-white/20 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-white transition-all duration-100"
|
|
style={{ width: `${progress * 100}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Scrub handle */}
|
|
<div
|
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg"
|
|
style={{ left: `${progress * 100}%`, marginLeft: '-6px' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Video Action Bar (Right Side)
|
|
|
|
```tsx
|
|
interface VideoActionBarProps {
|
|
video: VideoItem;
|
|
}
|
|
|
|
export const VideoActionBar: React.FC<VideoActionBarProps> = ({ video }) => {
|
|
const [isFollowing, setIsFollowing] = useState(false);
|
|
const [isLiked, setIsLiked] = useState(false);
|
|
|
|
const formatCount = (count: number): string => {
|
|
if (count >= 1000000) {
|
|
return `${(count / 1000000).toFixed(1)}M`;
|
|
} else if (count >= 1000) {
|
|
return `${(count / 1000).toFixed(1)}K`;
|
|
}
|
|
return count.toString();
|
|
};
|
|
|
|
return (
|
|
<section className="flex flex-col items-center justify-end space-y-4 p-4 min-w-[80px]">
|
|
{/* Author avatar and follow button */}
|
|
<div className="flex flex-col items-center space-y-2">
|
|
<a
|
|
href={`/@${video.author.uniqueId}`}
|
|
className="relative block focus:outline-none focus:ring-2 focus:ring-white/20 rounded-full"
|
|
>
|
|
<div className="w-12 h-12 rounded-full overflow-hidden border-2 border-white/20">
|
|
<img
|
|
src={video.author.avatarThumb}
|
|
alt={video.author.nickname}
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
</a>
|
|
|
|
<button
|
|
onClick={() => setIsFollowing(!isFollowing)}
|
|
className={`
|
|
w-6 h-6 rounded-full flex items-center justify-center text-white
|
|
transition-all duration-200 hover:scale-110
|
|
${isFollowing
|
|
? 'bg-gray-600 hover:bg-gray-700'
|
|
: 'bg-red-500 hover:bg-red-600'
|
|
}
|
|
`}
|
|
aria-label={isFollowing ? "Unfollow" : "Follow"}
|
|
>
|
|
{isFollowing ? (
|
|
<svg width="16" height="16" viewBox="0 0 48 48" fill="currentColor">
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M43 6.08c.7.45 1.06.67 1.25.98.16.27.23.59.2.9-.03.36-.26.72-.7 1.43L23.06 42.14a3.5 3.5 0 0 1-5.63.39L4.89 27.62c-.54-.64-.81-.96-.9-1.32a1.5 1.5 0 0 1 .09-.92c.14-.33.46-.6 1.1-1.14l1.69-1.42c.64-.54.96-.81 1.31-.9.3-.06.63-.04.92.09.34.14.6.46 1.15 1.1l9.46 11.25 18.11-28.7c.45-.72.68-1.07.99-1.26.27-.16.59-.23.9-.2.36.03.71.25 1.43.7L43 6.08Z"/>
|
|
</svg>
|
|
) : (
|
|
<svg width="16" height="16" viewBox="0 0 48 48" fill="currentColor">
|
|
<path d="M26 7a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h15v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1H26V7Z"/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex flex-col items-center space-y-6">
|
|
{/* Like button */}
|
|
<button
|
|
onClick={() => setIsLiked(!isLiked)}
|
|
className="flex flex-col items-center space-y-1 text-white hover:text-red-400 transition-colors group"
|
|
aria-label={`${isLiked ? 'Unlike' : 'Like'} video`}
|
|
>
|
|
<div className={`p-2 rounded-full transition-all duration-200 group-hover:scale-110 ${isLiked ? 'text-red-500' : ''}`}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M7.5 2.25C10.5 2.25 12 4.25 12 4.25C12 4.25 13.5 2.25 16.5 2.25C20 2.25 22.5 4.99999 22.5 8.5C22.5 12.5 19.2311 16.0657 16.25 18.75C14.4095 20.4072 13 21.5 12 21.5C11 21.5 9.55051 20.3989 7.75 18.75C4.81949 16.0662 1.5 12.5 1.5 8.5C1.5 4.99999 4 2.25 7.5 2.25Z"/>
|
|
</svg>
|
|
</div>
|
|
<span className="text-xs font-semibold">
|
|
{formatCount(video.stats.diggCount || 0)}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Comment button */}
|
|
<button className="flex flex-col items-center space-y-1 text-white hover:text-blue-400 transition-colors group">
|
|
<div className="p-2 rounded-full transition-all duration-200 group-hover:scale-110">
|
|
<svg width="24" height="24" viewBox="0 0 48 48" fill="currentColor">
|
|
<path fillRule="evenodd" d="M2 21.5c0-10.22 9.88-18 22-18s22 7.78 22 18c0 5.63-3.19 10.74-7.32 14.8a43.6 43.6 0 0 1-14.14 9.1A1.5 1.5 0 0 1 22.5 44v-5.04C11.13 38.4 2 31.34 2 21.5M14 25a3 3 0 1 0 0-6 3 3 0 0 0 0 6m10 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6m13-3a3 3 0 1 1-6 0 3 3 0 0 1 6 0" clipRule="evenodd"/>
|
|
</svg>
|
|
</div>
|
|
<span className="text-xs font-semibold">
|
|
{formatCount(video.stats.commentCount || 0)}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Bookmark button */}
|
|
<button className="flex flex-col items-center space-y-1 text-white hover:text-yellow-400 transition-colors group">
|
|
<div className="p-2 rounded-full transition-all duration-200 group-hover:scale-110">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M4 4.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v15.13a1 1 0 0 1-1.555.831l-6.167-4.12a.5.5 0 0 0-.556 0l-6.167 4.12A1 1 0 0 1 4 19.63z"/>
|
|
</svg>
|
|
</div>
|
|
<span className="text-xs font-semibold">
|
|
{formatCount(video.stats.collectCount || 0)}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Share button */}
|
|
<button className="flex flex-col items-center space-y-1 text-white hover:text-green-400 transition-colors group">
|
|
<div className="p-2 rounded-full transition-all duration-200 group-hover:scale-110">
|
|
<svg width="24" height="24" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fillRule="evenodd" d="M10.938 3.175a.674.674 0 0 1 1.138-.488l6.526 6.215c.574.547.554 1.47-.043 1.991l-6.505 5.676a.674.674 0 0 1-1.116-.508V13.49s-6.985-1.258-9.225 2.854c-.209.384-1.023.518-.857-1.395.692-3.52 2.106-9.017 10.082-9.017z" clipRule="evenodd"/>
|
|
</svg>
|
|
</div>
|
|
<span className="text-xs font-semibold">
|
|
{formatCount(video.stats.shareCount || 0)}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Music disc */}
|
|
<a
|
|
href={`/music/original-sound-${video.id}`}
|
|
className="block focus:outline-none focus:ring-2 focus:ring-white/20 rounded-full"
|
|
>
|
|
<div
|
|
className="w-10 h-10 rounded-full bg-cover bg-center animate-spin-slow border-2 border-white/20"
|
|
style={{
|
|
backgroundImage: `url("${video.author.avatarThumb}")`,
|
|
animationDuration: '3s'
|
|
}}
|
|
/>
|
|
</a>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Video Metadata Overlay
|
|
|
|
```tsx
|
|
interface VideoMetadataOverlayProps {
|
|
video: VideoItem;
|
|
}
|
|
|
|
export const VideoMetadataOverlay: React.FC<VideoMetadataOverlayProps> = ({ video }) => {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
return (
|
|
<div className="absolute bottom-4 left-4 right-20 z-10">
|
|
<div className="space-y-2">
|
|
{/* Author name */}
|
|
<div>
|
|
<a
|
|
href={`/@${video.author.uniqueId}`}
|
|
className="text-white font-semibold hover:underline focus:outline-none focus:underline"
|
|
>
|
|
{video.author.nickname || video.author.uniqueId}
|
|
</a>
|
|
</div>
|
|
|
|
{/* Video description */}
|
|
<div className="text-white">
|
|
<div
|
|
className={`
|
|
text-sm leading-relaxed whitespace-pre-wrap
|
|
${isExpanded ? '' : 'line-clamp-2'}
|
|
`}
|
|
>
|
|
{/* Parse description with hashtags */}
|
|
<VideoDescription
|
|
description={video.description || ""}
|
|
onHashtagClick={(hashtag) => {
|
|
// Handle hashtag navigation
|
|
window.location.href = `/tag/${hashtag}`;
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{!isExpanded && (
|
|
<button
|
|
onClick={() => setIsExpanded(true)}
|
|
className="text-white/70 hover:text-white text-sm mt-1 transition-colors"
|
|
>
|
|
more
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Effects and music info */}
|
|
<div className="flex items-center space-x-2 text-white/80 text-xs">
|
|
{/* Effect tag */}
|
|
{video.effectInfo && (
|
|
<div className="flex items-center space-x-1 bg-black/20 backdrop-blur-sm rounded px-2 py-1">
|
|
<img
|
|
src={video.effectInfo.iconUrl}
|
|
alt="Effect"
|
|
className="w-4 h-4"
|
|
/>
|
|
<span>{video.effectInfo.name}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Video Description Parser
|
|
|
|
```tsx
|
|
interface VideoDescriptionProps {
|
|
description: string;
|
|
onHashtagClick: (hashtag: string) => void;
|
|
}
|
|
|
|
export const VideoDescription: React.FC<VideoDescriptionProps> = ({
|
|
description,
|
|
onHashtagClick
|
|
}) => {
|
|
const parseDescription = (text: string) => {
|
|
const hashtagRegex = /#(\w+)/g;
|
|
const parts = [];
|
|
let lastIndex = 0;
|
|
let match;
|
|
|
|
while ((match = hashtagRegex.exec(text)) !== null) {
|
|
// Add text before hashtag
|
|
if (match.index > lastIndex) {
|
|
parts.push({
|
|
type: 'text',
|
|
content: text.slice(lastIndex, match.index)
|
|
});
|
|
}
|
|
|
|
// Add hashtag
|
|
parts.push({
|
|
type: 'hashtag',
|
|
content: match[0],
|
|
hashtag: match[1]
|
|
});
|
|
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
// Add remaining text
|
|
if (lastIndex < text.length) {
|
|
parts.push({
|
|
type: 'text',
|
|
content: text.slice(lastIndex)
|
|
});
|
|
}
|
|
|
|
return parts;
|
|
};
|
|
|
|
const parts = parseDescription(description);
|
|
|
|
return (
|
|
<>
|
|
{parts.map((part, index) => (
|
|
part.type === 'hashtag' ? (
|
|
<button
|
|
key={index}
|
|
onClick={() => onHashtagClick(part.hashtag)}
|
|
className="text-blue-300 hover:text-blue-200 font-semibold transition-colors"
|
|
>
|
|
{part.content}
|
|
</button>
|
|
) : (
|
|
<span key={index}>{part.content}</span>
|
|
)
|
|
))}
|
|
</>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Video Item Placeholder
|
|
|
|
```tsx
|
|
interface VideoItemPlaceholderProps {
|
|
index: number;
|
|
}
|
|
|
|
export const VideoItemPlaceholder: React.FC<VideoItemPlaceholderProps> = ({ index }) => {
|
|
return (
|
|
<article
|
|
data-scroll-index={index}
|
|
className="h-screen snap-start flex animate-pulse"
|
|
>
|
|
<div className="flex-1 bg-gray-800 rounded-2xl m-4">
|
|
<div className="relative w-full h-full">
|
|
{/* Placeholder content */}
|
|
<div className="absolute inset-0 bg-gradient-to-br from-gray-700 to-gray-900 rounded-2xl" />
|
|
|
|
{/* Placeholder metadata */}
|
|
<div className="absolute bottom-4 left-4 right-20 space-y-2">
|
|
<div className="h-4 bg-gray-600 rounded w-24" />
|
|
<div className="h-3 bg-gray-600 rounded w-full" />
|
|
<div className="h-3 bg-gray-600 rounded w-3/4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Placeholder action bar */}
|
|
<div className="flex flex-col items-center justify-end space-y-4 p-4 min-w-[80px]">
|
|
<div className="w-12 h-12 bg-gray-600 rounded-full" />
|
|
<div className="w-6 h-6 bg-gray-600 rounded-full" />
|
|
<div className="w-8 h-8 bg-gray-600 rounded-full" />
|
|
<div className="w-8 h-8 bg-gray-600 rounded-full" />
|
|
<div className="w-8 h-8 bg-gray-600 rounded-full" />
|
|
<div className="w-10 h-10 bg-gray-600 rounded-full" />
|
|
</div>
|
|
</article>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Infinite Scroll Implementation
|
|
|
|
### Core Scrolling Logic
|
|
|
|
```typescript
|
|
// Custom hook for infinite scroll video feed
|
|
export const useInfiniteVideoScroll = (
|
|
videos: VideoItem[],
|
|
onLoadMore: () => void,
|
|
hasMore: boolean
|
|
) => {
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Scroll event handler with throttling
|
|
const handleScroll = useCallback(
|
|
throttle(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
const container = containerRef.current;
|
|
const scrollTop = container.scrollTop;
|
|
const containerHeight = container.clientHeight;
|
|
const scrollHeight = container.scrollHeight;
|
|
|
|
// Calculate current video index based on scroll position
|
|
const videoHeight = containerHeight;
|
|
const newIndex = Math.round(scrollTop / videoHeight);
|
|
|
|
// Update current index if changed
|
|
if (newIndex !== currentIndex && newIndex >= 0 && newIndex < videos.length) {
|
|
setCurrentIndex(newIndex);
|
|
|
|
// Track video switch analytics
|
|
trackVideoSwitch({
|
|
fromIndex: currentIndex,
|
|
toIndex: newIndex,
|
|
scrollPosition: scrollTop,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
|
|
// Preload more content when approaching end
|
|
const preloadThreshold = 0.8;
|
|
const shouldLoadMore = scrollTop + containerHeight >= scrollHeight * preloadThreshold;
|
|
|
|
if (shouldLoadMore && hasMore && !isLoading) {
|
|
setIsLoading(true);
|
|
onLoadMore();
|
|
}
|
|
|
|
// Preload next videos when within 6 items of current
|
|
const preloadDistance = 6;
|
|
if (videos.length - newIndex < preloadDistance && hasMore && !isLoading) {
|
|
preloadNextVideos(newIndex, preloadDistance);
|
|
}
|
|
}, 100),
|
|
[currentIndex, videos.length, hasMore, isLoading, onLoadMore]
|
|
);
|
|
|
|
return {
|
|
containerRef,
|
|
currentIndex,
|
|
handleScroll,
|
|
isLoading
|
|
};
|
|
};
|
|
|
|
// Throttle utility function
|
|
function throttle<T extends (...args: any[]) => any>(
|
|
func: T,
|
|
delay: number
|
|
): (...args: Parameters<T>) => void {
|
|
let timeoutId: NodeJS.Timeout | null = null;
|
|
let lastExecTime = 0;
|
|
|
|
return (...args: Parameters<T>) => {
|
|
const currentTime = Date.now();
|
|
|
|
if (currentTime - lastExecTime > delay) {
|
|
func(...args);
|
|
lastExecTime = currentTime;
|
|
} else {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
timeoutId = setTimeout(() => {
|
|
func(...args);
|
|
lastExecTime = Date.now();
|
|
}, delay - (currentTime - lastExecTime));
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
### Video Preloading Strategy
|
|
|
|
```typescript
|
|
// Video preloading service
|
|
export class VideoPreloadService {
|
|
private preloadedVideos = new Map<string, HTMLVideoElement>();
|
|
private preloadQueue: string[] = [];
|
|
private maxPreloadCount = 3;
|
|
|
|
/**
|
|
* Preload videos ahead of current position
|
|
*/
|
|
preloadNextVideos(currentIndex: number, videos: VideoItem[], preloadDistance: number = 3) {
|
|
const startIndex = currentIndex + 1;
|
|
const endIndex = Math.min(startIndex + preloadDistance, videos.length);
|
|
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const video = videos[i];
|
|
if (video && video.video.playAddr && !this.preloadedVideos.has(video.id)) {
|
|
this.preloadVideo(video);
|
|
}
|
|
}
|
|
|
|
// Clean up old preloaded videos
|
|
this.cleanupOldPreloads(currentIndex, preloadDistance);
|
|
}
|
|
|
|
/**
|
|
* Preload individual video
|
|
*/
|
|
private preloadVideo(video: VideoItem) {
|
|
if (this.preloadQueue.length >= this.maxPreloadCount) {
|
|
return; // Queue is full
|
|
}
|
|
|
|
const videoElement = document.createElement('video');
|
|
videoElement.preload = 'metadata';
|
|
videoElement.crossOrigin = 'use-credentials';
|
|
videoElement.src = video.video.playAddr || '';
|
|
|
|
// Add to preload tracking
|
|
this.preloadedVideos.set(video.id, videoElement);
|
|
this.preloadQueue.push(video.id);
|
|
|
|
// Handle preload completion
|
|
videoElement.addEventListener('loadedmetadata', () => {
|
|
console.log(`Preloaded video: ${video.id}`);
|
|
});
|
|
|
|
videoElement.addEventListener('error', () => {
|
|
console.warn(`Failed to preload video: ${video.id}`);
|
|
this.preloadedVideos.delete(video.id);
|
|
this.preloadQueue = this.preloadQueue.filter(id => id !== video.id);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clean up old preloaded videos to free memory
|
|
*/
|
|
private cleanupOldPreloads(currentIndex: number, keepDistance: number) {
|
|
const videosToKeep = new Set<string>();
|
|
|
|
// Keep videos within distance of current index
|
|
for (let i = Math.max(0, currentIndex - keepDistance);
|
|
i < currentIndex + keepDistance; i++) {
|
|
if (this.preloadQueue[i]) {
|
|
videosToKeep.add(this.preloadQueue[i]);
|
|
}
|
|
}
|
|
|
|
// Remove videos outside keep range
|
|
this.preloadedVideos.forEach((videoElement, videoId) => {
|
|
if (!videosToKeep.has(videoId)) {
|
|
videoElement.src = '';
|
|
this.preloadedVideos.delete(videoId);
|
|
}
|
|
});
|
|
|
|
this.preloadQueue = this.preloadQueue.filter(id => videosToKeep.has(id));
|
|
}
|
|
|
|
/**
|
|
* Get preloaded video element
|
|
*/
|
|
getPreloadedVideo(videoId: string): HTMLVideoElement | null {
|
|
return this.preloadedVideos.get(videoId) || null;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Infinite Scroll Mechanism Analysis
|
|
|
|
### Key Insights from DOM Structure
|
|
|
|
Based on the provided DOM structure, TikTok's infinite scroll works as follows:
|
|
|
|
1. **Container Structure**:
|
|
```html
|
|
<div id="column-list-container" class="css-1leh6wy-5e6d46e3--DivColumnListContainer">
|
|
<article data-scroll-index="0">...</article> <!-- Active video -->
|
|
<article data-scroll-index="1">...</article> <!-- Empty placeholder -->
|
|
<article data-scroll-index="2">...</article> <!-- Empty placeholder -->
|
|
<!-- ... more empty placeholders ... -->
|
|
<article data-scroll-index="32">...</article> <!-- Next loaded video -->
|
|
<article data-scroll-index="33">...</article> <!-- Another loaded video -->
|
|
</div>
|
|
```
|
|
|
|
2. **Loading Strategy**:
|
|
- **Sparse Loading**: Only loads content for visible and near-visible videos
|
|
- **Placeholder Articles**: Creates empty `<article>` elements as placeholders
|
|
- **Dynamic Content**: Populates placeholders when they come into view
|
|
- **Memory Management**: Unloads content from videos far from current position
|
|
|
|
3. **Scroll Detection**:
|
|
- Uses `data-scroll-index` attributes for position tracking
|
|
- Calculates current video based on scroll position
|
|
- Triggers content loading when approaching new videos
|
|
|
|
### Implementation Strategy
|
|
|
|
```typescript
|
|
// Main video feed hook
|
|
export const useVideoFeed = () => {
|
|
const [videos, setVideos] = useState<VideoItem[]>([]);
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Create placeholder structure (similar to TikTok)
|
|
const createPlaceholders = (count: number, startIndex: number = 0) => {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
id: `placeholder-${startIndex + i}`,
|
|
isPlaceholder: true,
|
|
index: startIndex + i
|
|
}));
|
|
};
|
|
|
|
// Load more videos when needed
|
|
const loadMoreVideos = useCallback(async () => {
|
|
if (loading || !hasMore) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const newVideos = await fetchMoreVideos(videos.length);
|
|
setVideos(prev => [...prev, ...newVideos]);
|
|
setHasMore(newVideos.length > 0);
|
|
} catch (error) {
|
|
console.error('Failed to load more videos:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [videos.length, loading, hasMore]);
|
|
|
|
// Populate placeholder with actual content
|
|
const populateVideo = useCallback(async (index: number) => {
|
|
if (videos[index] && !videos[index].isPlaceholder) return;
|
|
|
|
try {
|
|
const videoData = await fetchVideoAtIndex(index);
|
|
setVideos(prev => {
|
|
const newVideos = [...prev];
|
|
newVideos[index] = videoData;
|
|
return newVideos;
|
|
});
|
|
} catch (error) {
|
|
console.error(`Failed to load video at index ${index}:`, error);
|
|
}
|
|
}, [videos]);
|
|
|
|
return {
|
|
videos,
|
|
currentIndex,
|
|
hasMore,
|
|
loading,
|
|
loadMoreVideos,
|
|
populateVideo,
|
|
setCurrentIndex
|
|
};
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## WebVTT Subtitle System
|
|
|
|
### Subtitle Parser Implementation
|
|
|
|
```typescript
|
|
// WebVTT subtitle parser
|
|
export class WebVTTParser {
|
|
static parse(vttContent: string, options: ParseOptions = {}): WebVTTResult {
|
|
const { strict = true, includeMeta = false } = options;
|
|
|
|
if (!vttContent || typeof vttContent !== 'string') {
|
|
return { cues: [], valid: false, errors: [] };
|
|
}
|
|
|
|
try {
|
|
// Normalize content
|
|
const normalized = vttContent
|
|
.trim()
|
|
.replace(/\r\n/g, '\n')
|
|
.replace(/\r/g, '\n');
|
|
|
|
const blocks = normalized.split('\n\n');
|
|
const header = blocks.shift();
|
|
|
|
// Validate WebVTT header
|
|
if (!header?.startsWith('WEBVTT')) {
|
|
throw new Error('Invalid WebVTT format: must start with "WEBVTT"');
|
|
}
|
|
|
|
// Parse cues
|
|
const cues: SubtitleCue[] = [];
|
|
const errors: Error[] = [];
|
|
|
|
blocks.forEach((block, index) => {
|
|
try {
|
|
const cue = this.parseCue(block, index, strict);
|
|
if (cue) cues.push(cue);
|
|
} catch (error) {
|
|
errors.push(error as Error);
|
|
}
|
|
});
|
|
|
|
if (strict && errors.length > 0) {
|
|
throw errors[0];
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
strict,
|
|
cues,
|
|
errors,
|
|
meta: includeMeta ? this.parseMetadata(header) : undefined
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
strict,
|
|
cues: [],
|
|
errors: [error as Error]
|
|
};
|
|
}
|
|
}
|
|
|
|
private static parseCue(block: string, index: number, strict: boolean): SubtitleCue | null {
|
|
const lines = block.split('\n').filter(Boolean);
|
|
|
|
if (lines.length === 0) return null;
|
|
|
|
// Skip NOTE blocks
|
|
if (lines[0].trim().startsWith('NOTE')) return null;
|
|
|
|
// Find timestamp line
|
|
const timestampLineIndex = lines.findIndex(line => line.includes('-->'));
|
|
if (timestampLineIndex === -1) {
|
|
throw new Error(`No timestamp found in cue ${index}`);
|
|
}
|
|
|
|
const timestampLine = lines[timestampLineIndex];
|
|
const [startStr, endStr] = timestampLine.split(' --> ');
|
|
|
|
if (!startStr || !endStr) {
|
|
throw new Error(`Invalid timestamp format in cue ${index}`);
|
|
}
|
|
|
|
const startTime = this.parseTimestamp(startStr);
|
|
const endTime = this.parseTimestamp(endStr);
|
|
|
|
// Validate timestamp order
|
|
if (strict && startTime >= endTime) {
|
|
throw new Error(`Invalid timestamp order in cue ${index}`);
|
|
}
|
|
|
|
// Extract cue text (everything after timestamp line)
|
|
const textLines = lines.slice(timestampLineIndex + 1);
|
|
const text = textLines.join('\n').trim();
|
|
|
|
if (!text) return null;
|
|
|
|
return {
|
|
start: startTime,
|
|
end: endTime,
|
|
text,
|
|
startStr: this.formatTime(startTime)
|
|
};
|
|
}
|
|
|
|
private static parseTimestamp(timeStr: string): number {
|
|
const match = timeStr.match(/(?:(\d{1,2}):)?(\d{2}):(\d{2})\.(\d{3})/);
|
|
if (!match) throw new Error(`Invalid timestamp format: ${timeStr}`);
|
|
|
|
const [, hours = '0', minutes, seconds, milliseconds] = match;
|
|
return (
|
|
parseInt(hours) * 3600 +
|
|
parseInt(minutes) * 60 +
|
|
parseInt(seconds) +
|
|
parseInt(milliseconds) / 1000
|
|
);
|
|
}
|
|
|
|
private static formatTime(seconds: number): string {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const mins = Math.floor((seconds % 3600) / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
private static parseMetadata(header: string): Record<string, string> | undefined {
|
|
const lines = header.split('\n').slice(1); // Skip WEBVTT line
|
|
const metadata: Record<string, string> = {};
|
|
|
|
lines.forEach(line => {
|
|
const colonIndex = line.indexOf(':');
|
|
if (colonIndex > 0) {
|
|
const key = line.slice(0, colonIndex).trim();
|
|
const value = line.slice(colonIndex + 1).trim();
|
|
metadata[key] = value;
|
|
}
|
|
});
|
|
|
|
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
|
}
|
|
}
|
|
|
|
interface ParseOptions {
|
|
strict?: boolean;
|
|
includeMeta?: boolean;
|
|
}
|
|
|
|
interface WebVTTResult {
|
|
valid: boolean;
|
|
strict: boolean;
|
|
cues: SubtitleCue[];
|
|
errors: Error[];
|
|
meta?: Record<string, string>;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Usage Example
|
|
|
|
### Complete Implementation
|
|
|
|
```tsx
|
|
import React from 'react';
|
|
import { VideoFeedContainer } from './components/VideoFeedContainer';
|
|
import { useVideoFeed } from './hooks/useVideoFeed';
|
|
import { VideoPreloadService } from './services/VideoPreloadService';
|
|
|
|
const App: React.FC = () => {
|
|
const {
|
|
videos,
|
|
currentIndex,
|
|
hasMore,
|
|
loading,
|
|
loadMoreVideos,
|
|
populateVideo
|
|
} = useVideoFeed();
|
|
|
|
const preloadService = new VideoPreloadService();
|
|
|
|
// Handle video index changes for preloading
|
|
useEffect(() => {
|
|
preloadService.preloadNextVideos(currentIndex, videos);
|
|
}, [currentIndex, videos]);
|
|
|
|
return (
|
|
<div className="h-screen bg-black">
|
|
<VideoFeedContainer
|
|
videos={videos}
|
|
onLoadMore={loadMoreVideos}
|
|
hasMore={hasMore}
|
|
loading={loading}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|
|
```
|
|
|
|
---
|
|
|
|
## Advanced Scrolling Mechanism (Updated)
|
|
|
|
### Swiper Mode Navigation
|
|
|
|
Based on the additional deobfuscated file (`89650.836eaa0d.js`), TikTok uses a sophisticated **Swiper Mode Module** for video navigation:
|
|
|
|
```typescript
|
|
// Enhanced swiper navigation system
|
|
interface SwiperModeState {
|
|
currentIndex: Record<string, number>; // Current indices by list type
|
|
disabled: boolean; // Navigation disabled state
|
|
itemListKey: ItemListKey; // Current item list key
|
|
onboardingShowing: boolean; // Onboarding modal state
|
|
loginCTAShowing: boolean; // Login prompt state
|
|
leavingModalShowing: boolean; // Exit confirmation state
|
|
playbackRate: number; // Video playback speed
|
|
dimmer: boolean; // Screen dimmer state
|
|
showBrowseMode: boolean; // Browse mode state
|
|
needLeavingModal: boolean; // Exit modal requirement
|
|
iconType: IconType; // Current control icon
|
|
seekType: SeekType; // Seek operation type
|
|
}
|
|
|
|
enum IconType {
|
|
None = "none",
|
|
Mute = "mute",
|
|
Unmute = "unmute",
|
|
Play = "play",
|
|
Pause = "pause"
|
|
}
|
|
|
|
enum SeekType {
|
|
Forward = "forward",
|
|
BackWard = "backward",
|
|
None = "none"
|
|
}
|
|
|
|
// Enhanced navigation service
|
|
export class VideoNavigationService {
|
|
/**
|
|
* Navigate to next video with analytics tracking
|
|
*/
|
|
async handleNextVideo(currentState: SwiperModeState): Promise<void> {
|
|
// Report interaction start
|
|
this.reportVideoInteraction({
|
|
startTime: Date.now(),
|
|
situation: 'SwiperSlideNext'
|
|
});
|
|
|
|
const { itemListKey, currentIndex } = currentState;
|
|
const browserList = this.getBrowserList(itemListKey);
|
|
const currentIdx = currentIndex[itemListKey] || 0;
|
|
const nextIndex = Math.min(currentIdx + 1, browserList.length - 1);
|
|
|
|
if (this.canNavigate(nextIndex, browserList)) {
|
|
await this.updateVideoIndex({
|
|
newIndex: nextIndex,
|
|
newId: browserList[nextIndex],
|
|
playMode: currentState.playMode,
|
|
itemListKey
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Navigate to previous video with analytics tracking
|
|
*/
|
|
async handlePrevVideo(currentState: SwiperModeState): Promise<void> {
|
|
// Report interaction start
|
|
this.reportVideoInteraction({
|
|
startTime: Date.now(),
|
|
situation: 'SwiperSlidePrev'
|
|
});
|
|
|
|
const { itemListKey, currentIndex } = currentState;
|
|
const browserList = this.getBrowserList(itemListKey);
|
|
const currentIdx = currentIndex[itemListKey] || 0;
|
|
const prevIndex = Math.max(currentIdx - 1, 0);
|
|
|
|
if (this.canNavigate(prevIndex, browserList)) {
|
|
await this.updateVideoIndex({
|
|
newIndex: prevIndex,
|
|
newId: browserList[prevIndex],
|
|
playMode: currentState.playMode,
|
|
itemListKey
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Core video index update with comprehensive state management
|
|
*/
|
|
private async updateVideoIndex(params: {
|
|
newIndex: number;
|
|
newId: string;
|
|
playMode: PlayMode;
|
|
itemListKey: string;
|
|
}): Promise<void> {
|
|
const { newIndex, newId, playMode, itemListKey } = params;
|
|
|
|
// Update video player state
|
|
await this.videoPlayerService.updateVideo({
|
|
currentVideo: {
|
|
index: newIndex,
|
|
id: newId,
|
|
mode: playMode
|
|
},
|
|
playProgress: 0
|
|
});
|
|
|
|
// Update swiper current index
|
|
this.setSwiperCurrentIndex({
|
|
key: itemListKey,
|
|
value: newIndex
|
|
});
|
|
|
|
// Trigger preloading if near end of list
|
|
if (this.shouldPreload(newIndex, this.getBrowserList(itemListKey))) {
|
|
await this.preloadMoreContent(newIndex, newId);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Comment System Integration
|
|
|
|
The scrolling system is tightly integrated with TikTok's comment system:
|
|
|
|
```typescript
|
|
// Comment state management for video feed
|
|
interface CommentState {
|
|
awemeId?: string;
|
|
cursor: string;
|
|
comments: CommentItem[];
|
|
hasMore: boolean;
|
|
loading: boolean;
|
|
isFirstLoad: boolean;
|
|
fetchType: 'load_by_current' | 'preload_by_ml';
|
|
currentAspect: 'all' | string;
|
|
}
|
|
|
|
interface CommentItem {
|
|
cid: string;
|
|
user_digged: boolean;
|
|
is_author_digged: boolean;
|
|
digg_count: number;
|
|
reply_comment_total: number;
|
|
reply_comment?: CommentItem[];
|
|
replyCache?: {
|
|
comments: CommentItem[];
|
|
cursor: string;
|
|
hasMore: boolean;
|
|
loading: boolean;
|
|
};
|
|
}
|
|
|
|
// Comment preloading service
|
|
export class CommentPreloadService {
|
|
/**
|
|
* Preload comments for upcoming videos
|
|
*/
|
|
async preloadComments(videoId: string, fetchType: string = 'preload_by_ml'): Promise<void> {
|
|
try {
|
|
const response = await this.fetchComments({
|
|
aweme_id: videoId,
|
|
cursor: '0',
|
|
count: 20,
|
|
fetch_type: fetchType
|
|
});
|
|
|
|
if (response.status_code === 0 && response.comments?.length) {
|
|
// Process and cache comment data
|
|
const processedData = this.processCommentData(response.comments);
|
|
|
|
// Update comment state
|
|
this.setCommentItem({
|
|
item: {
|
|
comments: processedData.comments,
|
|
hasMore: !!response.has_more,
|
|
cursor: response.cursor,
|
|
loading: false,
|
|
isFirstLoad: true,
|
|
fetchType
|
|
},
|
|
itemId: videoId
|
|
});
|
|
|
|
// Update user data
|
|
this.updateUserData(processedData.users);
|
|
}
|
|
} catch (error) {
|
|
console.error('Comment preload failed:', error);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Key Implementation Notes
|
|
|
|
### 1. **Sparse Loading Pattern**
|
|
- TikTok creates placeholder `<article>` elements for the entire scroll range
|
|
- Only populates content when videos come into view
|
|
- This prevents excessive DOM manipulation and memory usage
|
|
|
|
### 2. **Scroll-Based Navigation**
|
|
- Uses `data-scroll-index` attributes for position tracking
|
|
- Calculates current video based on scroll position relative to container height
|
|
- Smooth transitions with CSS `snap-scroll` properties
|
|
|
|
### 3. **Preloading Strategy**
|
|
- Preloads metadata for upcoming videos
|
|
- Loads full video content when within 6 items of current position
|
|
- Cleans up old preloaded content to manage memory
|
|
|
|
### 4. **Analytics Integration**
|
|
- Tracks every video switch with detailed metrics
|
|
- Records scroll patterns and engagement data
|
|
- Integrates with TikTok's comprehensive analytics system
|
|
|
|
### 5. **Performance Optimizations**
|
|
- Throttled scroll event handlers
|
|
- Lazy loading of video content
|
|
- Memory management for preloaded videos
|
|
- CSS-based smooth scrolling with hardware acceleration
|
|
|
|
### 6. **Enhanced Navigation System**
|
|
- **Swiper Mode Module**: Dedicated service for next/prev navigation
|
|
- **Multi-List Support**: Handles different content types (ForYou, Search, etc.)
|
|
- **State Validation**: Comprehensive checks before navigation
|
|
- **Analytics Integration**: Detailed tracking for every navigation event
|
|
|
|
### 7. **Advanced Comment System**
|
|
- **ML-Driven Preloading**: Comments preloaded using machine learning predictions
|
|
- **Threaded Replies**: Nested reply system with separate caching
|
|
- **Real-time Interactions**: Instant like/unlike with optimistic updates
|
|
- **Translation Support**: Multi-language comment translation
|
|
- **Topic Filtering**: Comments can be filtered by topics and categories
|
|
- **Batch Operations**: Efficient bulk comment operations
|
|
|
|
This implementation replicates TikTok's sophisticated video feed system while providing clean TypeScript interfaces and React components that can be styled with Tailwind CSS.
|
|
|
|
---
|
|
|
|
## Tailwind CSS Configuration
|
|
|
|
Add these custom utilities to your `tailwind.config.js`:
|
|
|
|
```javascript
|
|
module.exports = {
|
|
theme: {
|
|
extend: {
|
|
animation: {
|
|
'spin-slow': 'spin 3s linear infinite',
|
|
},
|
|
backdropBlur: {
|
|
'xs': '2px',
|
|
}
|
|
}
|
|
},
|
|
plugins: [
|
|
require('@tailwindcss/line-clamp'),
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Enhanced React Hook Implementation
|
|
|
|
### Complete Swiper Navigation Hook
|
|
|
|
```tsx
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
interface UseSwiperNavigationProps {
|
|
videos: VideoItem[];
|
|
onVideoChange: (index: number) => void;
|
|
onLoadMore: () => void;
|
|
hasMore: boolean;
|
|
}
|
|
|
|
export const useSwiperNavigation = ({
|
|
videos,
|
|
onVideoChange,
|
|
onLoadMore,
|
|
hasMore
|
|
}: UseSwiperNavigationProps) => {
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [isNavigating, setIsNavigating] = useState(false);
|
|
const [swiperState, setSwiperState] = useState<SwiperModeState>({
|
|
currentIndex: { video: 0 },
|
|
disabled: false,
|
|
itemListKey: ItemListKey.Video,
|
|
onboardingShowing: false,
|
|
loginCTAShowing: false,
|
|
leavingModalShowing: false,
|
|
playbackRate: 1,
|
|
dimmer: false,
|
|
showBrowseMode: false,
|
|
needLeavingModal: false,
|
|
iconType: IconType.None,
|
|
seekType: SeekType.None
|
|
});
|
|
|
|
// Navigation service instance
|
|
const navigationService = useRef(new VideoNavigationService());
|
|
|
|
/**
|
|
* Handle next video navigation
|
|
*/
|
|
const handleNextVideo = useCallback(async () => {
|
|
if (isNavigating || swiperState.disabled) return;
|
|
|
|
setIsNavigating(true);
|
|
|
|
try {
|
|
await navigationService.current.handleNextVideo(swiperState);
|
|
|
|
const nextIndex = Math.min(currentIndex + 1, videos.length - 1);
|
|
setCurrentIndex(nextIndex);
|
|
onVideoChange(nextIndex);
|
|
|
|
// Trigger load more if near end
|
|
if (videos.length - nextIndex < 6 && hasMore) {
|
|
onLoadMore();
|
|
}
|
|
} catch (error) {
|
|
console.error('Navigation to next video failed:', error);
|
|
} finally {
|
|
setIsNavigating(false);
|
|
}
|
|
}, [currentIndex, videos.length, isNavigating, swiperState, onVideoChange, onLoadMore, hasMore]);
|
|
|
|
/**
|
|
* Handle previous video navigation
|
|
*/
|
|
const handlePrevVideo = useCallback(async () => {
|
|
if (isNavigating || swiperState.disabled) return;
|
|
|
|
setIsNavigating(true);
|
|
|
|
try {
|
|
await navigationService.current.handlePrevVideo(swiperState);
|
|
|
|
const prevIndex = Math.max(currentIndex - 1, 0);
|
|
setCurrentIndex(prevIndex);
|
|
onVideoChange(prevIndex);
|
|
} catch (error) {
|
|
console.error('Navigation to previous video failed:', error);
|
|
} finally {
|
|
setIsNavigating(false);
|
|
}
|
|
}, [currentIndex, isNavigating, swiperState, onVideoChange]);
|
|
|
|
/**
|
|
* Handle keyboard navigation
|
|
*/
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
switch (event.key) {
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
handlePrevVideo();
|
|
break;
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
handleNextVideo();
|
|
break;
|
|
case ' ':
|
|
event.preventDefault();
|
|
// Handle play/pause
|
|
setSwiperState(prev => ({
|
|
...prev,
|
|
iconType: prev.iconType === IconType.Play ? IconType.Pause : IconType.Play
|
|
}));
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [handleNextVideo, handlePrevVideo]);
|
|
|
|
return {
|
|
currentIndex,
|
|
swiperState,
|
|
setSwiperState,
|
|
handleNextVideo,
|
|
handlePrevVideo,
|
|
isNavigating
|
|
};
|
|
};
|
|
```
|
|
|
|
### Enhanced Video Feed with Swiper Navigation
|
|
|
|
```tsx
|
|
export const EnhancedVideoFeedContainer: React.FC<VideoFeedProps> = ({
|
|
videos,
|
|
onLoadMore,
|
|
hasMore,
|
|
loading
|
|
}) => {
|
|
const {
|
|
currentIndex,
|
|
swiperState,
|
|
handleNextVideo,
|
|
handlePrevVideo,
|
|
isNavigating
|
|
} = useSwiperNavigation({
|
|
videos,
|
|
onVideoChange: (index) => {
|
|
// Handle video change analytics
|
|
trackVideoSwitch({
|
|
fromIndex: currentIndex,
|
|
toIndex: index,
|
|
method: 'swiper_navigation'
|
|
});
|
|
},
|
|
onLoadMore,
|
|
hasMore
|
|
});
|
|
|
|
return (
|
|
<div className="relative h-screen overflow-hidden">
|
|
{/* Navigation overlay */}
|
|
<div className="absolute inset-0 z-10 pointer-events-none">
|
|
{/* Previous video trigger area */}
|
|
<button
|
|
className="absolute top-0 left-0 w-full h-1/3 pointer-events-auto opacity-0 hover:opacity-10 bg-gradient-to-b from-black/20 to-transparent transition-opacity"
|
|
onClick={handlePrevVideo}
|
|
disabled={currentIndex === 0 || isNavigating}
|
|
aria-label="Previous video"
|
|
/>
|
|
|
|
{/* Next video trigger area */}
|
|
<button
|
|
className="absolute bottom-0 left-0 w-full h-1/3 pointer-events-auto opacity-0 hover:opacity-10 bg-gradient-to-t from-black/20 to-transparent transition-opacity"
|
|
onClick={handleNextVideo}
|
|
disabled={currentIndex >= videos.length - 1 || isNavigating}
|
|
aria-label="Next video"
|
|
/>
|
|
</div>
|
|
|
|
{/* Video container with snap scrolling */}
|
|
<div
|
|
className="h-full overflow-y-auto snap-y snap-mandatory"
|
|
style={{
|
|
scrollBehavior: 'smooth',
|
|
scrollSnapType: 'y mandatory'
|
|
}}
|
|
>
|
|
{videos.map((video, index) => (
|
|
<VideoItemContainer
|
|
key={video.id}
|
|
video={video}
|
|
index={index}
|
|
isActive={index === currentIndex}
|
|
swiperState={swiperState}
|
|
onSelect={() => {
|
|
// Handle direct video selection
|
|
if (index !== currentIndex) {
|
|
if (index > currentIndex) {
|
|
handleNextVideo();
|
|
} else {
|
|
handlePrevVideo();
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Navigation indicators */}
|
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 z-20 flex flex-col space-y-2">
|
|
{videos.slice(Math.max(0, currentIndex - 2), currentIndex + 3).map((_, relativeIndex) => {
|
|
const actualIndex = Math.max(0, currentIndex - 2) + relativeIndex;
|
|
const isActive = actualIndex === currentIndex;
|
|
|
|
return (
|
|
<div
|
|
key={actualIndex}
|
|
className={`
|
|
w-1 h-8 rounded-full transition-all duration-200
|
|
${isActive
|
|
? 'bg-white'
|
|
: 'bg-white/30 hover:bg-white/50'
|
|
}
|
|
`}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Complete Comment System Implementation
|
|
|
|
### Enhanced Comment Types
|
|
|
|
```typescript
|
|
// Complete comment system types
|
|
interface CommentSystemState {
|
|
// Video-level comment state
|
|
videoComments: Record<string, VideoCommentState>;
|
|
// Individual comment items
|
|
commentItems: Record<string, CommentItem>;
|
|
}
|
|
|
|
interface VideoCommentState {
|
|
awemeId?: string;
|
|
cursor: string;
|
|
comments: CommentItem[];
|
|
hasMore: boolean;
|
|
loading: boolean;
|
|
isFirstLoad: boolean;
|
|
fetchType: 'load_by_current' | 'preload_by_ml';
|
|
currentAspect: 'all' | string; // Can be topic-specific like 'topic_123'
|
|
}
|
|
|
|
interface CommentItem {
|
|
cid: string; // Comment ID
|
|
user: string; // User ID who posted
|
|
user_digged: boolean; // Whether current user liked this comment
|
|
is_author_digged: boolean; // Whether video author liked this comment
|
|
digg_count: number; // Total likes on this comment
|
|
reply_comment_total: number; // Total replies count
|
|
reply_comment?: CommentItem[]; // Loaded reply comments
|
|
replyCache?: { // Reply pagination cache
|
|
comments: CommentItem[];
|
|
cursor: string;
|
|
hasMore: boolean;
|
|
loading: boolean;
|
|
};
|
|
comment_language?: string; // Language code for translation
|
|
}
|
|
|
|
// Comment interaction analytics
|
|
interface CommentAnalytics {
|
|
comment_id: string;
|
|
group_id: string; // Video ID
|
|
comment_user_id: string; // Comment author ID
|
|
author_id: string; // Video author ID
|
|
enter_method: string; // How user entered comment section
|
|
parent_comment_id?: string; // For reply analytics
|
|
}
|
|
```
|
|
|
|
### Comment System React Components
|
|
|
|
```tsx
|
|
// Enhanced comment system with all discovered features
|
|
export const CommentSystem: React.FC<{ videoId: string }> = ({ videoId }) => {
|
|
const [commentState, setCommentState] = useState<VideoCommentState>();
|
|
const [selectedAspect, setSelectedAspect] = useState<string>('all');
|
|
const commentService = useCommentService();
|
|
|
|
// Load comments when video changes
|
|
useEffect(() => {
|
|
commentService.fetchComment({
|
|
aweme_id: videoId,
|
|
cursor: '0',
|
|
fetch_type: 'load_by_current',
|
|
commentAspect: selectedAspect
|
|
});
|
|
}, [videoId, selectedAspect]);
|
|
|
|
return (
|
|
<div className="comment-system">
|
|
{/* Comment aspect filter */}
|
|
<CommentAspectFilter
|
|
currentAspect={selectedAspect}
|
|
onAspectChange={setSelectedAspect}
|
|
/>
|
|
|
|
{/* Comment list */}
|
|
<CommentList
|
|
videoId={videoId}
|
|
comments={commentState?.comments || []}
|
|
onLoadMore={() => commentService.fetchComment({
|
|
aweme_id: videoId,
|
|
cursor: commentState?.cursor || '0'
|
|
})}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Individual comment component with reply threading
|
|
export const CommentItem: React.FC<{
|
|
comment: CommentItem;
|
|
videoId: string;
|
|
onReply: (commentId: string) => void;
|
|
}> = ({ comment, videoId, onReply }) => {
|
|
const [showReplies, setShowReplies] = useState(false);
|
|
const [isLiking, setIsLiking] = useState(false);
|
|
const commentService = useCommentService();
|
|
|
|
const handleLike = async () => {
|
|
if (isLiking) return;
|
|
setIsLiking(true);
|
|
|
|
try {
|
|
await commentService.handleCommentLike({
|
|
cid: comment.cid,
|
|
itemId: videoId,
|
|
teaAuthorId: comment.user,
|
|
enterMethod: 'comment_panel'
|
|
});
|
|
} finally {
|
|
setIsLiking(false);
|
|
}
|
|
};
|
|
|
|
const handleShowMoreReplies = async () => {
|
|
await commentService.handleShowMoreReply({
|
|
itemId: videoId,
|
|
cid: comment.cid
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="comment-item p-4 border-b border-gray-800">
|
|
{/* Comment header */}
|
|
<div className="flex items-start space-x-3">
|
|
<UserAvatar userId={comment.user} size="sm" />
|
|
|
|
<div className="flex-1 min-w-0">
|
|
{/* Comment content */}
|
|
<div className="text-white text-sm">
|
|
{comment.text}
|
|
</div>
|
|
|
|
{/* Comment actions */}
|
|
<div className="flex items-center space-x-4 mt-2 text-gray-400 text-xs">
|
|
<button
|
|
onClick={handleLike}
|
|
disabled={isLiking}
|
|
className={`
|
|
flex items-center space-x-1 hover:text-red-400 transition-colors
|
|
${comment.user_digged ? 'text-red-500' : ''}
|
|
${isLiking ? 'opacity-50' : ''}
|
|
`}
|
|
>
|
|
<HeartIcon filled={comment.user_digged} />
|
|
<span>{formatCount(comment.digg_count)}</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => onReply(comment.cid)}
|
|
className="hover:text-blue-400 transition-colors"
|
|
>
|
|
Reply
|
|
</button>
|
|
|
|
{comment.reply_comment_total > 0 && (
|
|
<button
|
|
onClick={() => setShowReplies(!showReplies)}
|
|
className="hover:text-gray-300 transition-colors"
|
|
>
|
|
{showReplies ? 'Hide' : 'Show'} {comment.reply_comment_total} replies
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reply thread */}
|
|
{showReplies && (
|
|
<div className="ml-12 mt-4 space-y-3">
|
|
{comment.reply_comment?.map((reply, index) => (
|
|
<CommentReply
|
|
key={reply.cid}
|
|
reply={reply}
|
|
parentId={comment.cid}
|
|
videoId={videoId}
|
|
/>
|
|
))}
|
|
|
|
{/* Load more replies button */}
|
|
{comment.replyCache?.hasMore && (
|
|
<button
|
|
onClick={handleShowMoreReplies}
|
|
className="text-blue-400 hover:text-blue-300 text-xs transition-colors"
|
|
disabled={comment.replyCache?.loading}
|
|
>
|
|
{comment.replyCache?.loading ? 'Loading...' : 'Show more replies'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Comment aspect filter component
|
|
export const CommentAspectFilter: React.FC<{
|
|
currentAspect: string;
|
|
onAspectChange: (aspect: string) => void;
|
|
}> = ({ currentAspect, onAspectChange }) => {
|
|
const aspects = [
|
|
{ id: 'all', label: 'All Comments' },
|
|
{ id: 'topic_trending', label: 'Trending' },
|
|
{ id: 'topic_recent', label: 'Recent' }
|
|
];
|
|
|
|
return (
|
|
<div className="flex space-x-2 p-4 border-b border-gray-800">
|
|
{aspects.map(aspect => (
|
|
<button
|
|
key={aspect.id}
|
|
onClick={() => onAspectChange(aspect.id)}
|
|
className={`
|
|
px-3 py-1 rounded-full text-sm transition-colors
|
|
${currentAspect === aspect.id
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
|
}
|
|
`}
|
|
>
|
|
{aspect.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Comment Preloading Service
|
|
|
|
```typescript
|
|
// ML-driven comment preloading system
|
|
export class CommentPreloadService {
|
|
private preloadedComments = new Map<string, VideoCommentState>();
|
|
private preloadQueue: string[] = [];
|
|
|
|
/**
|
|
* Preload comments for upcoming videos using ML predictions
|
|
*/
|
|
async preloadCommentsForVideo(
|
|
videoId: string,
|
|
fetchType: 'preload_by_ml' | 'load_by_current' = 'preload_by_ml'
|
|
): Promise<void> {
|
|
if (this.preloadedComments.has(videoId)) return;
|
|
|
|
try {
|
|
const response = await this.fetchComments({
|
|
aweme_id: videoId,
|
|
cursor: '0',
|
|
count: 20,
|
|
fetch_type: fetchType
|
|
});
|
|
|
|
if (response.status_code === 0 && response.comments?.length) {
|
|
const processedData = this.processCommentData(response.comments);
|
|
|
|
const commentState: VideoCommentState = {
|
|
awemeId: videoId,
|
|
cursor: response.cursor || '0',
|
|
comments: processedData.comments,
|
|
hasMore: !!response.has_more,
|
|
loading: false,
|
|
isFirstLoad: true,
|
|
fetchType,
|
|
currentAspect: 'all'
|
|
};
|
|
|
|
// Cache preloaded comments
|
|
this.preloadedComments.set(videoId, commentState);
|
|
this.preloadQueue.push(videoId);
|
|
|
|
// Update global state
|
|
this.updateCommentState(videoId, commentState);
|
|
this.updateUserData(processedData.users);
|
|
|
|
// Track preload analytics
|
|
this.trackCommentPreload({
|
|
group_id: videoId,
|
|
comment_count: processedData.comments.length,
|
|
fetch_type: fetchType
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to preload comments for video ${videoId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get preloaded comments for a video
|
|
*/
|
|
getPreloadedComments(videoId: string): VideoCommentState | null {
|
|
return this.preloadedComments.get(videoId) || null;
|
|
}
|
|
|
|
/**
|
|
* Clean up old preloaded comments
|
|
*/
|
|
cleanupOldPreloads(currentVideoIndex: number, keepDistance: number = 10) {
|
|
const videosToKeep = new Set<string>();
|
|
|
|
// Keep comments for videos within distance
|
|
for (let i = Math.max(0, currentVideoIndex - keepDistance);
|
|
i < currentVideoIndex + keepDistance; i++) {
|
|
if (this.preloadQueue[i]) {
|
|
videosToKeep.add(this.preloadQueue[i]);
|
|
}
|
|
}
|
|
|
|
// Remove comments outside keep range
|
|
this.preloadedComments.forEach((_, videoId) => {
|
|
if (!videosToKeep.has(videoId)) {
|
|
this.preloadedComments.delete(videoId);
|
|
}
|
|
});
|
|
|
|
this.preloadQueue = this.preloadQueue.filter(id => videosToKeep.has(id));
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Core Utility System
|
|
|
|
### Utility Bundle Analysis (`3813.1e571ef0.js`)
|
|
|
|
The utility bundle provides essential infrastructure for TikTok's web application:
|
|
|
|
#### **1. Invariant Error Handling**
|
|
```typescript
|
|
// Core error checking utility used throughout TikTok's codebase
|
|
interface InvariantFunction {
|
|
(condition: boolean, message?: string, ...args: any[]): void;
|
|
}
|
|
|
|
// Usage in TikTok's code
|
|
invariant(videoId, 'Video ID is required for playback');
|
|
invariant(userPermissions.canView, 'User %s does not have permission to view video %s', userId, videoId);
|
|
```
|
|
|
|
#### **2. Dynamic Script Loading**
|
|
```typescript
|
|
// Advanced script loading with comprehensive options
|
|
interface ScriptLoadOptions {
|
|
type?: string; // Script type (default: 'text/javascript')
|
|
charset?: string; // Character encoding (default: 'utf8')
|
|
async?: boolean; // Async loading (default: true)
|
|
attrs?: Record<string, string>; // Custom attributes
|
|
text?: string; // Inline script content
|
|
}
|
|
|
|
interface ScriptLoader {
|
|
(src: string, options?: ScriptLoadOptions, callback?: (error: Error | null, element: HTMLScriptElement) => void): void;
|
|
}
|
|
|
|
// TikTok uses this for:
|
|
// - Loading video player chunks on demand
|
|
// - Dynamic feature loading based on user interactions
|
|
// - A/B testing script injection
|
|
// - Analytics and tracking script management
|
|
```
|
|
|
|
#### **3. PropTypes Validation System**
|
|
```typescript
|
|
// React component validation (development mode)
|
|
interface PropTypesSystem {
|
|
array: PropTypeValidator;
|
|
bool: PropTypeValidator;
|
|
func: PropTypeValidator;
|
|
number: PropTypeValidator;
|
|
object: PropTypeValidator;
|
|
string: PropTypeValidator;
|
|
symbol: PropTypeValidator;
|
|
any: PropTypeValidator;
|
|
arrayOf: (validator: PropTypeValidator) => PropTypeValidator;
|
|
element: PropTypeValidator;
|
|
instanceOf: (constructor: Function) => PropTypeValidator;
|
|
node: PropTypeValidator;
|
|
objectOf: (validator: PropTypeValidator) => PropTypeValidator;
|
|
oneOf: (values: any[]) => PropTypeValidator;
|
|
oneOfType: (validators: PropTypeValidator[]) => PropTypeValidator;
|
|
shape: (shape: Record<string, PropTypeValidator>) => PropTypeValidator;
|
|
exact: (shape: Record<string, PropTypeValidator>) => PropTypeValidator;
|
|
}
|
|
```
|
|
|
|
### **Utility Integration in Video Player**
|
|
|
|
```tsx
|
|
// Example of how utilities integrate with video player components
|
|
import { invariant } from './utils/invariant';
|
|
import { loadScript } from './utils/scriptLoader';
|
|
|
|
export const VideoPlayerWithUtilities: React.FC<VideoPlayerProps> = ({ videoId, onLoad }) => {
|
|
const [playerReady, setPlayerReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Validate required props
|
|
invariant(videoId, 'VideoPlayer requires a valid videoId');
|
|
|
|
// Dynamically load video player engine
|
|
loadScript('/static/js/video-engine.js', {
|
|
async: true,
|
|
attrs: { 'data-video-id': videoId }
|
|
}, (error, script) => {
|
|
if (error) {
|
|
console.error('Failed to load video engine:', error);
|
|
return;
|
|
}
|
|
|
|
setPlayerReady(true);
|
|
onLoad?.(videoId);
|
|
});
|
|
}, [videoId, onLoad]);
|
|
|
|
return (
|
|
<div className="video-player-container">
|
|
{playerReady ? (
|
|
<VideoEngine videoId={videoId} />
|
|
) : (
|
|
<VideoPlayerSkeleton />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// PropTypes validation for development
|
|
VideoPlayerWithUtilities.propTypes = {
|
|
videoId: PropTypes.string.isRequired,
|
|
onLoad: PropTypes.func,
|
|
autoplay: PropTypes.bool,
|
|
controls: PropTypes.bool,
|
|
muted: PropTypes.bool
|
|
};
|
|
```
|
|
|
|
### **Error Handling Strategy**
|
|
|
|
```typescript
|
|
// TikTok's comprehensive error handling approach
|
|
class VideoPlayerErrorHandler {
|
|
/**
|
|
* Handle video loading errors with graceful degradation
|
|
*/
|
|
static handleVideoError(error: Error, videoId: string, retryCount: number = 0): void {
|
|
// Use invariant for critical errors
|
|
invariant(retryCount < 3, 'Video loading failed after %s retries for video %s', retryCount, videoId);
|
|
|
|
// Log error for analytics
|
|
this.logError({
|
|
type: 'video_load_error',
|
|
videoId,
|
|
error: error.message,
|
|
retryCount,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
// Attempt recovery strategies
|
|
if (retryCount < 2) {
|
|
// Try alternative video source
|
|
this.loadAlternativeSource(videoId, retryCount + 1);
|
|
} else {
|
|
// Show error state to user
|
|
this.showErrorState(videoId, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dynamic loading of error recovery modules
|
|
*/
|
|
static async loadErrorRecovery(): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
loadScript('/static/js/error-recovery.js', {
|
|
async: true,
|
|
attrs: { 'data-module': 'error-recovery' }
|
|
}, (error) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### **Performance Optimization Utilities**
|
|
|
|
```typescript
|
|
// Advanced performance utilities discovered in the bundle
|
|
interface PerformanceUtils {
|
|
// Throttle function calls for scroll events
|
|
throttle: <T extends (...args: any[]) => any>(func: T, delay: number) => T;
|
|
|
|
// Debounce function calls for search inputs
|
|
debounce: <T extends (...args: any[]) => any>(func: T, delay: number) => T;
|
|
|
|
// Lazy loading with intersection observer
|
|
lazyLoad: (element: HTMLElement, callback: () => void) => void;
|
|
|
|
// Memory management for large lists
|
|
virtualizeList: (items: any[], containerHeight: number, itemHeight: number) => any[];
|
|
}
|
|
|
|
// Implementation examples
|
|
export const usePerformanceOptimizedScroll = (onScroll: () => void) => {
|
|
const throttledScroll = useMemo(
|
|
() => throttle(onScroll, 16), // 60fps
|
|
[onScroll]
|
|
);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('scroll', throttledScroll, { passive: true });
|
|
return () => window.removeEventListener('scroll', throttledScroll);
|
|
}, [throttledScroll]);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
---
|
|
|
|
## Privacy and Network Security (PNS) System
|
|
|
|
### Core Security Architecture (`index.js`)
|
|
|
|
The `index.js` file reveals TikTok's comprehensive **Privacy and Network Security (PNS)** system - a sophisticated privacy compliance and security framework:
|
|
|
|
#### **1. Privacy Compliance Engine**
|
|
```typescript
|
|
// Cookie consent management with domain-based blocking
|
|
interface CookieConsentManager {
|
|
blockedCookies: string[]; // Cookies to block based on domain
|
|
hookCookieSetter: () => void; // Intercept document.cookie operations
|
|
processCookieSet: (value: string) => CookieData;
|
|
}
|
|
|
|
interface CookieData {
|
|
rawValue: string;
|
|
name: string;
|
|
_time: number;
|
|
_blocked: boolean;
|
|
_sample_rate: number;
|
|
_stack_rate: number;
|
|
_rule_names: string[];
|
|
}
|
|
|
|
// Usage in TikTok's privacy system
|
|
const cookieManager = new CookieConsentManager({
|
|
blockers: [{
|
|
domains: ["tiktok.com", "tiktokv.com"],
|
|
cookies: ["MONITOR_WEB_ID", "MONITOR_DEVICE_ID", "ktlvDW7IG5ClOcxYTbmY"]
|
|
}],
|
|
sampleRate: 0.07
|
|
});
|
|
```
|
|
|
|
#### **2. Network Request Interception**
|
|
```typescript
|
|
// Comprehensive network security with request/response monitoring
|
|
interface NetworkInterceptor {
|
|
originalFetch: typeof fetch;
|
|
originalXHR: typeof XMLHttpRequest;
|
|
|
|
hookFetch: () => void; // Intercept fetch API
|
|
hookXMLHttpRequest: () => void; // Intercept XHR requests
|
|
applySecurityRules: (data: RequestData) => ProcessedRequestData;
|
|
}
|
|
|
|
interface RequestData {
|
|
request_url: string;
|
|
request_host: string;
|
|
request_path: string;
|
|
method: string;
|
|
headers: Record<string, string>;
|
|
_request_time: number;
|
|
_blocked: boolean;
|
|
_sample_rate: number;
|
|
}
|
|
|
|
// Security rules applied to every request
|
|
interface SecurityRule {
|
|
conditions: Array<{
|
|
type: 'url' | 'host' | 'path' | 'header' | 'method';
|
|
pattern: {
|
|
$eq?: string;
|
|
$prefix?: string;
|
|
$regex?: string;
|
|
$in?: string[];
|
|
};
|
|
}>;
|
|
handlers: Array<{
|
|
handler: 'block' | 'report' | 'replace';
|
|
type: 'self' | 'url' | 'header';
|
|
value?: any;
|
|
}>;
|
|
priority: number;
|
|
ruleName: string;
|
|
}
|
|
```
|
|
|
|
#### **3. Web API Monitoring**
|
|
```typescript
|
|
// Monitor sensitive API usage for privacy compliance
|
|
interface WebAPIMonitor {
|
|
hookSensitiveAPIs: () => void;
|
|
hookAPI: (config: APIConfig) => void;
|
|
}
|
|
|
|
interface APIConfig {
|
|
apiName: string; // API method name
|
|
apiObj: string; // Object path (e.g., "navigator.geolocation")
|
|
apiType: 'method' | 'constructor' | 'attribute_get' | 'attribute_set';
|
|
block: boolean; // Whether to block the API
|
|
sampleRate: number; // Sampling rate for reporting
|
|
stackRate: number; // Stack trace collection rate
|
|
withRawArguments: boolean[]; // Which arguments to log
|
|
withStack: boolean; // Whether to collect stack traces
|
|
}
|
|
|
|
// Examples of monitored APIs
|
|
const monitoredAPIs = [
|
|
{
|
|
apiName: "getUserMedia",
|
|
apiObj: "navigator.mediaDevices",
|
|
apiType: "method",
|
|
sampleRate: 1.0,
|
|
stackRate: 1.0,
|
|
block: false
|
|
},
|
|
{
|
|
apiName: "getCurrentPosition",
|
|
apiObj: "navigator.geolocation",
|
|
apiType: "method",
|
|
sampleRate: 1.0,
|
|
stackRate: 1.0,
|
|
block: false
|
|
},
|
|
{
|
|
apiName: "cookie",
|
|
apiObj: "document",
|
|
apiType: "attribute_get",
|
|
sampleRate: 0.00005,
|
|
stackRate: 0.001,
|
|
block: false
|
|
}
|
|
];
|
|
```
|
|
|
|
#### **4. Page Context Management**
|
|
```typescript
|
|
// Track navigation and page context changes
|
|
interface PageContextManager {
|
|
observers: Array<{
|
|
func: (context: PageContext) => void;
|
|
fields?: string[];
|
|
}>;
|
|
context: PageContext;
|
|
|
|
buildInitialContext: () => PageContext;
|
|
setupNavigationTracking: () => void;
|
|
updateContext: (newContext: PageContext) => void;
|
|
}
|
|
|
|
interface PageContext {
|
|
url: string;
|
|
host: string;
|
|
path: string;
|
|
search: string;
|
|
hash: string;
|
|
region: string; // Geographic region
|
|
business: string; // Business context
|
|
env: 'prod' | 'dev' | 'staging'; // Environment
|
|
gtm?: string; // Google Tag Manager status
|
|
ftc?: string; // FTC compliance status
|
|
login?: string; // Login status
|
|
}
|
|
```
|
|
|
|
#### **5. Service Worker Communication**
|
|
```typescript
|
|
// Secure communication with service worker for enhanced privacy
|
|
interface ServiceWorkerCommunication {
|
|
SW_EVENTS: {
|
|
RUNTIME_SW_EVENT: "__PNS_RUNTIME_SW_EVENT__";
|
|
RUNTIME_SE_ERROR: "__PNS_RUNTIME_SE_ERROR__";
|
|
RUNTIME: "__PNS_RUNTIME__";
|
|
};
|
|
|
|
setupCommunication: (runtime: PNSRuntime) => void;
|
|
sendConfigToSW: (config: PNSConfig) => void;
|
|
handleSWMessages: (event: MessageEvent) => void;
|
|
}
|
|
```
|
|
|
|
### **Integration with Video Player System**
|
|
|
|
The PNS system directly impacts video player functionality:
|
|
|
|
```tsx
|
|
// Enhanced video player with PNS integration
|
|
export const PrivacyAwareVideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
videoId,
|
|
onLoad,
|
|
requiresConsent = true
|
|
}) => {
|
|
const [consentGranted, setConsentGranted] = useState(false);
|
|
const [pnsBlocked, setPnsBlocked] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Check PNS consent status
|
|
const runtime = window.__PNS_RUNTIME__;
|
|
if (runtime) {
|
|
// Monitor for privacy-related blocks
|
|
runtime.addObserver((context) => {
|
|
if (context.cookie_ga !== true && requiresConsent) {
|
|
setPnsBlocked(true);
|
|
}
|
|
}, ['cookie_ga', 'cookie_fbp', 'cookie_optional']);
|
|
}
|
|
}, [requiresConsent]);
|
|
|
|
// Handle video loading with privacy compliance
|
|
const loadVideo = useCallback(async () => {
|
|
if (pnsBlocked) {
|
|
console.warn('Video loading blocked by privacy policy');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Video loading with PNS monitoring
|
|
const response = await fetch(`/api/video/${videoId}`, {
|
|
headers: {
|
|
'X-Privacy-Consent': consentGranted ? '1' : '0'
|
|
}
|
|
});
|
|
|
|
if (response.status === 410) {
|
|
// Request blocked by PNS
|
|
setPnsBlocked(true);
|
|
return;
|
|
}
|
|
|
|
const videoData = await response.json();
|
|
onLoad?.(videoData);
|
|
|
|
} catch (error) {
|
|
console.error('Video loading failed:', error);
|
|
}
|
|
}, [videoId, consentGranted, pnsBlocked, onLoad]);
|
|
|
|
if (pnsBlocked) {
|
|
return (
|
|
<div className="video-blocked-notice">
|
|
<p>Video unavailable due to privacy settings</p>
|
|
<button onClick={() => setConsentGranted(true)}>
|
|
Grant Consent
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<VideoPlayer
|
|
videoId={videoId}
|
|
onLoad={loadVideo}
|
|
privacyCompliant={true}
|
|
/>
|
|
);
|
|
};
|
|
```
|
|
|
|
### **PNS Configuration Example**
|
|
|
|
```typescript
|
|
// Complete PNS configuration as used by TikTok
|
|
const pnsConfig: PNSConfig = {
|
|
cookie: {
|
|
enabled: true,
|
|
sampleRate: 0.07,
|
|
stackSampleRate: 0.07,
|
|
blockers: [{
|
|
domains: ["tiktok.com", "tiktokv.com"],
|
|
cookies: ["MONITOR_WEB_ID", "MONITOR_DEVICE_ID", "ktlvDW7IG5ClOcxYTbmY"]
|
|
}]
|
|
},
|
|
|
|
network: {
|
|
sampleRate: 0.03,
|
|
intercept: [
|
|
{
|
|
// Force HTTPS
|
|
conditions: [{
|
|
type: "url",
|
|
pattern: { $prefix: "http://" }
|
|
}],
|
|
handlers: [{
|
|
handler: "replace",
|
|
type: "url",
|
|
pattern: { $prefix: "http://" },
|
|
value: "https://"
|
|
}],
|
|
priority: -1000,
|
|
ruleName: "force_https"
|
|
},
|
|
{
|
|
// Block YouTube API on TikTok
|
|
conditions: [
|
|
{ type: "url", pattern: { $prefix: "https://www.youtube.com/iframe_api" } },
|
|
{ type: "context", field: "host", pattern: { $eq: "www.tiktok.com" } }
|
|
],
|
|
handlers: [{ handler: "block", type: "self" }],
|
|
priority: 22,
|
|
ruleName: "youtube_cutoff"
|
|
}
|
|
]
|
|
},
|
|
|
|
webapi: {
|
|
enabled: true,
|
|
apis: [
|
|
{
|
|
apiName: "getUserMedia",
|
|
apiObj: "navigator.mediaDevices",
|
|
apiType: "method",
|
|
sampleRate: 1.0,
|
|
stackRate: 1.0,
|
|
withRawArguments: [true],
|
|
withStack: true
|
|
}
|
|
]
|
|
}
|
|
};
|
|
```
|
|
|
|
This enhanced implementation now includes TikTok's complete privacy and security infrastructure alongside the sophisticated video player system, providing comprehensive replication of their privacy-compliant video feed mechanics.
|