Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 29 additions & 44 deletions landing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,42 @@ The public landing page. A separate Vite project that shares Zuno's look but non
cd landing
npm install
npm run dev # http://localhost:5173
npm run check # the *.check.ts self-checks
npm run build # -> landing/dist
```

## Why it is its own project

The desktop app's Vite config has fixed Rollup inputs (`index.html`, `mini.html`), a strict dev
port Tauri depends on, and a `src-tauri` watcher. Adding a third entry point would tie a public
website's release cadence to the app's, and a stray import could pull `@tauri-apps/*` into a
bundle that has no Tauri runtime under it.

Nothing here is reachable from the app: its `tsconfig` is `include: ["src"]` and its Rollup
inputs are the two HTML files above. Running `npm run build` in either directory leaves the
other untouched.

## What is shared, and how

- **Colour tokens** — `src/styles.css` carries a byte-identical copy of the app's `@theme`
block and its `html[data-theme="light"]` overrides. Copied rather than imported, because
`src/ui/styles/global.css` also contains window rounding, titlebar heights and Paper-PC rules
that a website has no use for. If the brand shifts, change both.
- **Component shapes** — `src/components/ui.tsx` reproduces the app's primary/secondary pill
buttons and its `rounded-2xl bg-card/50` section card, so the page and the product read as one
thing.
- **Icons** — OS marks come from Iconify's `logos` set via `@iconify/react`. Iconify normally
fetches at runtime and the full set is 7.4 MB, so the four marks actually used are extracted
into `src/components/brandIcons.tsx` and registered up front — no request, no pop-in.
`@iconify-json/logos` stays a devDependency as the source of record; regenerate that file from
it if a mark changes. The handful of UI glyphs are hand-rolled in `src/components/icons.tsx`.
- **Screenshots** — copied into `public/` from `docs/`. Re-copy them when the app's shots are
refreshed:

```bash
cp ../assets/img/Logo.png public/logo.png
cp ../docs/zuno-d.PNG public/screenshot-dark.png
cp ../docs/zuno-l.PNG public/screenshot-light.png
```
It is its own project so the website can ship on its own schedule, and so no stray import can
pull `@tauri-apps/*` into a bundle with no Tauri runtime under it.

## Downloads
## The demo player

The page plays one track (`public/low-tide.mp3`), and only when someone presses play.
`src/audio/demoPlayer.ts` owns it: one `<audio>` element, a Web Audio graph built on the first
press, and a store the components read with `useSyncExternalStore`.

- **EQ** — ten peaking filters with the app's bands, Q and presets (`src/audio/eq.ts` mirrors
`src/ui/settings/equalizerCurve.ts`). If the app's EQ changes, change both.
- **Visuals** — an analyser feeds the spectrum ring and writes `--bass`, `--level` and
`--progress` on `<html>` every frame; CSS does the rest. Nothing animates under reduced motion.
- **Mini player** — appears once the hero scrolls away (`data-hero-out`), hides over the footer.

`src/releases.ts` reads the newest release from the GitHub API at page load and points each
platform button at the matching installer. Asset names carry the version
(`Zuno_1.1.1_x64-setup.exe`), so there is no stable per-file URL to hardcode.
## Shared with the app

Every button falls back to the releases page — while the request is in flight, if it fails, and
if a release is missing an asset for that platform. A rate-limited or offline visitor still gets
somewhere useful.
- **Colour tokens** — `src/styles.css` copies the app's `@theme` block byte for byte, plus a
few site-only brand tokens (the mascot's colours, the record). If the brand shifts, change both.
- **Fonts** — Inter (as in the app) and Geist Mono, self-hosted through Fontsource.
- **Icons** — OS and service marks are extracted from `@iconify-json/logos` into
`src/components/brandIcons.tsx`, so nothing is fetched at runtime.
- **Images** — `ghost.webp` and `theme-{dark,light}.webp` are resized from `logo.png` and the
1.2 screenshots; regenerate them if those change.

## Downloads

The visitor's platform is detected only to emphasise one card. Detection is unreliable and
downloading for another machine is ordinary, so the other three stay fully available.
`src/releases.ts` reads the newest release from the GitHub API and points each tile at the
matching installer. Every button falls back to the releases page if the request fails or an
asset is missing. Repo stats in `src/github.ts` work the same way: no data, no numbers.

## Deploying

`base` is `"./"`, so the build works from a subpath — including GitHub Pages project sites,
which serve from `/<repo>/`. Publish `landing/dist` as-is.
`base` is `"./"`, so the build works from a subpath — including GitHub Pages project sites.
Publish `landing/dist` as-is.
5 changes: 3 additions & 2 deletions landing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@
<link rel="icon" type="image/png" href="./logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0a0a0a" />

<title>Zuno — a desktop client for YouTube Music</title>
<meta
name="description"
content="A fast, native desktop client for YouTube Music. Tabs with independent playback, offline downloads, synced lyrics, Last.fm and Discord. Free and open source for Windows, macOS and Linux."
content="A native desktop player for YouTube Music. Tabs that each keep playing, synced lyrics, offline downloads, a ten-band EQ, Discord and Last.fm. Free and open source for Windows, macOS and Linux."
/>

<meta property="og:type" content="website" />
<meta property="og:title" content="Zuno — a desktop client for YouTube Music" />
<meta
property="og:description"
content="Tabs that each keep playing, offline downloads, synced lyrics. Free and open source for Windows, macOS and Linux."
content="Tabs that each keep playing, synced lyrics, offline downloads. Free and open source for Windows, macOS and Linux."
/>
<meta property="og:image" content="./screenshot-dark.png" />
<meta name="twitter:card" content="summary_large_image" />
Expand Down
20 changes: 20 additions & 0 deletions landing/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"check": "node scripts/run-checks.mjs"
},
"dependencies": {
"@fontsource-variable/geist-mono": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@iconify/react": "^6.0.2",
"@vercel/analytics": "^2.0.1",
"react": "^19.0.0",
Expand Down
Binary file added landing/public/ghost.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added landing/public/theme-dark.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added landing/public/theme-light.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 46 additions & 0 deletions landing/scripts/run-checks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Runs every `*.check.ts` under landing/src, one process each (same approach as the app's scripts/run-checks.mjs).
import { build } from "esbuild";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative, resolve } from "node:path";

const ROOT = resolve(import.meta.dirname, "..");

function findChecks(dir) {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
if (statSync(path).isDirectory()) return findChecks(path);
return entry.endsWith(".check.ts") ? [path] : [];
});
}

const checks = findChecks(join(ROOT, "src")).sort();
const outDir = mkdtempSync(join(tmpdir(), "zuno-landing-checks-"));
let failed = 0;

try {
for (const check of checks) {
const name = relative(ROOT, check);
const bundle = join(outDir, `${name.replace(/[\\/]/g, "_")}.mjs`);
try {
await build({ entryPoints: [check], bundle: true, platform: "node", format: "esm", outfile: bundle, logLevel: "silent" });
} catch (error) {
failed += 1;
console.error(`FAILED (bundle) ${name}\n${error?.message ?? error}`);
continue;
}
const ran = spawnSync(process.execPath, [bundle], { encoding: "utf8", timeout: 30_000 });
if (ran.status === 0) {
console.log(` ok ${name}`);
} else {
failed += 1;
console.error(`FAILED ${name}\n${(ran.stderr || ran.stdout || "timed out").trim()}`);
}
}
} finally {
rmSync(outDir, { recursive: true, force: true });
}

if (checks.length === 0 || failed > 0) process.exit(1);
console.log(`\n${checks.length} checks passed.`);
113 changes: 32 additions & 81 deletions landing/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,90 +1,41 @@
import { useLatestRelease } from "@/useLatestRelease";
import { Downloads } from "@/components/Downloads";
import { Hero } from "@/components/Hero";
import { MiniPlayer } from "@/components/MiniPlayer";
import { Mono, Section } from "./components/ui";
import { GITHUB_REPO, RELEASES_URL } from "./releases";

/**
* The Zuno character, looping.
*
* The one place with room for personality — everything else on the page is doing a job. Muted,
* looping and `playsInline` so it behaves like a graphic rather than media: no controls, nothing
* to pause, and it never asks for sound.
*/
function Character() {
return (
<video
className="size-16 shrink-0 rounded-2xl bg-card/40 object-cover"
src="./zuno-character.mp4"
autoPlay
muted
loop
playsInline
aria-hidden="true"
/>
);
}

function Footer() {
return (
<footer className=" bg-black">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-14 sm:flex-row sm:items-end sm:justify-between">
<div className="flex items-end gap-4">
<Character />
<div className="flex max-w-sm flex-col gap-2">
<span className="flex items-center gap-2 text-base font-bold text-foreground">
<img className="size-5" src="./logo.png" alt="" />
zuno_
</span>
<Mono className="leading-relaxed">
an independent, unofficial project. not affiliated with, authorised by or endorsed
by youtube or google.
</Mono>
</div>
</div>

<nav className="flex flex-wrap items-center gap-5" aria-label="Links">
{[
["github", GITHUB_REPO],
["releases", RELEASES_URL],
["issues", `${GITHUB_REPO}/issues`],
["licence", `${GITHUB_REPO}/blob/main/LICENSE`],
].map(([label, href]) => (
<a
key={href}
className="font-mono text-[13px] text-muted-foreground transition-colors hover:text-foreground"
href={href}
rel="noopener"
>
{label}
</a>
))}
</nav>
</div>
</footer>
);
}
import { Downloads } from "./components/Downloads";
import { Features } from "./components/Features";
import { Film } from "./components/Film";
import { Footer } from "./components/Footer";
import { Header } from "./components/Header";
import { Hero } from "./components/Hero";
import { MiniPlayer } from "./components/MiniPlayer";
import { OpenSource } from "./components/OpenSource";
import { FOCUS_RING, cn } from "./components/ui";
import { useRepoStats } from "./github";
import { useLatestRelease } from "./useLatestRelease";

export function App() {
const latest = useLatestRelease();
const { release, platform } = useLatestRelease();
const repo = useRepoStats();
const stars = repo?.stars ?? null;

return (
<div className="flex min-h-screen flex-col">
<main className="flex-1">
<Hero release={latest.release} platform={latest.platform} />

<Section
id="download"
index="01"
title="Get it"
lede="Free and open source. Pick your system — the format choice inside each tile is the only decision to make."
>
<Downloads release={latest.release} platform={latest.platform} />
</Section>
<>
<a
href="#download"
className={cn(
"sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[60] focus:rounded-full focus:bg-foreground focus:px-4 focus:py-2 focus:text-background",
FOCUS_RING,
)}
>
Skip to download
</a>
<Header stars={stars} />
<main>
<Hero release={release} platform={platform} stars={stars} />
<Film />
<Features />
<OpenSource repo={repo} release={release} />
<Downloads release={release} platform={platform} />
</main>
<Footer />
<MiniPlayer />
</div>
</>
);
}
Loading
Loading