Compare commits
No commits in common. "bd54ba9cfbef668cf3006f9db7bb798fb8f196c0" and "1b87a2611ee3b5404b8759a484b06a1222f9f79f" have entirely different histories.
bd54ba9cfb
...
1b87a2611e
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.20",
|
"version": "4.6.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.20",
|
"version": "4.6.19",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.20",
|
"version": "4.6.19",
|
||||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"author": "xRangerDE",
|
"author": "xRangerDE",
|
||||||
|
|||||||
@ -842,7 +842,6 @@
|
|||||||
<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,7 +2303,6 @@ 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;
|
||||||
@ -2312,8 +2311,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -2396,31 +2393,25 @@ 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> {
|
||||||
// Same query also pulls bannerImageURL and the current stream's
|
// The public (unauthenticated) GQL schema does NOT expose
|
||||||
// preview + viewer count when live — saves a separate roundtrip.
|
// `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.
|
||||||
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
|
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
|
||||||
`query($login: String!) {
|
`query($login: String!) {
|
||||||
user(login: $login) {
|
user(login: $login) {
|
||||||
@ -2429,17 +2420,8 @@ 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 }
|
||||||
@ -2449,21 +2431,12 @@ 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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2491,43 +2464,37 @@ 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;
|
||||||
|
|
||||||
// Public GQL is now the SOURCE for everything except some of the
|
// Prefer Helix for the core fields when we have credentials (richer
|
||||||
// core text fields when Helix is authenticated — because public
|
// bio, faster, less likely to rate-limit), then fill in followers
|
||||||
// GQL is the only route that gives us the banner image + current
|
// via public GQL since Helix /channels/followers needs a moderator
|
||||||
// stream preview in one shot, and skipping it would mean two
|
// token we don't have. Fall back fully to public GQL when not
|
||||||
// extra roundtrips. Helix takes precedence for displayName /
|
// authenticated.
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const helixUser = await fetchHelixUserInfo(normalized);
|
// Follower count always from public GQL — even when Helix
|
||||||
if (helixUser) {
|
// succeeded for the rest. One extra ~50ms call, but it's the
|
||||||
displayName = helixUser.display_name || displayName;
|
// only reliable way without scoped tokens.
|
||||||
if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url;
|
const followerCount = await fetchOnlyFollowerCount(normalized);
|
||||||
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.
|
||||||
@ -2549,19 +2516,6 @@ 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) {
|
||||||
@ -2570,28 +2524,16 @@ 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 AND banner bytes as data URLs in parallel.
|
// Embed the avatar bytes as a data URL so the renderer doesn't
|
||||||
// Renderer can't reliably fetch Twitch CDN images directly from
|
// have to make its own HTTPS request. Bypasses any CSP, CORS,
|
||||||
// an Electron renderer process, plus the data URL approach skips
|
// referrer-policy, or Electron renderer image-loading quirks.
|
||||||
// any CSP/referer/CORS quirks. Live preview also goes through
|
const avatarDataUrl = avatarUrl ? await fetchAvatarAsDataUrl(avatarUrl) : '';
|
||||||
// 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,
|
||||||
@ -2600,8 +2542,6 @@ 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()
|
||||||
};
|
};
|
||||||
@ -2611,122 +2551,6 @@ 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) {
|
||||||
@ -6976,10 +6800,6 @@ 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,7 +92,6 @@ 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,7 +239,6 @@ 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;
|
||||||
@ -248,22 +247,10 @@ 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;
|
||||||
@ -345,7 +332,6 @@ 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,8 +326,6 @@ 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,8 +326,6 @@ 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 = 'block';
|
el.style.display = 'flex';
|
||||||
|
|
||||||
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,9 +87,14 @@ 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>`
|
||||||
: '';
|
: '';
|
||||||
@ -110,34 +115,7 @@ 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>
|
||||||
@ -147,6 +125,7 @@ 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}
|
||||||
@ -158,24 +137,9 @@ 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();
|
||||||
@ -229,5 +193,3 @@ 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;
|
|
||||||
|
|||||||
@ -1,148 +0,0 @@
|
|||||||
// 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,40 +1948,17 @@ 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: sticky;
|
position: relative;
|
||||||
top: 0;
|
display: flex;
|
||||||
z-index: 20;
|
gap: 18px;
|
||||||
display: block;
|
align-items: center;
|
||||||
padding: 0;
|
padding: 18px 22px;
|
||||||
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 {
|
||||||
@ -2230,7 +2207,7 @@ body.theme-light .modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.streamer-profile-row {
|
.streamer-profile-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@ -2239,159 +2216,3 @@ 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