mirror of
https://github.com/barkeser2002/offline-db.git
synced 2026-09-25 00:59:54 +03:00
- Added `focus-visible:ring-2` to buttons, links, and inputs across templates. - Added `focus-within` to anime cards. - Added `aria-label` to chat inputs and send buttons. - Documented learning in `.jules/palette.md`. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
306 lines
13 KiB
HTML
306 lines
13 KiB
HTML
{% extends 'base.html' %}
|
|
|
|
{% block content %}
|
|
<div class="max-w-7xl mx-auto px-4">
|
|
<div class="mb-4 flex justify-between items-end">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-white">Watch Party: {{ episode.season.anime.title }}</h1>
|
|
<h2 class="text-xl text-gray-400">Host: {{ party.host.username }} | Episode {{ episode.number }}</h2>
|
|
</div>
|
|
<button onclick="navigator.clipboard.writeText(window.location.href); if(window.showToast) { window.showToast({title: 'Success', message: 'Invite link copied!'}); } else { alert('Invite link copied!'); }" class="bg-brand text-white px-4 py-2 rounded hover:bg-brand-dark transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand">
|
|
Copy Invite Link
|
|
</button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<!-- Video Player Column (2/3 width) -->
|
|
<div class="lg:col-span-2">
|
|
<div class="relative w-full pb-[56.25%] bg-black rounded-xl overflow-hidden shadow-2xl border border-gray-800">
|
|
{% if video %}
|
|
<video id="player" playsinline controls class="absolute top-0 left-0 w-full h-full"></video>
|
|
|
|
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
|
<script src="https://cdn.plyr.io/3.7.8/plyr.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const video = document.getElementById('player');
|
|
// Construct URL assuming standard media structure
|
|
const source = "/media/videos/{{ episode.id }}/{{ video.quality }}/index.m3u8";
|
|
|
|
if (Hls.isSupported()) {
|
|
const hls = new Hls({
|
|
xhrSetup: function(xhr, url) {
|
|
xhr.withCredentials = true;
|
|
}
|
|
});
|
|
hls.loadSource(source);
|
|
hls.attachMedia(video);
|
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
video.src = source;
|
|
}
|
|
|
|
window.player = new Plyr(video, {
|
|
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
|
|
});
|
|
});
|
|
</script>
|
|
{% else %}
|
|
<div class="absolute top-0 left-0 w-full h-full flex items-center justify-center text-red-500 font-bold bg-gray-900">
|
|
VIDEO SOURCE NOT FOUND
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="mt-6 p-4 bg-gray-800 rounded-lg">
|
|
<h3 class="font-bold text-lg mb-2">Party Details</h3>
|
|
<p class="text-gray-400">You are watching a private session of {{ episode.season.anime.title }}.</p>
|
|
<p class="mt-2 text-sm text-brand">UUID: {{ party.uuid }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chat Column (1/3 width) -->
|
|
<div class="bg-gray-800 rounded-xl border border-gray-700 flex flex-col h-[600px] overflow-hidden">
|
|
<div class="p-4 border-b border-gray-700 bg-gray-900 flex justify-between items-center">
|
|
<div>
|
|
<h3 class="font-bold text-white">Party Chat</h3>
|
|
<p class="text-xs text-gray-400">Connected as {{ user.username|default:"Anonymous" }}</p>
|
|
</div>
|
|
<div class="flex items-center gap-1.5 px-2 py-1 bg-gray-800 rounded-lg border border-gray-700">
|
|
<span class="relative flex h-2 w-2">
|
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
|
</span>
|
|
<span id="online-count" class="text-xs font-mono text-gray-300">0</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="chat-messages" class="flex-1 p-4 overflow-y-auto space-y-2 bg-gray-800 scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-gray-800">
|
|
<!-- Messages will appear here -->
|
|
</div>
|
|
|
|
<div id="typing-indicator" class="px-4 py-1 text-xs text-gray-500 italic hidden bg-gray-900">
|
|
<!-- User is typing... -->
|
|
</div>
|
|
|
|
<div class="p-3 bg-gray-900 border-t border-gray-700">
|
|
<div class="flex gap-2">
|
|
<input type="text" id="chat-input"
|
|
class="flex-1 bg-gray-700 text-white rounded-lg px-3 py-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
aria-label="Chat message"
|
|
placeholder="Say something..."
|
|
{% if not user.is_authenticated %}disabled title="Login to chat"{% endif %}
|
|
>
|
|
<button id="chat-send"
|
|
class="bg-brand text-white px-4 py-2 rounded-lg hover:bg-brand-dark transition disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
|
|
aria-label="Send message"
|
|
{% if not user.is_authenticated %}disabled{% endif %}
|
|
>
|
|
Send
|
|
</button>
|
|
</div>
|
|
{% if not user.is_authenticated %}
|
|
<p class="text-xs text-center text-red-400 mt-2">Please login to chat.</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Use the room_name passed from view
|
|
const roomId = "{{ room_name }}";
|
|
const isHost = "{{ user.id }}" == "{{ party.host.id }}";
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const chatSocket = new WebSocket(
|
|
protocol + '//' + window.location.host + '/ws/watch-party/' + roomId + '/'
|
|
);
|
|
|
|
// Video Sync Logic
|
|
setTimeout(() => {
|
|
const player = window.player;
|
|
if (player) {
|
|
let ignoreEvent = false;
|
|
|
|
if (isHost) {
|
|
player.on('play', () => {
|
|
if (!ignoreEvent) {
|
|
chatSocket.send(JSON.stringify({ type: 'video_control', action: 'play', timestamp: player.currentTime }));
|
|
}
|
|
ignoreEvent = false;
|
|
});
|
|
player.on('pause', () => {
|
|
if (!ignoreEvent) {
|
|
chatSocket.send(JSON.stringify({ type: 'video_control', action: 'pause', timestamp: player.currentTime }));
|
|
}
|
|
ignoreEvent = false;
|
|
});
|
|
player.on('seeked', () => {
|
|
if (!ignoreEvent) {
|
|
chatSocket.send(JSON.stringify({ type: 'video_control', action: 'seek', timestamp: player.currentTime }));
|
|
}
|
|
ignoreEvent = false;
|
|
});
|
|
}
|
|
}
|
|
}, 500); // Slight delay to ensure player is ready
|
|
|
|
chatSocket.onmessage = function(e) {
|
|
const data = JSON.parse(e.data);
|
|
|
|
if (data.type === 'video_control') {
|
|
const player = window.player;
|
|
if (player && !isHost) {
|
|
// Sync logic for viewers
|
|
if (Math.abs(player.currentTime - data.timestamp) > 0.5) {
|
|
player.currentTime = data.timestamp;
|
|
}
|
|
if (data.action === 'play') player.play();
|
|
else if (data.action === 'pause') player.pause();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.type === 'user_count') {
|
|
const countElement = document.getElementById('online-count');
|
|
if (countElement) {
|
|
countElement.textContent = data.count;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.type === 'typing') {
|
|
updateTypingIndicator(data.username, data.is_typing);
|
|
return;
|
|
}
|
|
|
|
const messages = document.getElementById('chat-messages');
|
|
|
|
const messageItem = document.createElement('div');
|
|
|
|
if (data.is_system) {
|
|
messageItem.className = 'text-center text-xs text-gray-500 italic my-2 animate-fade-in';
|
|
messageItem.textContent = data.message;
|
|
} else {
|
|
messageItem.className = 'bg-gray-700/50 p-2 rounded animate-fade-in';
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'flex justify-between text-xs text-gray-400 mb-1';
|
|
|
|
const userSpan = document.createElement('span');
|
|
userSpan.className = 'font-bold text-blue-400';
|
|
userSpan.textContent = data.username;
|
|
|
|
const timeSpan = document.createElement('span');
|
|
const date = new Date(data.created_at || Date.now());
|
|
timeSpan.textContent = date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
|
|
|
meta.appendChild(userSpan);
|
|
meta.appendChild(timeSpan);
|
|
|
|
const textDiv = document.createElement('div');
|
|
textDiv.className = 'text-sm text-gray-200 break-words';
|
|
textDiv.textContent = data.message;
|
|
|
|
messageItem.appendChild(meta);
|
|
messageItem.appendChild(textDiv);
|
|
}
|
|
|
|
messages.appendChild(messageItem);
|
|
messages.scrollTop = messages.scrollHeight;
|
|
};
|
|
|
|
chatSocket.onclose = function(e) {
|
|
console.error('Chat socket closed unexpectedly');
|
|
const messages = document.getElementById('chat-messages');
|
|
const errorMsg = document.createElement('div');
|
|
errorMsg.className = 'text-center text-red-500 text-xs mt-2';
|
|
errorMsg.textContent = 'Connection lost. Please refresh.';
|
|
messages.appendChild(errorMsg);
|
|
};
|
|
|
|
const chatInput = document.querySelector('#chat-input');
|
|
const chatSend = document.querySelector('#chat-send');
|
|
let typingTimeout = null;
|
|
let isTyping = false;
|
|
const typingUsers = new Set();
|
|
|
|
function updateTypingIndicator(username, isTypingStatus) {
|
|
const indicator = document.getElementById('typing-indicator');
|
|
if (isTypingStatus) {
|
|
typingUsers.add(username);
|
|
} else {
|
|
typingUsers.delete(username);
|
|
}
|
|
|
|
if (typingUsers.size > 0) {
|
|
const users = Array.from(typingUsers);
|
|
let text = '';
|
|
if (typingUsers.size === 1) {
|
|
text = `${users[0]} is typing...`;
|
|
} else if (typingUsers.size === 2) {
|
|
text = `${users[0]} and ${users[1]} are typing...`;
|
|
} else {
|
|
text = `${users[0]}, ${users[1]} and ${typingUsers.size - 2} others are typing...`;
|
|
}
|
|
indicator.textContent = text;
|
|
indicator.classList.remove('hidden');
|
|
} else {
|
|
indicator.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
if (chatInput && chatSend) {
|
|
chatInput.focus();
|
|
|
|
chatInput.oninput = function() {
|
|
if (!isTyping) {
|
|
isTyping = true;
|
|
chatSocket.send(JSON.stringify({
|
|
'type': 'typing',
|
|
'is_typing': true
|
|
}));
|
|
}
|
|
|
|
clearTimeout(typingTimeout);
|
|
typingTimeout = setTimeout(() => {
|
|
isTyping = false;
|
|
chatSocket.send(JSON.stringify({
|
|
'type': 'typing',
|
|
'is_typing': false
|
|
}));
|
|
}, 1500);
|
|
};
|
|
|
|
chatInput.onkeyup = function(e) {
|
|
if (e.key === 'Enter') {
|
|
chatSend.click();
|
|
}
|
|
};
|
|
|
|
chatSend.onclick = function(e) {
|
|
const message = chatInput.value;
|
|
if (message) {
|
|
chatSocket.send(JSON.stringify({
|
|
'message': message,
|
|
}));
|
|
chatInput.value = '';
|
|
|
|
// Stop typing indicator immediately
|
|
if (isTyping) {
|
|
isTyping = false;
|
|
clearTimeout(typingTimeout);
|
|
chatSocket.send(JSON.stringify({
|
|
'type': 'typing',
|
|
'is_typing': false
|
|
}));
|
|
}
|
|
}
|
|
};
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|