Files
Barış Keserandgoogle-labs-jules[bot] 099ef897a8 🎨 Palette: Add focus-visible styles for keyboard accessibility (#182)
- 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>
2026-02-28 20:12:30 +00:00

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 %}