mirror of
https://github.com/vndangkhoa/purestream.git
synced 2026-04-06 01:47:59 +07:00
feat: client-side video optimization - remove server transcoding for instant loading and zero CPU
This commit is contained in:
parent
13841f479e
commit
aad352a80f
3 changed files with 91 additions and 79 deletions
|
|
@ -190,18 +190,21 @@ async def proxy_video(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Proxy video with LRU caching for mobile optimization.
|
Proxy video with LRU caching for mobile optimization.
|
||||||
Prefers H.264 codec for browser compatibility, re-encodes HEVC if needed.
|
OPTIMIZED: No server-side transcoding - client handles decoding.
|
||||||
|
This reduces server CPU to ~0% during video playback.
|
||||||
"""
|
"""
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
import re
|
import re
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# Check cache first
|
# Check cache first
|
||||||
cached_path = get_cached_path(url)
|
cached_path = get_cached_path(url)
|
||||||
if cached_path:
|
if cached_path:
|
||||||
print(f"CACHE HIT: {url[:50]}...")
|
print(f"CACHE HIT: {url[:50]}...")
|
||||||
|
|
||||||
response_headers = {}
|
response_headers = {
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
}
|
||||||
if download:
|
if download:
|
||||||
video_id_match = re.search(r'/video/(\d+)', url)
|
video_id_match = re.search(r'/video/(\d+)', url)
|
||||||
video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
|
video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
|
||||||
|
|
@ -232,10 +235,10 @@ async def proxy_video(
|
||||||
cookie_file.close()
|
cookie_file.close()
|
||||||
cookie_file_path = cookie_file.name
|
cookie_file_path = cookie_file.name
|
||||||
|
|
||||||
# Prefer H.264 (avc1) over HEVC (hvc1/hev1) for browser compatibility
|
# Download best quality - NO TRANSCODING (let client decode)
|
||||||
# Format selection: prefer mp4 with h264, fallback to best mp4
|
# Prefer H.264 when available, but accept any codec
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best', # Prefer H.264
|
'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best',
|
||||||
'outtmpl': output_template,
|
'outtmpl': output_template,
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
|
|
@ -249,7 +252,7 @@ async def proxy_video(
|
||||||
ydl_opts['cookiefile'] = cookie_file_path
|
ydl_opts['cookiefile'] = cookie_file_path
|
||||||
|
|
||||||
video_path = None
|
video_path = None
|
||||||
video_codec = None
|
video_codec = "unknown"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
@ -258,8 +261,7 @@ async def proxy_video(
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=True)
|
info = ydl.extract_info(url, download=True)
|
||||||
ext = info.get('ext', 'mp4')
|
ext = info.get('ext', 'mp4')
|
||||||
# Get codec info
|
vcodec = info.get('vcodec', 'unknown') or 'unknown'
|
||||||
vcodec = info.get('vcodec', '') or ''
|
|
||||||
return os.path.join(temp_dir, f"video.{ext}"), vcodec
|
return os.path.join(temp_dir, f"video.{ext}"), vcodec
|
||||||
|
|
||||||
video_path, video_codec = await loop.run_in_executor(None, download_video)
|
video_path, video_codec = await loop.run_in_executor(None, download_video)
|
||||||
|
|
@ -267,70 +269,9 @@ async def proxy_video(
|
||||||
if not os.path.exists(video_path):
|
if not os.path.exists(video_path):
|
||||||
raise Exception("Video file not created")
|
raise Exception("Video file not created")
|
||||||
|
|
||||||
print(f"Downloaded codec: {video_codec}")
|
print(f"Downloaded codec: {video_codec} (no transcoding - client will decode)")
|
||||||
|
|
||||||
# Check if we need to re-encode HEVC to H.264
|
# Save to cache directly - NO TRANSCODING
|
||||||
# If codec unknown, probe the actual file
|
|
||||||
is_hevc = False
|
|
||||||
if video_codec:
|
|
||||||
is_hevc = any(x in video_codec.lower() for x in ['hevc', 'hev1', 'hvc1', 'h265', 'h.265'])
|
|
||||||
else:
|
|
||||||
# Try probing with yt-dlp's ffprobe
|
|
||||||
try:
|
|
||||||
probe_cmd = ['ffprobe', '-v', 'error', '-select_streams', 'v:0',
|
|
||||||
'-show_entries', 'stream=codec_name', '-of', 'csv=p=0', video_path]
|
|
||||||
probe_result = subprocess.run(probe_cmd, capture_output=True, timeout=10)
|
|
||||||
detected_codec = probe_result.stdout.decode().strip().lower()
|
|
||||||
is_hevc = 'hevc' in detected_codec or 'h265' in detected_codec
|
|
||||||
print(f"Probed codec: {detected_codec}")
|
|
||||||
except Exception as probe_err:
|
|
||||||
print(f"Probe failed: {probe_err}")
|
|
||||||
|
|
||||||
if is_hevc:
|
|
||||||
print(f"HEVC detected, re-encoding to H.264 for browser compatibility...")
|
|
||||||
h264_path = os.path.join(temp_dir, "video_h264.mp4")
|
|
||||||
|
|
||||||
def reencode_to_h264():
|
|
||||||
# Use ffmpeg to re-encode from HEVC to H.264
|
|
||||||
cmd = [
|
|
||||||
'ffmpeg', '-y',
|
|
||||||
'-i', video_path,
|
|
||||||
'-c:v', 'libx264', # H.264 codec
|
|
||||||
'-preset', 'fast', # Balance speed and quality
|
|
||||||
'-crf', '23', # Quality (lower = better, 18-28 is good)
|
|
||||||
'-c:a', 'aac', # AAC audio
|
|
||||||
'-movflags', '+faststart', # Web optimization
|
|
||||||
h264_path
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, timeout=120)
|
|
||||||
if result.returncode != 0:
|
|
||||||
print(f"FFmpeg error: {result.stderr.decode()[:200]}")
|
|
||||||
return None
|
|
||||||
return h264_path
|
|
||||||
except FileNotFoundError:
|
|
||||||
print("FFmpeg not installed, trying yt-dlp postprocessor...")
|
|
||||||
# Fallback: re-download with recode postprocessor
|
|
||||||
recode_opts = ydl_opts.copy()
|
|
||||||
recode_opts['postprocessors'] = [{'key': 'FFmpegVideoConvertor', 'preferedformat': 'mp4'}]
|
|
||||||
recode_opts['postprocessor_args'] = ['-c:v', 'libx264', '-preset', 'fast', '-crf', '23']
|
|
||||||
recode_opts['outtmpl'] = h264_path.replace('.mp4', '.%(ext)s')
|
|
||||||
try:
|
|
||||||
with yt_dlp.YoutubeDL(recode_opts) as ydl:
|
|
||||||
ydl.download([url])
|
|
||||||
return h264_path if os.path.exists(h264_path) else None
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
reencoded_path = await loop.run_in_executor(None, reencode_to_h264)
|
|
||||||
|
|
||||||
if reencoded_path and os.path.exists(reencoded_path):
|
|
||||||
video_path = reencoded_path
|
|
||||||
print("Re-encoding successful!")
|
|
||||||
else:
|
|
||||||
print("Re-encoding failed, using original video")
|
|
||||||
|
|
||||||
# Save to cache for future requests
|
|
||||||
cached_path = save_to_cache(url, video_path)
|
cached_path = save_to_cache(url, video_path)
|
||||||
stats = get_cache_stats()
|
stats = get_cache_stats()
|
||||||
print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)")
|
print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)")
|
||||||
|
|
@ -349,8 +290,12 @@ async def proxy_video(
|
||||||
os.unlink(cookie_file_path)
|
os.unlink(cookie_file_path)
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
# Return from cache
|
# Return from cache with codec info header
|
||||||
response_headers = {}
|
response_headers = {
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
"X-Video-Codec": video_codec, # Let client know the codec
|
||||||
|
}
|
||||||
if download:
|
if download:
|
||||||
video_id_match = re.search(r'/video/(\d+)', url)
|
video_id_match = re.search(r'/video/(\d+)', url)
|
||||||
video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
|
video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
|
||||||
|
|
@ -363,6 +308,7 @@ async def proxy_video(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/thin-proxy")
|
@router.get("/thin-proxy")
|
||||||
async def thin_proxy_video(
|
async def thin_proxy_video(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
|
|
@ -81,6 +81,7 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -1409,6 +1410,7 @@
|
||||||
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1426,6 +1428,7 @@
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
|
|
@ -1486,6 +1489,7 @@
|
||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
|
|
@ -1737,6 +1741,7 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -1959,6 +1964,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -2364,6 +2370,7 @@
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -3007,6 +3014,7 @@
|
||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
|
|
@ -3507,6 +3515,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -3702,6 +3711,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -3714,6 +3724,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
|
|
@ -4121,6 +4132,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -4186,6 +4198,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -4279,6 +4292,7 @@
|
||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
import { Download, UserPlus, Check, Volume2, VolumeX } from 'lucide-react';
|
import { Download, UserPlus, Check, Volume2, VolumeX, AlertCircle } from 'lucide-react';
|
||||||
import type { Video } from '../types';
|
import type { Video } from '../types';
|
||||||
import { API_BASE_URL } from '../config';
|
import { API_BASE_URL } from '../config';
|
||||||
import { videoCache } from '../utils/videoCache';
|
import { videoCache } from '../utils/videoCache';
|
||||||
|
|
||||||
|
// Check if browser supports HEVC codec (Safari, Chrome 107+, Edge)
|
||||||
|
const supportsHEVC = (): boolean => {
|
||||||
|
if (typeof MediaSource === 'undefined') return false;
|
||||||
|
return MediaSource.isTypeSupported('video/mp4; codecs="hvc1"') ||
|
||||||
|
MediaSource.isTypeSupported('video/mp4; codecs="hev1"');
|
||||||
|
};
|
||||||
|
|
||||||
interface HeartParticle {
|
interface HeartParticle {
|
||||||
id: number;
|
id: number;
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -42,9 +49,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
const [localMuted, setLocalMuted] = useState(true);
|
const [localMuted, setLocalMuted] = useState(true);
|
||||||
const isMuted = externalMuted !== undefined ? externalMuted : localMuted;
|
const isMuted = externalMuted !== undefined ? externalMuted : localMuted;
|
||||||
const [hearts, setHearts] = useState<HeartParticle[]>([]);
|
const [hearts, setHearts] = useState<HeartParticle[]>([]);
|
||||||
|
<<<<<<< HEAD
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [cachedUrl, setCachedUrl] = useState<string | null>(null);
|
const [cachedUrl, setCachedUrl] = useState<string | null>(null);
|
||||||
|
=======
|
||||||
|
const [isLoading, setIsLoading] = useState(true); // Show loading spinner until video is ready
|
||||||
|
const [codecError, setCodecError] = useState(false); // True if video codec not supported
|
||||||
|
>>>>>>> 6153739 (feat: client-side video optimization - remove server transcoding for instant loading and zero CPU)
|
||||||
const lastTapRef = useRef<number>(0);
|
const lastTapRef = useRef<number>(0);
|
||||||
|
const browserSupportsHEVC = useRef(supportsHEVC());
|
||||||
|
|
||||||
const fullProxyUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}`;
|
const fullProxyUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}`;
|
||||||
const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null;
|
const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null;
|
||||||
|
|
@ -106,6 +119,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
// Reset fallback and loading state when video changes
|
// Reset fallback and loading state when video changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUseFallback(false);
|
setUseFallback(false);
|
||||||
|
<<<<<<< HEAD
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setCachedUrl(null);
|
setCachedUrl(null);
|
||||||
|
|
||||||
|
|
@ -118,6 +132,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
checkCache();
|
checkCache();
|
||||||
|
=======
|
||||||
|
setIsLoading(true); // Show loading for new video
|
||||||
|
setCodecError(false); // Reset codec error for new video
|
||||||
|
>>>>>>> 6153739 (feat: client-side video optimization - remove server transcoding for instant loading and zero CPU)
|
||||||
}, [video.id]);
|
}, [video.id]);
|
||||||
|
|
||||||
// Progress tracking
|
// Progress tracking
|
||||||
|
|
@ -134,7 +152,22 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback on error - if thin proxy fails, switch to full proxy
|
// Fallback on error - if thin proxy fails, switch to full proxy
|
||||||
const handleError = () => {
|
// Also detect codec errors for graceful fallback UI
|
||||||
|
const handleError = (e: Event) => {
|
||||||
|
const videoEl = e.target as HTMLVideoElement;
|
||||||
|
const error = videoEl?.error;
|
||||||
|
|
||||||
|
// Check if this is a codec/decode error (MEDIA_ERR_DECODE = 3)
|
||||||
|
if (error?.code === 3 || error?.code === 4) {
|
||||||
|
console.log(`Codec error detected (code ${error.code}):`, error.message);
|
||||||
|
// Only show codec error if browser doesn't support HEVC
|
||||||
|
if (!browserSupportsHEVC.current) {
|
||||||
|
setCodecError(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (thinProxyUrl && !useFallback) {
|
if (thinProxyUrl && !useFallback) {
|
||||||
console.log('Thin proxy failed, falling back to full proxy...');
|
console.log('Thin proxy failed, falling back to full proxy...');
|
||||||
setUseFallback(true);
|
setUseFallback(true);
|
||||||
|
|
@ -329,13 +362,13 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
onClick={handleVideoClick}
|
onClick={handleVideoClick}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
>
|
>
|
||||||
{/* Video Element */}
|
{/* Video Element - preload="metadata" for instant player readiness */}
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={proxyUrl}
|
src={proxyUrl}
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
preload="auto"
|
preload="metadata"
|
||||||
muted={isMuted}
|
muted={isMuted}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
style={{ objectFit }}
|
style={{ objectFit }}
|
||||||
|
|
@ -345,7 +378,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Loading Overlay - Subtle pulsing logo */}
|
{/* Loading Overlay - Subtle pulsing logo */}
|
||||||
{isLoading && (
|
{isLoading && !codecError && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-20">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-20">
|
||||||
<div className="w-16 h-16 bg-gradient-to-r from-cyan-400/80 to-pink-500/80 rounded-2xl flex items-center justify-center animate-pulse">
|
<div className="w-16 h-16 bg-gradient-to-r from-cyan-400/80 to-pink-500/80 rounded-2xl flex items-center justify-center animate-pulse">
|
||||||
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
|
@ -355,6 +388,25 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Codec Error Fallback - Graceful UI for unsupported video codecs */}
|
||||||
|
{codecError && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 z-20 p-6 text-center">
|
||||||
|
<AlertCircle className="w-12 h-12 text-amber-400 mb-3" />
|
||||||
|
<h3 className="text-white font-semibold text-lg mb-2">Video Format Not Supported</h3>
|
||||||
|
<p className="text-white/60 text-sm mb-4 max-w-xs">
|
||||||
|
This video uses HEVC codec. Try Safari, Chrome 107+, or download to watch.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={downloadUrl}
|
||||||
|
download
|
||||||
|
className="px-4 py-2 bg-gradient-to-r from-cyan-500 to-pink-500 text-white text-sm font-medium rounded-full hover:opacity-90 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
Download Video
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Heart Animation Particles */}
|
{/* Heart Animation Particles */}
|
||||||
{hearts.map(heart => (
|
{hearts.map(heart => (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue