feat: show round profile pictures in the streamer sidebar
Windows CI / verify (push) Canceled after 0s
Windows CI / verify (push) Canceled after 0s
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Show small round profile pictures beside streamer names in the sidebar, with an initial fallback when an image is unavailable.
|
||||||
|
|
||||||
## 1.0.21 - 2026-09-06
|
## 1.0.21 - 2026-09-06
|
||||||
|
|
||||||
- Show the Twitch display name with its original capitalization in queue details and refresh it when profile names load.
|
- Show the Twitch display name with its original capitalization in queue details and refresh it when profile names load.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Aktueller veröffentlichter Stand: **1.0.21**, Tag `v1.0.21`, veröffentlicht am
|
|||||||
|
|
||||||
## Letzte Änderungen
|
## Letzte Änderungen
|
||||||
|
|
||||||
|
- Linke Streamerliste zeigt runde Profilbilder in 24 × 24 px direkt vor dem Namen. Verwendet den bestehenden Profil-Cache und dessen Vorladen/Hintergrundaktualisierung ohne zusätzliche Profilanfragen. Bilder werden in der vorhandenen Zeile nachgeladen und erhalten den Tastaturfokus; fehlende oder defekte Bilder zeigen den Anfangsbuchstaben. Bildklick wählt den Streamer, lange Namen werden einzeilig gekürzt und bleiben im Tooltip lesbar. Build, 37 gezielte Tests und vollständige Workspace-UI-Prüfung inklusive Laden, Fallback, Auswahl und Hell/Dunkel erfolgreich; Lint ohne Fehler bei 15 bestehenden Warnungen. Screenshots unter `tmp_streamer-avatar-artifacts/`. Noch nicht veröffentlicht, im Hot-Dev aktiv.
|
||||||
- Release 1.0.21 abgeschlossen: alle Queue-Nachbesserungen seit 1.0.20 enthalten; auf GitHub und Forgejo mit identischen Assets sowie englischem/deutschem Changelog veröffentlicht und geprüft.
|
- Release 1.0.21 abgeschlossen: alle Queue-Nachbesserungen seit 1.0.20 enthalten; auf GitHub und Forgejo mit identischen Assets sowie englischem/deutschem Changelog veröffentlicht und geprüft.
|
||||||
- Streamer in Queue-Details verwendet jetzt den aufgelösten Twitch-Anzeigenamen mit originaler Groß-/Kleinschreibung, etwa `xRohat` statt `xrohat`. Gespeicherter Login bleibt unverändert; noch unbekannte Anzeigenamen fallen auf den Login zurück. Nachgeladene Profilnamen aktualisieren vorhandene Queue-Cards sofort und werden bei der Render-Erkennung berücksichtigt. Build, 43 gezielte Tests, Queue-UI-Test einschließlich nachgeladener Groß-/Kleinschreibung und Lint erfolgreich (15 bestehende Warnungen). Mit 1.0.21 veröffentlicht.
|
- Streamer in Queue-Details verwendet jetzt den aufgelösten Twitch-Anzeigenamen mit originaler Groß-/Kleinschreibung, etwa `xRohat` statt `xrohat`. Gespeicherter Login bleibt unverändert; noch unbekannte Anzeigenamen fallen auf den Login zurück. Nachgeladene Profilnamen aktualisieren vorhandene Queue-Cards sofort und werden bei der Render-Erkennung berücksichtigt. Build, 43 gezielte Tests, Queue-UI-Test einschließlich nachgeladener Groß-/Kleinschreibung und Lint erfolgreich (15 bestehende Warnungen). Mit 1.0.21 veröffentlicht.
|
||||||
- Queue-Details zeigen `URL:` separat und darunter einen einzeiligen, anklickbaren Link in 11 px. Klick, Enter oder Leertaste kopieren die vollständige URL über die vorhandene Kopierfunktion mit lokalisierter Erfolgs-/Fehlermeldung. Standard-VOD-URL einschließlich zehnstelliger ID vollständig sichtbar; überlange URLs werden nur in der Anzeige mit Ellipse gekürzt, bleiben vollständig kopierbar und im Tooltip lesbar. Build, 19 gezielte Tests, Queue-UI-Prüfung einschließlich Layout und Kopieraktionen sowie Lint erfolgreich (15 bestehende Warnungen); Hell/Dunkel visuell geprüft. Mit 1.0.21 veröffentlicht.
|
- Queue-Details zeigen `URL:` separat und darunter einen einzeiligen, anklickbaren Link in 11 px. Klick, Enter oder Leertaste kopieren die vollständige URL über die vorhandene Kopierfunktion mit lokalisierter Erfolgs-/Fehlermeldung. Standard-VOD-URL einschließlich zehnstelliger ID vollständig sichtbar; überlange URLs werden nur in der Anzeige mit Ellipse gekürzt, bleiben vollständig kopierbar und im Tooltip lesbar. Build, 19 gezielte Tests, Queue-UI-Prüfung einschließlich Layout und Kopieraktionen sowie Lint erfolgreich (15 bestehende Warnungen); Hell/Dunkel visuell geprüft. Mit 1.0.21 veröffentlicht.
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ async function run() {
|
|||||||
check(mergeAddToolbarActions.english.label === 'Add videos' && mergeAddToolbarActions.english.accessibleLabel === 'Add videos', `English Add videos toolbar label is "${mergeAddToolbarActions.english.accessibleLabel}"`);
|
check(mergeAddToolbarActions.english.label === 'Add videos' && mergeAddToolbarActions.english.accessibleLabel === 'Add videos', `English Add videos toolbar label is "${mergeAddToolbarActions.english.accessibleLabel}"`);
|
||||||
check(mergeAddToolbarActions.german.label === 'Videos hinzufügen' && mergeAddToolbarActions.german.accessibleLabel === 'Videos hinzufügen', `German Videos hinzufügen toolbar label is "${mergeAddToolbarActions.german.accessibleLabel}"`);
|
check(mergeAddToolbarActions.german.label === 'Videos hinzufügen' && mergeAddToolbarActions.german.accessibleLabel === 'Videos hinzufügen', `German Videos hinzufügen toolbar label is "${mergeAddToolbarActions.german.accessibleLabel}"`);
|
||||||
|
|
||||||
await app.evaluate(({ ipcMain }) => {
|
await app.evaluate(({ ipcMain }, avatarUrl) => {
|
||||||
globalThis.__workspaceStreamerCacheCalls = { ids: 0, vods: 0, profiles: 0 };
|
globalThis.__workspaceStreamerCacheCalls = { ids: 0, vods: 0, profiles: 0 };
|
||||||
ipcMain.removeHandler('get-user-id');
|
ipcMain.removeHandler('get-user-id');
|
||||||
ipcMain.handle('get-user-id', (_, login) => {
|
ipcMain.handle('get-user-id', (_, login) => {
|
||||||
@@ -382,15 +382,20 @@ async function run() {
|
|||||||
ipcMain.removeHandler('get-streamer-profile');
|
ipcMain.removeHandler('get-streamer-profile');
|
||||||
ipcMain.handle('get-streamer-profile', (_, login) => {
|
ipcMain.handle('get-streamer-profile', (_, login) => {
|
||||||
globalThis.__workspaceStreamerCacheCalls.profiles += 1;
|
globalThis.__workspaceStreamerCacheCalls.profiles += 1;
|
||||||
return { login, displayName: login, avatarUrl: '', bannerUrl: '', description: '', broadcasterType: '', followerCount: 100, vodCount: 1, lastStreamAt: '2026-08-09T12:00:00Z', isLive: false, currentTitle: null, currentGame: null, currentStreamPreviewUrl: '', currentStreamViewers: null, twitchUrl: `https://twitch.tv/${login}`, fetchedAt: Date.now() };
|
return { login, displayName: login, avatarUrl: login === 'cache_alpha' ? avatarUrl : '', bannerUrl: '', description: '', broadcasterType: '', followerCount: 100, vodCount: 1, lastStreamAt: '2026-08-09T12:00:00Z', isLive: false, currentTitle: null, currentGame: null, currentStreamPreviewUrl: '', currentStreamViewers: null, twitchUrl: `https://twitch.tv/${login}`, fetchedAt: Date.now() };
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
}, `data:image/png;base64,${fs.readFileSync(path.join(process.cwd(), 'build', 'icon.png')).toString('base64')}`);
|
||||||
const streamerCacheBehavior = await win.evaluate(async () => {
|
const streamerCacheBehavior = await win.evaluate(async () => {
|
||||||
streamerVodCache.clear();
|
streamerVodCache.clear();
|
||||||
streamerProfileCache.clear();
|
streamerProfileCache.clear();
|
||||||
isConnected = true;
|
isConnected = true;
|
||||||
config.streamers = ['cache_alpha', 'cache_beta'];
|
config.streamers = ['cache_alpha', 'cache_beta'];
|
||||||
|
renderStreamers();
|
||||||
|
const loadingRow = document.querySelector('[data-streamer-name="cache_alpha"]');
|
||||||
|
loadingRow.focus();
|
||||||
|
const fallbackBeforeLoading = loadingRow.querySelector('.streamer-avatar-fallback')?.textContent;
|
||||||
await window.preloadConfiguredStreamerData(config.streamers);
|
await window.preloadConfiguredStreamerData(config.streamers);
|
||||||
|
const avatarLoadedInPlace = loadingRow === document.querySelector('[data-streamer-name="cache_alpha"]') && document.activeElement === loadingRow && Boolean(loadingRow.querySelector('img.streamer-avatar'));
|
||||||
const selection = selectStreamer('cache_beta');
|
const selection = selectStreamer('cache_beta');
|
||||||
const skeletonDuringCachedSelection = document.querySelectorAll('#vodGrid .vod-card-skeleton').length;
|
const skeletonDuringCachedSelection = document.querySelectorAll('#vodGrid .vod-card-skeleton').length;
|
||||||
await selection;
|
await selection;
|
||||||
@@ -401,6 +406,8 @@ async function run() {
|
|||||||
await window.refreshConfiguredStreamersInBackground();
|
await window.refreshConfiguredStreamersInBackground();
|
||||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
return {
|
return {
|
||||||
|
fallbackBeforeLoading,
|
||||||
|
avatarLoadedInPlace,
|
||||||
cachedVodStreamers: streamerVodCache.size,
|
cachedVodStreamers: streamerVodCache.size,
|
||||||
cachedProfiles: streamerProfileCache.size,
|
cachedProfiles: streamerProfileCache.size,
|
||||||
skeletonDuringCachedSelection,
|
skeletonDuringCachedSelection,
|
||||||
@@ -411,12 +418,33 @@ async function run() {
|
|||||||
});
|
});
|
||||||
const streamerCacheCalls = await app.evaluate(() => ({ ...globalThis.__workspaceStreamerCacheCalls }));
|
const streamerCacheCalls = await app.evaluate(() => ({ ...globalThis.__workspaceStreamerCacheCalls }));
|
||||||
checks.streamerCache = { ...streamerCacheBehavior, calls: streamerCacheCalls };
|
checks.streamerCache = { ...streamerCacheBehavior, calls: streamerCacheCalls };
|
||||||
|
check(streamerCacheBehavior.fallbackBeforeLoading === 'C' && streamerCacheBehavior.avatarLoadedInPlace, 'Loading a streamer avatar must replace the initial without replacing the row or losing focus');
|
||||||
check(streamerCacheBehavior.cachedVodStreamers === 2 && streamerCacheBehavior.cachedProfiles === 2, `Configured streamers were not fully preloaded: ${streamerCacheBehavior.cachedVodStreamers}/${streamerCacheBehavior.cachedProfiles}`);
|
check(streamerCacheBehavior.cachedVodStreamers === 2 && streamerCacheBehavior.cachedProfiles === 2, `Configured streamers were not fully preloaded: ${streamerCacheBehavior.cachedVodStreamers}/${streamerCacheBehavior.cachedProfiles}`);
|
||||||
check(streamerCacheBehavior.skeletonDuringCachedSelection === 0, `Cached streamer selection still shows ${streamerCacheBehavior.skeletonDuringCachedSelection} skeleton cards`);
|
check(streamerCacheBehavior.skeletonDuringCachedSelection === 0, `Cached streamer selection still shows ${streamerCacheBehavior.skeletonDuringCachedSelection} skeleton cards`);
|
||||||
check(streamerCacheBehavior.profileWidthDelta <= 1, `Streamer profile width changes by ${streamerCacheBehavior.profileWidthDelta}px between cached accounts`);
|
check(streamerCacheBehavior.profileWidthDelta <= 1, `Streamer profile width changes by ${streamerCacheBehavior.profileWidthDelta}px between cached accounts`);
|
||||||
check(streamerCacheBehavior.cardsAfterRefresh === 2, `Silent refresh did not add the new VOD: ${streamerCacheBehavior.cardsAfterRefresh}`);
|
check(streamerCacheBehavior.cardsAfterRefresh === 2, `Silent refresh did not add the new VOD: ${streamerCacheBehavior.cardsAfterRefresh}`);
|
||||||
check(streamerCacheBehavior.activeAnimations > 0, 'Silent refresh does not animate the new VOD or grid reflow');
|
check(streamerCacheBehavior.activeAnimations > 0, 'Silent refresh does not animate the new VOD or grid reflow');
|
||||||
check(streamerCacheCalls.ids === 4 && streamerCacheCalls.vods === 4, `Cached account switching triggered unexpected VOD requests: ${JSON.stringify(streamerCacheCalls)}`);
|
check(streamerCacheCalls.ids === 4 && streamerCacheCalls.vods === 4, `Cached account switching triggered unexpected VOD requests: ${JSON.stringify(streamerCacheCalls)}`);
|
||||||
|
const originalStreamerTheme = await win.evaluate(() => document.body.className);
|
||||||
|
for (const theme of ['twitch', 'light']) {
|
||||||
|
await win.evaluate((theme) => { document.body.className = `theme-${theme}`; }, theme);
|
||||||
|
const avatars = await win.locator('#streamerList').evaluate(async (list) => {
|
||||||
|
await Promise.all([...list.querySelectorAll('img.streamer-avatar')].map((image) => image.decode()));
|
||||||
|
return [...list.querySelectorAll('.streamer-item')].map((row) => {
|
||||||
|
const avatar = row.querySelector('.streamer-avatar, .streamer-avatar-fallback');
|
||||||
|
const rect = avatar.getBoundingClientRect();
|
||||||
|
return { width: rect.width, height: rect.height, round: getComputedStyle(avatar).borderRadius, beforeName: rect.right <= row.querySelector('.streamer-name').getBoundingClientRect().left, overflow: row.scrollWidth - row.clientWidth };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
check(avatars.length === 2 && avatars.every((avatar) => avatar.width === 24 && avatar.height === 24 && avatar.round === '50%' && avatar.beforeName && avatar.overflow <= 1), `Streamer avatar layout (${theme}): ${JSON.stringify(avatars)}`);
|
||||||
|
await win.locator('.streamer-section').screenshot({ path: path.join(artifactDir, `streamer-avatars-${theme}.png`) });
|
||||||
|
}
|
||||||
|
await win.evaluate((theme) => { document.body.className = theme; }, originalStreamerTheme);
|
||||||
|
await win.locator('[data-streamer-name="cache_alpha"] .streamer-avatar').click();
|
||||||
|
await win.waitForFunction(() => currentStreamer === 'cache_alpha');
|
||||||
|
await win.locator('[data-streamer-name="cache_alpha"] .streamer-avatar').dispatchEvent('error');
|
||||||
|
check(await win.locator('[data-streamer-name="cache_alpha"] .streamer-avatar-fallback').textContent() === 'C', 'Broken streamer avatars should show an initial');
|
||||||
|
await win.evaluate(() => updateStreamerAvatars('CACHE_ALPHA'));
|
||||||
|
|
||||||
const cutterDropFixturePath = path.join(environment.mediaDir, 'electron-43-cutter-drop.mp4');
|
const cutterDropFixturePath = path.join(environment.mediaDir, 'electron-43-cutter-drop.mp4');
|
||||||
fs.writeFileSync(cutterDropFixturePath, 'electron-43-cutter-drop-fixture', 'utf8');
|
fs.writeFileSync(cutterDropFixturePath, 'electron-43-cutter-drop-fixture', 'utf8');
|
||||||
|
|||||||
@@ -222,7 +222,10 @@ async function fetchStreamerProfile(login: string, forceRefresh = false): Promis
|
|||||||
const pending = streamerProfileLoads.get(key);
|
const pending = streamerProfileLoads.get(key);
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
const task = window.api.getStreamerProfile(login, forceRefresh).then((profile) => {
|
const task = window.api.getStreamerProfile(login, forceRefresh).then((profile) => {
|
||||||
if (profile) streamerProfileCache.set(key, profile);
|
if (profile) {
|
||||||
|
streamerProfileCache.set(key, profile);
|
||||||
|
updateStreamerAvatars(login);
|
||||||
|
}
|
||||||
return profile;
|
return profile;
|
||||||
});
|
});
|
||||||
streamerProfileLoads.set(key, task);
|
streamerProfileLoads.set(key, task);
|
||||||
|
|||||||
@@ -590,6 +590,32 @@ function showStreamerContextMenu(event: MouseEvent, streamer: string): void {
|
|||||||
menu.querySelector<HTMLButtonElement>('button')?.focus();
|
menu.querySelector<HTMLButtonElement>('button')?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createStreamerAvatar(streamer: string): HTMLElement {
|
||||||
|
const fallback = document.createElement('span');
|
||||||
|
fallback.className = 'streamer-avatar-fallback';
|
||||||
|
fallback.textContent = Array.from(getStreamerDisplayName(streamer))[0]?.toUpperCase() || '?';
|
||||||
|
fallback.setAttribute('aria-hidden', 'true');
|
||||||
|
const profile = streamerProfileCache.get(streamer.trim().toLowerCase());
|
||||||
|
if (!profile?.avatarUrl) return fallback;
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.className = 'streamer-avatar';
|
||||||
|
image.alt = '';
|
||||||
|
image.draggable = false;
|
||||||
|
image.referrerPolicy = 'no-referrer';
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.addEventListener('error', () => image.replaceWith(fallback), { once: true });
|
||||||
|
image.src = profile.avatarUrl;
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStreamerAvatars(login: string): void {
|
||||||
|
const key = login.trim().toLowerCase();
|
||||||
|
for (const item of Array.from(document.querySelectorAll<HTMLElement>('#streamerList .streamer-item'))) {
|
||||||
|
if (item.dataset.streamerName?.trim().toLowerCase() !== key) continue;
|
||||||
|
item.querySelector('.streamer-avatar, .streamer-avatar-fallback')?.replaceWith(createStreamerAvatar(login));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderStreamers(): void {
|
function renderStreamers(): void {
|
||||||
const list = byId('streamerList');
|
const list = byId('streamerList');
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
@@ -639,7 +665,8 @@ function renderStreamers(): void {
|
|||||||
// first, then AUTO / VOD / REC / remove in order.
|
// first, then AUTO / VOD / REC / remove in order.
|
||||||
item.setAttribute('role', 'button');
|
item.setAttribute('role', 'button');
|
||||||
item.setAttribute('tabindex', '0');
|
item.setAttribute('tabindex', '0');
|
||||||
item.setAttribute('aria-label', streamer);
|
item.setAttribute('aria-label', getStreamerDisplayName(streamer));
|
||||||
|
item.title = getStreamerDisplayName(streamer);
|
||||||
if (currentStreamer === streamer) item.setAttribute('aria-current', 'true');
|
if (currentStreamer === streamer) item.setAttribute('aria-current', 'true');
|
||||||
|
|
||||||
// Live-dot — red pulsing dot when this streamer is currently
|
// Live-dot — red pulsing dot when this streamer is currently
|
||||||
@@ -677,7 +704,7 @@ function renderStreamers(): void {
|
|||||||
void removeStreamer(streamer);
|
void removeStreamer(streamer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
item.append(nameSpan, removeSpan);
|
item.append(createStreamerAvatar(streamer), nameSpan, removeSpan);
|
||||||
|
|
||||||
item.addEventListener('contextmenu', (contextEvent) => {
|
item.addEventListener('contextmenu', (contextEvent) => {
|
||||||
showStreamerContextMenu(contextEvent, streamer);
|
showStreamerContextMenu(contextEvent, streamer);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe('production style modules', () => {
|
|||||||
.replace(/\r\n/g, '\n'));
|
.replace(/\r\n/g, '\n'));
|
||||||
const digest = createHash('sha256').update(content).digest('hex');
|
const digest = createHash('sha256').update(content).digest('hex');
|
||||||
|
|
||||||
expect(digest).toBe('89aee0dd2015cbf2890d9acfb4e5661f8520777f43fe48a86a89abc821982f4d');
|
expect(digest).toBe('ece1a9e9f064bc42b0797cd15fcdcdc6367530a67f7c8f6b72c9e10253d73f9a');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('derives the Windows hot-development executable version from package metadata', () => {
|
test('derives the Windows hot-development executable version from package metadata', () => {
|
||||||
|
|||||||
@@ -859,6 +859,23 @@ textarea:disabled,
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
flex: 0 0 24px;
|
flex: 0 0 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--workspace-control);
|
||||||
|
color: var(--workspace-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-sidebar .streamer-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-sidebar .queue-section {
|
.context-sidebar .queue-section {
|
||||||
|
|||||||
Reference in New Issue
Block a user