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:
Koala
2026-05-25 09:53:25 +02:00
parent d66c68be5d
commit 4909a86a13
6 changed files with 217 additions and 661 deletions
+21 -18
View File
@@ -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
});
});
}
+7
View File
@@ -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;
+44
View File
@@ -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
View File
@@ -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;