feat: banner background + live preview card + VOD hover storyboard + sticky header
Four interlocking visual upgrades that push the profile area from
"works" to "looks like a real Twitch app". Single release because
all four share data plumbing and need to land coherently.
1) Banner background — getStreamerProfile now also pulls
bannerImageURL via public GQL, fetches the bytes server-side as a
data URL (same path as the avatar fix in 4.6.18-4.6.19), and the
renderer puts it behind the header content with blur(18px) +
saturate(1.2) + a 0.55 opacity overlay. Result: per-streamer
colour identity at a glance, like twitch.tv's channel page.
2) Live preview card — when isLive, the public-GQL stream block also
carries previewImageURL(640x360), viewersCount, title, game{name}.
A second card slides in below the main profile row showing the
current frame at 240×135, eye-icon viewer count, big bold title,
game, and a red "Jetzt aufnehmen" CTA. Click anywhere on the card
OR on the button triggers triggerLiveRecording — same path as
the sidebar REC dot, so the recording reaches the queue with
identical settings.
3) VOD hover storyboard — Twitch ships a seekPreviewsURL per VOD
pointing at a JSON manifest of sprite-sheet images, each a grid
of preview thumbnails spanning the recording. New IPC
get-vod-storyboard fetches the manifest, picks the high-quality
first sprite, fetches its bytes as a data URL, and returns the
grid metadata. Renderer (new renderer-vod-hover.ts) hooks
delegated mouseover on #vodGrid: 220ms debounce, then on
activation overlays a div positioned over the thumbnail with
background-image=sprite + a setInterval cycling
background-position through 4 evenly-spaced cells at 600ms each.
Per-VOD result cached client-side so repeated hovers don't
re-fetch. Negative results (private VODs, expired) are also
cached so we don't re-query a known-empty manifest.
4) Sticky header — position:sticky;top:0;z-index:20 plus a
backdrop-filter:blur(6px) so the VOD grid scrolling underneath
reads through the banner subtly. Header stays anchored to the top
of .content as the user scrolls hundreds of VODs.
GQL refresher: the public schema rejects `broadcasterType` but
accepts `roles{isPartner isAffiliate}`, plus the same query now
includes bannerImageURL and stream{previewImageURL viewersCount
title game{name}}. One single roundtrip pulls everything we need
for the header AND the live card. The old separate-follower-count
roundtrip (fetchOnlyFollowerCount) is now redundant but kept around
for back-compat in case other call sites grow into it.
Also: profile layout switched from one big flex row to a relative
container with two children (.streamer-profile-row for the meta,
.streamer-profile-live-card for the live block). The .live-card
only renders when isLive — offline streamers get the same compact
header they had before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1b87a2611e
commit
3c73efbad7
@ -842,6 +842,7 @@
|
||||
<script src="../dist/renderer-stats.js"></script>
|
||||
<script src="../dist/renderer-archive.js"></script>
|
||||
<script src="../dist/renderer-profile.js"></script>
|
||||
<script src="../dist/renderer-vod-hover.js"></script>
|
||||
<script src="../dist/renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
262
src/main.ts
262
src/main.ts
@ -2303,6 +2303,7 @@ interface StreamerProfile {
|
||||
login: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
bannerUrl: string;
|
||||
description: string;
|
||||
broadcasterType: '' | 'partner' | 'affiliate';
|
||||
followerCount: number | null;
|
||||
@ -2311,6 +2312,8 @@ interface StreamerProfile {
|
||||
isLive: boolean;
|
||||
currentTitle: string | null;
|
||||
currentGame: string | null;
|
||||
currentStreamPreviewUrl: string;
|
||||
currentStreamViewers: number | null;
|
||||
twitchUrl: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
@ -2393,25 +2396,31 @@ interface PublicProfileQueryResult {
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
profileImageURL: string | null;
|
||||
bannerImageURL: string | null;
|
||||
roles?: { isPartner: boolean; isAffiliate: boolean } | null;
|
||||
followers?: { totalCount: number } | null;
|
||||
stream?: {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string | null;
|
||||
viewersCount: number | null;
|
||||
previewImageURL: string | null;
|
||||
game: { name: string } | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
async function fetchPublicStreamerProfile(login: string): Promise<{
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
bannerUrl: string;
|
||||
description: string;
|
||||
broadcasterType: '' | 'partner' | 'affiliate';
|
||||
followerCount: number | null;
|
||||
stream: { previewUrl: string; viewers: number | null; title: string | null; game: string | null } | null;
|
||||
} | null> {
|
||||
// The public (unauthenticated) GQL schema does NOT expose
|
||||
// `broadcasterType` directly — querying it returns an errors[] response
|
||||
// which the upstream helper treats as a complete failure (null data),
|
||||
// which in turn left the avatar empty and the user's whole profile
|
||||
// fell through to the fallback letter tile. Use `roles{isPartner
|
||||
// isAffiliate}` instead, which the public schema does expose, and
|
||||
// derive broadcasterType locally.
|
||||
// Same query also pulls bannerImageURL and the current stream's
|
||||
// preview + viewer count when live — saves a separate roundtrip.
|
||||
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
|
||||
`query($login: String!) {
|
||||
user(login: $login) {
|
||||
@ -2420,8 +2429,17 @@ async function fetchPublicStreamerProfile(login: string): Promise<{
|
||||
displayName
|
||||
description
|
||||
profileImageURL(width: 150)
|
||||
bannerImageURL
|
||||
roles { isPartner isAffiliate }
|
||||
followers { totalCount }
|
||||
stream {
|
||||
id
|
||||
type
|
||||
title
|
||||
viewersCount
|
||||
previewImageURL(width: 640, height: 360)
|
||||
game { name }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ login }
|
||||
@ -2431,12 +2449,21 @@ async function fetchPublicStreamerProfile(login: string): Promise<{
|
||||
const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner
|
||||
? 'partner'
|
||||
: (roles?.isAffiliate ? 'affiliate' : '');
|
||||
const s = data.user.stream;
|
||||
const stream = (s && s.type === 'live') ? {
|
||||
previewUrl: s.previewImageURL || '',
|
||||
viewers: typeof s.viewersCount === 'number' ? s.viewersCount : null,
|
||||
title: s.title || null,
|
||||
game: s.game?.name || null
|
||||
} : null;
|
||||
return {
|
||||
displayName: data.user.displayName || login,
|
||||
avatarUrl: data.user.profileImageURL || '',
|
||||
bannerUrl: data.user.bannerImageURL || '',
|
||||
description: data.user.description || '',
|
||||
broadcasterType,
|
||||
followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null
|
||||
followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null,
|
||||
stream
|
||||
};
|
||||
}
|
||||
|
||||
@ -2464,37 +2491,43 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
|
||||
return await withInFlightDedup(inFlightProfileRequests, normalized, async () => {
|
||||
runtimeMetrics.cacheMisses += 1;
|
||||
|
||||
// Prefer Helix for the core fields when we have credentials (richer
|
||||
// bio, faster, less likely to rate-limit), then fill in followers
|
||||
// via public GQL since Helix /channels/followers needs a moderator
|
||||
// token we don't have. Fall back fully to public GQL when not
|
||||
// authenticated.
|
||||
// Public GQL is now the SOURCE for everything except some of the
|
||||
// core text fields when Helix is authenticated — because public
|
||||
// GQL is the only route that gives us the banner image + current
|
||||
// stream preview in one shot, and skipping it would mean two
|
||||
// extra roundtrips. Helix takes precedence for displayName /
|
||||
// description (those fields are sometimes richer there).
|
||||
let displayName = normalized;
|
||||
let avatarUrl = '';
|
||||
let bannerUrl = '';
|
||||
let description = '';
|
||||
let broadcasterType: '' | 'partner' | 'affiliate' = '';
|
||||
let streamFromPublic: Awaited<ReturnType<typeof fetchPublicStreamerProfile>> extends infer R ? (R extends null ? null : R extends { stream: infer S } ? S : null) : null = null;
|
||||
let followerCountFromPublic: number | null = null;
|
||||
|
||||
const publicProfile = await fetchPublicStreamerProfile(normalized);
|
||||
if (publicProfile) {
|
||||
displayName = publicProfile.displayName;
|
||||
avatarUrl = publicProfile.avatarUrl;
|
||||
bannerUrl = publicProfile.bannerUrl;
|
||||
description = publicProfile.description;
|
||||
broadcasterType = publicProfile.broadcasterType;
|
||||
followerCountFromPublic = publicProfile.followerCount;
|
||||
streamFromPublic = publicProfile.stream;
|
||||
}
|
||||
|
||||
const helixUser = await fetchHelixUserInfo(normalized);
|
||||
if (helixUser) {
|
||||
displayName = helixUser.display_name || normalized;
|
||||
avatarUrl = helixUser.profile_image_url || '';
|
||||
description = helixUser.description || '';
|
||||
displayName = helixUser.display_name || displayName;
|
||||
if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url;
|
||||
if (helixUser.description) description = helixUser.description;
|
||||
const bt = (helixUser.broadcaster_type || '').toLowerCase();
|
||||
broadcasterType = (bt === 'partner' || bt === 'affiliate') ? bt : '';
|
||||
} else {
|
||||
const publicProfile = await fetchPublicStreamerProfile(normalized);
|
||||
if (publicProfile) {
|
||||
displayName = publicProfile.displayName;
|
||||
avatarUrl = publicProfile.avatarUrl;
|
||||
description = publicProfile.description;
|
||||
broadcasterType = publicProfile.broadcasterType;
|
||||
}
|
||||
if (bt === 'partner' || bt === 'affiliate') broadcasterType = bt;
|
||||
}
|
||||
|
||||
// Follower count always from public GQL — even when Helix
|
||||
// succeeded for the rest. One extra ~50ms call, but it's the
|
||||
// only reliable way without scoped tokens.
|
||||
const followerCount = await fetchOnlyFollowerCount(normalized);
|
||||
// followerCountFromPublic comes from the public profile query
|
||||
// above — no separate follower roundtrip needed.
|
||||
const followerCount = followerCountFromPublic;
|
||||
|
||||
// Derive vod count + last stream from the already-cached VOD list
|
||||
// when we have an id. No extra network hit.
|
||||
@ -2516,24 +2549,49 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
|
||||
let isLive = false;
|
||||
let currentTitle: string | null = null;
|
||||
let currentGame: string | null = null;
|
||||
try {
|
||||
const live = await getLiveStreamInfo(normalized);
|
||||
if (live) {
|
||||
isLive = live.isLive;
|
||||
currentTitle = live.title || null;
|
||||
currentGame = live.gameName || null;
|
||||
}
|
||||
} catch (_) { /* best-effort */ }
|
||||
let currentStreamPreviewRemoteUrl = '';
|
||||
let currentStreamViewers: number | null = null;
|
||||
|
||||
// Embed the avatar bytes as a data URL so the renderer doesn't
|
||||
// have to make its own HTTPS request. Bypasses any CSP, CORS,
|
||||
// referrer-policy, or Electron renderer image-loading quirks.
|
||||
const avatarDataUrl = avatarUrl ? await fetchAvatarAsDataUrl(avatarUrl) : '';
|
||||
if (streamFromPublic) {
|
||||
// Public-GQL already told us this user is live and gave us a
|
||||
// preview frame URL + viewer count + game/title. Don't double-
|
||||
// call getLiveStreamInfo when we already have a fresh answer.
|
||||
isLive = true;
|
||||
currentTitle = streamFromPublic.title;
|
||||
currentGame = streamFromPublic.game;
|
||||
currentStreamPreviewRemoteUrl = streamFromPublic.previewUrl;
|
||||
currentStreamViewers = streamFromPublic.viewers;
|
||||
} else {
|
||||
try {
|
||||
const live = await getLiveStreamInfo(normalized);
|
||||
if (live) {
|
||||
isLive = live.isLive;
|
||||
currentTitle = live.title || null;
|
||||
currentGame = live.gameName || null;
|
||||
}
|
||||
} catch (_) { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Embed the avatar AND banner bytes as data URLs in parallel.
|
||||
// Renderer can't reliably fetch Twitch CDN images directly from
|
||||
// an Electron renderer process, plus the data URL approach skips
|
||||
// any CSP/referer/CORS quirks. Live preview also goes through
|
||||
// this path — adds a cache-busting query string so a returning
|
||||
// user gets a fresh frame each time the profile refreshes.
|
||||
const livePreviewUrlForFetch = currentStreamPreviewRemoteUrl
|
||||
? `${currentStreamPreviewRemoteUrl}${currentStreamPreviewRemoteUrl.includes('?') ? '&' : '?'}_=${Date.now()}`
|
||||
: '';
|
||||
const [avatarDataUrl, bannerDataUrl, livePreviewDataUrl] = await Promise.all([
|
||||
avatarUrl ? fetchAvatarAsDataUrl(avatarUrl) : Promise.resolve(''),
|
||||
bannerUrl ? fetchAvatarAsDataUrl(bannerUrl) : Promise.resolve(''),
|
||||
livePreviewUrlForFetch ? fetchAvatarAsDataUrl(livePreviewUrlForFetch) : Promise.resolve('')
|
||||
]);
|
||||
|
||||
const profile: StreamerProfile = {
|
||||
login: normalized,
|
||||
displayName,
|
||||
avatarUrl: avatarDataUrl || avatarUrl,
|
||||
bannerUrl: bannerDataUrl || bannerUrl,
|
||||
description,
|
||||
broadcasterType,
|
||||
followerCount,
|
||||
@ -2542,6 +2600,8 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
|
||||
isLive,
|
||||
currentTitle,
|
||||
currentGame,
|
||||
currentStreamPreviewUrl: livePreviewDataUrl || currentStreamPreviewRemoteUrl,
|
||||
currentStreamViewers,
|
||||
twitchUrl: `https://www.twitch.tv/${normalized}`,
|
||||
fetchedAt: Date.now()
|
||||
};
|
||||
@ -2551,6 +2611,122 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VOD STORYBOARD — animated hover preview
|
||||
// ==========================================
|
||||
// Twitch publishes a "storyboard" JSON per VOD with sprite-sheet URLs
|
||||
// containing N preview thumbnails covering the full length of the
|
||||
// recording. We pull the JSON via public GQL (seekPreviewsURL), then
|
||||
// hand the renderer the first high-quality sprite as a data URL plus
|
||||
// the grid metadata. The renderer animates background-position across
|
||||
// 4 cells to produce a scrub-preview effect on hover, twitch.tv-style.
|
||||
interface VodStoryboard {
|
||||
vodId: string;
|
||||
spriteDataUrl: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
framesInSprite: number;
|
||||
}
|
||||
|
||||
const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 1024;
|
||||
const vodStoryboardCache = new Map<string, CacheEntry<VodStoryboard | null>>();
|
||||
const inFlightStoryboardRequests = new Map<string, Promise<VodStoryboard | null>>();
|
||||
|
||||
interface StoryboardManifestEntry {
|
||||
count: number;
|
||||
width: number;
|
||||
height: number;
|
||||
cols: number;
|
||||
rows: number;
|
||||
images: string[];
|
||||
quality: string;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
|
||||
if (!vodId) return null;
|
||||
|
||||
const cached = getCachedValue(vodStoryboardCache, vodId);
|
||||
if (cached !== undefined) {
|
||||
runtimeMetrics.cacheHits += 1;
|
||||
return cached;
|
||||
}
|
||||
|
||||
return await withInFlightDedup(inFlightStoryboardRequests, vodId, async () => {
|
||||
runtimeMetrics.cacheMisses += 1;
|
||||
|
||||
// Step 1: GQL gives us the seekPreviewsURL pointing at a JSON
|
||||
// manifest. The manifest lists sprite images at multiple quality
|
||||
// levels; we pick the high-quality first sprite (covers the
|
||||
// beginning of the VOD with the most detail).
|
||||
const data = await fetchPublicTwitchGql<{ video: { seekPreviewsURL: string | null } | null }>(
|
||||
`query($id: ID!) { video(id: $id) { seekPreviewsURL } }`,
|
||||
{ id: vodId }
|
||||
);
|
||||
const manifestUrl = data?.video?.seekPreviewsURL;
|
||||
if (!manifestUrl) {
|
||||
// Cache the negative result so a VOD without a storyboard
|
||||
// (private/unlisted/expired) doesn't get re-queried on every
|
||||
// subsequent hover.
|
||||
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
||||
return null;
|
||||
}
|
||||
|
||||
let manifest: StoryboardManifestEntry[] | null = null;
|
||||
try {
|
||||
const manifestResp = await axios.get<StoryboardManifestEntry[]>(manifestUrl, {
|
||||
timeout: 6000,
|
||||
responseType: 'json',
|
||||
headers: { 'User-Agent': 'TwitchVODManager/1.0' }
|
||||
});
|
||||
manifest = manifestResp.data;
|
||||
} catch (e) {
|
||||
appendDebugLog('storyboard-manifest-failed', { vodId, error: String(e) });
|
||||
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(manifest) || manifest.length === 0) {
|
||||
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer the "high" quality entry — Twitch ships both "low" and
|
||||
// "high" alongside each other. Falls back to whichever is present.
|
||||
const entry = manifest.find((m) => m.quality === 'high') || manifest[0];
|
||||
if (!entry?.images?.length) {
|
||||
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
||||
return null;
|
||||
}
|
||||
|
||||
// The manifest URL points at e.g. .../storyboards/2767872722-info.json
|
||||
// and sprite filenames are relative (e.g. "2767872722-high-0.jpg").
|
||||
// Strip the JSON filename to get the base, then append the sprite.
|
||||
const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/');
|
||||
const firstSpriteUrl = baseUrl + entry.images[0];
|
||||
|
||||
const spriteDataUrl = await fetchAvatarAsDataUrl(firstSpriteUrl);
|
||||
if (!spriteDataUrl) {
|
||||
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
||||
return null;
|
||||
}
|
||||
|
||||
const storyboard: VodStoryboard = {
|
||||
vodId,
|
||||
spriteDataUrl,
|
||||
cols: entry.cols,
|
||||
rows: entry.rows,
|
||||
cellWidth: entry.width,
|
||||
cellHeight: entry.height,
|
||||
framesInSprite: entry.cols * entry.rows
|
||||
};
|
||||
setCachedValue(vodStoryboardCache, vodId, storyboard, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
||||
return storyboard;
|
||||
});
|
||||
}
|
||||
|
||||
async function getClipInfo(clipId: string): Promise<any | null> {
|
||||
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
||||
if (cachedClip !== undefined) {
|
||||
@ -6800,6 +6976,10 @@ ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: b
|
||||
return await getStreamerProfile(login, forceRefresh === true);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise<VodStoryboard | null> => {
|
||||
return await getVodStoryboard(vodId);
|
||||
});
|
||||
|
||||
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
|
||||
const normalized: ArchiveSearchFilter = {
|
||||
query: typeof filter?.query === 'string' ? filter.query.trim() : '',
|
||||
|
||||
@ -92,6 +92,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
|
||||
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
|
||||
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
|
||||
getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId),
|
||||
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
||||
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
||||
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
|
||||
|
||||
14
src/renderer-globals.d.ts
vendored
14
src/renderer-globals.d.ts
vendored
@ -239,6 +239,7 @@ interface StreamerProfile {
|
||||
login: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
bannerUrl: string;
|
||||
description: string;
|
||||
broadcasterType: '' | 'partner' | 'affiliate';
|
||||
followerCount: number | null;
|
||||
@ -247,10 +248,22 @@ interface StreamerProfile {
|
||||
isLive: boolean;
|
||||
currentTitle: string | null;
|
||||
currentGame: string | null;
|
||||
currentStreamPreviewUrl: string;
|
||||
currentStreamViewers: number | null;
|
||||
twitchUrl: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
interface VodStoryboard {
|
||||
vodId: string;
|
||||
spriteDataUrl: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
framesInSprite: number;
|
||||
}
|
||||
|
||||
interface ArchiveSearchHit {
|
||||
fullPath: string;
|
||||
fileName: string;
|
||||
@ -332,6 +345,7 @@ interface ApiBridge {
|
||||
getStorageStats(): Promise<StorageStatsResult>;
|
||||
getArchiveStats(): Promise<ArchiveStats>;
|
||||
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
|
||||
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
|
||||
searchArchive(filter: {
|
||||
query?: string;
|
||||
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
||||
|
||||
@ -326,6 +326,8 @@ const UI_TEXT_DE = {
|
||||
lastStream: 'Letzter Stream',
|
||||
openTwitch: 'Auf Twitch oeffnen',
|
||||
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
|
||||
liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten',
|
||||
recordNow: 'Jetzt aufnehmen',
|
||||
refresh: 'Aktualisieren',
|
||||
agoMinutes: 'vor {n} Min',
|
||||
agoHours: 'vor {n} h',
|
||||
|
||||
@ -326,6 +326,8 @@ const UI_TEXT_EN = {
|
||||
lastStream: 'Last stream',
|
||||
openTwitch: 'Open on Twitch',
|
||||
openTwitchTooltip: 'Open this channel on twitch.tv',
|
||||
liveCardTooltip: 'Click to start a live recording right now',
|
||||
recordNow: 'Record now',
|
||||
refresh: 'Refresh',
|
||||
agoMinutes: '{n} min ago',
|
||||
agoHours: '{n} h ago',
|
||||
|
||||
@ -77,7 +77,7 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
|
||||
if (!el) return;
|
||||
el.classList.remove('streamer-profile-skeleton');
|
||||
if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live');
|
||||
el.style.display = 'flex';
|
||||
el.style.display = 'block';
|
||||
|
||||
const safeLogin = p.login.replace(/'/g, "\\'");
|
||||
const safeUrl = p.twitchUrl.replace(/'/g, "\\'");
|
||||
@ -87,14 +87,9 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
|
||||
: `<div class="streamer-profile-avatar-fallback">${escapeProfileHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
|
||||
|
||||
const badges: string[] = [];
|
||||
if (p.isLive) badges.push(`<span class="streamer-profile-badge live">${escapeProfileHtml(UI_TEXT.profile.liveBadge)}</span>`);
|
||||
if (p.broadcasterType === 'partner') badges.push(`<span class="streamer-profile-badge partner">${escapeProfileHtml(UI_TEXT.profile.partner)}</span>`);
|
||||
if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeProfileHtml(UI_TEXT.profile.affiliate)}</span>`);
|
||||
|
||||
const liveInfo = (p.isLive && (p.currentTitle || p.currentGame))
|
||||
? `<div class="streamer-profile-live-info">${p.currentTitle ? `<strong>${escapeProfileHtml(p.currentTitle)}</strong>` : ''}${p.currentTitle && p.currentGame ? ' · ' : ''}${p.currentGame ? escapeProfileHtml(p.currentGame) : ''}</div>`
|
||||
: '';
|
||||
|
||||
const bio = p.description
|
||||
? `<div class="streamer-profile-bio" title="${escapeProfileHtml(p.description)}">${escapeProfileHtml(p.description)}</div>`
|
||||
: '';
|
||||
@ -115,31 +110,72 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
|
||||
${escapeProfileHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
|
||||
</div>`;
|
||||
|
||||
// Banner-as-background — set inline so the URL stays per-streamer.
|
||||
const bannerStyle = p.bannerUrl
|
||||
? `background-image: linear-gradient(135deg, rgba(14,14,16,0.78) 0%, rgba(14,14,16,0.92) 100%), url("${p.bannerUrl.replace(/"/g, '%22')}");`
|
||||
: '';
|
||||
|
||||
// Live preview block — only when currently live. Big card with
|
||||
// current preview frame + viewer count + title + game + record CTA.
|
||||
const liveCard = p.isLive
|
||||
? `
|
||||
<div class="streamer-profile-live-card" onclick="triggerLiveRecordingFromProfile('${safeLogin}')" title="${escapeProfileHtml(UI_TEXT.profile.liveCardTooltip)}">
|
||||
${p.currentStreamPreviewUrl
|
||||
? `<img class="streamer-profile-live-thumb" src="${escapeProfileHtml(p.currentStreamPreviewUrl)}" alt="Live preview" onerror="onProfileLivePreviewError(this)">`
|
||||
: `<div class="streamer-profile-live-thumb-fallback"></div>`}
|
||||
<div class="streamer-profile-live-body">
|
||||
<div class="streamer-profile-live-badge-row">
|
||||
<span class="streamer-profile-badge live">${escapeProfileHtml(UI_TEXT.profile.liveBadge)}</span>
|
||||
${typeof p.currentStreamViewers === 'number' ? `<span class="streamer-profile-live-viewers"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg> ${escapeProfileHtml(formatProfileFollowers(p.currentStreamViewers))}</span>` : ''}
|
||||
</div>
|
||||
${p.currentTitle ? `<div class="streamer-profile-live-title">${escapeProfileHtml(p.currentTitle)}</div>` : ''}
|
||||
${p.currentGame ? `<div class="streamer-profile-live-game">${escapeProfileHtml(p.currentGame)}</div>` : ''}
|
||||
<button class="streamer-profile-btn primary streamer-profile-live-rec-btn" onclick="event.stopPropagation(); triggerLiveRecordingFromProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.recordNow)}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
applyProfileHtml(el, `
|
||||
<div class="streamer-profile-avatar-wrap" onclick="openTwitchChannel('${safeUrl}')" title="${escapeProfileHtml(UI_TEXT.profile.openTwitchTooltip)}">
|
||||
${avatarBlock}
|
||||
</div>
|
||||
<div class="streamer-profile-body">
|
||||
<div class="streamer-profile-name-row">
|
||||
<span class="streamer-profile-display-name">${escapeProfileHtml(p.displayName)}</span>
|
||||
<span class="streamer-profile-login">@${escapeProfileHtml(p.login)}</span>
|
||||
${badges.join('')}
|
||||
${bannerStyle ? `<div class="streamer-profile-banner-bg" style="${bannerStyle}"></div>` : ''}
|
||||
<div class="streamer-profile-row">
|
||||
<div class="streamer-profile-avatar-wrap" onclick="openTwitchChannel('${safeUrl}')" title="${escapeProfileHtml(UI_TEXT.profile.openTwitchTooltip)}">
|
||||
${avatarBlock}
|
||||
</div>
|
||||
${liveInfo}
|
||||
${bio}
|
||||
<div class="streamer-profile-stats">
|
||||
${followersStat}
|
||||
${vodsStat}
|
||||
${lastStreamStat}
|
||||
<div class="streamer-profile-body">
|
||||
<div class="streamer-profile-name-row">
|
||||
<span class="streamer-profile-display-name">${escapeProfileHtml(p.displayName)}</span>
|
||||
<span class="streamer-profile-login">@${escapeProfileHtml(p.login)}</span>
|
||||
${badges.join('')}
|
||||
</div>
|
||||
${bio}
|
||||
<div class="streamer-profile-stats">
|
||||
${followersStat}
|
||||
${vodsStat}
|
||||
${lastStreamStat}
|
||||
</div>
|
||||
</div>
|
||||
<div class="streamer-profile-actions">
|
||||
<button class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeProfileHtml(UI_TEXT.profile.openTwitch)}</button>
|
||||
<button class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.refresh)}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="streamer-profile-actions">
|
||||
<button class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeProfileHtml(UI_TEXT.profile.openTwitch)}</button>
|
||||
<button class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.refresh)}</button>
|
||||
</div>
|
||||
${liveCard}
|
||||
`);
|
||||
}
|
||||
|
||||
function onProfileLivePreviewError(img: HTMLImageElement): void {
|
||||
const parent = img.parentElement;
|
||||
if (!parent) return;
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'streamer-profile-live-thumb-fallback';
|
||||
parent.replaceChild(fallback, img);
|
||||
}
|
||||
|
||||
function triggerLiveRecordingFromProfile(login: string): void {
|
||||
const fn = (window as unknown as { triggerLiveRecording?: (login: string) => Promise<void> }).triggerLiveRecording;
|
||||
if (typeof fn === 'function') void fn(login);
|
||||
}
|
||||
|
||||
async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> {
|
||||
if (!login) {
|
||||
hideStreamerProfileHeader();
|
||||
@ -193,3 +229,5 @@ function onProfileAvatarError(img: HTMLImageElement): void {
|
||||
(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader;
|
||||
(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel;
|
||||
(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError;
|
||||
(window as unknown as { onProfileLivePreviewError: typeof onProfileLivePreviewError }).onProfileLivePreviewError = onProfileLivePreviewError;
|
||||
(window as unknown as { triggerLiveRecordingFromProfile: typeof triggerLiveRecordingFromProfile }).triggerLiveRecordingFromProfile = triggerLiveRecordingFromProfile;
|
||||
|
||||
148
src/renderer-vod-hover.ts
Normal file
148
src/renderer-vod-hover.ts
Normal file
@ -0,0 +1,148 @@
|
||||
// VOD hover preview. When the user mouses over a VOD card, we lazy-fetch
|
||||
// the channel's seek-preview storyboard sprite for that VOD and cycle
|
||||
// through 4 evenly-spaced cells to produce a scrub-preview animation —
|
||||
// the same UX twitch.tv ships on its VOD browsing pages.
|
||||
//
|
||||
// The storyboard fetch goes through the main process (axios via Node's
|
||||
// http client) so the renderer never has to make its own HTTPS request
|
||||
// to the Twitch CDN, sidestepping the same set of Electron renderer
|
||||
// image-loading quirks the avatar code hit.
|
||||
|
||||
interface ActiveHover {
|
||||
vodId: string;
|
||||
intervalId: number;
|
||||
overlay: HTMLElement;
|
||||
}
|
||||
|
||||
const vodStoryboardClientCache = new Map<string, VodStoryboard | null>();
|
||||
let activeHover: ActiveHover | null = null;
|
||||
let pendingHoverVodId: string | null = null;
|
||||
|
||||
const HOVER_DEBOUNCE_MS = 220;
|
||||
const FRAME_INTERVAL_MS = 600;
|
||||
const FRAMES_TO_CYCLE = 4;
|
||||
|
||||
function ensureVodHoverHandlersBound(): void {
|
||||
const grid = document.getElementById('vodGrid');
|
||||
if (!grid || grid.dataset.hoverBound === '1') return;
|
||||
grid.dataset.hoverBound = '1';
|
||||
|
||||
// Delegated mouseover/mouseout on the grid — re-renders of the
|
||||
// grid replace the card DOM but the grid root persists, so the
|
||||
// listener stays bound across streamer switches.
|
||||
grid.addEventListener('mouseover', (e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const card = target?.closest('.vod-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
const vodId = card.dataset.vodId;
|
||||
if (!vodId) return;
|
||||
scheduleHoverPreview(card, vodId);
|
||||
});
|
||||
grid.addEventListener('mouseout', (e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const card = target?.closest('.vod-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
// Only clear when leaving the card entirely (not just moving
|
||||
// within it between child elements).
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
if (related && card.contains(related)) return;
|
||||
clearHoverPreview();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleHoverPreview(card: HTMLElement, vodId: string): void {
|
||||
if (pendingHoverVodId === vodId) return;
|
||||
pendingHoverVodId = vodId;
|
||||
// Debounce so rapid mouse passes (scrolling, dragging across cards)
|
||||
// don't trigger a download for every card brushed.
|
||||
window.setTimeout(() => {
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
void activateHoverPreview(card, vodId);
|
||||
}, HOVER_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearHoverPreview(): void {
|
||||
pendingHoverVodId = null;
|
||||
if (!activeHover) return;
|
||||
window.clearInterval(activeHover.intervalId);
|
||||
const card = activeHover.overlay.parentElement;
|
||||
if (card) card.classList.remove('preview-active');
|
||||
// Brief opacity fade-out, then remove from DOM.
|
||||
activeHover.overlay.style.opacity = '0';
|
||||
const overlayToRemove = activeHover.overlay;
|
||||
window.setTimeout(() => { try { overlayToRemove.remove(); } catch { /* gone */ } }, 220);
|
||||
activeHover = null;
|
||||
}
|
||||
|
||||
async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<void> {
|
||||
// Stale-guard: user might have moved off the card in the debounce window.
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
|
||||
let storyboard: VodStoryboard | null | undefined = vodStoryboardClientCache.get(vodId);
|
||||
if (storyboard === undefined) {
|
||||
try {
|
||||
storyboard = await window.api.getVodStoryboard(vodId);
|
||||
} catch (_) {
|
||||
storyboard = null;
|
||||
}
|
||||
vodStoryboardClientCache.set(vodId, storyboard);
|
||||
}
|
||||
|
||||
// Cursor may have moved on while we awaited; re-check guard.
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
if (!storyboard) return;
|
||||
|
||||
clearHoverPreview();
|
||||
|
||||
// Pick FRAMES_TO_CYCLE evenly-spaced cells from the first sprite —
|
||||
// distributes the chosen preview frames across the early/mid portion
|
||||
// of the VOD. For very short VODs the first sprite is the only one,
|
||||
// so this still gives a representative spread.
|
||||
const totalCells = Math.min(storyboard.framesInSprite, storyboard.cols * storyboard.rows);
|
||||
const stride = Math.max(1, Math.floor(totalCells / FRAMES_TO_CYCLE));
|
||||
const cellsToShow: Array<{ col: number; row: number }> = [];
|
||||
for (let i = 0; i < FRAMES_TO_CYCLE; i++) {
|
||||
const idx = Math.min(totalCells - 1, i * stride);
|
||||
const col = idx % storyboard.cols;
|
||||
const row = Math.floor(idx / storyboard.cols);
|
||||
cellsToShow.push({ col, row });
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'vod-storyboard-preview';
|
||||
// Scale the sprite so a single cell exactly fills the card width.
|
||||
// The thumbnail aspect-ratio (16:9) matches typical cell aspect
|
||||
// (e.g. 220x124 ≈ 1.77) so width-stretch keeps proportions.
|
||||
const cardWidth = card.getBoundingClientRect().width;
|
||||
const cellAspect = storyboard.cellWidth / storyboard.cellHeight;
|
||||
const scale = cardWidth / storyboard.cellWidth;
|
||||
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
|
||||
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scale}px ${storyboard.rows * storyboard.cellHeight * scale}px`;
|
||||
overlay.style.height = `${cardWidth / cellAspect}px`;
|
||||
// Initial position = first chosen cell.
|
||||
const first = cellsToShow[0];
|
||||
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scale}px -${first.row * storyboard.cellHeight * scale}px`;
|
||||
|
||||
card.appendChild(overlay);
|
||||
// Trigger CSS transition to opacity:1 on the next frame.
|
||||
requestAnimationFrame(() => { card.classList.add('preview-active'); });
|
||||
|
||||
let frameIdx = 1;
|
||||
const intervalId = window.setInterval(() => {
|
||||
const cell = cellsToShow[frameIdx % cellsToShow.length];
|
||||
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scale}px -${cell.row * storyboard.cellHeight * scale}px`;
|
||||
frameIdx++;
|
||||
}, FRAME_INTERVAL_MS);
|
||||
|
||||
activeHover = { vodId, intervalId, overlay };
|
||||
}
|
||||
|
||||
(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound;
|
||||
|
||||
// Bind once the grid exists. Tab switches don't re-create the grid, so
|
||||
// one-time binding via DOMContentLoaded is enough.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => { ensureVodHoverHandlersBound(); });
|
||||
} else {
|
||||
ensureVodHoverHandlersBound();
|
||||
}
|
||||
191
src/styles.css
191
src/styles.css
@ -1948,17 +1948,40 @@ body.theme-light .modal {
|
||||
when a streamer is selected. Modeled on Twitch's own profile header
|
||||
for instant familiarity, but trimmed for the desktop-app context. */
|
||||
.streamer-profile-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 18px 22px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin-bottom: 14px;
|
||||
background: linear-gradient(135deg, rgba(145, 70, 255, 0.10) 0%, rgba(0, 200, 83, 0.04) 100%), var(--bg-card);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
animation: profile-fade-in 0.32s ease-out;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.streamer-profile-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
.streamer-profile-banner-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(18px) saturate(1.2);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
transform: scale(1.1); /* avoid the blur edge bleed */
|
||||
}
|
||||
|
||||
@keyframes profile-fade-in {
|
||||
@ -2207,7 +2230,7 @@ body.theme-light .modal {
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.streamer-profile-header {
|
||||
.streamer-profile-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@ -2216,3 +2239,159 @@ body.theme-light .modal {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LIVE PREVIEW CARD — inside the profile header
|
||||
============================================ */
|
||||
.streamer-profile-live-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin: 0 14px 14px;
|
||||
padding: 12px;
|
||||
background: rgba(233, 25, 22, 0.10);
|
||||
border: 1px solid rgba(233, 25, 22, 0.5);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s, box-shadow 0.18s, background 0.18s;
|
||||
animation: profile-fade-in 0.4s ease-out;
|
||||
}
|
||||
|
||||
.streamer-profile-live-card:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(233, 25, 22, 0.16);
|
||||
box-shadow: 0 6px 22px rgba(233, 25, 22, 0.20);
|
||||
}
|
||||
|
||||
.streamer-profile-live-thumb {
|
||||
width: 240px;
|
||||
height: 135px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: #000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.streamer-profile-live-thumb-fallback {
|
||||
width: 240px;
|
||||
height: 135px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #2a0a0a, #1a0606);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(233, 25, 22, 0.5);
|
||||
}
|
||||
|
||||
.streamer-profile-live-thumb-fallback svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.streamer-profile-live-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.streamer-profile-live-badge-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.streamer-profile-live-viewers {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.streamer-profile-live-viewers svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.streamer-profile-live-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.streamer-profile-live-game {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.streamer-profile-live-rec-btn {
|
||||
margin-top: 6px;
|
||||
align-self: flex-start;
|
||||
background: #e91916 !important;
|
||||
border-color: #e91916 !important;
|
||||
}
|
||||
|
||||
.streamer-profile-live-rec-btn:hover {
|
||||
background: #ff3733 !important;
|
||||
border-color: #ff3733 !important;
|
||||
box-shadow: 0 4px 14px rgba(233, 25, 22, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.streamer-profile-live-card { flex-direction: column; }
|
||||
.streamer-profile-live-thumb,
|
||||
.streamer-profile-live-thumb-fallback { width: 100%; height: 180px; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
VOD HOVER PREVIEW — storyboard sprite cycling
|
||||
============================================
|
||||
Overlay sits as a direct child of .vod-card, positioned over the
|
||||
thumbnail's bounding box. Width matches the card; aspect-ratio
|
||||
16/9 anchors the height to align with the thumbnail. */
|
||||
.vod-storyboard-preview {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
aspect-ratio: 16/9;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0;
|
||||
transition: opacity 0.22s ease-out;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
border-radius: 8px 8px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vod-card.preview-active .vod-storyboard-preview {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vod-card.preview-active .vod-thumbnail {
|
||||
filter: brightness(0.92);
|
||||
transition: filter 0.3s;
|
||||
}
|
||||
|
||||
.vod-storyboard-preview::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 70%, rgba(0, 0, 0, 0.18) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user