Redesign Classic layout for desktop and mobile

This commit is contained in:
Jonathan Sykes
2026-10-03 05:41:45 +08:00
parent 54fcd12119
commit 343eb53d89
14 changed files with 1155 additions and 25 deletions

View File

@@ -0,0 +1,103 @@
const { test, expect } = require('@playwright/test');
const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
for (const width of [320, 390, 768, 1024, 1280, 1440]) {
test(`empty home is unclipped at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await openClassic(page);
const result = await page.evaluate(() => {
const stage = document.querySelector('.player-stage').getBoundingClientRect();
const hero = document.querySelector('.hero').getBoundingClientRect();
const chips = [...document.querySelectorAll('.hero .chip')].map(el => el.getBoundingClientRect());
return { stageBottom: stage.bottom, heroBottom: hero.bottom, stageTop: stage.top, heroTop: hero.top,
chipsFit: chips.every(r => r.right <= innerWidth && r.left >= 0 && r.bottom <= stage.bottom),
overflow: document.querySelector('.main').scrollWidth > document.querySelector('.main').clientWidth + 1 };
});
expect(result.heroTop).toBeGreaterThanOrEqual(result.stageTop);
expect(result.heroBottom).toBeLessThanOrEqual(result.stageBottom + 1);
expect(result.chipsFit).toBe(true);
expect(result.overflow).toBe(false);
await expect(page.locator('#bottomNav')).toBeVisible({ visible: width <= 860 });
});
}
for (const standalone of [false, true]) {
test(`navigation during playback and mini-player return (${standalone ? 'PWA' : 'browser'})`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openClassic(page, { populated: true, standalone });
await showPlayingFixture(page);
await expect(page.locator('#back10Btn')).toBeVisible();
await expect(page.locator('#fwd10Btn')).toBeVisible();
await page.locator('#bottomNav [data-view="settings"]').click();
await expect(page.locator('.settings')).toBeVisible();
await expect.poll(() => page.locator('.list-pane').evaluate(el => el.getBoundingClientRect().top)).toBeLessThan(130);
await expect(page.locator('#miniBar')).toBeVisible();
const boxes = await page.evaluate(() => ({
mini: document.getElementById('miniBar').getBoundingClientRect().bottom,
nav: document.getElementById('bottomNav').getBoundingClientRect().top,
}));
expect(boxes.mini).toBeLessThanOrEqual(boxes.nav + 1);
await page.locator('.body').evaluate(el => { el.scrollTop = el.scrollHeight; });
const last = page.locator('.settings .set-group').last();
await expect(last).toBeInViewport();
expect(await last.evaluate(el => el.getBoundingClientRect().bottom)).toBeLessThanOrEqual(boxes.nav);
await page.locator('#miniTitle').click();
await expect.poll(() => page.locator('.body').evaluate(el => el.scrollTop)).toBe(0);
expect(await page.evaluate(() => window.scrollY)).toBe(0);
await page.locator('#ctlMoreBtn').click();
await expect(page.locator('#modal')).toBeVisible();
});
}
test('playlist, appearance settings, rotation, and layout switching preserve data', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await openClassic(page, { populated: true });
await expect(page.locator('#homePlGrid')).toContainText('Studio sessions');
await showPlayingFixture(page);
expect(await page.locator('.list-pane').evaluate(el => el.getBoundingClientRect().width)).toBeGreaterThanOrEqual(340);
expect(await page.locator('.card-info').first().evaluate(el => el.getBoundingClientRect().width)).toBeGreaterThan(120);
const original = await page.evaluate(() => JSON.stringify(data.playlists));
for (const theme of ['light', 'contrast', 'dark']) {
await page.evaluate(theme => { data.settings.theme = theme; applyAppearance(); }, theme);
expect(await page.locator('.hero-title').evaluate(el => getComputedStyle(el).webkitTextFillColor)).not.toBe('rgba(0, 0, 0, 0)');
}
for (const layout of ['glass-stage', 'sanctuary-stage', 'classic']) {
await page.evaluate(layout => { data.settings.layout = layout; applyAppearance(); }, layout);
await expect(page.locator('html')).toHaveAttribute('data-layout', layout);
expect(await page.evaluate(() => JSON.stringify(data.playlists))).toBe(original);
}
await page.setViewportSize({ width: 844, height: 390 });
await page.locator('#bottomNav [data-view="history"]').click();
await expect(page.locator('#listTitle')).toHaveText('History');
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => { data.settings.fontScale = 'xl'; data.settings.density = 'compact'; applyAppearance(); });
await expect(page.locator('#bottomNav [data-view="settings"]')).toBeInViewport();
expect(await page.evaluate(() => document.querySelector('.main').scrollWidth <= document.querySelector('.main').clientWidth + 1)).toBe(true);
});
test('320px with extra-large text keeps navigation and controls reachable', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 844 });
await openClassic(page, { populated: true, settings: { fontScale: 'xl' } });
await showPlayingFixture(page);
await expect(page.locator('#bottomNav [data-view="settings"]')).toBeInViewport();
expect(await page.locator('#controls').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
expect(await page.locator('.app').evaluate(el => Math.abs(el.getBoundingClientRect().height - innerHeight))).toBeLessThan(2);
expect(await page.locator('#bottomNav').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await page.locator('#bottomNav [data-view="settings"]').click();
await expect(page.locator('.settings')).toBeVisible();
});
test.describe('Classic offline shell', () => {
test.use({ serviceWorkers: 'allow' });
test('caches the Classic stylesheet and restores it offline', async ({ page, context }) => {
await openClassic(page);
await page.evaluate(() => navigator.serviceWorker.ready);
await page.reload();
await page.waitForFunction(() => !!navigator.serviceWorker.controller);
expect(await page.evaluate(async () => !!(await caches.match('/theme-classic.css')))).toBe(true);
await context.setOffline(true);
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-layout', 'classic');
expect(await page.locator('html').evaluate(el => getComputedStyle(el).getPropertyValue('--bg').trim())).toBe('#121315');
});
});

View File

@@ -0,0 +1,56 @@
// Synthetic UI fixtures: exercise real rendering and navigation, not streaming.
async function openClassic(page, { populated = false, settings = {}, standalone = false } = {}) {
await page.route('**/api/**', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ ok: true, results: [], items: [], holders: [], data: null }),
}));
await page.route('**/fixture-cover.svg', route => route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360"><rect width="640" height="360" fill="#343b42"/><text x="42" y="150" fill="#f3f1ed" font-family="sans-serif" font-size="46">Studio session</text><text x="44" y="200" fill="#c3c4c7" font-family="sans-serif" font-size="20">Synthetic preview fixture</text></svg>',
}));
await page.addInitScript(({ populated, settings, standalone }) => {
if (standalone) {
const match = window.matchMedia.bind(window);
window.matchMedia = query => query.includes('display-mode: standalone')
? match(query.replace(/\(display-mode: standalone\)/g, '(min-width: 0px)')) : match(query);
}
const videos = Array.from({ length: 12 }, (_, i) => ({
id: 'preview' + String(i).padStart(4, '0'),
title: ['A quiet morning — live studio session', 'Late afternoon, piano and strings', 'An extended conversation about making music and finding a little room to listen'][i % 3],
channel: 'Preview fixtures', duration: 240 + i * 36, thumbnail: '/fixture-cover.svg',
}));
localStorage.setItem('_ytpdata', JSON.stringify({
settings: { layout: 'classic', reduceMotion: true, p2pShare: false, p2pReceive: false, ...settings },
playlists: populated ? [{ id: 'studio', name: 'Studio sessions', videos }] : [],
history: populated ? videos : [], queue: populated ? videos : [],
}));
}, { populated, settings, standalone });
await page.goto('/');
await page.waitForFunction(() => document.documentElement.dataset.layout === 'classic' && document.querySelectorAll('.smart-pl-list .playlist-item').length > 0);
await page.locator('#searchInput').blur();
}
async function showPlayingFixture(page) {
await page.evaluate(() => {
const videos = data.playlists[0].videos;
current = { meta: videos[0], qualities: [], audioUrl: '' };
queue = videos;
queueIndex = 0;
queueSource = 'playlist:studio';
view = { type: 'playlist', id: 'studio' };
document.getElementById('playerPane').classList.remove('empty');
document.getElementById('playerPlaceholder').classList.add('hidden');
document.getElementById('controls').classList.remove('hidden');
document.getElementById('nowPlayingMeta').classList.remove('hidden');
document.getElementById('npTitle').textContent = videos[0].title;
document.getElementById('npChannel').textContent = videos[0].channel;
document.getElementById('artFallback').classList.remove('hidden');
document.getElementById('artImg').src = '/fixture-cover.svg';
updateNowPlayingActions();
render();
renderUpNext();
document.querySelector('.body').scrollTop = 0;
});
}
module.exports = { openClassic, showPlayingFixture };