Kurs vibe coding z AI · Moduł 10: TikTok Clone
Sounds Library i Video Creation
W tej lekcji5
TikTok's power to w sounds - audio tracks używane przez miliony wideo. Plus advanced video creation z effects!
Sounds Library
interface Sound {
id: string;
name: string;
artist: string;
duration: number;
thumbnailUrl: string;
// Stats
useCount: number; // ile videos używa tego sound
favorites: number;
isTrending: boolean;
// Categories
category: 'original' | 'music' | 'trending' | 'effects';
tags: string[];
createdAt: Date;
}
interface SoundUsage {
soundId: string;
startTime: number; // which second of sound to start (0-based)
volume: number; // 0-1
}Sounds Discovery Page
'use client';
import { useState } from 'react';
interface SoundsPageProps {
sounds: Sound[];
onUseSound: (soundId: string) => void;
onFavorite: (soundId: string) => void;
}
export default function SoundsPage({
sounds,
onUseSound,
onFavorite
}: SoundsPageProps) {
const [activeTab, setActiveTab] = useState<'trending' | 'favorites' | 'discover'>('trending');
const [searchQuery, setSearchQuery] = useState('');
const filteredSounds = sounds.filter(sound => {
if (searchQuery) {
return sound.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
sound.artist.toLowerCase().includes(searchQuery.toLowerCase());
}
if (activeTab === 'trending') return sound.isTrending;
if (activeTab === 'favorites') return sound.favorites > 1000;
return true;
});
return (
<div className="min-h-screen bg-black text-white">
{/* Header */}
<div className="sticky top-0 bg-black z-10 border-b border-gray-800">
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Sounds</h1>
{/* Search */}
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Szukaj dźwięków..."
className="w-full bg-gray-900 text-white px-4 py-3 rounded-lg outline-none"
/>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-800">
{['trending', 'favorites', 'discover'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab as any)}
className={`flex-1 py-3 text-sm font-semibold capitalize ${
activeTab === tab
? 'border-b-2 border-white'
: 'text-gray-500'
}`}
>
{tab}
</button>
))}
</div>
</div>
{/* Sounds List */}
<div className="p-4 space-y-3">
{filteredSounds.map(sound => (
<SoundCard
key={sound.id}
sound={sound}
onUse={() => onUseSound(sound.id)}
onFavorite={() => onFavorite(sound.id)}
/>
))}
</div>
</div>
);
}
function SoundCard({
sound,
onUse,
onFavorite
}: {
sound: Sound;
onUse: () => void;
onFavorite: () => void;
}) {
const [isPlaying, setIsPlaying] = useState(false);
return (
<div className="bg-gray-900 rounded-lg p-4 flex items-center gap-4">
{/* Thumbnail */}
<button
onClick={() => setIsPlaying(!isPlaying)}
className="relative w-16 h-16 rounded-lg overflow-hidden shrink-0"
>
<img
src={sound.thumbnailUrl}
alt={sound.name}
className={`w-full h-full object-cover ${isPlaying ? 'animate-pulse' : ''}`}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<span className="text-white text-2xl">
{isPlaying ? '⏸' : '▶'}
</span>
</div>
</button>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="font-bold text-white truncate">{sound.name}</h3>
<p className="text-sm text-gray-400 truncate">{sound.artist}</p>
<div className="flex items-center gap-3 mt-2 text-xs text-gray-500">
<span>{formatCount(sound.useCount)} videos</span>
{sound.isTrending && (
<span className="flex items-center gap-1 text-pink-500">
Trending
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<button
onClick={onUse}
className="px-4 py-2 bg-white text-black rounded-full text-sm font-semibold hover:bg-gray-200"
>
Use Sound
</button>
<button
onClick={onFavorite}
className="p-2 hover:bg-gray-800 rounded-full"
>
Ulubione
</button>
</div>
</div>
);
}
function formatCount(count: number): string {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
}Video Creation Flow
interface VideoCreationState {
// Recording
recordedChunks: Blob[];
isRecording: boolean;
recordingDuration: number;
maxDuration: number; // 15, 60, or 180 seconds
// Editing
selectedSound?: Sound;
soundVolume: number;
selectedEffect?: Effect;
filters: Filter[];
trimStart: number;
trimEnd: number;
// Caption
caption: string;
hashtags: string[];
mentions: string[];
// Privacy
privacy: 'public' | 'friends' | 'private';
allowComments: boolean;
allowDuet: boolean;
allowStitch: boolean;
}
interface Effect {
id: string;
name: string;
type: 'beauty' | 'ar' | 'green-screen' | 'time';
thumbnailUrl: string;
isPopular: boolean;
}
interface Filter {
id: string;
name: string;
intensity: number; // 0-1
}Video Recorder
'use client';
import { useState, useRef, useEffect } from 'react';
export default function VideoRecorder({
maxDuration = 60,
onComplete: (videoBlob: Blob, metadata: any) => void
}: {
maxDuration?: number;
onComplete: (videoBlob: Blob, metadata: any) => void;
}) {
const [isRecording, setIsRecording] = useState(false);
const [duration, setDuration] = useState(0);
const [selectedSound, setSelectedSound] = useState<Sound | null>(null);
const [selectedEffect, setSelectedEffect] = useState<Effect | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
useEffect(() => {
// Initialize camera
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
});
}, []);
const startRecording = () => {
if (!videoRef.current?.srcObject) return;
const stream = videoRef.current.srcObject as MediaStream;
const mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: 'video/webm' });
onComplete(blob, {
duration,
sound: selectedSound,
effect: selectedEffect
});
};
chunksRef.current = [];
mediaRecorder.start();
mediaRecorderRef.current = mediaRecorder;
setIsRecording(true);
// Duration counter
const interval = setInterval(() => {
setDuration(d => {
if (d >= maxDuration) {
stopRecording();
clearInterval(interval);
return d;
}
return d + 0.1;
});
}, 100);
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
setDuration(0);
}
};
return (
<div className="relative h-screen bg-black flex flex-col">
{/* Video Preview */}
<div className="flex-1 relative">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
/>
{/* Effect Overlay */}
{selectedEffect && (
<div className="absolute top-4 left-4 bg-black/50 px-3 py-2 rounded-full text-white text-sm">
{selectedEffect.name}
</div>
)}
{/* Sound Indicator */}
{selectedSound && (
<div className="absolute top-4 right-4 bg-black/50 px-3 py-2 rounded-full text-white text-sm flex items-center gap-2">
{selectedSound.name}
</div>
)}
{/* Recording Indicator */}
{isRecording && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className="w-20 h-20 border-4 border-red-500 rounded-full animate-pulse"></div>
</div>
)}
</div>
{/* Controls */}
<div className="bg-black border-t border-gray-800 p-6">
{/* Duration Bar */}
<div className="mb-6">
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
isRecording ? 'bg-red-500' : 'bg-gray-500'
}`}
style={{ width: `${(duration / maxDuration) * 100}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-xs text-gray-400">
<span>{duration.toFixed(1)}s</span>
<span>{maxDuration}s</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-around">
{/* Effects */}
<button className="flex flex-col items-center gap-1">
<div className="w-12 h-12 bg-gray-800 rounded-full flex items-center justify-center text-2xl">
◆
</div>
<span className="text-xs text-white">Effects</span>
</button>
{/* Record Button */}
<button
onClick={isRecording ? stopRecording : startRecording}
className={`w-20 h-20 rounded-full border-4 flex items-center justify-center transition ${
isRecording
? 'border-red-500 bg-red-500'
: 'border-white bg-transparent'
}`}
>
{isRecording && (
<div className="w-8 h-8 bg-white rounded"></div>
)}
</button>
{/* Sounds */}
<button className="flex flex-col items-center gap-1">
<div className="w-12 h-12 bg-gray-800 rounded-full flex items-center justify-center text-2xl">
◉
</div>
<span className="text-xs text-white">Sounds</span>
</button>
</div>
</div>
</div>
);
}Podsumowanie
Sounds library - discover, search, trending sounds Sound card - preview, play, use sound Use count - ile wideo używa sound Video recorder - camera access, recording, duration Effects - AR effects, filters, beauty mode Recording controls - start/stop, duration bar Sound selection - pick audio track for video Duration limits - 15s, 60s, 180s options
W następnym ćwiczeniu dodamy duet, stitch i advanced editing!
Do zobaczenia!
Kod do tej lekcji: App.jsx
// Nakładka z informacjami o wideo
const video = {
author: { username: 'CaptainJack', avatar: '', isVerified: true },
caption: 'Znalazłem legendarny skarb Corteza! To była epicka podróż przez siedem mórz',
hashtags: ['skarb', 'przygoda', 'życiepirata'],
likes: 125300,
comments: 3420,
shares: 892,
sound: { name: "Hymn piratów - Redbeard" },
hasLiked: false,
hasBookmarked: false,
isFollowingAuthor: false
};
import { useState } from 'react';
export default function App() {
const [videoState, setVideoState] = useState(video);
const formatCount = (count) => {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
};
return (
<div className="h-screen bg-gradient-to-br from-purple-900 to-blue-900 relative flex items-center justify-center">
<div className="text-9xl">◆</div>
{/* Akcje po prawej */}
<div className="absolute right-4 bottom-20 flex flex-col gap-6 items-center">
{/* Awatar */}
<div className="relative">
<div className="text-4xl">{videoState.author.avatar}</div>
{!videoState.isFollowingAuthor && (
<button
onClick={() => setVideoState({...videoState, isFollowingAuthor: true})}
className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-6 h-6 bg-red-500 rounded-full text-white flex items-center justify-center"
>
+
</button>
)}
{videoState.author.isVerified && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs">
√
</div>
)}
</div>
{/* Polubienie */}
<button
onClick={() => setVideoState({
...videoState,
hasLiked: !videoState.hasLiked,
likes: videoState.hasLiked ? videoState.likes - 1 : videoState.likes + 1
})}
className="flex flex-col items-center gap-1"
>
<div className={`text-sm font-semibold ${videoState.hasLiked ? 'text-red-500' : 'text-white'}`}>
{videoState.hasLiked ? 'Polubione' : 'Lubię to'}
</div>
<span className="text-white text-xs font-bold">
{formatCount(videoState.likes)}
</span>
</button>
{/* Komentarz */}
<button className="flex flex-col items-center gap-1">
<div className="text-sm font-semibold text-white">Komentarz</div>
<span className="text-white text-xs font-bold">
{formatCount(videoState.comments)}
</span>
</button>
{/* Zakładka */}
<button
onClick={() => setVideoState({...videoState, hasBookmarked: !videoState.hasBookmarked})}
className="flex flex-col items-center gap-1"
>
<div className={`text-sm font-semibold ${videoState.hasBookmarked ? 'text-yellow-400' : 'text-white'}`}>
{videoState.hasBookmarked ? 'Zapisane' : 'Zapisz'}
</div>
</button>
{/* Udostępnianie */}
<button className="flex flex-col items-center gap-1">
<div className="text-sm font-semibold text-white">Udostępnij</div>
<span className="text-white text-xs font-bold">
{formatCount(videoState.shares)}
</span>
</button>
{/* Dźwięk */}
<button className="w-10 h-10 rounded-full bg-gradient-to-r from-pink-500 to-purple-500 animate-spin-slow flex items-center justify-center text-xl">
◉
</button>
</div>
{/* Informacje na dole */}
<div className="absolute bottom-4 left-4 right-20">
<div className="flex items-center gap-2 mb-2">
<span className="text-white font-bold">@{videoState.author.username}</span>
{videoState.author.isVerified && <span className="text-blue-400">√</span>}
{videoState.isFollowingAuthor && (
<span className="text-xs bg-gray-700 px-2 py-0.5 rounded text-white">Obserwujesz</span>
)}
</div>
<p className="text-white text-sm mb-2 line-clamp-2">{videoState.caption}</p>
<div className="flex flex-wrap gap-2 mb-3">
{videoState.hashtags.map((tag, idx) => (
<span key={idx} className="text-white text-sm font-semibold">#{tag}</span>
))}
</div>
<div className="flex items-center gap-2 bg-black/40 px-3 py-1.5 rounded-full max-w-xs">
<span className="text-white text-xs">Muzyka</span>
<span className="text-white text-xs truncate">{videoState.sound.name}</span>
</div>
</div>
</div>
);
}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Po co są sounds w TikTok?
2. Co przechowuje pole useCount w interfejsie Sound?
To 2 z 5 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w pionie
Uporządkuj zakładki strony Sounds w kolejności z kodu (activeTab):
- Układanie w poziomie
Ułóż kroki tworzenia TikTok video:
- Edytor kodu
Sounds discovery
- Klikanie w kolejności
Zbuduj wyrażenie sprawdzające nazwę sound po małych literach (klikaj po kolei):
- Klikanie w kolejności
Zbuduj odczyt kategorii sound w warunku (klikaj po kolei):
- Układanie w poziomie
Ułóż początek wywołania dostępu do kamery i mikrofonu:
- Układanie w pionie
Uporządkuj kroki nagrywania wideo w VideoRecorder (od góry):
- Edytor kodu
Video recording interface
- Klikanie w kolejności
Zbuduj utworzenie Bloba z nagranych chunków (klikaj po kolei):
- Układanie w poziomie
Ułóż wyrażenie obliczające szerokość paska postępu nagrania: