mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-31 04:58:09 +00:00
feat: implement sprint 1 quick wins - toast system, notifications, UX polish
- Add central toast notification system (popup.html, popup.js) - Add browser notifications toggle (opt-in) with event toasts - Fix interpolation memory leak (unload listener) - Add /health endpoint with IP-based rate limiting (server) - Improve tab sorting (current tab first, matches, alphabetical) - Add copy-to-clipboard visual feedback with toast - Show targetTime in last action card for seek/force sync - Add explicit video cleanup when element removed (content.js) - Update ROADMAP.md to remove implemented features
This commit is contained in:
+21
-18
@@ -411,25 +411,28 @@ function updateBadgeStatus() {
|
||||
}
|
||||
|
||||
function showNotification(senderName, action) {
|
||||
const label = action === 'play' ? 'started playback' :
|
||||
action === 'pause' ? 'paused playback' :
|
||||
action === 'seek' ? 'seeked the video' :
|
||||
action === 'force_sync_prepare' ? 'started force sync' :
|
||||
action === 'force_sync_execute' ? 'synchronized everyone' : action;
|
||||
|
||||
// Find username in current room if available
|
||||
let displayName = senderName || 'A peer';
|
||||
if (currentRoom && currentRoom.peers) {
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
|
||||
if (peer && peer.username) displayName = peer.username;
|
||||
}
|
||||
chrome.storage.sync.get(['browserNotifications'], (settings) => {
|
||||
if (!settings.browserNotifications) return;
|
||||
|
||||
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'KoalaSync',
|
||||
message: `${displayName} ${label}.`,
|
||||
priority: 1
|
||||
const label = action === 'play' ? 'started playback' :
|
||||
action === 'pause' ? 'paused playback' :
|
||||
action === 'seek' ? 'seeked the video' :
|
||||
action === 'force_sync_prepare' ? 'started force sync' :
|
||||
action === 'force_sync_execute' ? 'synchronized everyone' : action;
|
||||
|
||||
let displayName = senderName || 'A peer';
|
||||
if (currentRoom && currentRoom.peers) {
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
|
||||
if (peer && peer.username) displayName = peer.username;
|
||||
}
|
||||
|
||||
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'KoalaSync',
|
||||
message: `${displayName} ${label}.`,
|
||||
priority: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -507,6 +507,13 @@
|
||||
function checkVideo() {
|
||||
lastMutate = Date.now();
|
||||
const video = findVideo();
|
||||
|
||||
if (!video && lastVideoSrc) {
|
||||
reportLog('Video element removed from page', 'warn');
|
||||
lastVideoSrc = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video) return;
|
||||
|
||||
const currentSrc = video.currentSrc || video.src;
|
||||
|
||||
@@ -199,9 +199,48 @@
|
||||
}
|
||||
.invite-box input { flex: 1; font-size: 11px; }
|
||||
.invite-box button { width: 40px; padding: 0; }
|
||||
|
||||
/* Toast Notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
animation: toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in 2.7s forwards;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast-success { background: var(--success); color: white; }
|
||||
.toast-error { background: var(--error); color: white; }
|
||||
.toast-info { background: var(--accent); color: white; }
|
||||
.toast-warning { background: #f59e0b; color: white; }
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes toastFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toast-container"></div>
|
||||
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
|
||||
|
||||
<div class="tabs">
|
||||
@@ -332,6 +371,11 @@
|
||||
<label style="margin-bottom: 0;">Auto-Sync Next Episode</label>
|
||||
<input type="checkbox" id="autoSyncNextEpisode" style="width: auto;">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<label style="margin-bottom: 0;">Browser Notifications</label>
|
||||
<input type="checkbox" id="browserNotifications" style="width: auto;">
|
||||
</div>
|
||||
|
||||
<div style="font-size: 11px; color: var(--text-muted); padding: 8px;">
|
||||
<p>• Username helps others identify you.</p>
|
||||
|
||||
+104
-18
@@ -45,16 +45,18 @@ const elements = {
|
||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
|
||||
lobbyTitle: document.getElementById('lobbyTitle'),
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus')
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
|
||||
browserNotifications: document.getElementById('browserNotifications')
|
||||
};
|
||||
|
||||
let localPeerId = null;
|
||||
let lastPeersJson = null;
|
||||
let lastKnownPeers = [];
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Load Settings
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode']);
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications']);
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic'];
|
||||
@@ -70,6 +72,7 @@ async function init() {
|
||||
elements.filterNoise.checked = data.filterNoise !== false;
|
||||
elements.autoSyncNextEpisode.checked = data.autoSyncNextEpisode !== false;
|
||||
elements.forceSyncMode.value = data.forceSyncMode || 'jump-to-others';
|
||||
elements.browserNotifications.checked = data.browserNotifications === true;
|
||||
|
||||
// Set Version Info
|
||||
const versionEl = document.getElementById('appVersion');
|
||||
@@ -178,6 +181,20 @@ function updateLastActionUI(state, peers) {
|
||||
header.appendChild(infoSpan);
|
||||
elements.lastActionCard.appendChild(header);
|
||||
|
||||
if (state.targetTime !== undefined && state.action === 'seek') {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Target: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
|
||||
if (state.targetTime !== undefined && state.action.includes('force_sync')) {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Sync to: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.style.cssText = 'display:grid; grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); gap: 5px;';
|
||||
|
||||
@@ -389,6 +406,29 @@ function updatePeerList(peers) {
|
||||
populateTabs(peers);
|
||||
}
|
||||
|
||||
function detectPeerChanges(newPeers) {
|
||||
const oldIds = new Set(lastKnownPeers.map(p => p.peerId || p));
|
||||
const newIds = new Set(newPeers.map(p => p.peerId || p));
|
||||
|
||||
for (const peer of newPeers) {
|
||||
const id = peer.peerId || peer;
|
||||
if (!oldIds.has(id)) {
|
||||
const name = peer.username || id.substring(0, 4);
|
||||
showToast(`${name} joined the room`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
for (const oldPeer of lastKnownPeers) {
|
||||
const id = oldPeer.peerId || oldPeer;
|
||||
if (!newIds.has(id)) {
|
||||
const name = oldPeer.username || id.substring(0, 4);
|
||||
showToast(`${name} left the room`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
lastKnownPeers = newPeers;
|
||||
}
|
||||
|
||||
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
const data = await chrome.storage.sync.get(['filterNoise']);
|
||||
const isFilterActive = data.filterNoise !== false;
|
||||
@@ -451,10 +491,25 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
elements.targetTab.appendChild(option);
|
||||
});
|
||||
|
||||
// Sort: Matches first
|
||||
// Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically
|
||||
const options = Array.from(elements.targetTab.options);
|
||||
const placeholder = options.shift(); // Remove placeholder
|
||||
options.sort((a, b) => (b.textContent.includes('⭐') ? 1 : 0) - (a.textContent.includes('⭐') ? 1 : 0));
|
||||
const placeholder = options.shift();
|
||||
const currentTabId = providedTargetTabId ? parseInt(providedTargetTabId) : null;
|
||||
|
||||
options.sort((a, b) => {
|
||||
const aId = parseInt(a.value);
|
||||
const bId = parseInt(b.value);
|
||||
|
||||
if (aId === currentTabId) return -1;
|
||||
if (bId === currentTabId) return 1;
|
||||
|
||||
const aMatch = a.textContent.includes('⭐');
|
||||
const bMatch = b.textContent.includes('⭐');
|
||||
if (aMatch && !bMatch) return -1;
|
||||
if (!aMatch && bMatch) return 1;
|
||||
|
||||
return a.textContent.localeCompare(b.textContent);
|
||||
});
|
||||
elements.targetTab.innerHTML = '';
|
||||
elements.targetTab.appendChild(placeholder);
|
||||
options.forEach(opt => elements.targetTab.appendChild(opt));
|
||||
@@ -664,6 +719,10 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
});
|
||||
|
||||
elements.browserNotifications.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ browserNotifications: elements.browserNotifications.checked });
|
||||
});
|
||||
|
||||
elements.forceSyncMode.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ forceSyncMode: elements.forceSyncMode.value });
|
||||
});
|
||||
@@ -695,6 +754,16 @@ elements.tabs.forEach(btn => {
|
||||
});
|
||||
});
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
if (!elements.roomError) return;
|
||||
elements.roomError.textContent = msg;
|
||||
@@ -702,16 +771,7 @@ function showError(msg) {
|
||||
elements.roomId.style.borderColor = 'var(--error)';
|
||||
elements.password.style.borderColor = 'var(--error)';
|
||||
|
||||
// Shake effect
|
||||
const activeTab = document.querySelector('.tab-content.active');
|
||||
if (activeTab) {
|
||||
activeTab.animate([
|
||||
{ transform: 'translateX(0)' },
|
||||
{ transform: 'translateX(-5px)' },
|
||||
{ transform: 'translateX(5px)' },
|
||||
{ transform: 'translateX(0)' }
|
||||
], { duration: 200, iterations: 2 });
|
||||
}
|
||||
showToast(msg, 'error', 5000);
|
||||
|
||||
setTimeout(() => {
|
||||
if (elements.roomError) elements.roomError.style.display = 'none';
|
||||
@@ -891,9 +951,18 @@ elements.clearLogs.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.copyInvite.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value);
|
||||
elements.copyInvite.textContent = '✅';
|
||||
setTimeout(() => { elements.copyInvite.textContent = '📋'; }, 2000);
|
||||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||||
const original = elements.copyInvite.textContent;
|
||||
elements.copyInvite.textContent = '✓';
|
||||
elements.copyInvite.style.background = 'var(--success)';
|
||||
elements.copyInvite.style.color = 'white';
|
||||
showToast('Invite link copied!', 'success', 2000);
|
||||
setTimeout(() => {
|
||||
elements.copyInvite.textContent = original;
|
||||
elements.copyInvite.style.background = '';
|
||||
elements.copyInvite.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Logs & Status ---
|
||||
@@ -919,11 +988,24 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
showError(msg.log.message);
|
||||
}
|
||||
} else if (msg.type === 'ACTION_UPDATE') {
|
||||
const state = msg.state;
|
||||
if (state && state.senderId && state.senderId !== 'You') {
|
||||
const actionNames = {
|
||||
'play': '▶ Play',
|
||||
'pause': '⏸ Pause',
|
||||
'seek': '⏩ Seek',
|
||||
'force_sync_prepare': '⚡ Force Sync',
|
||||
'force_sync_execute': '⚡ Force Play'
|
||||
};
|
||||
const action = actionNames[state.action] || state.action;
|
||||
showToast(`${state.senderId} ${action}`, 'info', 2000);
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res && res.peers) updateLastActionUI(msg.state, res.peers);
|
||||
});
|
||||
} else if (msg.type === 'PEER_UPDATE') {
|
||||
updatePeerList(msg.peers);
|
||||
if (msg.peers) detectPeerChanges(msg.peers);
|
||||
} else if (msg.type === 'CONNECTION_STATUS') {
|
||||
applyConnectionStatus(msg.status);
|
||||
if (msg.status === 'connected') {
|
||||
@@ -1054,6 +1136,10 @@ function refreshDebugInfo() {
|
||||
init();
|
||||
setInterval(refreshLogs, 5000);
|
||||
|
||||
window.addEventListener('unload', () => {
|
||||
stopInterpolation();
|
||||
});
|
||||
|
||||
// --- Episode Lobby UI ---
|
||||
function updateLobbyUI(lobby, peers) {
|
||||
if (!elements.episodeLobbyCard) return;
|
||||
|
||||
Reference in New Issue
Block a user