feat: sidebar live indicators + polished hover + empty-state animation
The killer-feature of this pass is the live indicator: red pulsing
dot next to every streamer in the sidebar that is currently
broadcasting on Twitch. Suddenly the sidebar conveys real-time
state at a glance — you know who to click before clicking.
How it works:
- New live-status batch poller (main.ts) fires every 60s, packs
every streamer in the user's watch list into a single GQL query
using aliased user lookups (`u0:user(login:$l0){stream{type}} ...`),
chunked at 50 logins per request. One roundtrip for the whole
list — far cheaper than per-streamer polling.
- Updates a liveStatusByLogin Map on the main side, emits an IPC
`live-status-batch-update` event with only the entries that
flipped (plus a full snapshot for the renderer to keep in sync).
- Renderer subscribes once at boot via initLiveStatusSubscription,
keeps a parallel Map, and re-renders the streamer list on
change. Stamps a .streamer-live-dot before the name. Bold name
for live streamers so they pop in scannability.
- Restart triggers: app boot, streamer-list change (added/removed
via save-config) so a freshly added streamer gets their dot in
seconds without waiting for the next 60s tick.
Polish bundled in the same release:
- VOD card hover gets a more substantial lift: 12px shadow + faint
purple border-glow on hover. Subtle but enough to feel
"tactile". Border-color transitions alongside the shadow.
- Empty states get a floating animation and a bigger SVG icon
with accent-colored tint. "No VODs / select a streamer" now
feels intentional instead of an oversight.
- Streamer-name span dedicated class (.streamer-name +
.streamer-name.is-live) so a live streamer's name itself bolds,
not just gets a dot beside it.
Locale strings: liveNowTooltip ("Currently live on Twitch" / "Aktuell
live auf Twitch") for the dot's tooltip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fa8c2b2658
commit
11883889de
119
src/main.ts
119
src/main.ts
@ -3694,6 +3694,108 @@ async function runAutoVodPoll(): Promise<number> {
|
|||||||
return queuedCount;
|
return queuedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LIVE STATUS BATCH POLLER — for the sidebar live indicators
|
||||||
|
// ==========================================
|
||||||
|
// Background poller that asks "which of these streamers are live right
|
||||||
|
// now?" for every streamer in the user's list, in a single GQL roundtrip
|
||||||
|
// (per chunk of 50). Results are stamped into liveStatusByLogin and
|
||||||
|
// pushed to the renderer so the sidebar gets a red pulsing dot next to
|
||||||
|
// anyone currently broadcasting. Independent from the auto-record
|
||||||
|
// poller — that one only watches a small subset and needs title/game,
|
||||||
|
// this one just needs the boolean and covers everyone.
|
||||||
|
const liveStatusByLogin = new Map<string, boolean>();
|
||||||
|
let liveStatusPollTimer: NodeJS.Timeout | null = null;
|
||||||
|
let liveStatusPollInFlight = false;
|
||||||
|
const LIVE_STATUS_POLL_INTERVAL_MS = 60_000;
|
||||||
|
const LIVE_STATUS_BATCH_CHUNK_SIZE = 50;
|
||||||
|
|
||||||
|
async function fetchLiveStatusBatch(logins: string[]): Promise<Map<string, boolean>> {
|
||||||
|
const result = new Map<string, boolean>();
|
||||||
|
if (logins.length === 0) return result;
|
||||||
|
|
||||||
|
for (let i = 0; i < logins.length; i += LIVE_STATUS_BATCH_CHUNK_SIZE) {
|
||||||
|
const chunk = logins.slice(i, i + LIVE_STATUS_BATCH_CHUNK_SIZE);
|
||||||
|
const vars: Record<string, string> = {};
|
||||||
|
const varDecls: string[] = [];
|
||||||
|
const aliases: string[] = [];
|
||||||
|
chunk.forEach((login, idx) => {
|
||||||
|
const varName = `l${idx}`;
|
||||||
|
vars[varName] = login;
|
||||||
|
varDecls.push(`$${varName}:String!`);
|
||||||
|
aliases.push(`u${idx}:user(login:$${varName}){login stream{type}}`);
|
||||||
|
});
|
||||||
|
const query = `query(${varDecls.join(',')}){${aliases.join(' ')}}`;
|
||||||
|
try {
|
||||||
|
const data = await fetchPublicTwitchGql<Record<string, { login: string; stream: { type: string } | null } | null>>(
|
||||||
|
query, vars
|
||||||
|
);
|
||||||
|
if (!data) continue;
|
||||||
|
for (const key of Object.keys(data)) {
|
||||||
|
const user = data[key];
|
||||||
|
if (!user || !user.login) continue;
|
||||||
|
result.set(normalizeLogin(user.login), user.stream?.type === 'live');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
appendDebugLog('live-status-batch-failed', { chunkStart: i, error: String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLiveStatusBatchPoll(): Promise<void> {
|
||||||
|
if (liveStatusPollInFlight) return;
|
||||||
|
liveStatusPollInFlight = true;
|
||||||
|
try {
|
||||||
|
const logins = ((config.streamers as string[]) || [])
|
||||||
|
.map((s) => normalizeLogin(s))
|
||||||
|
.filter((s): s is string => Boolean(s));
|
||||||
|
if (logins.length === 0) return;
|
||||||
|
|
||||||
|
const fresh = await fetchLiveStatusBatch(logins);
|
||||||
|
const changes: Array<{ login: string; isLive: boolean }> = [];
|
||||||
|
const seen = new Set(fresh.keys());
|
||||||
|
for (const [login, isLive] of fresh.entries()) {
|
||||||
|
const prev = liveStatusByLogin.get(login);
|
||||||
|
if (prev !== isLive) changes.push({ login, isLive });
|
||||||
|
liveStatusByLogin.set(login, isLive);
|
||||||
|
}
|
||||||
|
// Streamers that vanished from the watch list (or that GQL didn't
|
||||||
|
// return for) get evicted so a removed streamer doesn't leave a
|
||||||
|
// ghost-live dot behind.
|
||||||
|
for (const oldLogin of Array.from(liveStatusByLogin.keys())) {
|
||||||
|
if (!seen.has(oldLogin)) {
|
||||||
|
liveStatusByLogin.delete(oldLogin);
|
||||||
|
changes.push({ login: oldLogin, isLive: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow) {
|
||||||
|
const snapshot: Record<string, boolean> = {};
|
||||||
|
for (const [k, v] of liveStatusByLogin.entries()) snapshot[k] = v;
|
||||||
|
mainWindow.webContents.send('live-status-batch-update', { changes, snapshot });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
appendDebugLog('live-status-poll-failed', String(e));
|
||||||
|
} finally {
|
||||||
|
liveStatusPollInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLiveStatusPoller(): void {
|
||||||
|
if (liveStatusPollTimer) {
|
||||||
|
clearInterval(liveStatusPollTimer);
|
||||||
|
liveStatusPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartLiveStatusPoller(): void {
|
||||||
|
stopLiveStatusPoller();
|
||||||
|
liveStatusPollTimer = setInterval(() => { void runLiveStatusBatchPoll(); }, LIVE_STATUS_POLL_INTERVAL_MS);
|
||||||
|
liveStatusPollTimer.unref?.();
|
||||||
|
setTimeout(() => { void runLiveStatusBatchPoll(); }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// CHAT REPLAY DOWNLOAD
|
// CHAT REPLAY DOWNLOAD
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -6408,6 +6510,7 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
|||||||
const previousAutoRecordSeconds = config.auto_record_poll_seconds;
|
const previousAutoRecordSeconds = config.auto_record_poll_seconds;
|
||||||
const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
|
const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
|
||||||
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
|
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
|
||||||
|
const previousStreamerList = JSON.stringify(config.streamers || []);
|
||||||
|
|
||||||
config = normalizeConfigTemplates({ ...config, ...newConfig });
|
config = normalizeConfigTemplates({ ...config, ...newConfig });
|
||||||
|
|
||||||
@ -6457,6 +6560,14 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
|||||||
restartAutoVodPoller();
|
restartAutoVodPoller();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live-status batch poller — fire an immediate refresh when the
|
||||||
|
// streamer list itself changes (added/removed) so the sidebar dots
|
||||||
|
// update instantly instead of waiting for the next 60s tick.
|
||||||
|
const newStreamerList = JSON.stringify(config.streamers || []);
|
||||||
|
if (newStreamerList !== previousStreamerList) {
|
||||||
|
restartLiveStatusPoller();
|
||||||
|
}
|
||||||
|
|
||||||
// Restart cleanup timer when the toggle flips; harmless to call when
|
// Restart cleanup timer when the toggle flips; harmless to call when
|
||||||
// unchanged because restartAutoCleanupTimer just resets the interval.
|
// unchanged because restartAutoCleanupTimer just resets the interval.
|
||||||
restartAutoCleanupTimer();
|
restartAutoCleanupTimer();
|
||||||
@ -6980,6 +7091,12 @@ ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise<VodStoryb
|
|||||||
return await getVodStoryboard(vodId);
|
return await getVodStoryboard(vodId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-live-status-snapshot', (): Record<string, boolean> => {
|
||||||
|
const snap: Record<string, boolean> = {};
|
||||||
|
for (const [k, v] of liveStatusByLogin.entries()) snap[k] = v;
|
||||||
|
return snap;
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
|
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
|
||||||
const normalized: ArchiveSearchFilter = {
|
const normalized: ArchiveSearchFilter = {
|
||||||
query: typeof filter?.query === 'string' ? filter.query.trim() : '',
|
query: typeof filter?.query === 'string' ? filter.query.trim() : '',
|
||||||
@ -7250,6 +7367,7 @@ app.whenReady().then(() => {
|
|||||||
startDebugLogFlushTimer();
|
startDebugLogFlushTimer();
|
||||||
restartAutoRecordPoller();
|
restartAutoRecordPoller();
|
||||||
restartAutoVodPoller();
|
restartAutoVodPoller();
|
||||||
|
restartLiveStatusPoller();
|
||||||
restartAutoCleanupTimer();
|
restartAutoCleanupTimer();
|
||||||
createWindow();
|
createWindow();
|
||||||
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
||||||
@ -7279,6 +7397,7 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
|
|||||||
stopAutoUpdatePolling();
|
stopAutoUpdatePolling();
|
||||||
stopAutoRecordPoller();
|
stopAutoRecordPoller();
|
||||||
stopAutoVodPoller();
|
stopAutoVodPoller();
|
||||||
|
stopLiveStatusPoller();
|
||||||
stopAutoCleanupTimer();
|
stopAutoCleanupTimer();
|
||||||
|
|
||||||
// Kill all active children: queue downloads, standalone clip downloads,
|
// Kill all active children: queue downloads, standalone clip downloads,
|
||||||
|
|||||||
@ -93,6 +93,10 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
|
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
|
||||||
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
|
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
|
||||||
getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId),
|
getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId),
|
||||||
|
getLiveStatusSnapshot: () => ipcRenderer.invoke('get-live-status-snapshot'),
|
||||||
|
onLiveStatusBatchUpdate: (callback: (info: { changes: Array<{ login: string; isLive: boolean }>; snapshot: Record<string, boolean> }) => void) => {
|
||||||
|
ipcRenderer.on('live-status-batch-update', (_, info) => callback(info));
|
||||||
|
},
|
||||||
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
||||||
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
||||||
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
|
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
|
||||||
|
|||||||
2
src/renderer-globals.d.ts
vendored
2
src/renderer-globals.d.ts
vendored
@ -346,6 +346,8 @@ interface ApiBridge {
|
|||||||
getArchiveStats(): Promise<ArchiveStats>;
|
getArchiveStats(): Promise<ArchiveStats>;
|
||||||
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
|
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
|
||||||
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
|
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
|
||||||
|
getLiveStatusSnapshot(): Promise<Record<string, boolean>>;
|
||||||
|
onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }>; snapshot: Record<string, boolean> }) => void): void;
|
||||||
searchArchive(filter: {
|
searchArchive(filter: {
|
||||||
query?: string;
|
query?: string;
|
||||||
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
||||||
|
|||||||
@ -350,7 +350,8 @@ const UI_TEXT_DE = {
|
|||||||
autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.',
|
autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.',
|
||||||
autoVodScanEmpty: 'Keine neuen VODs gefunden.',
|
autoVodScanEmpty: 'Keine neuen VODs gefunden.',
|
||||||
autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.',
|
autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.',
|
||||||
autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.'
|
autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.',
|
||||||
|
liveNowTooltip: 'Aktuell live auf Twitch'
|
||||||
},
|
},
|
||||||
vods: {
|
vods: {
|
||||||
noneTitle: 'Keine VODs',
|
noneTitle: 'Keine VODs',
|
||||||
|
|||||||
@ -350,7 +350,8 @@ const UI_TEXT_EN = {
|
|||||||
autoVodScanQueued: '{count} new VOD(s) auto-queued.',
|
autoVodScanQueued: '{count} new VOD(s) auto-queued.',
|
||||||
autoVodScanEmpty: 'No new VODs found.',
|
autoVodScanEmpty: 'No new VODs found.',
|
||||||
autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.',
|
autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.',
|
||||||
autoRecordScanEmpty: 'Manual scan: no streamers currently live.'
|
autoRecordScanEmpty: 'Manual scan: no streamers currently live.',
|
||||||
|
liveNowTooltip: 'Currently live on Twitch'
|
||||||
},
|
},
|
||||||
vods: {
|
vods: {
|
||||||
noneTitle: 'No VODs',
|
noneTitle: 'No VODs',
|
||||||
|
|||||||
@ -2,6 +2,36 @@ let selectStreamerRequestId = 0;
|
|||||||
let vodRenderTaskId = 0;
|
let vodRenderTaskId = 0;
|
||||||
const VOD_RENDER_CHUNK_SIZE = 64;
|
const VOD_RENDER_CHUNK_SIZE = 64;
|
||||||
|
|
||||||
|
// Live status snapshot — updated by the main process via the
|
||||||
|
// 'live-status-batch-update' IPC event. Keys are lowercase logins so
|
||||||
|
// the lookup is case-insensitive regardless of how the streamer's
|
||||||
|
// name was added (display-cased vs login-cased).
|
||||||
|
const liveStatusByLogin = new Map<string, boolean>();
|
||||||
|
|
||||||
|
async function initLiveStatusSubscription(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const initial = await window.api.getLiveStatusSnapshot();
|
||||||
|
for (const [k, v] of Object.entries(initial)) {
|
||||||
|
liveStatusByLogin.set(k.toLowerCase(), v === true);
|
||||||
|
}
|
||||||
|
renderStreamers();
|
||||||
|
} catch (_) { /* poller may not have fired yet — silent */ }
|
||||||
|
|
||||||
|
window.api.onLiveStatusBatchUpdate(({ changes }) => {
|
||||||
|
let touched = false;
|
||||||
|
for (const change of changes) {
|
||||||
|
const key = change.login.toLowerCase();
|
||||||
|
const prev = liveStatusByLogin.get(key);
|
||||||
|
if (prev !== change.isLive) {
|
||||||
|
liveStatusByLogin.set(key, change.isLive);
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (touched) renderStreamers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(window as unknown as { initLiveStatusSubscription: typeof initLiveStatusSubscription }).initLiveStatusSubscription = initLiveStatusSubscription;
|
||||||
|
|
||||||
// VOD filter state — persists across renderer reloads via localStorage so the
|
// VOD filter state — persists across renderer reloads via localStorage so the
|
||||||
// user's search query survives an app restart. Cleared explicitly via Esc /
|
// user's search query survives an app restart. Cleared explicitly via Esc /
|
||||||
// the clear button. Shared across streamers (acts like a search bar).
|
// the clear button. Shared across streamers (acts like a search bar).
|
||||||
@ -404,7 +434,20 @@ function renderStreamers(): void {
|
|||||||
item.setAttribute('draggable', 'true');
|
item.setAttribute('draggable', 'true');
|
||||||
item.dataset.streamerName = streamer;
|
item.dataset.streamerName = streamer;
|
||||||
|
|
||||||
|
// Live-dot — red pulsing dot when this streamer is currently
|
||||||
|
// broadcasting on Twitch. Populated from the live-status batch
|
||||||
|
// poller's snapshot. Renders before the name so the streamer
|
||||||
|
// identity stays primary visually.
|
||||||
|
const isLive = liveStatusByLogin.get(streamer.toLowerCase()) === true;
|
||||||
|
if (isLive) {
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'streamer-live-dot';
|
||||||
|
dot.title = UI_TEXT.streamers.liveNowTooltip || 'Live now';
|
||||||
|
item.appendChild(dot);
|
||||||
|
}
|
||||||
|
|
||||||
const nameSpan = document.createElement('span');
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : '');
|
||||||
nameSpan.textContent = streamer;
|
nameSpan.textContent = streamer;
|
||||||
|
|
||||||
// AUTO toggle — when enabled, the main-process auto-record poller
|
// AUTO toggle — when enabled, the main-process auto-record poller
|
||||||
|
|||||||
@ -42,6 +42,10 @@ async function init(): Promise<void> {
|
|||||||
changeTheme(config.theme ?? 'twitch');
|
changeTheme(config.theme ?? 'twitch');
|
||||||
renderStreamers();
|
renderStreamers();
|
||||||
renderQueue();
|
renderQueue();
|
||||||
|
|
||||||
|
// Kick off live-status subscription so the sidebar dots populate.
|
||||||
|
const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription;
|
||||||
|
if (typeof liveStatusInit === 'function') void liveStatusInit();
|
||||||
initQueueDragDrop();
|
initQueueDragDrop();
|
||||||
updateDownloadButtonState();
|
updateDownloadButtonState();
|
||||||
updateStatusBarQueueSummary();
|
updateStatusBarQueueSummary();
|
||||||
|
|||||||
@ -142,6 +142,30 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Live-dot — red pulsing indicator shown next to a streamer's name in
|
||||||
|
the sidebar when they are currently broadcasting on Twitch. */
|
||||||
|
.streamer-live-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e91916;
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: streamer-live-pulse 1.6s ease-in-out infinite;
|
||||||
|
box-shadow: 0 0 0 0 rgba(233, 25, 22, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes streamer-live-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(233, 25, 22, 0.55); }
|
||||||
|
70% { box-shadow: 0 0 0 6px rgba(233, 25, 22, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(233, 25, 22, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-name.is-live {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.add-streamer {
|
.add-streamer {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -578,14 +602,16 @@ body {
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.22s ease-out, box-shadow 0.22s ease-out, border-color 0.22s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vod-card:hover {
|
.vod-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(145, 70, 255, 0.35);
|
||||||
|
border-color: rgba(145, 70, 255, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vod-card.selected {
|
.vod-card.selected {
|
||||||
@ -1156,15 +1182,30 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-state svg {
|
.empty-state svg {
|
||||||
width: 64px;
|
width: 80px;
|
||||||
height: 64px;
|
height: 80px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 18px;
|
||||||
opacity: 0.5;
|
opacity: 0.45;
|
||||||
|
color: var(--accent);
|
||||||
|
animation: empty-state-float 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes empty-state-float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-6px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state h3 {
|
.empty-state h3 {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 10px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
max-width: 380px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status Bar */
|
/* Status Bar */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user