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-stats.js"></script>
|
||||||
<script src="../dist/renderer-archive.js"></script>
|
<script src="../dist/renderer-archive.js"></script>
|
||||||
<script src="../dist/renderer-profile.js"></script>
|
<script src="../dist/renderer-profile.js"></script>
|
||||||
|
<script src="../dist/renderer-vod-hover.js"></script>
|
||||||
<script src="../dist/renderer.js"></script>
|
<script src="../dist/renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
240
src/main.ts
240
src/main.ts
@ -2303,6 +2303,7 @@ interface StreamerProfile {
|
|||||||
login: string;
|
login: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
bannerUrl: string;
|
||||||
description: string;
|
description: string;
|
||||||
broadcasterType: '' | 'partner' | 'affiliate';
|
broadcasterType: '' | 'partner' | 'affiliate';
|
||||||
followerCount: number | null;
|
followerCount: number | null;
|
||||||
@ -2311,6 +2312,8 @@ interface StreamerProfile {
|
|||||||
isLive: boolean;
|
isLive: boolean;
|
||||||
currentTitle: string | null;
|
currentTitle: string | null;
|
||||||
currentGame: string | null;
|
currentGame: string | null;
|
||||||
|
currentStreamPreviewUrl: string;
|
||||||
|
currentStreamViewers: number | null;
|
||||||
twitchUrl: string;
|
twitchUrl: string;
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
}
|
}
|
||||||
@ -2393,25 +2396,31 @@ interface PublicProfileQueryResult {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
profileImageURL: string | null;
|
profileImageURL: string | null;
|
||||||
|
bannerImageURL: string | null;
|
||||||
roles?: { isPartner: boolean; isAffiliate: boolean } | null;
|
roles?: { isPartner: boolean; isAffiliate: boolean } | null;
|
||||||
followers?: { totalCount: number } | 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;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPublicStreamerProfile(login: string): Promise<{
|
async function fetchPublicStreamerProfile(login: string): Promise<{
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
bannerUrl: string;
|
||||||
description: string;
|
description: string;
|
||||||
broadcasterType: '' | 'partner' | 'affiliate';
|
broadcasterType: '' | 'partner' | 'affiliate';
|
||||||
followerCount: number | null;
|
followerCount: number | null;
|
||||||
|
stream: { previewUrl: string; viewers: number | null; title: string | null; game: string | null } | null;
|
||||||
} | null> {
|
} | null> {
|
||||||
// The public (unauthenticated) GQL schema does NOT expose
|
// Same query also pulls bannerImageURL and the current stream's
|
||||||
// `broadcasterType` directly — querying it returns an errors[] response
|
// preview + viewer count when live — saves a separate roundtrip.
|
||||||
// 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.
|
|
||||||
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
|
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
|
||||||
`query($login: String!) {
|
`query($login: String!) {
|
||||||
user(login: $login) {
|
user(login: $login) {
|
||||||
@ -2420,8 +2429,17 @@ async function fetchPublicStreamerProfile(login: string): Promise<{
|
|||||||
displayName
|
displayName
|
||||||
description
|
description
|
||||||
profileImageURL(width: 150)
|
profileImageURL(width: 150)
|
||||||
|
bannerImageURL
|
||||||
roles { isPartner isAffiliate }
|
roles { isPartner isAffiliate }
|
||||||
followers { totalCount }
|
followers { totalCount }
|
||||||
|
stream {
|
||||||
|
id
|
||||||
|
type
|
||||||
|
title
|
||||||
|
viewersCount
|
||||||
|
previewImageURL(width: 640, height: 360)
|
||||||
|
game { name }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ login }
|
{ login }
|
||||||
@ -2431,12 +2449,21 @@ async function fetchPublicStreamerProfile(login: string): Promise<{
|
|||||||
const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner
|
const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner
|
||||||
? 'partner'
|
? 'partner'
|
||||||
: (roles?.isAffiliate ? 'affiliate' : '');
|
: (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 {
|
return {
|
||||||
displayName: data.user.displayName || login,
|
displayName: data.user.displayName || login,
|
||||||
avatarUrl: data.user.profileImageURL || '',
|
avatarUrl: data.user.profileImageURL || '',
|
||||||
|
bannerUrl: data.user.bannerImageURL || '',
|
||||||
description: data.user.description || '',
|
description: data.user.description || '',
|
||||||
broadcasterType,
|
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 () => {
|
return await withInFlightDedup(inFlightProfileRequests, normalized, async () => {
|
||||||
runtimeMetrics.cacheMisses += 1;
|
runtimeMetrics.cacheMisses += 1;
|
||||||
|
|
||||||
// Prefer Helix for the core fields when we have credentials (richer
|
// Public GQL is now the SOURCE for everything except some of the
|
||||||
// bio, faster, less likely to rate-limit), then fill in followers
|
// core text fields when Helix is authenticated — because public
|
||||||
// via public GQL since Helix /channels/followers needs a moderator
|
// GQL is the only route that gives us the banner image + current
|
||||||
// token we don't have. Fall back fully to public GQL when not
|
// stream preview in one shot, and skipping it would mean two
|
||||||
// authenticated.
|
// extra roundtrips. Helix takes precedence for displayName /
|
||||||
|
// description (those fields are sometimes richer there).
|
||||||
let displayName = normalized;
|
let displayName = normalized;
|
||||||
let avatarUrl = '';
|
let avatarUrl = '';
|
||||||
|
let bannerUrl = '';
|
||||||
let description = '';
|
let description = '';
|
||||||
let broadcasterType: '' | 'partner' | 'affiliate' = '';
|
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 helixUser = await fetchHelixUserInfo(normalized);
|
|
||||||
if (helixUser) {
|
|
||||||
displayName = helixUser.display_name || normalized;
|
|
||||||
avatarUrl = helixUser.profile_image_url || '';
|
|
||||||
description = helixUser.description || '';
|
|
||||||
const bt = (helixUser.broadcaster_type || '').toLowerCase();
|
|
||||||
broadcasterType = (bt === 'partner' || bt === 'affiliate') ? bt : '';
|
|
||||||
} else {
|
|
||||||
const publicProfile = await fetchPublicStreamerProfile(normalized);
|
const publicProfile = await fetchPublicStreamerProfile(normalized);
|
||||||
if (publicProfile) {
|
if (publicProfile) {
|
||||||
displayName = publicProfile.displayName;
|
displayName = publicProfile.displayName;
|
||||||
avatarUrl = publicProfile.avatarUrl;
|
avatarUrl = publicProfile.avatarUrl;
|
||||||
|
bannerUrl = publicProfile.bannerUrl;
|
||||||
description = publicProfile.description;
|
description = publicProfile.description;
|
||||||
broadcasterType = publicProfile.broadcasterType;
|
broadcasterType = publicProfile.broadcasterType;
|
||||||
}
|
followerCountFromPublic = publicProfile.followerCount;
|
||||||
|
streamFromPublic = publicProfile.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follower count always from public GQL — even when Helix
|
const helixUser = await fetchHelixUserInfo(normalized);
|
||||||
// succeeded for the rest. One extra ~50ms call, but it's the
|
if (helixUser) {
|
||||||
// only reliable way without scoped tokens.
|
displayName = helixUser.display_name || displayName;
|
||||||
const followerCount = await fetchOnlyFollowerCount(normalized);
|
if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url;
|
||||||
|
if (helixUser.description) description = helixUser.description;
|
||||||
|
const bt = (helixUser.broadcaster_type || '').toLowerCase();
|
||||||
|
if (bt === 'partner' || bt === 'affiliate') broadcasterType = bt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Derive vod count + last stream from the already-cached VOD list
|
||||||
// when we have an id. No extra network hit.
|
// when we have an id. No extra network hit.
|
||||||
@ -2516,6 +2549,19 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
|
|||||||
let isLive = false;
|
let isLive = false;
|
||||||
let currentTitle: string | null = null;
|
let currentTitle: string | null = null;
|
||||||
let currentGame: string | null = null;
|
let currentGame: string | null = null;
|
||||||
|
let currentStreamPreviewRemoteUrl = '';
|
||||||
|
let currentStreamViewers: number | null = null;
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const live = await getLiveStreamInfo(normalized);
|
const live = await getLiveStreamInfo(normalized);
|
||||||
if (live) {
|
if (live) {
|
||||||
@ -2524,16 +2570,28 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
|
|||||||
currentGame = live.gameName || null;
|
currentGame = live.gameName || null;
|
||||||
}
|
}
|
||||||
} catch (_) { /* best-effort */ }
|
} catch (_) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Embed the avatar bytes as a data URL so the renderer doesn't
|
// Embed the avatar AND banner bytes as data URLs in parallel.
|
||||||
// have to make its own HTTPS request. Bypasses any CSP, CORS,
|
// Renderer can't reliably fetch Twitch CDN images directly from
|
||||||
// referrer-policy, or Electron renderer image-loading quirks.
|
// an Electron renderer process, plus the data URL approach skips
|
||||||
const avatarDataUrl = avatarUrl ? await fetchAvatarAsDataUrl(avatarUrl) : '';
|
// 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 = {
|
const profile: StreamerProfile = {
|
||||||
login: normalized,
|
login: normalized,
|
||||||
displayName,
|
displayName,
|
||||||
avatarUrl: avatarDataUrl || avatarUrl,
|
avatarUrl: avatarDataUrl || avatarUrl,
|
||||||
|
bannerUrl: bannerDataUrl || bannerUrl,
|
||||||
description,
|
description,
|
||||||
broadcasterType,
|
broadcasterType,
|
||||||
followerCount,
|
followerCount,
|
||||||
@ -2542,6 +2600,8 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
|
|||||||
isLive,
|
isLive,
|
||||||
currentTitle,
|
currentTitle,
|
||||||
currentGame,
|
currentGame,
|
||||||
|
currentStreamPreviewUrl: livePreviewDataUrl || currentStreamPreviewRemoteUrl,
|
||||||
|
currentStreamViewers,
|
||||||
twitchUrl: `https://www.twitch.tv/${normalized}`,
|
twitchUrl: `https://www.twitch.tv/${normalized}`,
|
||||||
fetchedAt: Date.now()
|
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> {
|
async function getClipInfo(clipId: string): Promise<any | null> {
|
||||||
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
||||||
if (cachedClip !== undefined) {
|
if (cachedClip !== undefined) {
|
||||||
@ -6800,6 +6976,10 @@ ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: b
|
|||||||
return await getStreamerProfile(login, forceRefresh === true);
|
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 => {
|
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() : '',
|
||||||
|
|||||||
@ -92,6 +92,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
|
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
|
||||||
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),
|
||||||
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),
|
||||||
|
|||||||
14
src/renderer-globals.d.ts
vendored
14
src/renderer-globals.d.ts
vendored
@ -239,6 +239,7 @@ interface StreamerProfile {
|
|||||||
login: string;
|
login: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
bannerUrl: string;
|
||||||
description: string;
|
description: string;
|
||||||
broadcasterType: '' | 'partner' | 'affiliate';
|
broadcasterType: '' | 'partner' | 'affiliate';
|
||||||
followerCount: number | null;
|
followerCount: number | null;
|
||||||
@ -247,10 +248,22 @@ interface StreamerProfile {
|
|||||||
isLive: boolean;
|
isLive: boolean;
|
||||||
currentTitle: string | null;
|
currentTitle: string | null;
|
||||||
currentGame: string | null;
|
currentGame: string | null;
|
||||||
|
currentStreamPreviewUrl: string;
|
||||||
|
currentStreamViewers: number | null;
|
||||||
twitchUrl: string;
|
twitchUrl: string;
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VodStoryboard {
|
||||||
|
vodId: string;
|
||||||
|
spriteDataUrl: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
cellWidth: number;
|
||||||
|
cellHeight: number;
|
||||||
|
framesInSprite: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ArchiveSearchHit {
|
interface ArchiveSearchHit {
|
||||||
fullPath: string;
|
fullPath: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -332,6 +345,7 @@ interface ApiBridge {
|
|||||||
getStorageStats(): Promise<StorageStatsResult>;
|
getStorageStats(): Promise<StorageStatsResult>;
|
||||||
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>;
|
||||||
searchArchive(filter: {
|
searchArchive(filter: {
|
||||||
query?: string;
|
query?: string;
|
||||||
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
||||||
|
|||||||
@ -326,6 +326,8 @@ const UI_TEXT_DE = {
|
|||||||
lastStream: 'Letzter Stream',
|
lastStream: 'Letzter Stream',
|
||||||
openTwitch: 'Auf Twitch oeffnen',
|
openTwitch: 'Auf Twitch oeffnen',
|
||||||
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
|
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
|
||||||
|
liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten',
|
||||||
|
recordNow: 'Jetzt aufnehmen',
|
||||||
refresh: 'Aktualisieren',
|
refresh: 'Aktualisieren',
|
||||||
agoMinutes: 'vor {n} Min',
|
agoMinutes: 'vor {n} Min',
|
||||||
agoHours: 'vor {n} h',
|
agoHours: 'vor {n} h',
|
||||||
|
|||||||
@ -326,6 +326,8 @@ const UI_TEXT_EN = {
|
|||||||
lastStream: 'Last stream',
|
lastStream: 'Last stream',
|
||||||
openTwitch: 'Open on Twitch',
|
openTwitch: 'Open on Twitch',
|
||||||
openTwitchTooltip: 'Open this channel on twitch.tv',
|
openTwitchTooltip: 'Open this channel on twitch.tv',
|
||||||
|
liveCardTooltip: 'Click to start a live recording right now',
|
||||||
|
recordNow: 'Record now',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
agoMinutes: '{n} min ago',
|
agoMinutes: '{n} min ago',
|
||||||
agoHours: '{n} h ago',
|
agoHours: '{n} h ago',
|
||||||
|
|||||||
@ -77,7 +77,7 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.classList.remove('streamer-profile-skeleton');
|
el.classList.remove('streamer-profile-skeleton');
|
||||||
if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live');
|
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 safeLogin = p.login.replace(/'/g, "\\'");
|
||||||
const safeUrl = p.twitchUrl.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>`;
|
: `<div class="streamer-profile-avatar-fallback">${escapeProfileHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
|
||||||
|
|
||||||
const badges: string[] = [];
|
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 === '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>`);
|
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
|
const bio = p.description
|
||||||
? `<div class="streamer-profile-bio" title="${escapeProfileHtml(p.description)}">${escapeProfileHtml(p.description)}</div>`
|
? `<div class="streamer-profile-bio" title="${escapeProfileHtml(p.description)}">${escapeProfileHtml(p.description)}</div>`
|
||||||
: '';
|
: '';
|
||||||
@ -115,7 +110,34 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
|
|||||||
${escapeProfileHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
|
${escapeProfileHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
|
||||||
</div>`;
|
</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, `
|
applyProfileHtml(el, `
|
||||||
|
${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)}">
|
<div class="streamer-profile-avatar-wrap" onclick="openTwitchChannel('${safeUrl}')" title="${escapeProfileHtml(UI_TEXT.profile.openTwitchTooltip)}">
|
||||||
${avatarBlock}
|
${avatarBlock}
|
||||||
</div>
|
</div>
|
||||||
@ -125,7 +147,6 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
|
|||||||
<span class="streamer-profile-login">@${escapeProfileHtml(p.login)}</span>
|
<span class="streamer-profile-login">@${escapeProfileHtml(p.login)}</span>
|
||||||
${badges.join('')}
|
${badges.join('')}
|
||||||
</div>
|
</div>
|
||||||
${liveInfo}
|
|
||||||
${bio}
|
${bio}
|
||||||
<div class="streamer-profile-stats">
|
<div class="streamer-profile-stats">
|
||||||
${followersStat}
|
${followersStat}
|
||||||
@ -137,9 +158,24 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
|
|||||||
<button class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeProfileHtml(UI_TEXT.profile.openTwitch)}</button>
|
<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>
|
<button class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.refresh)}</button>
|
||||||
</div>
|
</div>
|
||||||
|
</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> {
|
async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> {
|
||||||
if (!login) {
|
if (!login) {
|
||||||
hideStreamerProfileHeader();
|
hideStreamerProfileHeader();
|
||||||
@ -193,3 +229,5 @@ function onProfileAvatarError(img: HTMLImageElement): void {
|
|||||||
(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader;
|
(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader;
|
||||||
(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel;
|
(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel;
|
||||||
(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError;
|
(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
|
when a streamer is selected. Modeled on Twitch's own profile header
|
||||||
for instant familiarity, but trimmed for the desktop-app context. */
|
for instant familiarity, but trimmed for the desktop-app context. */
|
||||||
.streamer-profile-header {
|
.streamer-profile-header {
|
||||||
position: relative;
|
position: sticky;
|
||||||
display: flex;
|
top: 0;
|
||||||
gap: 18px;
|
z-index: 20;
|
||||||
align-items: center;
|
display: block;
|
||||||
padding: 18px 22px;
|
padding: 0;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
background: linear-gradient(135deg, rgba(145, 70, 255, 0.10) 0%, rgba(0, 200, 83, 0.04) 100%), var(--bg-card);
|
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: 1px solid var(--border-soft);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: profile-fade-in 0.32s ease-out;
|
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 {
|
@keyframes profile-fade-in {
|
||||||
@ -2207,7 +2230,7 @@ body.theme-light .modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.streamer-profile-header {
|
.streamer-profile-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@ -2216,3 +2239,159 @@ body.theme-light .modal {
|
|||||||
width: 100%;
|
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