Compare commits
No commits in common. "8928d1f8ed3ccf4b9168733480ec228d823c2dc9" and "fa8c2b2658fe1a0f1cf95fcac109449384cac832" have entirely different histories.
8928d1f8ed
...
fa8c2b2658
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.22",
|
"version": "4.6.21",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.22",
|
"version": "4.6.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.22",
|
"version": "4.6.21",
|
||||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"author": "xRangerDE",
|
"author": "xRangerDE",
|
||||||
|
|||||||
119
src/main.ts
119
src/main.ts
@ -3694,108 +3694,6 @@ 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
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -6510,7 +6408,6 @@ 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 });
|
||||||
|
|
||||||
@ -6560,14 +6457,6 @@ 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();
|
||||||
@ -7091,12 +6980,6 @@ 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() : '',
|
||||||
@ -7367,7 +7250,6 @@ 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');
|
||||||
@ -7397,7 +7279,6 @@ 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,10 +93,6 @@ 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,8 +346,6 @@ 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,8 +350,7 @@ 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,8 +350,7 @@ 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,36 +2,6 @@ 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).
|
||||||
@ -434,20 +404,7 @@ 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,10 +42,6 @@ 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,30 +142,6 @@ 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;
|
||||||
@ -602,16 +578,14 @@ body {
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.22s ease-out, box-shadow 0.22s ease-out, border-color 0.22s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
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 12px 30px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(145, 70, 255, 0.35);
|
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
||||||
border-color: rgba(145, 70, 255, 0.35);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vod-card.selected {
|
.vod-card.selected {
|
||||||
@ -1182,30 +1156,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-state svg {
|
.empty-state svg {
|
||||||
width: 80px;
|
width: 64px;
|
||||||
height: 80px;
|
height: 64px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 15px;
|
||||||
opacity: 0.45;
|
opacity: 0.5;
|
||||||
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: 10px;
|
margin-bottom: 8px;
|
||||||
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