Initial commit: Expo app with Better Auth integration

- Complete Expo React Native app setup with TypeScript
- Better Auth authentication system integration
- Secure storage implementation for session tokens
- Authentication flow with login/logout functionality
- API client configuration for backend communication
- Responsive UI components with themed styling
- Expo Router navigation setup
- Development configuration and scripts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
imeepos
2025-10-14 10:32:52 +08:00
commit 7e3f94bae3
71 changed files with 20800 additions and 0 deletions

212
utils/video-utils.ts Normal file
View File

@@ -0,0 +1,212 @@
import { AVPlaybackStatus } from 'expo-av';
export interface VideoMetadata {
width: number;
height: number;
duration: number;
aspectRatio: number;
orientation: 'portrait' | 'landscape' | 'square';
}
/**
* 从AVPlaybackStatus中提取视频元数据
*/
export function extractVideoMetadata(status: AVPlaybackStatus): VideoMetadata | null {
if (!status.isLoaded) {
return null;
}
// 尝试从不同的属性获取视频尺寸
let width = 0, height = 0;
// 检查是否有naturalSize属性
if ('naturalSize' in status && status.naturalSize) {
const naturalSize = status.naturalSize as any;
width = naturalSize.width || 0;
height = naturalSize.height || 0;
}
// 如果没有获取到尺寸,使用默认值
if (width === 0 || height === 0) {
width = 1920; // 默认宽度
height = 1080; // 默认高度
}
const duration = status.durationMillis ? status.durationMillis / 1000 : 0;
const aspectRatio = width > 0 ? width / height : 1;
// 判断视频方向
let orientation: 'portrait' | 'landscape' | 'square';
if (Math.abs(width - height) < 10) {
orientation = 'square';
} else if (width > height) {
orientation = 'landscape';
} else {
orientation = 'portrait';
}
return {
width,
height,
duration,
aspectRatio,
orientation,
};
}
/**
* 根据容器尺寸和视频元数据计算最佳显示尺寸
*/
export function calculateOptimalVideoSize(
containerWidth: number,
maxHeight: number,
metadata: VideoMetadata,
resizeMode: 'contain' | 'cover' | 'stretch' = 'contain'
): { width: number; height: number } {
const { aspectRatio } = metadata;
switch (resizeMode) {
case 'contain':
// 保持宽高比,完全显示在容器内
if (containerWidth / aspectRatio <= maxHeight) {
return {
width: containerWidth,
height: containerWidth / aspectRatio,
};
} else {
return {
width: maxHeight * aspectRatio,
height: maxHeight,
};
}
case 'cover':
// 保持宽高比,填满容器(可能裁剪)
if (containerWidth / aspectRatio >= maxHeight) {
return {
width: containerWidth,
height: containerWidth / aspectRatio,
};
} else {
return {
width: maxHeight * aspectRatio,
height: maxHeight,
};
}
case 'stretch':
// 拉伸填满容器
return {
width: containerWidth,
height: maxHeight,
};
default:
return {
width: containerWidth,
height: containerWidth / aspectRatio,
};
}
}
/**
* 格式化视频时长
*/
export function formatVideoDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (minutes === 0) {
return `${remainingSeconds}`;
} else if (remainingSeconds === 0) {
return `${minutes}分钟`;
} else {
return `${minutes}${remainingSeconds}`;
}
}
/**
* 格式化视频尺寸信息
*/
export function formatVideoSize(metadata: VideoMetadata): string {
const { width, height } = metadata;
if (width >= 1000 || height >= 1000) {
return `${(width / 1000).toFixed(1)}k × ${(height / 1000).toFixed(1)}k`;
} else {
return `${width} × ${height}`;
}
}
/**
* 检测视频文件类型
*/
export function getVideoFileType(url: string): string | null {
const extension = url.split('.').pop()?.toLowerCase();
const videoExtensions = [
'mp4', 'webm', 'ogg', 'mov', 'avi',
'mkv', 'flv', 'wmv', 'm4v', '3gp'
];
return extension && videoExtensions.includes(extension) ? extension : null;
}
/**
* 生成视频封面图的URI如果需要
*/
export function generateVideoPosterUrl(videoUrl: string): string {
// 某些CDN支持通过参数生成封面图
// 这里可以根据实际使用的视频服务进行调整
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
// YouTube视频封面图逻辑
const videoId = extractYouTubeVideoId(videoUrl);
return videoId ? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg` : '';
}
// 其他视频服务的封面图逻辑可以在这里添加
return '';
}
/**
* 从YouTube URL提取视频ID
*/
function extractYouTubeVideoId(url: string): string | null {
const regex = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/;
const match = url.match(regex);
return match ? match[1] : null;
}
/**
* 验证视频URL是否有效
*/
export function isValidVideoUrl(url: string): boolean {
if (!url || typeof url !== 'string') {
return false;
}
try {
const urlObj = new URL(url);
const isValidProtocol = ['http:', 'https:', 'ftp:'].includes(urlObj.protocol);
const hasVideoExtension = getVideoFileType(url) !== null;
return isValidProtocol && (hasVideoExtension || url.includes('stream'));
} catch {
return false;
}
}
/**
* 获取推荐的缩略图时间点视频的10%、30%、60%位置)
*/
export function getThumbnailTimepoints(duration: number): number[] {
if (duration <= 0) return [1];
const points = [
duration * 0.1,
duration * 0.3,
duration * 0.6,
];
return points.map(t => Math.max(1, Math.floor(t)));
}