diff --git a/scripts/theme-report.test.mts b/scripts/theme-report.test.mts index a5da605a..c1cf6197 100644 --- a/scripts/theme-report.test.mts +++ b/scripts/theme-report.test.mts @@ -35,6 +35,7 @@ import { snapshotBaseline, requireOutsideGit, type LiveResult, + type SelectorRun, contrastRatio, DERIVED_COLORS, fillCanonical, @@ -47,6 +48,10 @@ import { resolveScheme, schemeVars, readSettingsControls, + probeSelectors, + swapPlayback, + lostSelectors, + SELECTORS_FILE, } from "./theme-report.ts"; describe("screenshot region", () => { @@ -73,6 +78,156 @@ describe("screenshot region", () => { }); }); +describe("pinned playback", () => { + const fakeClient = (failPlay = false) => { + const log: string[] = []; + const state = { + item: { uri: "spotify:track:mine" }, + context: { uri: "spotify:album:mine" }, + positionAsOfTimestamp: 1234, + isPaused: false, + }; + let muted = false; + (globalThis as any).window = { + Spicetify: { + Player: { getMute: () => muted, setMute: (m: boolean) => (log.push(`mute:${m}`), (muted = m)) }, + Platform: { + PlayerAPI: { + getState: () => state, + play: async (ctx: { uri: string }, _: object, opts: { skipTo?: { uri: string } }) => { + if (failPlay) throw new Error("offline"); + log.push(`play:${opts.skipTo?.uri ?? ctx.uri}`); + state.item = { uri: opts.skipTo?.uri ?? ctx.uri }; + state.isPaused = false; + }, + pause: async () => (log.push("pause"), (state.isPaused = true)), + seekTo: async (ms: number) => (log.push(`seek:${ms}`), (state.positionAsOfTimestamp = ms)), + }, + }, + }, + }; + return { log, state, isMuted: () => muted }; + }; + after(() => delete (globalThis as any).window); + + it("loads the track muted and paused, and reports what was playing", async () => { + const client = fakeClient(); + const previous = await swapPlayback({ uri: "spotify:track:fixed" }, 0, true); + assert.deepEqual(previous, { + uri: "spotify:track:mine", + context: "spotify:album:mine", + position: 1234, + paused: false, + }); + assert.deepEqual(client.log, ["mute:true", "play:spotify:track:fixed", "pause", "seek:0", "mute:false"]); + assert.equal(client.state.isPaused, true); + }); + + it("puts the mute state back when the track cannot be loaded", async () => { + const client = fakeClient(true); + await assert.rejects(swapPlayback({ uri: "spotify:track:fixed" }, 0, true), /offline/); + assert.equal(client.isMuted(), false); + }); +}); + +describe("selector binding", () => { + const probe = (html: string, css: string, key?: string) => { + const window = new Window(); + try { + window.document.body.innerHTML = html; + const args = [css, key].filter((x) => x !== undefined).map((x) => JSON.stringify(x)); + return structuredClone(window.eval(`(${probeSelectors.toString()})(${args.join(",")})`)); + } finally { + window.close(); + } + }; + + it("reports which selector parts match, ignoring interaction states and pseudo-elements", () => { + const result = probe( + ``, + ` + .gone .cover-art, [data-testid="cover-art-button"] .cover-art { width: 1px } + [data-testid="cover-art-button"]:hover, .cover-art:focus-within { color: red } + .cover-art::after, form .search:not(:placeholder-shown) { content: "" } + @media (min-width: 1px) { .also-gone { color: red } }`, + ); + assert.deepEqual(result.matched, [ + '[data-testid="cover-art-button"] .cover-art', + '[data-testid="cover-art-button"]:hover', + ".cover-art:focus-within", + ".cover-art::after", + "form .search:not(:placeholder-shown)", + ]); + assert.deepEqual( + result.all.filter((s: string) => !result.matched.includes(s)), + [".gone .cover-art", ".also-gone"], + ); + }); + + it("splits selector lists only on top-level commas", () => { + const result = probe( + `
`, + `:is(.a, :not(.b)) .c, [aria-label="Play, pause"].x { color: red }`, + ); + assert.deepEqual(result, { + all: [":is(.a, :not(.b)) .c", '[aria-label="Play, pause"].x'], + matched: [":is(.a, :not(.b)) .c", '[aria-label="Play, pause"].x'], + skipped: 0, + }); + }); + + it("names parts after the source stylesheet when the served one only differs in class names", () => { + const result = probe( + `
`, + ".hashed123 { color: red }", + ".main-stable { color: red }", + ); + assert.deepEqual(result, { all: [".main-stable"], matched: [".main-stable"], skipped: 0 }); + }); + + it("keeps the served names when the source does not line up rule for rule", () => { + const result = probe( + `
`, + ".hashed123 span { color: red }", + "[data-testid=x] { color: red }", + ); + assert.deepEqual(result.all, [".hashed123 span"]); + }); + + it("flags only selectors that matched before, still exist, and match nothing now on the same routes", () => { + const run = (themes: SelectorRun["themes"]): SelectorRun => ({ playing: true, themes }); + const baseline = run({ + starry: { + "/": { all: [".a", ".b", ".c", ".d"], matched: [".a", ".b", ".c"], skipped: 0 }, + "/search": { all: [".s"], matched: [".s"], skipped: 0 }, + }, + gone: { "/": { all: [".x"], matched: [".x"], skipped: 0 } }, + }); + const current = run({ + starry: { "/": { all: [".a", ".b", ".d", ".s"], matched: [".a"], skipped: 0 } }, + fresh: { "/": { all: [".y"], matched: [], skipped: 0 } }, + }); + assert.deepEqual(lostSelectors(baseline, current), [{ theme: "starry", selector: ".b" }]); + }); + + it("accepting a run keeps its selector results as the next baseline", () => { + const current = mkdtempSync(path.join(tmpdir(), "theme-report-current-")); + const baseline = path.join(mkdtempSync(path.join(tmpdir(), "theme-report-baseline-")), "baseline"); + writeFileSync(path.join(current, SELECTORS_FILE), "{}"); + writeFileSync(path.join(current, "frame.png"), ""); + writeFileSync(path.join(current, "shots.json"), "{}"); + try { + accept(current, baseline); + assert.ok(existsSync(path.join(baseline, SELECTORS_FILE))); + assert.ok(existsSync(path.join(baseline, "frame.png"))); + assert.ok(!existsSync(path.join(baseline, "shots.json"))); + } finally { + rmSync(current, { recursive: true, force: true }); + rmSync(path.dirname(baseline), { recursive: true, force: true }); + } + }); +}); + describe("settings control capture", () => { for (const encore of ["e-10750", "e-10800"]) { it(`includes links and both toggle states with ${encore} classes`, async () => { diff --git a/scripts/theme-report.ts b/scripts/theme-report.ts index 8923e9d8..2b5a94e6 100644 --- a/scripts/theme-report.ts +++ b/scripts/theme-report.ts @@ -14,6 +14,7 @@ * * node scripts/theme-report.ts capture, compare, write * node scripts/theme-report.ts --accept make this run the baseline + * node scripts/theme-report.ts --track spotify:track:… show this track paused instead of the default * node scripts/theme-report.ts --no-capture rebuild the page from disk * node scripts/theme-report.ts --no-open write without opening a browser * node scripts/theme-report.ts --themes flow just one @@ -25,7 +26,7 @@ * Spotify must be running with --remote-debugging-port=9229. Output defaults * to ../scratchpad/theme-shots, which is outside every repo. * - * Four checks, and they cover different things on purpose: + * Five checks, and they cover different things on purpose: * * what moved each frame against the last accepted run, so an intended * change is reviewed once and everything else stays quiet @@ -35,6 +36,10 @@ * ever shows, which is the only place they are checked at all * animation a surface that never settles cannot be tracked, and saying * so beats reporting it as changed every run + * selectors each theme selector that matched an element on some route in + * the accepted run and matches nothing now; it names the rule + * that broke, and works for animated themes too, which a + * whole-theme repaint share cannot do */ import { @@ -445,6 +450,113 @@ export function readSettingsControls() { }; } +/** + * Runs in the real client. Splits a theme stylesheet into selector parts and + * reports which of them match an element in the current document. Interaction + * states and pseudo-elements are stripped first, so a :hover rule counts as + * bound when its element exists. Parts the engine cannot query are counted in + * `skipped` rather than checked. `keyCss` names the parts: the theme's source, + * whose stable class names do not change between Spotify builds the way the + * served (css-mapped) ones do. It is only used when every part has the same + * shape as its served counterpart once class names are blanked out. + */ +export function probeSelectors(servedCss: string, keyCss: string = servedCss) { + const split = (list: string) => { + const out: string[] = []; + let depth = 0; + let quote = ""; + let start = 0; + for (let i = 0; i < list.length; i++) { + const c = list[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(" || c === "[") depth++; + else if (c === ")" || c === "]") depth--; + else if (c === "," && depth === 0) { + out.push(list.slice(start, i).trim()); + start = i + 1; + } + } + out.push(list.slice(start).trim()); + return out; + }; + const parts = (css: string) => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(css); + const out: string[] = []; + const visit = (rules: CSSRuleList) => { + for (const rule of Array.from(rules)) { + const selectorText = (rule as CSSStyleRule).selectorText; + if (selectorText) out.push(...split(selectorText)); + const nested = (rule as CSSGroupingRule).cssRules; + if (nested) visit(nested); + } + }; + visit(sheet.cssRules); + return out; + }; + const served = parts(servedCss); + const keyed = parts(keyCss); + // The css-map only renames classes, so matching parts have the same shape once classes are blanked. + const shape = (part: string) => part.replace(/\.[\w-]+/g, ".c"); + const aligned = keyed.length === served.length && keyed.every((part, i) => shape(part) === shape(served[i])); + const names = aligned ? keyed : served; + const all: string[] = []; + const matched: string[] = []; + let skipped = 0; + served.forEach((part, i) => { + const query = + part + .replace(/::?(?:before|after|placeholder|selection|backdrop|marker|-webkit-[\w-]+)(?![\w-])/g, "") + .replace(/:(?:focus-visible|focus-within|hover|focus|active|visited)(?![\w-])/g, "") + .trim() || "*"; + let hit: boolean; + try { + hit = document.querySelector(query) !== null; + } catch { + skipped++; + return; + } + all.push(names[i]); + if (hit) matched.push(names[i]); + }); + return { all, matched, skipped }; +} + +export type SelectorProbe = ReturnType; + +/** Per theme, per route: what the theme's stylesheet selected there. */ +export interface SelectorRun { + /** Whether music was playing; selectors that depend on playback state flip with it. */ + playing: boolean | null; + themes: Record>; +} + +/** + * Selector parts that matched an element on a route in the baseline run and + * match nothing on that run's routes now. Only routes captured by both runs + * count, so a partial run is not reported against routes it never visited, + * and a part the theme no longer has is a change to the theme, not a loss. + */ +export function lostSelectors(baseline: SelectorRun, current: SelectorRun): { theme: string; selector: string }[] { + const lost: { theme: string; selector: string }[] = []; + for (const [theme, nowRoutes] of Object.entries(current.themes)) { + const beforeRoutes = baseline.themes[theme]; + if (!beforeRoutes) continue; + const routes = Object.keys(nowRoutes).filter((route) => route in beforeRoutes); + const union = (runs: Record, key: "all" | "matched") => + new Set(routes.flatMap((route) => runs[route][key])); + const present = union(nowRoutes, "all"); + const matched = union(nowRoutes, "matched"); + for (const selector of union(beforeRoutes, "matched")) { + if (present.has(selector) && !matched.has(selector)) lost.push({ theme, selector }); + } + } + return lost; +} + export interface LiveShot { theme: string; themeVersion?: string; @@ -474,6 +586,8 @@ export interface LiveResult { viewport?: { width: number; height: number; dpr: number }; selector?: string; suite?: "classmaps"; + /** What each theme's stylesheet selected on each captured route. */ + selectors?: SelectorRun; cleanupVerified?: boolean; navigation?: string; } @@ -675,6 +789,9 @@ const STABILISE = ` Removed rather than paused: pausing freezes each one wherever it had got to, which differs every run and diffs against itself. */ *, *::before, *::after { animation: none !important; transition: none !important; } + /* Canvas loops and music videos keep playing while the track is paused, + and a paused video stops on a different frame every run. */ + video { visibility: hidden !important; } \`; document.head.appendChild(s); } @@ -702,8 +819,53 @@ async function settle(cdp: Cdp, before: string): Promise { return main; } +/** Loaded paused into the player for every capture, so the playbar shows the same thing each run. */ +export const REPORT_TRACK = "spotify:track:0DiWol3AO6WpXZgp0goxAV"; + +/** + * Runs in the real client. Loads `target` (from its context when given) with + * the client muted, pauses it the moment it becomes the current item when + * `paused`, seeks to `position`, and puts the mute state back. Returns what + * was playing before, for a later call to restore. + */ +export async function swapPlayback(target: { uri: string; context?: string }, position: number, paused: boolean) { + const api = window.Spicetify.Platform.PlayerAPI; + const player = window.Spicetify.Player; + const until = async (test: () => boolean) => { + const end = Date.now() + 5000; + while (Date.now() < end && !test()) await new Promise((r) => setTimeout(r, 10)); + }; + const state = api.getState(); + const previous = { + uri: state.item?.uri as string | undefined, + context: state.context?.uri as string | undefined, + position: state.positionAsOfTimestamp as number, + paused: state.isPaused as boolean, + }; + const muted = player.getMute(); + player.setMute(true); + try { + await api.play( + { uri: target.context ?? target.uri }, + {}, + target.context ? { skipTo: { uri: target.uri } } : {}, + ); + await until(() => api.getState().item?.uri === target.uri); + if (paused) { + await api.pause(); + await until(() => api.getState().isPaused); + } + await api.seekTo(position); + } finally { + player.setMute(muted); + } + return previous; +} + export interface LiveOptions { outDir: string; + /** Track to show paused in the playbar; the previous playback is restored afterwards. */ + track?: string; selector?: string; port?: number; themes?: string[]; @@ -742,6 +904,39 @@ export async function captureLive(opts: LiveOptions): Promise { const routes = opts.routes?.length ? opts.routes : Object.keys(ROUTES); const shots: LiveShot[] = []; const failures: LiveFailure[] = []; + const selectors: SelectorRun = { + playing: await cdp.eval(`return window.Spicetify?.Player?.isPlaying?.() ?? null;`), + themes: {}, + }; + + /** Records which of the active theme's selector parts match something on this route. */ + const probeTheme = async (theme: string, route: string) => { + // Source names only line up when the client runs the repo's version of the theme. + const dir = path.join(REPO, "themes", theme); + const sourceVersion = existsSync(path.join(dir, "metadata.json")) + ? JSON.parse(readFileSync(path.join(dir, "metadata.json"), "utf8")).version + : null; + const source = + sourceVersion && sourceVersion === installed.get(theme) && existsSync(path.join(dir, "index.css")) + ? readFileSync(path.join(dir, "index.css"), "utf8") + : null; + try { + const probe = await cdp.eval(` + const registry = window.Spicetify.Modules.registry; + const entry = registry?.modules?.get?.(${JSON.stringify(theme)})?.entries?.css; + if (!entry) return null; + let served = registry.localFiles?.get?.(${JSON.stringify(theme)})?.[entry]; + if (served === undefined) { + const res = await fetch(${JSON.stringify(`/modules/${theme}/`)} + entry); + if (!res.ok) throw new Error("stylesheet fetch returned " + res.status); + served = await res.text(); + } + return (${probeSelectors.toString()})(served, ${JSON.stringify(source)} ?? served);`); + if (probe) (selectors.themes[theme] ??= {})[route] = probe; + } catch (e) { + failures.push({ theme, error: `selector probe on ${route}: ${(e as Error).message.slice(0, 80)}` }); + } + }; const clientVersion = await cdp.eval( `return navigator.userAgent.match(/Spotify\\/(\\S+)/)?.[1] ?? null;`, @@ -772,6 +967,7 @@ export async function captureLive(opts: LiveOptions): Promise { await cdp.eval(STABILISE); const file = path.join(opts.outDir, `${label}--${surface}.png`); const stable = await cdp.shootStable(file, { selector: opts.selector }); + if (label !== UNTHEMED) await probeTheme(label, route); const settingsControls = route === "/preferences" ? await cdp.eval>( @@ -792,7 +988,20 @@ export async function captureLive(opts: LiveOptions): Promise { } }; + let previousPlayback: Awaited> | null = null; try { + if (opts.track) { + try { + previousPlayback = await cdp.eval>>( + `return await (${swapPlayback.toString()})(${JSON.stringify({ uri: opts.track })}, 0, true);`, + ); + } catch (e) { + failures.push({ + theme: "(playback)", + error: `could not load ${opts.track}: ${(e as Error).message.slice(0, 80)}`, + }); + } + } if (opts.includeUnthemed && known.active) { const before = await mainColour(cdp); // Transient unload, not disable: disable now writes the persisted @@ -833,6 +1042,14 @@ export async function captureLive(opts: LiveOptions): Promise { await tour(theme, scheme, main); } } finally { + if (previousPlayback?.uri) { + const { uri, context, position, paused } = previousPlayback; + await cdp + .eval( + `return await (${swapPlayback.toString()})(${JSON.stringify({ uri, context })}, ${position}, ${paused});`, + ) + .catch((e: Error) => console.error(`could not restore playback: ${e.message}`)); + } if (restoreTo) { await cdp .eval( @@ -848,7 +1065,7 @@ export async function captureLive(opts: LiveOptions): Promise { cdp.close(); } - return { shots, failures, restored: restoreTo, clientVersion, viewport, selector: opts.selector }; + return { shots, failures, restored: restoreTo, clientVersion, viewport, selector: opts.selector, selectors }; } export const CLASSMAP_STATES = [ @@ -1557,12 +1774,14 @@ export function checkBinding(currentDir: string, shots: LiveShot[], floor = BIND return rows; } +export const SELECTORS_FILE = "selectors.json"; + /** Promote the run just taken to be what the next one is measured against. */ export function accept(currentDir: string, baselineDir: string): number { requireOutsideGit(baselineDir); rmSync(baselineDir, { recursive: true, force: true }); mkdirSync(baselineDir, { recursive: true }); - const files = readdirSync(currentDir).filter((f) => f.endsWith(".png")); + const files = readdirSync(currentDir).filter((f) => f.endsWith(".png") || f === SELECTORS_FILE); for (const f of files) copyFileSync(path.join(currentDir, f), path.join(baselineDir, f)); return files.length; } @@ -1574,11 +1793,17 @@ function page(opts: { changes: ShotChange[]; findings: Finding[]; bindings: Binding[]; + lost: { theme: string; selector: string }[]; + selectorBaseline: boolean; + playbackDiffers: boolean; live: LiveResult; capturedAt: string; hasBaseline: boolean; }): string { - const { changes, findings, bindings, live, capturedAt, hasBaseline } = opts; + const { changes, findings, bindings, lost, selectorBaseline, playbackDiffers, live, capturedAt, hasBaseline } = + opts; + const lostByTheme = new Map(); + for (const l of lost) lostByTheme.set(l.theme, [...(lostByTheme.get(l.theme) ?? []), l.selector]); const bindingByTheme = new Map(); for (const b of bindings) bindingByTheme.set(b.theme, Math.max(bindingByTheme.get(b.theme) ?? 0, b.ratio)); const unbound = [...bindingByTheme].filter(([, r]) => r < BINDING_FLOOR).map(([t]) => t); @@ -1623,8 +1848,18 @@ function page(opts: { ${esc(first?.scheme ?? "no scheme")} · ${esc(first?.main ?? "")} ${bindingByTheme.has(theme) ? `repaints ${(bindingByTheme.get(theme)! * 100).toFixed(0)}% of the bare client${bindingByTheme.get(theme)! < BINDING_FLOOR ? " — not binding" : ""}` : ""} ${worst !== null ? `${issues.length} contrast issue${issues.length === 1 ? "" : "s"}, worst ${worst.toFixed(2)}:1` : ""} + ${lostByTheme.has(theme) ? `${lostByTheme.get(theme)!.length} selector${lostByTheme.get(theme)!.length === 1 ? "" : "s"} stopped matching` : ""}
${shots.map(shotFigure).join("")}
+ ${ + lostByTheme.has(theme) + ? `
Matched an element in the baseline run, match nothing on the same routes now${playbackDiffers ? " (playback state differs from the baseline, so playback-dependent rules may appear)" : ""} +
    ${lostByTheme + .get(theme)! + .map((selector) => `
  • ${esc(selector)}
  • `) + .join("")}
` + : "" + } ${ issues.length ? `
${issues.length} pair${issues.length === 1 ? "" : "s"} under ${MIN_RATIO}:1 @@ -1703,6 +1938,7 @@ footer{margin-top:46px;padding-top:18px;border-top:1px solid var(--rule);color:v
${findings.length}contrast issues
${animated.length ? `
${animated.length}animated, not tracked
` : ""}
${unbound.length}themes not binding
+
${selectorBaseline ? lost.length : "–"}${selectorBaseline ? "selectors stopped matching" : "selectors, no baseline yet"}
${ @@ -1779,6 +2015,9 @@ async function main(): Promise { live = JSON.parse(readFileSync(path.join(outDir, "shots.json"), "utf8")); console.log(`reusing ${live.shots.length} frames already on disk`); } else { + // Frames left by an earlier run, say of a theme no longer installed, + // would otherwise be compared and promoted by --accept with this run. + for (const f of readdirSync(currentDir)) if (f.endsWith(".png")) rmSync(path.join(currentDir, f)); console.log("capturing from the live client…"); live = suite ? await captureClassmaps({ outDir: currentDir, port: flag("port") ? Number(flag("port")) : undefined }) @@ -1790,6 +2029,7 @@ async function main(): Promise { routes: list("routes"), candidates: themeIds(), includeUnthemed: true, + track: flag("track") ?? REPORT_TRACK, }); writeFileSync(path.join(outDir, "shots.json"), JSON.stringify(live, null, "\t") + "\n"); for (const f of live.failures) console.error(` FAILED ${f.theme}: ${f.error}`); @@ -1812,15 +2052,42 @@ async function main(): Promise { const findings = auditAll(path.join(REPO, "themes")).findings; const bindings = live.selector ? [] : checkBinding(currentDir, live.shots); const unbound = bindings.filter((b) => !b.bound); + // A run without selector results must not leave an older run's file for --accept to promote. + if (live.selectors) { + writeFileSync(path.join(currentDir, SELECTORS_FILE), JSON.stringify(live.selectors, null, "\t") + "\n"); + } else { + rmSync(path.join(currentDir, SELECTORS_FILE), { force: true }); + } + const baselineSelectorsFile = path.join(baselineDir, SELECTORS_FILE); + const selectorBaseline: SelectorRun | null = + live.selectors && existsSync(baselineSelectorsFile) + ? JSON.parse(readFileSync(baselineSelectorsFile, "utf8")) + : null; + const lost = selectorBaseline && live.selectors ? lostSelectors(selectorBaseline, live.selectors) : []; + const playbackDiffers = + !!selectorBaseline && + selectorBaseline.playing !== null && + live.selectors?.playing != null && + selectorBaseline.playing !== live.selectors.playing; writeFileSync( path.join(outDir, "index.html"), - page({ changes, findings, bindings, live, capturedAt: dateStamp(), hasBaseline }), + page({ + changes, + findings, + bindings, + lost, + selectorBaseline: !!selectorBaseline, + playbackDiffers, + live, + capturedAt: dateStamp(), + hasBaseline, + }), ); writeFileSync( path.join(outDir, "report.json"), JSON.stringify( - { changes, findings, bindings, failures: live.failures, incomplete, baselineSource }, + { changes, findings, bindings, lostSelectors: lost, failures: live.failures, incomplete, baselineSource }, null, "\t", ) + "\n", @@ -1842,6 +2109,13 @@ async function main(): Promise { } } if (unbound.length) console.log(`not binding: ${[...new Set(unbound.map((u) => u.theme))].join(", ")}`); + if (!selectorBaseline) console.log("selectors: no baseline yet, nothing compared"); + else { + console.log(`selectors that matched in the baseline and match nothing now: ${lost.length}`); + if (playbackDiffers) + console.log(" playback state differs from the baseline; playback-dependent rules may be listed"); + for (const l of lost) console.log(` ${l.theme}: ${l.selector}`); + } console.log(hasBaseline ? `changed since baseline: ${moved.length}` : "no baseline yet, nothing compared"); for (const c of moved) console.log(` ${c.shot.theme}/${c.shot.surface}: ${(c.changedRatio * 100).toFixed(2)}%`); diff --git a/themes/dribbblish/index.css b/themes/dribbblish/index.css index 61452f57..7ca0d233 100644 --- a/themes/dribbblish/index.css +++ b/themes/dribbblish/index.css @@ -481,7 +481,8 @@ html.spicetify-themed .Root__top-container { } /* customise player cover art */ -.main-nowPlayingWidget-coverArt .cover-art { +.main-nowPlayingWidget-coverArt .cover-art, +[data-testid="cover-art-button"] .cover-art { width: var(--bar-cover-art-size) !important; height: var(--bar-cover-art-size) !important; background-color: transparent; diff --git a/themes/dribbblish/metadata.json b/themes/dribbblish/metadata.json index 93bd2e50..f26ce63c 100644 --- a/themes/dribbblish/metadata.json +++ b/themes/dribbblish/metadata.json @@ -1,7 +1,7 @@ { "name": "dribbblish", "kind": "theme", - "version": "0.1.3", + "version": "0.1.4", "authors": ["khanhas", "harbassan"], "description": "Compact card-based redesign with rounded art and nineteen schemes", "entries": { diff --git a/themes/starry-night/index.css b/themes/starry-night/index.css index 5a556d2d..2eed9f01 100644 --- a/themes/starry-night/index.css +++ b/themes/starry-night/index.css @@ -168,7 +168,8 @@ width: 100%; } -.main-nowPlayingWidget-coverArt .cover-art { +.main-nowPlayingWidget-coverArt .cover-art, +[data-testid="cover-art-button"] .cover-art { height: 220px !important; width: 220px !important; margin: auto; @@ -176,7 +177,8 @@ box-shadow: 0 0 0px var(--spice-rgb-shadow); } -.main-nowPlayingWidget-coverArt .cover-art img { +.main-nowPlayingWidget-coverArt .cover-art img, +[data-testid="cover-art-button"] .cover-art img { border-radius: 50%; animation: spin 25s linear infinite; animation-play-state: paused; @@ -187,6 +189,10 @@ [data-testid="now-playing-bar"]:has([data-testid="control-button-playpause"][aria-label="Pause"]) .main-nowPlayingWidget-coverArt .cover-art + img, +[data-testid="now-playing-bar"]:has([data-testid="control-button-playpause"][aria-label="Pause"]) + [data-testid="cover-art-button"] + .cover-art img { animation-play-state: running !important; } @@ -207,7 +213,8 @@ background-color: #00000000; } -.main-nowPlayingWidget-coverArt { +.main-nowPlayingWidget-coverArt, +[data-testid="cover-art-button"] > div { width: 100%; height: 100%; padding-top: 5px; diff --git a/themes/starry-night/metadata.json b/themes/starry-night/metadata.json index 8b0db507..c94f1017 100644 --- a/themes/starry-night/metadata.json +++ b/themes/starry-night/metadata.json @@ -1,7 +1,7 @@ { "name": "starry-night", "kind": "theme", - "version": "0.1.4", + "version": "0.1.5", "authors": ["Brandon Chen", "Julissa Laignelet"], "description": "Night sky backdrop with twinkling stars and shooting stars, in seven skies", "entries": { diff --git a/themes/text/index.css b/themes/text/index.css index 943c5220..b1806dd1 100644 --- a/themes/text/index.css +++ b/themes/text/index.css @@ -849,7 +849,8 @@ form .main-topBar-searchBar:placeholder-shown { } /* cover art */ -.main-nowPlayingWidget-coverArt .cover-art { +.main-nowPlayingWidget-coverArt .cover-art, +[data-testid="cover-art-button"] .cover-art { height: 32px !important; width: 32px !important; } diff --git a/themes/text/metadata.json b/themes/text/metadata.json index 6e61a6e7..10b815f0 100644 --- a/themes/text/metadata.json +++ b/themes/text/metadata.json @@ -1,7 +1,7 @@ { "name": "text", "kind": "theme", - "version": "0.1.6", + "version": "0.1.7", "authors": ["darkthemer"], "description": "Mimics the terminal look of spotify-tui, with 21 schemes", "entries": { diff --git a/themes/turntable/index.css b/themes/turntable/index.css index 91361689..e82c5e92 100644 --- a/themes/turntable/index.css +++ b/themes/turntable/index.css @@ -84,7 +84,8 @@ box-shadow: 0 0 5px rgba(200, 200, 200, 0.4); } -.main-nowPlayingWidget-coverArt .cover-art { +.main-nowPlayingWidget-coverArt .cover-art, +[data-testid="cover-art-button"] .cover-art { width: 62px !important; height: 62px !important; box-shadow: unset; @@ -320,11 +321,13 @@ module toggles the root class, so the client is free to re-render the cover art without losing the state. */ .main-nowPlayingWidget-coverArt > .cover-art, +[data-testid="cover-art-button"] .cover-art, #fad-art { animation: rotate-cover_img 24s linear infinite paused; } .turntable-spinning .main-nowPlayingWidget-coverArt > .cover-art, +.turntable-spinning [data-testid="cover-art-button"] .cover-art, .turntable-spinning #fad-art { animation-play-state: running; } diff --git a/themes/turntable/metadata.json b/themes/turntable/metadata.json index 0e391377..9f8cf320 100644 --- a/themes/turntable/metadata.json +++ b/themes/turntable/metadata.json @@ -1,7 +1,7 @@ { "name": "turntable", "kind": "theme", - "version": "0.1.0", + "version": "0.1.1", "authors": ["Grason Chan"], "description": "Vinyl deck look whose round cover art spins while a track plays", "entries": {