Spaces:
Running
Running
Create a full-featured watch party platform where users can watch YouTube videos together in real-time, with synchronized playback and a modern UI/UX. 🎯 Core Requirements: ✅ Real-time sync: Video playback must stay synced for all users in the party room (like Teleparty, Watch2Gether). ✅ YouTube support: Use yt-dlp (mu38 supported) in the backend to fetch direct video/audio stream URLs for smooth playback. ✅ High quality playback: Allow up to 1080p+ streaming with lowest latency using HTML5 video player. ✅ Custom room system: Room create/join via unique link Host has control (play, pause, seek) Guest syncs automatically ✅ Chat box: Real-time text chat for viewers in the room. ✅ User presence: Show online/offline users with colored indicators (e.g. green dot). ✅ Responsive UI: Should work perfectly on desktop and mobile browsers. ✅ No YouTube Ads during playback (since yt-dlp is used). ✅ Optionally allow private rooms (password protected). ⚙️ Tech Stack Suggestions: Frontend: React + Tailwind CSS (or Next.js) Backend: Node.js + Socket.IO (for real-time), Express Streaming: yt-dlp (mu38 version), ffmpeg (if needed for format conversion) Database: MongoDB or any fast NoSQL (for room metadata) ✨ Extra Features (if possible): Dark/light mode toggle Video queue/playlist support Host transfer option Emoji reactions in chat Watch history for users 🔐 The entire system must be self-hostable on a VPS, and capable of running without YouTube API key. - Initial Deployment
e0b94eb verified | <html lang="en" class="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>SyncStream - Watch Videos Together</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <script src="https://cdn.jsdelivr.net/npm/socket.io-client@4.7.2/dist/socket.io.min.js"></script> | |
| <style> | |
| /* Custom scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #1e293b; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #475569; | |
| border-radius: 4px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #64748b; | |
| } | |
| /* Video player aspect ratio */ | |
| .video-container { | |
| position: relative; | |
| padding-bottom: 56.25%; /* 16:9 */ | |
| height: 0; | |
| overflow: hidden; | |
| } | |
| .video-container iframe, | |
| .video-container video { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* Animation for new messages */ | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(5px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .message-animate { | |
| animation: fadeIn 0.3s ease-out; | |
| } | |
| /* Custom toggle switch */ | |
| .toggle-checkbox:checked { | |
| right: 0; | |
| border-color: #3b82f6; | |
| } | |
| .toggle-checkbox:checked + .toggle-label { | |
| background-color: #3b82f6; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200"> | |
| <!-- Navigation --> | |
| <nav class="bg-white dark:bg-gray-800 shadow-md py-4 px-6 flex justify-between items-center"> | |
| <div class="flex items-center space-x-2"> | |
| <i class="fas fa-film text-blue-500 text-2xl"></i> | |
| <h1 class="text-xl font-bold">SyncStream</h1> | |
| </div> | |
| <div class="flex items-center space-x-4"> | |
| <button id="theme-toggle" class="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"> | |
| <i class="fas fa-moon dark:hidden"></i> | |
| <i class="fas fa-sun hidden dark:inline"></i> | |
| </button> | |
| <button id="user-menu" class="flex items-center space-x-2"> | |
| <div class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-semibold">U</div> | |
| <span class="hidden md:inline">User</span> | |
| </button> | |
| </div> | |
| </nav> | |
| <!-- Main Content --> | |
| <div class="container mx-auto px-4 py-8"> | |
| <!-- Room Controls --> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg mb-6 p-6"> | |
| <div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0"> | |
| <div class="flex items-center space-x-4"> | |
| <div class="relative"> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
| <i class="fas fa-link text-gray-400"></i> | |
| </div> | |
| <input type="text" id="room-link" class="pl-10 pr-4 py-2 border rounded-lg w-full md:w-64 bg-gray-50 dark:bg-gray-700 border-gray-300 dark:border-gray-600" value="https://syncstream.party/room/abc123" readonly> | |
| </div> | |
| <button id="copy-link" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"> | |
| <i class="fas fa-copy mr-2"></i>Copy Link | |
| </button> | |
| </div> | |
| <div class="flex items-center space-x-4"> | |
| <div class="flex items-center space-x-2"> | |
| <i class="fas fa-users text-gray-500 dark:text-gray-400"></i> | |
| <span id="user-count" class="font-medium">1</span> | |
| </div> | |
| <div class="relative"> | |
| <button id="settings-toggle" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition"> | |
| <i class="fas fa-cog mr-2"></i>Settings | |
| </button> | |
| <div id="settings-dropdown" class="hidden absolute right-0 mt-2 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 z-10"> | |
| <div class="flex items-center justify-between mb-4"> | |
| <span>Dark Mode</span> | |
| <div class="relative inline-block w-10 mr-2 align-middle select-none"> | |
| <input type="checkbox" id="dark-mode-toggle" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/> | |
| <label for="dark-mode-toggle" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label> | |
| </div> | |
| </div> | |
| <div class="flex items-center justify-between mb-4"> | |
| <span>Private Room</span> | |
| <div class="relative inline-block w-10 mr-2 align-middle select-none"> | |
| <input type="checkbox" id="private-room-toggle" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/> | |
| <label for="private-room-toggle" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label> | |
| </div> | |
| </div> | |
| <div id="password-field" class="hidden mb-4"> | |
| <label for="room-password" class="block text-sm font-medium mb-1">Room Password</label> | |
| <input type="password" id="room-password" class="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-gray-700 border-gray-300 dark:border-gray-600"> | |
| </div> | |
| <button id="transfer-host" class="w-full px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition mb-2"> | |
| <i class="fas fa-crown mr-2"></i>Transfer Host | |
| </button> | |
| <button id="leave-room" class="w-full px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition"> | |
| <i class="fas fa-sign-out-alt mr-2"></i>Leave Room | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex flex-col lg:flex-row gap-6"> | |
| <!-- Video Player Section --> | |
| <div class="lg:w-2/3"> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden"> | |
| <!-- Video Player --> | |
| <div class="video-container"> | |
| <video id="video-player" class="w-full" controls></video> | |
| </div> | |
| <!-- Video Controls --> | |
| <div class="p-4 bg-gray-50 dark:bg-gray-700"> | |
| <div class="flex items-center justify-between mb-4"> | |
| <div class="flex items-center space-x-4"> | |
| <button id="play-pause" class="w-10 h-10 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 transition"> | |
| <i class="fas fa-play" id="play-icon"></i> | |
| </button> | |
| <div class="flex items-center space-x-2"> | |
| <button id="volume-btn" class="w-8 h-8 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100"> | |
| <i class="fas fa-volume-up" id="volume-icon"></i> | |
| </button> | |
| <input type="range" id="volume-slider" min="0" max="1" step="0.01" value="1" class="w-20"> | |
| </div> | |
| </div> | |
| <div class="flex items-center space-x-4"> | |
| <div class="text-sm text-gray-600 dark:text-gray-300" id="current-time">0:00</div> | |
| <div class="flex items-center"> | |
| <input type="range" id="progress-bar" min="0" max="100" value="0" class="w-64 md:w-96"> | |
| </div> | |
| <div class="text-sm text-gray-600 dark:text-gray-300" id="duration">0:00</div> | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <button id="quality-btn" class="px-3 py-1 bg-gray-200 dark:bg-gray-600 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-500 transition"> | |
| <span id="quality-text">Auto</span> | |
| <i class="fas fa-caret-down ml-1"></i> | |
| </button> | |
| <div id="quality-dropdown" class="hidden absolute mt-2 w-32 bg-white dark:bg-gray-800 rounded-lg shadow-lg py-1 z-10"> | |
| <a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700" data-quality="auto">Auto</a> | |
| <a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700" data-quality="1080">1080p</a> | |
| <a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700" data-quality="720">720p</a> | |
| <a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700" data-quality="480">480p</a> | |
| </div> | |
| <button id="fullscreen-btn" class="w-8 h-8 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100"> | |
| <i class="fas fa-expand"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Video Info --> | |
| <div class="mb-4"> | |
| <h2 class="text-xl font-bold mb-1" id="video-title">No video selected</h2> | |
| <p class="text-gray-600 dark:text-gray-300 text-sm" id="video-description">Add a video to start watching together</p> | |
| </div> | |
| <!-- Video URL Input --> | |
| <div class="flex space-x-2"> | |
| <div class="flex-grow relative"> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
| <i class="fas fa-link text-gray-400"></i> | |
| </div> | |
| <input type="text" id="video-url" class="pl-10 pr-4 py-2 border rounded-lg w-full bg-gray-50 dark:bg-gray-600 border-gray-300 dark:border-gray-500" placeholder="Paste YouTube URL here"> | |
| </div> | |
| <button id="add-video" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"> | |
| <i class="fas fa-plus mr-2"></i>Add | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Playlist --> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg mt-6 overflow-hidden"> | |
| <div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> | |
| <h3 class="font-semibold text-lg"> | |
| <i class="fas fa-list-ol mr-2 text-blue-500"></i>Playlist | |
| </h3> | |
| <button id="clear-playlist" class="text-sm text-red-500 hover:text-red-600"> | |
| <i class="fas fa-trash mr-1"></i>Clear All | |
| </button> | |
| </div> | |
| <div class="divide-y divide-gray-200 dark:divide-gray-700 max-h-64 overflow-y-auto" id="playlist-container"> | |
| <div class="p-4 flex items-center hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"> | |
| <div class="w-16 h-10 bg-gray-200 dark:bg-gray-600 rounded flex-shrink-0 overflow-hidden"> | |
| <img src="https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg" class="w-full h-full object-cover"> | |
| </div> | |
| <div class="ml-3 flex-grow"> | |
| <h4 class="font-medium text-sm line-clamp-1">Never Gonna Give You Up</h4> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">Rick Astley • 3:32</p> | |
| </div> | |
| <button class="text-gray-400 hover:text-red-500"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <!-- More playlist items will be added here dynamically --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Chat Section --> | |
| <div class="lg:w-1/3"> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden h-full flex flex-col"> | |
| <div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> | |
| <h3 class="font-semibold text-lg"> | |
| <i class="fas fa-comments mr-2 text-blue-500"></i>Chat | |
| </h3> | |
| <div class="flex items-center space-x-2"> | |
| <button id="emoji-picker-btn" class="w-8 h-8 text-gray-600 dark:text-gray-300 hover:text-blue-500"> | |
| <i class="far fa-smile"></i> | |
| </button> | |
| <div id="emoji-picker" class="hidden absolute right-0 mt-2 w-64 h-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 z-10 overflow-y-auto"> | |
| <!-- Emoji categories and emojis will be added here --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Chat Messages --> | |
| <div class="flex-grow p-4 overflow-y-auto" id="chat-messages"> | |
| <div class="message mb-4 message-animate"> | |
| <div class="flex items-start"> | |
| <div class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-semibold mr-2">S</div> | |
| <div> | |
| <div class="flex items-center"> | |
| <span class="font-semibold mr-2">System</span> | |
| <span class="text-xs text-gray-500 dark:text-gray-400">Just now</span> | |
| </div> | |
| <p class="text-sm mt-1">Welcome to the room! Start watching videos together with your friends.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- More chat messages will be added here dynamically --> | |
| </div> | |
| <!-- Chat Input --> | |
| <div class="p-4 border-t border-gray-200 dark:border-gray-700"> | |
| <div class="flex space-x-2"> | |
| <input type="text" id="chat-input" class="flex-grow px-4 py-2 border rounded-lg bg-gray-50 dark:bg-gray-700 border-gray-300 dark:border-gray-600" placeholder="Type a message..."> | |
| <button id="send-message" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"> | |
| <i class="fas fa-paper-plane"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Users Online --> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg mt-6 overflow-hidden"> | |
| <div class="p-4 border-b border-gray-200 dark:border-gray-700"> | |
| <h3 class="font-semibold text-lg"> | |
| <i class="fas fa-users mr-2 text-blue-500"></i>Users Online | |
| </h3> | |
| </div> | |
| <div class="divide-y divide-gray-200 dark:divide-gray-700 max-h-64 overflow-y-auto" id="users-list"> | |
| <div class="p-3 flex items-center"> | |
| <div class="relative"> | |
| <div class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-semibold mr-2">Y</div> | |
| <div class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white dark:border-gray-800"></div> | |
| </div> | |
| <div class="ml-2"> | |
| <p class="font-medium">You (Host)</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">Watching</p> | |
| </div> | |
| </div> | |
| <!-- More users will be added here dynamically --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Modals --> | |
| <!-- Create/Join Room Modal --> | |
| <div id="room-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-xl font-bold">Join a Watch Party</h3> | |
| <button id="close-room-modal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div class="space-y-4"> | |
| <div> | |
| <label for="room-id" class="block text-sm font-medium mb-1">Room ID</label> | |
| <input type="text" id="room-id" class="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-gray-700 border-gray-300 dark:border-gray-600" placeholder="Enter room ID"> | |
| </div> | |
| <div id="password-input" class="hidden"> | |
| <label for="join-password" class="block text-sm font-medium mb-1">Room Password</label> | |
| <input type="password" id="join-password" class="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-gray-700 border-gray-300 dark:border-gray-600" placeholder="Enter password"> | |
| </div> | |
| <div class="flex space-x-3 pt-2"> | |
| <button id="join-room" class="flex-grow px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"> | |
| Join Room | |
| </button> | |
| <button id="create-room" class="flex-grow px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition"> | |
| Create Room | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Transfer Host Modal --> | |
| <div id="transfer-host-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> | |
| <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-xl font-bold">Transfer Host</h3> | |
| <button id="close-transfer-modal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div class="space-y-4"> | |
| <p class="text-gray-600 dark:text-gray-300">Select a user to transfer host privileges to:</p> | |
| <div class="max-h-60 overflow-y-auto" id="transfer-users-list"> | |
| <!-- Users will be listed here --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // DOM Elements | |
| const videoPlayer = document.getElementById('video-player'); | |
| const playPauseBtn = document.getElementById('play-pause'); | |
| const playIcon = document.getElementById('play-icon'); | |
| const progressBar = document.getElementById('progress-bar'); | |
| const currentTimeDisplay = document.getElementById('current-time'); | |
| const durationDisplay = document.getElementById('duration'); | |
| const volumeBtn = document.getElementById('volume-btn'); | |
| const volumeIcon = document.getElementById('volume-icon'); | |
| const volumeSlider = document.getElementById('volume-slider'); | |
| const fullscreenBtn = document.getElementById('fullscreen-btn'); | |
| const qualityBtn = document.getElementById('quality-btn'); | |
| const qualityDropdown = document.getElementById('quality-dropdown'); | |
| const qualityText = document.getElementById('quality-text'); | |
| const videoUrlInput = document.getElementById('video-url'); | |
| const addVideoBtn = document.getElementById('add-video'); | |
| const videoTitle = document.getElementById('video-title'); | |
| const videoDescription = document.getElementById('video-description'); | |
| const playlistContainer = document.getElementById('playlist-container'); | |
| const clearPlaylistBtn = document.getElementById('clear-playlist'); | |
| const chatInput = document.getElementById('chat-input'); | |
| const sendMessageBtn = document.getElementById('send-message'); | |
| const chatMessages = document.getElementById('chat-messages'); | |
| const emojiPickerBtn = document.getElementById('emoji-picker-btn'); | |
| const emojiPicker = document.getElementById('emoji-picker'); | |
| const usersList = document.getElementById('users-list'); | |
| const userCount = document.getElementById('user-count'); | |
| const roomLink = document.getElementById('room-link'); | |
| const copyLinkBtn = document.getElementById('copy-link'); | |
| const settingsToggle = document.getElementById('settings-toggle'); | |
| const settingsDropdown = document.getElementById('settings-dropdown'); | |
| const darkModeToggle = document.getElementById('dark-mode-toggle'); | |
| const privateRoomToggle = document.getElementById('private-room-toggle'); | |
| const passwordField = document.getElementById('password-field'); | |
| const roomPassword = document.getElementById('room-password'); | |
| const transferHostBtn = document.getElementById('transfer-host'); | |
| const leaveRoomBtn = document.getElementById('leave-room'); | |
| const themeToggle = document.getElementById('theme-toggle'); | |
| const roomModal = document.getElementById('room-modal'); | |
| const closeRoomModal = document.getElementById('close-room-modal'); | |
| const joinRoomBtn = document.getElementById('join-room'); | |
| const createRoomBtn = document.getElementById('create-room'); | |
| const roomIdInput = document.getElementById('room-id'); | |
| const passwordInput = document.getElementById('password-input'); | |
| const joinPassword = document.getElementById('join-password'); | |
| const transferHostModal = document.getElementById('transfer-host-modal'); | |
| const closeTransferModal = document.getElementById('close-transfer-modal'); | |
| const transferUsersList = document.getElementById('transfer-users-list'); | |
| // State | |
| let isHost = true; | |
| let currentVideo = null; | |
| let playlist = []; | |
| let users = []; | |
| let socket = null; | |
| let roomId = 'abc123'; // This would be generated or from URL in a real app | |
| let isPlaying = false; | |
| let currentVolume = 1; | |
| let isMuted = false; | |
| let currentQuality = 'auto'; | |
| let isDarkMode = localStorage.getItem('darkMode') === 'true' || | |
| (!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches); | |
| // Initialize | |
| document.documentElement.classList.toggle('dark', isDarkMode); | |
| darkModeToggle.checked = isDarkMode; | |
| // Mock socket connection (in a real app, this would connect to your backend) | |
| function connectSocket() { | |
| console.log('Connecting to socket server...'); | |
| // In a real app: socket = io('https://your-backend-url'); | |
| // Mock socket behavior | |
| setTimeout(() => { | |
| console.log('Socket connected'); | |
| addSystemMessage('Connected to the room'); | |
| updateUserList([ | |
| { id: 'user1', name: 'You', isHost: true, status: 'online', watching: true }, | |
| { id: 'user2', name: 'Friend 1', isHost: false, status: 'online', watching: true }, | |
| { id: 'user3', name: 'Friend 2', isHost: false, status: 'online', watching: false } | |
| ]); | |
| }, 500); | |
| } | |
| // Event Listeners | |
| themeToggle.addEventListener('click', toggleDarkMode); | |
| darkModeToggle.addEventListener('change', toggleDarkMode); | |
| privateRoomToggle.addEventListener('change', function() { | |
| passwordField.classList.toggle('hidden', !this.checked); | |
| }); | |
| settingsToggle.addEventListener('click', function() { | |
| settingsDropdown.classList.toggle('hidden'); | |
| }); | |
| document.addEventListener('click', function(event) { | |
| if (!settingsToggle.contains(event.target) && !settingsDropdown.contains(event.target)) { | |
| settingsDropdown.classList.add('hidden'); | |
| } | |
| if (!qualityBtn.contains(event.target) && !qualityDropdown.contains(event.target)) { | |
| qualityDropdown.classList.add('hidden'); | |
| } | |
| if (!emojiPickerBtn.contains(event.target) && !emojiPicker.contains(event.target)) { | |
| emojiPicker.classList.add('hidden'); | |
| } | |
| }); | |
| qualityBtn.addEventListener('click', function() { | |
| qualityDropdown.classList.toggle('hidden'); | |
| }); | |
| document.querySelectorAll('#quality-dropdown a').forEach(item => { | |
| item.addEventListener('click', function(e) { | |
| e.preventDefault(); | |
| currentQuality = this.dataset.quality; | |
| qualityText.textContent = currentQuality === 'auto' ? 'Auto' : `${currentQuality}p`; | |
| qualityDropdown.classList.add('hidden'); | |
| // In a real app, this would change the video quality | |
| console.log(`Changed quality to ${currentQuality}`); | |
| }); | |
| }); | |
| playPauseBtn.addEventListener('click', togglePlayPause); | |
| videoPlayer.addEventListener('play', function() { | |
| isPlaying = true; | |
| playIcon.className = 'fas fa-pause'; | |
| }); | |
| videoPlayer.addEventListener('pause', function() { | |
| isPlaying = false; | |
| playIcon.className = 'fas fa-play'; | |
| }); | |
| videoPlayer.addEventListener('timeupdate', updateProgressBar); | |
| videoPlayer.addEventListener('durationchange', updateDuration); | |
| videoPlayer.addEventListener('volumechange', updateVolumeUI); | |
| progressBar.addEventListener('input', function() { | |
| const seekTime = videoPlayer.duration * (this.value / 100); | |
| videoPlayer.currentTime = seekTime; | |
| // In a real app, this would sync with other users | |
| if (isHost) { | |
| // socket.emit('seek', { time: seekTime }); | |
| } | |
| }); | |
| volumeBtn.addEventListener('click', toggleMute); | |
| volumeSlider.addEventListener('input', function() { | |
| videoPlayer.volume = this.value; | |
| currentVolume = this.value; | |
| updateVolumeUI(); | |
| }); | |
| fullscreenBtn.addEventListener('click', toggleFullscreen); | |
| addVideoBtn.addEventListener('click', addVideoToPlaylist); | |
| videoUrlInput.addEventListener('keypress', function(e) { | |
| if (e.key === 'Enter') addVideoToPlaylist(); | |
| }); | |
| clearPlaylistBtn.addEventListener('click', clearPlaylist); | |
| sendMessageBtn.addEventListener('click', sendMessage); | |
| chatInput.addEventListener('keypress', function(e) { | |
| if (e.key === 'Enter') sendMessage(); | |
| }); | |
| emojiPickerBtn.addEventListener('click', function() { | |
| emojiPicker.classList.toggle('hidden'); | |
| // In a real app, this would load emoji categories and emojis | |
| }); | |
| copyLinkBtn.addEventListener('click', copyRoomLink); | |
| transferHostBtn.addEventListener('click', openTransferHostModal); | |
| leaveRoomBtn.addEventListener('click', leaveRoom); | |
| closeRoomModal.addEventListener('click', function() { | |
| roomModal.classList.add('hidden'); | |
| }); | |
| joinRoomBtn.addEventListener('click', joinRoom); | |
| createRoomBtn.addEventListener('click', createRoom); | |
| closeTransferModal.addEventListener('click', function() { | |
| transferHostModal.classList.add('hidden'); | |
| }); | |
| // Functions | |
| function toggleDarkMode() { | |
| isDarkMode = !isDarkMode; | |
| document.documentElement.classList.toggle('dark', isDarkMode); | |
| localStorage.setItem('darkMode', isDarkMode); | |
| darkModeToggle.checked = isDarkMode; | |
| } | |
| function togglePlayPause() { | |
| if (isPlaying) { | |
| videoPlayer.pause(); | |
| // In a real app, this would sync with other users | |
| if (isHost) { | |
| // socket.emit('pause'); | |
| } | |
| } else { | |
| videoPlayer.play(); | |
| // In a real app, this would sync with other users | |
| if (isHost) { | |
| // socket.emit('play'); | |
| } | |
| } | |
| } | |
| function updateProgressBar() { | |
| const percent = (videoPlayer.currentTime / videoPlayer.duration) * 100; | |
| progressBar.value = percent; | |
| // Update time display | |
| currentTimeDisplay.textContent = formatTime(videoPlayer.currentTime); | |
| } | |
| function updateDuration() { | |
| durationDisplay.textContent = formatTime(videoPlayer.duration); | |
| } | |
| function formatTime(seconds) { | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| return `${mins}:${secs < 10 ? '0' : ''}${secs}`; | |
| } | |
| function updateVolumeUI() { | |
| if (videoPlayer.muted || videoPlayer.volume === 0) { | |
| volumeIcon.className = 'fas fa-volume-mute'; | |
| isMuted = true; | |
| } else { | |
| isMuted = false; | |
| if (videoPlayer.volume >= 0.5) { | |
| volumeIcon.className = 'fas fa-volume-up'; | |
| } else if (videoPlayer.volume > 0) { | |
| volumeIcon.className = 'fas fa-volume-down'; | |
| } else { | |
| volumeIcon.className = 'fas fa-volume-off'; | |
| } | |
| } | |
| volumeSlider.value = videoPlayer.volume; | |
| } | |
| function toggleMute() { | |
| videoPlayer.muted = !videoPlayer.muted; | |
| updateVolumeUI(); | |
| } | |
| function toggleFullscreen() { | |
| if (!document.fullscreenElement) { | |
| videoPlayer.requestFullscreen().catch(err => { | |
| console.error(`Error attempting to enable fullscreen: ${err.message}`); | |
| }); | |
| } else { | |
| document.exitFullscreen(); | |
| } | |
| } | |
| function addVideoToPlaylist() { | |
| const url = videoUrlInput.value.trim(); | |
| if (!url) return; | |
| // In a real app, this would validate the URL and fetch video info from your backend | |
| // For demo purposes, we'll mock this | |
| const videoId = extractVideoId(url) || 'dQw4w9WgXcQ'; // Default to Rick Astley if invalid | |
| const videoInfo = { | |
| id: videoId, | |
| title: "Sample Video Title", | |
| duration: 183, // 3:03 in seconds | |
| thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, | |
| url: url | |
| }; | |
| playlist.push(videoInfo); | |
| renderPlaylist(); | |
| if (!currentVideo) { | |
| playVideo(videoInfo); | |
| } | |
| videoUrlInput.value = ''; | |
| // In a real app, this would sync with other users | |
| if (isHost) { | |
| // socket.emit('addToPlaylist', videoInfo); | |
| } | |
| } | |
| function extractVideoId(url) { | |
| // Simple URL parsing - in a real app you'd want more robust validation | |
| const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; | |
| const match = url.match(regExp); | |
| return (match && match[2].length === 11) ? match[2] : null; | |
| } | |
| function renderPlaylist() { | |
| playlistContainer.innerHTML = ''; | |
| playlist.forEach((video, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'p-4 flex items-center hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer'; | |
| if (currentVideo && currentVideo.id === video.id) { | |
| item.classList.add('bg-blue-50', 'dark:bg-blue-900'); | |
| } | |
| item.innerHTML = ` | |
| <div class="w-16 h-10 bg-gray-200 dark:bg-gray-600 rounded flex-shrink-0 overflow-hidden"> | |
| <img src="${video.thumbnail}" class="w-full h-full object-cover"> | |
| </div> | |
| <div class="ml-3 flex-grow"> | |
| <h4 class="font-medium text-sm line-clamp-1">${video.title}</h4> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">${formatTime(video.duration)}</p> | |
| </div> | |
| <button class="text-gray-400 hover:text-red-500 remove-video" data-index="${index}"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| `; | |
| item.addEventListener('click', () => { | |
| playVideo(video); | |
| }); | |
| playlistContainer.appendChild(item); | |
| }); | |
| // Add event listeners to remove buttons | |
| document.querySelectorAll('.remove-video').forEach(btn => { | |
| btn.addEventListener('click', function(e) { | |
| e.stopPropagation(); | |
| const index = parseInt(this.dataset.index); | |
| removeFromPlaylist(index); | |
| }); | |
| }); | |
| } | |
| function playVideo(video) { | |
| currentVideo = video; | |
| videoTitle.textContent = video.title; | |
| videoDescription.textContent = `Added by You • ${formatTime(video.duration)}`; | |
| // In a real app, this would use yt-dlp to get the direct stream URL | |
| // For demo, we'll just show a placeholder | |
| videoPlayer.src = ''; | |
| videoPlayer.poster = video.thumbnail; | |
| renderPlaylist(); | |
| // In a real app, this would sync with other users | |
| if (isHost) { | |
| // socket.emit('playVideo', video); | |
| } | |
| } | |
| function removeFromPlaylist(index) { | |
| playlist.splice(index, 1); | |
| renderPlaylist(); | |
| // In a real app, this would sync with other users | |
| if (isHost) { | |
| // socket.emit('removeFromPlaylist', index); | |
| } | |
| } | |
| function clearPlaylist() { | |
| playlist = []; | |
| renderPlaylist(); | |
| currentVideo = null; | |
| videoPlayer.src = ''; | |
| videoTitle.textContent = 'No video selected'; | |
| videoDescription.textContent = 'Add a video to start watching together'; | |
| // In a real app, this would sync with other users | |
| if (isHost) { | |
| // socket.emit('clearPlaylist'); | |
| } | |
| } | |
| function sendMessage() { | |
| const message = chatInput.value.trim(); | |
| if (!message) return; | |
| // In a real app, this would send via socket | |
| // socket.emit('chatMessage', { text: message }); | |
| addChatMessage('You', message, new Date(), true); | |
| chatInput.value = ''; | |
| } | |
| function addChatMessage(sender, text, timestamp, isSelf = false) { | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = `message mb-4 message-animate ${isSelf ? 'self-message' : ''}`; | |
| const timeString = formatChatTime(timestamp); | |
| messageDiv.innerHTML = ` | |
| <div class="flex items-start ${isSelf ? 'flex-row-reverse' : ''}"> | |
| <div class="w-8 h-8 rounded-full ${isSelf ? 'bg-blue-500' : 'bg-purple-500'} flex items-center justify-center text-white font-semibold ${isSelf ? 'ml-2' : 'mr-2'}"> | |
| ${sender.charAt(0).toUpperCase()} | |
| </div> | |
| <div class="${isSelf ? 'text-right' : ''}"> | |
| <div class="flex items-center ${isSelf ? 'justify-end' : ''}"> | |
| <span class="font-semibold mr-2">${isSelf ? 'You' : sender}</span> | |
| <span class="text-xs text-gray-500 dark:text-gray-400">${timeString}</span> | |
| </div> | |
| <p class="text-sm mt-1 ${isSelf ? 'bg-blue-500 text-white' : 'bg-gray-100 dark:bg-gray-700'} rounded-lg px-3 py-2 inline-block">${text}</p> | |
| </div> | |
| </div> | |
| `; | |
| chatMessages.appendChild(messageDiv); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| function addSystemMessage(text) { | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = 'message mb-4 message-animate'; | |
| messageDiv.innerHTML = ` | |
| <div class="flex items-start"> | |
| <div class="w-8 h-8 rounded-full bg-gray-500 flex items-center justify-center text-white font-semibold mr-2"> | |
| <i class="fas fa-info-circle"></i> | |
| </div> | |
| <div> | |
| <div class="flex items-center"> | |
| <span class="font-semibold mr-2">System</span> | |
| <span class="text-xs text-gray-500 dark:text-gray-400">just now</span> | |
| </div> | |
| <p class="text-sm mt-1">${text}</p> | |
| </div> | |
| </div> | |
| `; | |
| chatMessages.appendChild(messageDiv); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| function formatChatTime(date) { | |
| const now = new Date(); | |
| const diff = now - date; | |
| if (diff < 60000) return 'just now'; | |
| if (diff < 3600000) return `${Math.floor(diff / 60000)} min ago`; | |
| return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| } | |
| function updateUserList(userList) { | |
| users = userList; | |
| usersList.innerHTML = ''; | |
| let onlineCount = 0; | |
| userList.forEach(user => { | |
| if (user.status === 'online') onlineCount++; | |
| const userDiv = document.createElement('div'); | |
| userDiv.className = 'p-3 flex items-center'; | |
| userDiv.innerHTML = ` | |
| <div class="relative"> | |
| <div class="w-8 h-8 rounded-full ${user.isHost ? 'bg-yellow-500' : 'bg-green-500'} flex items-center justify-center text-white font-semibold mr-2"> | |
| ${user.name.charAt(0).toUpperCase()} | |
| </div> | |
| <div class="absolute bottom-0 right-0 w-3 h-3 ${user.status === 'online' ? 'bg-green-500' : 'bg-gray-500'} rounded-full border-2 border-white dark:border-gray-800"></div> | |
| </div> | |
| <div class="ml-2"> | |
| <p class="font-medium">${user.name}${user.isHost ? ' (Host)' : ''}</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">${user.watching ? 'Watching' : 'Idle'}</p> | |
| </div> | |
| `; | |
| usersList.appendChild(userDiv); | |
| }); | |
| userCount.textContent = onlineCount; | |
| } | |
| function copyRoomLink() { | |
| roomLink.select(); | |
| document.execCommand('copy'); | |
| // Show feedback | |
| const originalText = copyLinkBtn.innerHTML; | |
| copyLinkBtn.innerHTML = '<i class="fas fa-check mr-2"></i>Copied!'; | |
| setTimeout(() => { | |
| copyLinkBtn.innerHTML = originalText; | |
| }, 2000); | |
| } | |
| function openTransferHostModal() { | |
| transferUsersList.innerHTML = ''; | |
| // Filter out yourself and offline users | |
| const eligibleUsers = users.filter(user => | |
| user.id !== 'user1' && user.status === 'online' | |
| ); | |
| if (eligibleUsers.length === 0) { | |
| transferUsersList.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-4">No eligible users to transfer to</p>'; | |
| } else { | |
| eligibleUsers.forEach(user => { | |
| const userItem = document.createElement('div'); | |
| userItem.className = 'p-3 flex items-center hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer rounded-lg'; | |
| userItem.innerHTML = ` | |
| <div class="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center text-white font-semibold mr-3"> | |
| ${user.name.charAt(0).toUpperCase()} | |
| </div> | |
| <div> | |
| <p class="font-medium">${user.name}</p> | |
| </div> | |
| `; | |
| userItem.addEventListener('click', () => { | |
| // In a real app, this would transfer host via socket | |
| // socket.emit('transferHost', user.id); | |
| addSystemMessage(`You transferred host privileges to ${user.name}`); | |
| isHost = false; | |
| transferHostModal.classList.add('hidden'); | |
| }); | |
| transferUsersList.appendChild(userItem); | |
| }); | |
| } | |
| transferHostModal.classList.remove('hidden'); | |
| } | |
| function leaveRoom() { | |
| // In a real app, this would disconnect from socket | |
| addSystemMessage('You left the room'); | |
| // Redirect to home or show room modal | |
| roomModal.classList.remove('hidden'); | |
| } | |
| function joinRoom() { | |
| const roomId = roomIdInput.value.trim(); | |
| if (!roomId) return; | |
| const password = privateRoomToggle.checked ? joinPassword.value.trim() : null; | |
| // In a real app, this would validate with the backend | |
| addSystemMessage(`Joined room ${roomId}`); | |
| roomModal.classList.add('hidden'); | |
| isHost = false; | |
| } | |
| function createRoom() { | |
| // In a real app, this would create a room on the backend | |
| const roomId = generateRoomId(); | |
| const isPrivate = privateRoomToggle.checked; | |
| const password = isPrivate ? roomPassword.value.trim() : null; | |
| addSystemMessage(`Created new room ${roomId}`); | |
| roomModal.classList.add('hidden'); | |
| isHost = true; | |
| roomLink.value = `https://syncstream.party/room/${roomId}`; | |
| } | |
| function generateRoomId() { | |
| // Simple ID generator - in a real app you'd want something more robust | |
| return Math.random().toString(36).substring(2, 8).toUpperCase(); | |
| } | |
| // Initialize the app | |
| connectSocket(); | |
| // For demo purposes, add some mock data | |
| setTimeout(() => { | |
| addChatMessage('Friend 1', 'Hey everyone! Ready to watch some videos?', new Date(Date.now() - 120000)); | |
| addChatMessage('Friend 2', 'Yeah! What should we watch first?', new Date(Date.now() - 60000)); | |
| // Add a sample video to playlist | |
| const sampleVideo = { | |
| id: 'dQw4w9WgXcQ', | |
| title: "Never Gonna Give You Up", | |
| duration: 212, | |
| thumbnail: "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", | |
| url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" | |
| }; | |
| playlist.push(sampleVideo); | |
| renderPlaylist(); | |
| }, 1000); | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=Ahmadraza76/yp" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |