feat(web): play a random track from the Frutiger Aero SoundCloud playlist in aero mode (v2.2.22)
This commit is contained in:
parent
e4530afd98
commit
be33317d54
12 changed files with 387 additions and 83 deletions
|
|
@ -56,7 +56,7 @@ frosted-glass **web** site, and an alternate KDE/Plasma **desktop simulation**
|
|||
utilities in the components (Tailwind is present via PostCSS but the look is
|
||||
hand-written CSS).
|
||||
- Path alias: `@/*` → `src/*`.
|
||||
- Version: `2.2.21` (see `package.json`).
|
||||
- Version: `2.2.22` (see `package.json`).
|
||||
|
||||
### Scripts
|
||||
```bash
|
||||
|
|
@ -103,6 +103,7 @@ src/app/
|
|||
components/
|
||||
ThemeRoot.tsx → picks which shell to mount (the router of the UI)
|
||||
WebLayout.tsx → "web" theme (default): sticky sidebar + glass content
|
||||
AeroMusic.tsx → Frutiger Aero soundtrack (random SoundCloud playlist track)
|
||||
FeatureWindow.tsx → decorative CSS "windows" (game / browser / forge); used in the
|
||||
web feature panels and the desktop app windows
|
||||
WindowsDesktop.tsx → "skeuo" theme, desktop: icons, windows, taskbar
|
||||
|
|
@ -181,7 +182,11 @@ Themes: `web` = default frosted-glass website (sidebar + content, light/dark),
|
|||
bubbles (`.web-aero-bubbles` / `.web-aero-bubble`) rise across the backdrop, and
|
||||
all `.glass` panels and interactive controls become crystal-clear 3D bevel-rimmed
|
||||
liquid glass (the `.web-ctl-scheme` light/dark switch is hidden in aero mode —
|
||||
it's a fixed bright-sky look). The two remaining feature panels (`.web-split`) stretch
|
||||
it's a fixed bright-sky look). While aero is on, `AeroMusic.tsx` autoplays a
|
||||
random track from the *Frutiger Aero* SoundCloud playlist (a hidden SoundCloud
|
||||
iframe driven through their widget API: it picks a random index from `getSounds`
|
||||
and re-randomises on every `PLAY` event so the set never plays in order; the chip
|
||||
label is synced from `getCurrentSound`, not from what we asked for). The two remaining feature panels (`.web-split`) stretch
|
||||
to equal height and share a fixed `16 / 10` `.fw` window (the `FeatureWindow`
|
||||
component) — decorative, **asset-free CSS mock-ups** rather than screenshots:
|
||||
Revival Inc. is a little necromancy game scene (night sky, moon, tombstones,
|
||||
|
|
|
|||
|
|
@ -47,8 +47,10 @@ more.
|
|||
`freezmo_aero.png` and transforms the web shell into a Frutiger Aero theme:
|
||||
`frutigerbackground.png` as the full-screen backdrop, floating glossy water/soap bubbles
|
||||
across the viewport, and crystal-clear 3D bevel-rimmed liquid-glass panels and buttons
|
||||
with subtle cyan refractive rims. The light/dark switch is hidden while the mode is
|
||||
active (it's a fixed bright-sky look), and the desktop mode is left untouched.
|
||||
with subtle cyan refractive rims. A random track from the *Frutiger Aero* SoundCloud
|
||||
playlist plays while the mode is on (driven through SoundCloud's widget API), with a
|
||||
small glass "now playing" chip to pause it. The light/dark switch is hidden while the
|
||||
mode is active (it's a fixed bright-sky look), and the desktop mode is left untouched.
|
||||
- **Draggable desktop icons** — free placement that snaps to a measured grid, one icon per
|
||||
cell, with a follow-the-cursor drag ghost. Icons shrink on cramped viewports so they never
|
||||
collide.
|
||||
|
|
@ -93,7 +95,7 @@ more.
|
|||
icons (`public/freezmo.png`, `public/freezmo_aero.png`), the Frutiger Aero wallpaper
|
||||
(`public/frutigerbackground.png`), the Joker easter egg and the Gentoo mark; all cards,
|
||||
feature/app previews and the desktop wallpaper are CSS.
|
||||
- **Version:** `2.2.21` (see `package.json`)
|
||||
- **Version:** `2.2.22` (see `package.json`)
|
||||
- **Dependencies:** `npm audit` reports **0 vulnerabilities**. `next` is kept at a patched
|
||||
release (`>=16.3.6`) for the `next/og` RCE, and `brace-expansion` is force-upgraded via
|
||||
`overrides`. `braces` (a transitive ESLint/`fast-glob` dependency) has **no upstream fix**,
|
||||
|
|
@ -215,6 +217,7 @@ src/app/
|
|||
useTheme.ts → theme state via useSyncExternalStore
|
||||
useColorScheme.ts → light/dark state via useSyncExternalStore
|
||||
WebLayout.tsx → default web shell: sidebar + glass content
|
||||
AeroMusic.tsx → Frutiger Aero soundtrack (random SoundCloud playlist track)
|
||||
FeatureWindow.tsx → decorative CSS windows for the two feature panels
|
||||
WindowsDesktop.tsx → desktop shell: icons, windows, taskbar, Start menu
|
||||
WinWindow.tsx → one draggable/resizable window
|
||||
|
|
|
|||
|
|
@ -6,16 +6,11 @@
|
|||
"6": "braces Character Constants",
|
||||
"7": "Next Config & API Routes",
|
||||
"8": "TypeScript Compiler Config",
|
||||
"9": "Root Package Dependencies",
|
||||
"10": "Root Dev Dependencies",
|
||||
"12": "Debian Dependency Installer",
|
||||
"13": "brace-expansion Overrides",
|
||||
"14": "npm Scripts",
|
||||
"15": "Freezmo Icon Visual Identity",
|
||||
"16": "braces Public API Methods",
|
||||
"17": "Eye Logo & Favicon Assets",
|
||||
"18": "ESLint Configuration",
|
||||
"19": "Runtime Dependencies",
|
||||
"20": "Discord Icon Asset",
|
||||
"22": "Instagram Icon Asset",
|
||||
"23": "Gentoo Logo Asset",
|
||||
|
|
@ -23,7 +18,6 @@
|
|||
"25": "Revival Inc. Game Favicon",
|
||||
"26": "TikTok Icon Asset",
|
||||
"27": "System Service Installer",
|
||||
"28": "Package allowScripts Overrides",
|
||||
"29": "PostCSS Configuration",
|
||||
"30": "GitHub Icon Asset",
|
||||
"31": "itch.io Icon Asset",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
# Graph Report - dvorakmortonxd.dev (2026-10-15)
|
||||
# Graph Report - dvorakmortonxd.dev (2026-10-16)
|
||||
|
||||
## Corpus Check
|
||||
- 43 files · ~319,284 words
|
||||
- 44 files · ~320,188 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
- Unclassified: 19 file(s) not represented in the graph (top: (none) 6, .ttf 3, .css 3)
|
||||
|
||||
## Summary
|
||||
- 424 nodes · 592 edges · 40 communities (25 shown, 15 thin omitted)
|
||||
- 434 nodes · 607 edges · 37 communities (23 shown, 14 thin omitted)
|
||||
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 44 edges (avg confidence: 0.89)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `15ef4db7`
|
||||
- Built from commit: `e4530afd`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
|
|
@ -19,22 +19,20 @@
|
|||
- Desktop App Catalog
|
||||
- Deployment & Ops Docs
|
||||
- braces Package Metadata
|
||||
- WindowsDesktop.tsx
|
||||
- useTheme.ts
|
||||
- braces Parsing Engine
|
||||
- apps.tsx
|
||||
- braces Character Constants
|
||||
- Next Config & API Routes
|
||||
- TypeScript Compiler Config
|
||||
- Root Package Dependencies
|
||||
- package.json
|
||||
- Root Dev Dependencies
|
||||
- ProjectIndex.tsx
|
||||
- Debian Dependency Installer
|
||||
- brace-expansion Overrides
|
||||
- npm Scripts
|
||||
- dev-server.mjs
|
||||
- Freezmo Icon Visual Identity
|
||||
- braces Public API Methods
|
||||
- Eye Logo & Favicon Assets
|
||||
- ESLint Configuration
|
||||
- Runtime Dependencies
|
||||
- Discord Icon Asset
|
||||
- AGENTS.md — cold-boot notes
|
||||
- Instagram Icon Asset
|
||||
|
|
@ -43,7 +41,6 @@
|
|||
- Revival Inc. Game Favicon
|
||||
- TikTok Icon Asset
|
||||
- System Service Installer
|
||||
- Package allowScripts Overrides
|
||||
- PostCSS Configuration
|
||||
- GitHub Icon Asset
|
||||
- itch.io Icon Asset
|
||||
|
|
@ -55,9 +52,9 @@
|
|||
- Twitter/X Icon Asset
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `WebLayout()` - 18 edges
|
||||
2. `compilerOptions` - 16 edges
|
||||
3. `react` - 15 edges
|
||||
1. `WebLayout()` - 19 edges
|
||||
2. `react` - 16 edges
|
||||
3. `compilerOptions` - 16 edges
|
||||
4. `next` - 12 edges
|
||||
5. `FeatureWindow()` - 12 edges
|
||||
6. `useTheme()` - 12 edges
|
||||
|
|
@ -86,11 +83,11 @@
|
|||
- **** — public_revival_inc_favicon_png_image, public_revival_inc_favicon_png_revival_inc_game, public_revival_inc_favicon_png_game_controller_mark [INFERRED 0.75]
|
||||
- **Site identity: eye logo used as app icon and in-page logo** — src_app_icon_png_image, src_app_icon_png_eye_logo, src_app_icon_png_favicon, src_app_components_jokerlogo_tsx_logo [INFERRED 0.85]
|
||||
|
||||
## Communities (40 total, 15 thin omitted)
|
||||
## Communities (37 total, 14 thin omitted)
|
||||
|
||||
### Community 0 - "Desktop App Catalog"
|
||||
Cohesion: 0.11
|
||||
Nodes (26): Web theme + light/dark (v2.2 rework), react, ContributionsApp(), Clock(), getTime(), subscribe(), Activity, ActivityPayload (+18 more)
|
||||
Cohesion: 0.08
|
||||
Nodes (35): Web theme + light/dark (v2.2 rework), react, AeroMusic(), loadWidgetApi(), PLACEHOLDER, SoundCloudGlobal, SoundCloudSound, SoundCloudWidget (+27 more)
|
||||
|
||||
### Community 1 - "Deployment & Ops Docs"
|
||||
Cohesion: 0.09
|
||||
|
|
@ -100,17 +97,17 @@ Nodes (30): Autostart on Boot (systemd / OpenRC), Cloudflare Tunnel Reverse Prox
|
|||
Cohesion: 0.05
|
||||
Nodes (37): ansi-colors, bash-path, gulp-format-md, mocha, author, bugs, url, contributors (+29 more)
|
||||
|
||||
### Community 3 - "WindowsDesktop.tsx"
|
||||
Cohesion: 0.08
|
||||
Nodes (33): Conventions & things that will bite you, How UI selection works, APP_BY_ID, AppDef, APPS, createWebsiteApp(), EmbeddedSite(), WebsiteApp() (+25 more)
|
||||
### Community 3 - "useTheme.ts"
|
||||
Cohesion: 0.19
|
||||
Nodes (13): How UI selection works, WelcomeApp(), ThemeRoot(), DEFAULT_THEME, getServerSnapshot(), getSnapshot(), setTheme(), subscribe() (+5 more)
|
||||
|
||||
### Community 4 - "braces Parsing Engine"
|
||||
Cohesion: 0.10
|
||||
Nodes (17): compile, expand, parse, stringify, compile(), fill, utils, append() (+9 more)
|
||||
|
||||
### Community 5 - "apps.tsx"
|
||||
Cohesion: 0.07
|
||||
Nodes (36): ALACRITTY_APP, FriendsApp(), GitdxdApp(), HardwareApp(), NEOFETCH, ProjectsApp(), RevivalApp(), RustyWolfApp() (+28 more)
|
||||
Cohesion: 0.06
|
||||
Nodes (36): Conventions & things that will bite you, ALACRITTY_APP, APP_BY_ID, AppDef, APPS, createWebsiteApp(), EmbeddedSite(), FriendsApp() (+28 more)
|
||||
|
||||
### Community 6 - "braces Character Constants"
|
||||
Cohesion: 0.11
|
||||
|
|
@ -133,32 +130,32 @@ Nodes (3): {
|
|||
}, parse(), stringify
|
||||
|
||||
### Community 7 - "Next Config & API Routes"
|
||||
Cohesion: 0.10
|
||||
Nodes (20): nextConfig, next, app, hasForwardedHeaders(), isDevDebugRequest(), isLocalDebugRequest(), isLoopback(), port (+12 more)
|
||||
Cohesion: 0.15
|
||||
Nodes (13): nextConfig, next, ALLOWED_HOSTS, clickShim(), fail(), GET(), isAllowedHost(), ALLOWED_HOSTS (+5 more)
|
||||
|
||||
### Community 8 - "TypeScript Compiler Config"
|
||||
Cohesion: 0.11
|
||||
Nodes (18): compilerOptions, allowJs, esModuleInterop, incremental, isolatedModules, jsx, lib, module (+10 more)
|
||||
|
||||
### Community 9 - "Root Package Dependencies"
|
||||
Cohesion: 0.13
|
||||
Nodes (14): fill-range, name, private, version, autoprefixer, postcss, react-dom, @svgr/webpack (+6 more)
|
||||
### Community 9 - "package.json"
|
||||
Cohesion: 0.06
|
||||
Nodes (35): eslintConfig, allowScripts, unrs-resolver@1.12.2, dependencies, next, react, react-dom, fill-range (+27 more)
|
||||
|
||||
### Community 10 - "Root Dev Dependencies"
|
||||
Cohesion: 0.15
|
||||
Nodes (13): devDependencies, autoprefixer, eslint, eslint-config-next, fill-range, postcss, @svgr/webpack, tailwindcss (+5 more)
|
||||
|
||||
### Community 11 - "ProjectIndex.tsx"
|
||||
Cohesion: 0.14
|
||||
Nodes (20): HardwareApp(), ProjectsApp(), CodingProjectIndex(), CodingRow(), Group(), HARDWARE_GROUPS, HardwareProjectIndex(), HardwareRow() (+12 more)
|
||||
|
||||
### Community 12 - "Debian Dependency Installer"
|
||||
Cohesion: 0.43
|
||||
Nodes (6): die(), log(), node_ok(), install-dependencies-deb.sh script, usage(), warn()
|
||||
|
||||
### Community 13 - "brace-expansion Overrides"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): brace-expansion, brace-expansion, overrides, braces, minimatch@^10.0.0, minimatch@^3.0.0
|
||||
|
||||
### Community 14 - "npm Scripts"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): scripts, build, dev, dev:next, lint, start
|
||||
### Community 13 - "dev-server.mjs"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): app, hasForwardedHeaders(), isDevDebugRequest(), isLocalDebugRequest(), isLoopback(), port, server
|
||||
|
||||
### Community 15 - "Freezmo Icon Visual Identity"
|
||||
Cohesion: 0.53
|
||||
|
|
@ -168,14 +165,6 @@ Nodes (5): Dark Navy Rounded-Square (Squircle) Frame, Freeze / Preserve State, F
|
|||
Cohesion: 0.50
|
||||
Nodes (4): JokerLogo Component Logo Image, Eye Logo Mark (amber iris and pupil), App Icon - Macro Photograph of a Human Eye, All-Seeing Eye / Surveillance Motif
|
||||
|
||||
### Community 18 - "ESLint Configuration"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): eslintConfig, eslint, eslint-config-next
|
||||
|
||||
### Community 19 - "Runtime Dependencies"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): dependencies, next, react, react-dom
|
||||
|
||||
### Community 20 - "Discord Icon Asset"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): Discord (chat platform brand mark), Discord Icon (SVG social link asset), Profile social link icon, SVG Repo (icon asset source)
|
||||
|
|
@ -206,23 +195,23 @@ Nodes (3): TikTok, TikTok Logo Icon, SVG Repo
|
|||
|
||||
## Knowledge Gaps
|
||||
- **54 isolated node(s):** `react-dom`, `@svgr/webpack`, `@tailwindcss/postcss`, `@types/node`, `@types/react` (+49 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 227 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
|
||||
- **15 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 233 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
|
||||
- **14 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `Conventions & things that will bite you` connect `WindowsDesktop.tsx` to `braces Public API Methods`, `AGENTS.md — cold-boot notes`?**
|
||||
_High betweenness centrality (0.183) - this node is a cross-community bridge._
|
||||
- **Why does `Conventions & things that will bite you` connect `apps.tsx` to `braces Public API Methods`, `AGENTS.md — cold-boot notes`?**
|
||||
_High betweenness centrality (0.181) - this node is a cross-community bridge._
|
||||
- **Are the 2 inferred relationships involving `WebLayout()` (e.g. with `How UI selection works` and `Web theme + light/dark (v2.2 rework)`) actually correct?**
|
||||
_`WebLayout()` has 2 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `react-dom`, `@svgr/webpack`, `@tailwindcss/postcss` to the rest of the system?**
|
||||
_54 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `Desktop App Catalog` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.10873440285204991 - nodes in this community are weakly interconnected._
|
||||
- **Why does `braces()` connect `braces Public API Methods` to `WindowsDesktop.tsx`, `braces Parsing Engine`?**
|
||||
_High betweenness centrality (0.150) - this node is a cross-community bridge._
|
||||
_Cohesion score 0.080338266384778 - nodes in this community are weakly interconnected._
|
||||
- **Why does `braces()` connect `braces Public API Methods` to `braces Parsing Engine`, `apps.tsx`?**
|
||||
_High betweenness centrality (0.149) - this node is a cross-community bridge._
|
||||
- **Should `Deployment & Ops Docs` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.08735632183908046 - nodes in this community are weakly interconnected._
|
||||
- **Why does `next` connect `Next Config & API Routes` to `Desktop App Catalog`, `Root Package Dependencies`, `WindowsDesktop.tsx`, `apps.tsx`?**
|
||||
_High betweenness centrality (0.126) - this node is a cross-community bridge._
|
||||
- **Why does `react` connect `Desktop App Catalog` to `useTheme.ts`, `apps.tsx`, `Next Config & API Routes`, `package.json`, `ProjectIndex.tsx`?**
|
||||
_High betweenness centrality (0.136) - this node is a cross-community bridge._
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -24,9 +24,9 @@
|
|||
"semantic_hash": "03388a201be8750ddcddb14dfbc34411"
|
||||
},
|
||||
"package.json": {
|
||||
"mtime": 1792100566.4931674,
|
||||
"seen": 1792100581.439811,
|
||||
"ast_hash": "fceb2aeaea53cfa26e74f36ec2cf4e7c",
|
||||
"mtime": 1792102753.0037653,
|
||||
"seen": 1792102795.132633,
|
||||
"ast_hash": "5b8d59a5fd4c3052fa6fb1dd3616838e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"postcss.config.mjs": {
|
||||
|
|
@ -114,9 +114,9 @@
|
|||
"semantic_hash": "20e25c727b4bb982e5a22d65efece143"
|
||||
},
|
||||
"src/app/components/WebLayout.tsx": {
|
||||
"mtime": 1792100566.4931674,
|
||||
"seen": 1792100581.4398491,
|
||||
"ast_hash": "4c5f105a49587f804c8a59d17029e263",
|
||||
"mtime": 1792102753.0127654,
|
||||
"seen": 1792102795.1326752,
|
||||
"ast_hash": "19ff085bedc94e6cea703bfccfacd52d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/app/components/WinWindow.tsx": {
|
||||
|
|
@ -240,9 +240,9 @@
|
|||
"semantic_hash": "b81dea2175efa2b0b6c38451ab5a4bbd"
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1792100566.5121677,
|
||||
"seen": 1792100581.439904,
|
||||
"ast_hash": "374e4ca7cda87fbbe327cd53df378839",
|
||||
"mtime": 1792102753.0337658,
|
||||
"seen": 1792102795.1327267,
|
||||
"ast_hash": "d42062ffe4714c5f5c00a7a7849bcb80",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"public/llms.txt": {
|
||||
|
|
@ -366,9 +366,9 @@
|
|||
"semantic_hash": "6f69e16823f10d9c7af1625eff2b32e8"
|
||||
},
|
||||
"AGENTS.md": {
|
||||
"mtime": 1792100572.9662693,
|
||||
"seen": 1792100581.439899,
|
||||
"ast_hash": "13b85d5f3f9e7e69a7c3ec4862c4326d",
|
||||
"mtime": 1792102785.3782775,
|
||||
"seen": 1792102795.1327217,
|
||||
"ast_hash": "594842bd5f504ca50401180b54e743b8",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"public/freezmo_aero.png": {
|
||||
|
|
@ -382,5 +382,11 @@
|
|||
"seen": 1792099263.7671065,
|
||||
"ast_hash": "78344ac481fd50f9366c30f041c8e135",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/app/components/AeroMusic.tsx": {
|
||||
"mtime": 1792102680.4096167,
|
||||
"seen": 1792102795.1326532,
|
||||
"ast_hash": "9e5d7d4591477a92d78eebf144627856",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "dvorakmortonxd",
|
||||
"version": "2.2.21",
|
||||
"version": "2.2.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dvorakmortonxd",
|
||||
"version": "2.2.21",
|
||||
"version": "2.2.22",
|
||||
"dependencies": {
|
||||
"next": "^16.3.8",
|
||||
"react": "^19.3.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dvorakmortonxd",
|
||||
"version": "2.2.21",
|
||||
"version": "2.2.22",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-server.mjs",
|
||||
|
|
|
|||
211
src/app/components/AeroMusic.tsx
Normal file
211
src/app/components/AeroMusic.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/* Frutiger Aero soundtrack: a random track from the "Frutiger Aero" playlist on
|
||||
SoundCloud. A hidden SoundCloud iframe is driven through their Widget API so
|
||||
the playlist autoplays, jumps to a random track and re-randomises whenever
|
||||
playback moves on; a small glass chip shows what's playing and gives the
|
||||
visitor a play/pause control. The iframe is only mounted by `WebLayout` while
|
||||
aero mode is active, so unmounting it stops the music. */
|
||||
|
||||
const PLAYLIST_URL = "https://soundcloud.com/rustie-gamer/sets/frutiger-aero";
|
||||
const API_SRC = "https://w.soundcloud.com/player/api.js";
|
||||
const VOLUME = 35;
|
||||
/* Ignore PLAY events for this long after we pick a track: a `skip()` can still
|
||||
report the previous index for a moment, which would otherwise bounce us into
|
||||
an endless re-pick. */
|
||||
const PICK_GUARD_MS = 3000;
|
||||
|
||||
type SoundCloudSound = {
|
||||
title?: string;
|
||||
user?: { username?: string };
|
||||
};
|
||||
|
||||
type SoundCloudWidget = {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
skip: (index: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
getSounds: (callback: (sounds: SoundCloudSound[]) => void) => void;
|
||||
getCurrentSound: (callback: (sound: SoundCloudSound | null) => void) => void;
|
||||
getCurrentSoundIndex: (callback: (index: number) => void) => void;
|
||||
bind: (event: string, listener: () => void) => void;
|
||||
};
|
||||
|
||||
type SoundCloudGlobal = {
|
||||
Widget: ((element: HTMLIFrameElement) => SoundCloudWidget) & {
|
||||
Events: { READY: string; PLAY: string; FINISH: string };
|
||||
};
|
||||
};
|
||||
|
||||
type Track = { title: string; artist: string };
|
||||
|
||||
const PLACEHOLDER: Track = { title: "Frutiger Aero", artist: "SoundCloud playlist" };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SC?: SoundCloudGlobal;
|
||||
}
|
||||
}
|
||||
|
||||
const WIDGET_SRC =
|
||||
"https://w.soundcloud.com/player/?url=" +
|
||||
encodeURIComponent(PLAYLIST_URL) +
|
||||
"&color=%2364e3ff&auto_play=true&hide_related=true&show_comments=false" +
|
||||
"&show_user=false&show_reposts=false&show_teaser=false&visual=false";
|
||||
|
||||
let apiPromise: Promise<SoundCloudGlobal> | null = null;
|
||||
|
||||
/** Loads SoundCloud's Widget API once; later mounts reuse the same promise. */
|
||||
function loadWidgetApi(): Promise<SoundCloudGlobal> {
|
||||
if (window.SC) return Promise.resolve(window.SC);
|
||||
apiPromise ??= new Promise<SoundCloudGlobal>((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.dataset.soundcloudApi = "";
|
||||
script.src = API_SRC;
|
||||
script.async = true;
|
||||
script.addEventListener("load", () => {
|
||||
if (window.SC) resolve(window.SC);
|
||||
else reject(new Error("SoundCloud widget API did not initialise"));
|
||||
});
|
||||
script.addEventListener("error", () =>
|
||||
reject(new Error("SoundCloud widget API failed to load")),
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return apiPromise;
|
||||
}
|
||||
|
||||
function toTrack(sound: SoundCloudSound | null | undefined): Track {
|
||||
if (!sound) return PLACEHOLDER;
|
||||
return {
|
||||
title: sound.title || PLACEHOLDER.title,
|
||||
artist: sound.user?.username || "SoundCloud",
|
||||
};
|
||||
}
|
||||
|
||||
export default function AeroMusic() {
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const widgetRef = useRef<SoundCloudWidget | null>(null);
|
||||
const [track, setTrack] = useState<Track>(PLACEHOLDER);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
/* The index we deliberately jumped to, and when — see PICK_GUARD_MS. */
|
||||
let chosen = -1;
|
||||
let pickedAt = 0;
|
||||
let syncTimer = 0;
|
||||
|
||||
loadWidgetApi()
|
||||
.then((SC) => {
|
||||
const frame = frameRef.current;
|
||||
if (cancelled || !frame) return;
|
||||
const widget = SC.Widget(frame);
|
||||
widgetRef.current = widget;
|
||||
|
||||
/* The chip mirrors what the player is actually on, so a skip that gets
|
||||
ignored can't leave it lying. Partial reads are ignored rather than
|
||||
blanking the label. */
|
||||
const syncTrack = () => {
|
||||
widget.getCurrentSound((sound) => {
|
||||
if (cancelled || !sound || !sound.title) return;
|
||||
setTrack({ title: sound.title, artist: sound.user?.username || "SoundCloud" });
|
||||
});
|
||||
};
|
||||
|
||||
const playRandomTrack = () => {
|
||||
widget.getSounds((sounds) => {
|
||||
if (cancelled || !sounds || sounds.length === 0) return;
|
||||
let index = Math.floor(Math.random() * sounds.length);
|
||||
/* Don't just replay whatever is already on. */
|
||||
if (sounds.length > 1 && index === chosen) index = (index + 1) % sounds.length;
|
||||
chosen = index;
|
||||
pickedAt = Date.now();
|
||||
widget.skip(index);
|
||||
widget.play();
|
||||
/* Show it straight away, then settle on the truth once the player
|
||||
has caught up. */
|
||||
setTrack(toTrack(sounds[index]));
|
||||
window.clearTimeout(syncTimer);
|
||||
syncTimer = window.setTimeout(syncTrack, 900);
|
||||
});
|
||||
};
|
||||
|
||||
widget.bind(SC.Widget.Events.READY, () => {
|
||||
widget.setVolume(VOLUME);
|
||||
playRandomTrack();
|
||||
});
|
||||
|
||||
/* The set plays on in its own order — grab every chance to re-randomise
|
||||
instead of following the playlist track by track. */
|
||||
widget.bind(SC.Widget.Events.PLAY, () => {
|
||||
setPlaying(true);
|
||||
syncTrack();
|
||||
if (Date.now() - pickedAt < PICK_GUARD_MS) return;
|
||||
widget.getCurrentSoundIndex((index) => {
|
||||
if (cancelled || index === chosen) return;
|
||||
playRandomTrack();
|
||||
});
|
||||
});
|
||||
|
||||
/* Fires at the end of the set (or of the single selected track). */
|
||||
widget.bind(SC.Widget.Events.FINISH, playRandomTrack);
|
||||
|
||||
/* Cover the case where READY already fired before we bound it. */
|
||||
widget.setVolume(VOLUME);
|
||||
playRandomTrack();
|
||||
})
|
||||
.catch(() => {
|
||||
/* Offline, blocked or SoundCloud is down — the theme just stays silent. */
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(syncTimer);
|
||||
widgetRef.current?.pause();
|
||||
widgetRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const widget = widgetRef.current;
|
||||
if (!widget) return;
|
||||
if (playing) {
|
||||
widget.pause();
|
||||
setPlaying(false);
|
||||
} else {
|
||||
widget.play();
|
||||
setPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
className="web-aero-music-frame"
|
||||
src={WIDGET_SRC}
|
||||
title={`Frutiger Aero playlist on SoundCloud — ${track.title} by ${track.artist}`}
|
||||
allow="autoplay"
|
||||
loading="eager"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="web-aero-music"
|
||||
onClick={toggle}
|
||||
aria-pressed={playing}
|
||||
title={playing ? "Pause the Frutiger Aero soundtrack" : "Play the Frutiger Aero soundtrack"}
|
||||
>
|
||||
<span className="web-aero-music-icon" aria-hidden="true">
|
||||
{playing ? "❚❚" : "▶"}
|
||||
</span>
|
||||
<span className="web-aero-music-text">
|
||||
<span className="web-aero-music-title">{track.title}</span>
|
||||
<span className="web-aero-music-artist">{track.artist} · SoundCloud</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import ContributionHeatmap from "./ContributionHeatmap";
|
|||
import FeatureWindow from "./FeatureWindow";
|
||||
import { CodingProjectIndex, HardwareProjectIndex } from "./ProjectIndex";
|
||||
import Clock from "./Clock";
|
||||
import AeroMusic from "./AeroMusic";
|
||||
import { useTheme } from "./useTheme";
|
||||
import { useColorScheme } from "./useColorScheme";
|
||||
import { socials, friends } from "../data";
|
||||
|
|
@ -421,12 +422,14 @@ export default function WebLayout() {
|
|||
</Section>
|
||||
|
||||
<footer className="web-footer glass">
|
||||
<span className="web-footer-label">v2.2.21</span>
|
||||
<span className="web-footer-label">v2.2.22</span>
|
||||
© 2025-2026 Martin Dvořák. All rights reserved (not really I dont care, just
|
||||
follow the GPL3.0 License)
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{aero && <AeroMusic />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2189,6 +2189,7 @@ html[data-theme="web"][data-aero="true"] .web-freezmo-ghost,
|
|||
html[data-theme="web"][data-aero="true"] .web-freezmo-badge,
|
||||
html[data-theme="web"][data-aero="true"] .web-switch,
|
||||
html[data-theme="web"][data-aero="true"] .web-footer-label,
|
||||
html[data-theme="web"][data-aero="true"] .web-aero-music,
|
||||
html[data-theme="web"][data-aero="true"] .fw,
|
||||
html[data-theme="web"][data-aero="true"] .fw-url,
|
||||
html[data-theme="web"][data-aero="true"] .fw-search,
|
||||
|
|
@ -2248,6 +2249,89 @@ html[data-theme="web"][data-aero="true"] .social-icon {
|
|||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* ---------- Frutiger Aero soundtrack ("Gelwave" by skeuoss) ----------
|
||||
The SoundCloud iframe is park in the corner at 1px and fully transparent —
|
||||
it is only there to play audio; the glass chip below is the real control. */
|
||||
html[data-theme="web"][data-aero="true"] .web-aero-music-frame {
|
||||
position: fixed;
|
||||
left: -8px;
|
||||
bottom: -8px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html[data-theme="web"][data-aero="true"] .web-aero-music {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 40;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 8px 16px 8px 11px;
|
||||
border-radius: 999px;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
animation: aero-chip-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
html[data-theme="web"][data-aero="true"] .web-aero-music-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.58rem;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.03em;
|
||||
background: rgba(8, 28, 62, 0.3);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(190, 240, 255, 0.45),
|
||||
inset 0 -2px 5px rgba(0, 140, 220, 0.25);
|
||||
}
|
||||
|
||||
html[data-theme="web"][data-aero="true"] .web-aero-music-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
max-width: 42vw;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
html[data-theme="web"][data-aero="true"] .web-aero-music-title {
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
html[data-theme="web"][data-aero="true"] .web-aero-music-artist {
|
||||
overflow: hidden;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
@keyframes aero-chip-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.94);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme="web"][data-aero="true"] .heatmap-cell[data-level="1"],
|
||||
html[data-theme="web"][data-aero="true"] .heatmap-legend-cell[data-level="1"] {
|
||||
background-color: rgba(255, 255, 255, 0.26);
|
||||
|
|
@ -2412,6 +2496,15 @@ html[data-theme="web"][data-aero="true"] .heatmap-legend-cell[data-level="4"] {
|
|||
.web-side-foot {
|
||||
display: none;
|
||||
}
|
||||
/* Keep the Aero soundtrack chip out of the way on a phone — icon + title only. */
|
||||
html[data-theme="web"][data-aero="true"] .web-aero-music {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
padding: 7px 13px 7px 9px;
|
||||
}
|
||||
html[data-theme="web"][data-aero="true"] .web-aero-music-artist {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue