Kurs vibe coding z AI · Moduł 10: TikTok Clone

Sounds Library i Video Creation

6 min czytania
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. 1. Po co są sounds w TikTok?

  2. 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:

Przydatne artykuły