Compare commits

...

287 commits

Author SHA1 Message Date
Jordan Eldredge
2fe3235d51 Remove contained example and clarify position requirements 2026-01-01 15:44:11 -08:00
Jordan Eldredge
54dbe369d4
Fix TypeScript error in Webamp renderInto call (#1340)
Add non-null assertion operator to ref.current when calling webamp.renderInto()
to match the pattern used elsewhere in the component and resolve the CI type error:
"Argument of type 'HTMLDivElement | null' is not assignable to parameter of type 'HTMLElement'"

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-01 15:19:31 -08:00
Jordan Eldredge
bce845962f Enable Webamp for scroll UI 2025-12-31 15:18:45 -08:00
Jordan Eldredge
4b784b6eaf Start wiring up Webamp in scroller museum UI 2025-12-31 15:00:16 -08:00
Jordan Eldredge
52f12327fa Persist search query in URL 2025-12-31 15:00:16 -08:00
Jordan Eldredge
1d10f7a1f4 Don't wait as long to hint that you can scroll 2025-12-31 15:00:16 -08:00
Jordan Eldredge
99f06d3bfc Remove logging 2025-12-31 15:00:16 -08:00
Lorenzo Stanco
162025f8a0
Webamp optionally fully contained into a DOM element (#1338)
* Webamp optionally fully contained into a DOM element

* Fix spelling

* Reenable updating search index for uploads

* Replace contained flag on renderWhenReady with a new method. Add docs

---------

Co-authored-by: Jordan Eldredge <jordan@jordaneldredge.com>
2025-12-31 14:59:14 -08:00
Jordan Eldredge
f600fb0344 lints 2025-12-30 04:46:47 +00:00
Jordan Eldredge
04c396ed1f Scroll search 2025-12-29 20:22:39 -08:00
Jordan Eldredge
f74d7a6cdf Move search input 2025-12-29 17:38:14 -08:00
Jordan Eldredge
1fb930cd63 New pages for scroll 2025-12-29 17:29:11 -08:00
Jordan Eldredge
8d4ff41f42 Improve scroll UI 2025-12-29 17:03:29 -08:00
Jordan Eldredge
e062a51a88 Improve scroll UI 2025-12-29 15:32:03 -08:00
Jordan Eldredge
0895f9191f Add explicit app routes 2025-12-29 15:17:06 -08:00
Jordan Eldredge
3b4e5b17c3 Remove blocklist of skins with viruses. These are now purged. 2025-12-29 13:15:46 -08:00
Jordan Eldredge
91618c9c6b Block downloading skin that seems to trigger security block 2025-12-29 11:52:11 -08:00
Jordan Eldredge
6c732f8e24 Add bulk download page 2025-12-29 11:35:45 -08:00
Jordan Eldredge
d87cb6ffa3 Switch away from cached api 2025-12-26 19:20:30 +00:00
Jordan Eldredge
6997c852f9 Get uploads working again 2025-12-26 19:20:30 +00:00
Jordan Eldredge
61476591f8 Trusted publishing 2025-12-17 16:51:54 -08:00
Jordan Eldredge
33003a8f8f
Patch butterchurn to use eel-wasm 98 to fix mod bug (#1334) 2025-12-15 22:21:26 -08:00
Jordan Eldredge
50a7c2df49
Fix lints (#1335)
* Fix lints

* Fix typechecking
2025-12-15 22:12:14 -08:00
Jordan Eldredge
1f875a6155 Migrate from pm2 to systemd 2025-12-03 18:20:18 -05:00
Jordan Eldredge
26a6002ce8 Fix deploy 2025-12-03 18:12:43 -05:00
Jordan Eldredge
bd6c978d79 Remove duplicate function 2025-12-03 18:12:43 -05:00
Jordan Eldredge
18ee5418b6 Avoid circular imports 2025-12-03 18:12:43 -05:00
Jordan Eldredge
af21934296 Use node version from nvm 2025-12-03 18:12:43 -05:00
Jordan Eldredge
4b793c30b2 Deploy take 1 2025-12-03 18:12:43 -05:00
Jordan Eldredge
a6b0350a00 Get syncing to the archive working again 2025-12-03 18:12:43 -05:00
Jordan Eldredge
964a7c5f2f
Upgrade next (#1333) 2025-12-03 15:12:22 -08:00
Jordan Eldredge
b00e359a78
Enable more lints (#1330) 2025-11-28 12:19:10 -08:00
Jordan Eldredge
d159308352
Use turbo lint for root lint script (#1329)
This ensures `pnpm run lint` at the monorepo root runs the same
lint tasks as CI (`npx turbo lint`), providing consistent behavior
between local development and CI.
2025-11-28 12:06:41 -08:00
Jordan Eldredge
d687f4b06c
Disable silly lint rule (#1328) 2025-11-28 11:53:25 -08:00
Jordan Eldredge
8fa7701b47
Remove legacy JS files from winamp-eqf package (#1327)
These files were duplicates of the TypeScript source files in src/.
The package builds from src/*.ts to built/*.js, so these root-level
.js files were unused and causing lint failures when running
`pnpm run lint` from the monorepo root.
2025-11-28 11:51:02 -08:00
Jordan Eldredge
1da77a640a
Consolidate ESLint configs into root (#1324)
Move general-purpose lint rules from packages/webamp/.eslintrc to the root
.eslintrc so they apply to all packages consistently. This includes:

- Core JavaScript best practices (no-var, prefer-const, eqeqeq, etc.)
- TypeScript-specific rules (@typescript-eslint/no-unused-vars with patterns)
- Prettier integration

Package-specific configs now only contain rules unique to their needs:
- webamp: React, import, and react-hooks plugin rules
- skin-database: Extends @typescript-eslint/recommended, disables rules that
  conflict with existing code style
- webamp-modern: Unchanged (has root: true for isolation)

Also fixes lint errors in skin-database:
- Consolidate duplicate imports in App.js and Feedback.js
- Add radix parameter to parseInt
- Prefix unused function parameters with underscore
- Convert var to let/const
- Fix type import for Shooter
2025-11-27 21:32:10 -08:00
Jordan Eldredge
642fb964d6
Add skin-museum-og lint to CI (#1321)
The skin-museum-og package has a lint script but it was not included
in turbo.json, so it wasn't being checked in CI. This adds the
skin-museum-og#lint task to turbo.json so it will be linted along
with the other packages.
2025-11-27 18:34:41 -08:00
Jordan Eldredge
8358d4843c
Add ESLint to ani-cursor and include in CI (#1323)
- Add lint script to ani-cursor package.json
- Add ani-cursor#lint task to turbo.json so it runs in CI
2025-11-27 18:31:36 -08:00
Jordan Eldredge
bbd1d1224e
Add ESLint to winamp-eqf and include in CI (#1322)
- Add lint script to winamp-eqf package.json
- Add winamp-eqf#lint task to turbo.json so it runs in CI
2025-11-27 18:31:27 -08:00
Jordan Eldredge
b672de2515
Fix webamp-modern build outputs in turbo.json (#1320)
The webamp-modern#build task had outputs set to an empty array,
which meant turbo wouldn't cache/restore the build directory.
This caused the deploy script to fail when the mv command couldn't
find the build directory on cache hits.

Changed outputs from [] to ["build/**"] to properly cache and
restore the build output.
2025-11-27 17:46:44 -08:00
Jordan Eldredge
014c8eab28
Add missing BLUESKY env vars to test setup (#1319)
The skin-database tests were failing in CI because BLUESKY_PASSWORD and
BLUESKY_USERNAME environment variables were added to config.ts but
not to the jest-setup.js file that provides dummy values for tests.
2025-11-27 16:24:04 -08:00
Eris Lund
a56cbc54c5
High bitrate/samplerate is now correctly clipped (#1318)
* High bitrate/samplerate is now correctly clipped

This makes the ``kbps`` and ``kHz`` displays in the main window correctly emulate what Winamp does with high bitrates/samplerates.

* Moved globals to be local to their designated functions

Division is no longer performed in each if condition
Default to displaying "0" for the kbps and khz fields (doesn't seem to trigger, though)

* Use padStart and slice to more properly format the data

Added comment by Justin Frankel on the meaning of H and C

* Display "0" while gathering bitrate and khz

* Remove logging of kbps in console

Co-authored-by: Jordan Eldredge <jordan@jordaneldredge.com>

* Assign ``finalKhz`` properly

* Make CI hopefully happy

---------

Co-authored-by: Jordan Eldredge <jordan@jordaneldredge.com>
2025-11-27 16:10:11 -08:00
Jordan Eldredge
8efe121f3c More stuff for the new layout 2025-11-07 21:38:21 -08:00
Jordan Eldredge
c778464c42 Avoid url takeover and loading glitch 2025-11-07 19:58:30 -08:00
Jordan Eldredge
d6245c7c7e Add scroll hint 2025-11-07 19:41:50 -08:00
Jordan Eldredge
f1339901e6 Stub out permalink page 2025-11-07 19:12:08 -08:00
Jordan Eldredge
811fc977c4 Clean up 2025-11-07 18:56:10 -08:00
Jordan Eldredge
7afe3bd45b Stub out menu bar 2025-11-07 18:51:49 -08:00
Jordan Eldredge
608242b200 Improve landscape screens for scroll 2025-11-07 17:28:42 -08:00
Jordan Eldredge
f3054192e6 Make page dynamic and fix scroll loading on ios 2025-11-07 16:08:41 -08:00
Jordan Eldredge
0b2ff44b1c Action buttons 2025-11-07 15:44:54 -08:00
Jordan Eldredge
52ff84d29b Make scroll sessions more dynamic 2025-11-07 15:23:02 -08:00
Jordan Eldredge
fbe3a0090f Fix some layout issues 2025-11-04 21:44:50 -08:00
Jordan Eldredge
0705e9d89e Stub out shorts 2025-11-04 21:33:58 -08:00
Jordan Eldredge
340e2249ae Clean up outdated references to yarn 2025-11-03 15:39:26 -08:00
Jordan Eldredge
4b405bc831 Clean up console logs 2025-11-03 08:05:02 -08:00
Jordan Eldredge
50d5dbbf4f Always use .wsz in museum pages 2025-11-03 08:04:54 -08:00
Jordan Eldredge
96ffdcda59 Clean up log 2025-11-03 07:54:59 -08:00
Jordan Eldredge
e5ed88c8ec Bluesky bot 2025-10-10 20:25:57 -07:00
Jordan Eldredge
3c882550e3 Downgrade skin museum OG React 2025-10-10 16:22:51 -07:00
Jordan Eldredge
6434ecc626 Upgrade Grats 2025-10-03 17:48:14 -07:00
Jordan Eldredge
a20bab1877 Spelling 2025-07-21 22:24:23 -07:00
Jordan Eldredge
a0cecb8f93 Make shade mode trigger on click like the rest of the buttons 2025-07-21 20:03:04 -07:00
Jordan Eldredge
0608b2f9c6 Fix marquee dragging 2025-07-21 19:48:51 -07:00
Jordan Eldredge
27ba138a3e Try package-specific Netlify build 2025-07-15 20:13:45 -07:00
Jordan Eldredge
cb651adfaf Run library build one at a time to avoid OOM on Netlify 2025-07-15 20:00:41 -07:00
Jordan Eldredge
224b4b8058 Revive Webamp modern on Netlify 2025-07-15 19:33:26 -07:00
Jordan Eldredge
f35f1242ca Try harder to avoid OOms in Netlify build 2025-07-15 19:25:16 -07:00
Jordan Eldredge
acff24b7bb Try to avoid OOM on Netlify 2025-07-15 19:13:06 -07:00
Jordan Eldredge
e99b2ab6f7 Fix deploy script 2025-07-15 19:02:17 -07:00
Jordan Eldredge
a30ab82ccc Add example showing how to use requireButterchurnPresets 2025-07-15 19:00:09 -07:00
Jordan Eldredge
eaba9667e2 Try to fix netlify deploy 2025-07-15 18:57:35 -07:00
Jordan Eldredge
41cfbbb63c Update readme to reflect package changes 2025-07-15 16:59:41 -07:00
Jordan Eldredge
6f8f85c865
Allow users to provide their own Butterchurn presets (#1309)
* Allow users to provide their own Butterchurn presets

* Ensure entrypoints have anchor links
2025-07-15 16:58:52 -07:00
Jordan Eldredge
e82db4cddd Add example of using webamp/lazy entrypoint with vite 2025-07-15 16:41:24 -07:00
Jordan Eldredge
0d7cb8285f Document entrypoints and recommend webamp/butterchurn as the default. 2025-07-14 21:28:30 -07:00
Jordan Eldredge
a4dec85406 Document butterchurn entrypoint 2025-07-14 17:57:20 -07:00
Jordan Eldredge
5e88d4e37b Ignore dist in built 2025-07-14 17:06:30 -07:00
Jordan Eldredge
9b03dcd077 Version 2.2.0 release 2025-07-14 16:58:20 -07:00
Jordan Eldredge
cb2948c2d3 Add butterchurn security change to changelog 2025-07-14 16:56:42 -07:00
Jordan Eldredge
b7d759618e
Don't allow evaling JS in Milkdrop preset files (#1308)
* Don't allow evaling JS in Milkdrop preset files

* Add link
2025-07-14 16:31:27 -07:00
Jordan Eldredge
910995d469 Add links to changelog 2025-07-13 12:48:55 -07:00
Jordan Eldredge
35e6128224 Unfork butterchurn 2025-07-12 23:46:54 -07:00
Jordan Eldredge
b02fc7a5c6 Update docs 2025-07-12 23:44:21 -07:00
Jordan Eldredge
161c14b598 Bump version in butterchurn example 2025-07-12 23:03:32 -07:00
Jordan Eldredge
7cdc35b93a Update changelog to call out new butterchurn entrypoint 2025-07-12 22:56:03 -07:00
Jordan Eldredge
19668851d6 Bump butterchurn version to mostly unfork 2025-07-12 22:55:51 -07:00
Jordan Eldredge
b76491001c Remove touch 2025-07-12 13:11:11 -07:00
Jordan Eldredge
dbc64a8287 Skip cache to debug ci 2025-07-10 15:17:45 -07:00
Jordan Eldredge
9f789ae2ba
Fix butterchurn import in withButterchurn (#1306) 2025-07-10 14:58:10 -07:00
Jordan Eldredge
95e96ddb02 Update tests to use array not set 2025-07-10 14:57:17 -07:00
Jordan Eldredge
12c31f65ae Use fork of butterchurn 2025-07-10 09:03:55 -07:00
Jordan Eldredge
af1f294ada Format 2025-07-09 22:53:17 -07:00
Jordan Eldredge
a06485f1be Avoid some lint errors 2025-07-09 21:05:54 -07:00
Jordan Eldredge
a4f14d9a86 Resolve lint errors 2025-07-09 20:52:57 -07:00
Jordan Eldredge
4ead993d64 Fix tests for Algolia use 2025-07-09 20:39:18 -07:00
Jordan Eldredge
9d88134988 Finish algolia upgrade 2025-07-09 14:24:42 -07:00
Jordan Eldredge
157f8d28df Fix typescript errors 2025-07-09 14:07:30 -07:00
Jordan Eldredge
c55212b363 Fix download links and upgrade some dependencies 2025-07-09 13:25:06 -07:00
Jordan Eldredge
15b15d081a Revive compile skin script and convert to typescript 2025-07-09 13:25:06 -07:00
Jordan Eldredge
51859506db Fix compile skin script 2025-07-09 13:25:06 -07:00
Jordan Eldredge
2dee12e9e0 Avoid non-serializable object in Redux store 2025-07-09 13:25:06 -07:00
Jordan Eldredge
daf06567b0 Consolidate dependency versions 2025-07-09 13:25:05 -07:00
Jordan Eldredge
3a1ec73825
Update README.md 2025-07-08 07:32:24 -07:00
Jordan Eldredge
b090082dfa Check Grats artifacts in CI 2025-07-07 15:33:05 -07:00
Jordan Eldredge
6ce866f48c Include Grats in turbo.json 2025-07-07 15:11:18 -07:00
Jordan Eldredge
26e8e6dfeb Run more tests in CI 2025-07-07 12:51:20 -07:00
Jordan Eldredge
8425adaeff
Upgrade node 22 lts (#1305)
* Upgrade Node.js to v22 LTS across the entire repo

- Update CI workflows (.github/workflows/ci.yml, code-size.yml) to use Node.js 22.x
- Update Netlify config to use Node.js 22.11.0
- Update all package.json engines to require Node.js 22.0.0+
- Add .nvmrc file with Node.js 22 for local development
- Update documentation to reflect Node.js 22+ requirement
- Update skin-database scripts to use Node.js 22
- Tested locally with nvm and confirmed builds work correctly

Node.js v22 is the current LTS version and provides improved performance
and security features. All packages now require Node.js 22+ to ensure
consistency across development, CI, and production environments.

* Don't update node version here
2025-07-07 10:30:13 -07:00
Jordan Eldredge
c2c067588d Tidy ci 2025-07-06 22:56:29 -07:00
Jordan Eldredge
f45736c0b6
Try Turborepo (#1304)
* Try Turborepo

* Add webamp as explicit dependency of docs

* Update pnpm lock

* Generate types as part of webamp build-library

* To many dashes
2025-07-06 22:50:04 -07:00
Jordan Eldredge
28c36e5141 Ensure builds happen early enough in CI 2025-07-06 18:54:20 -07:00
Jordan Eldredge
99e4ae4f90 Try to fix tests in CI 2025-07-06 18:16:24 -07:00
Jordan Eldredge
ce8c4b1bcc Prettier 2025-07-06 18:13:37 -07:00
Jordan Eldredge
fee8030882 Clean up create-react-app cruft 2025-07-06 18:12:50 -07:00
Jordan Eldredge
46ad06054e Clean up old parcel cruft 2025-07-06 18:07:31 -07:00
Jordan Eldredge
60c94b4f00 Build dependencies before type checking 2025-07-06 18:02:31 -07:00
Jordan Eldredge
e641c61b64 Fixup winamp-eqf tests 2025-07-06 18:00:48 -07:00
Jordan Eldredge
f471cd1e69 Publish all 2025-07-06 17:39:36 -07:00
Jordan Eldredge
aef14f205d Update ani-cursor build 2025-07-06 17:30:06 -07:00
Jordan Eldredge
dbf54596e4 Type winamp-eqf 2025-07-06 17:17:55 -07:00
Jordan Eldredge
f2fd00b65b Improve typechecking of webamp-modern 2025-07-06 16:43:59 -07:00
Jordan Eldredge
b99ea291eb Fix type checking of skin-database 2025-07-06 16:33:09 -07:00
Jordan Eldredge
8abcf9d139 Typecheck more packages in CI 2025-07-06 16:23:57 -07:00
Jordan Eldredge
f9f892bde1 Try to fix npm release 2025-07-06 15:59:58 -07:00
Jordan Eldredge
08ec7ce69f
Migrate from yarn to pnpm (#1303)
* Migrate from yarn to pnpm

This comprehensive migration includes:

### Configuration Updates
- Updated root package.json with pnpm workspace configuration
- Added packageManager field and pnpm overrides for graphql version
- Updated GitHub Actions workflows (.github/workflows/ci.yml, code-size.yml)
- Updated Netlify configuration (netlify.toml)
- Updated deployment script (deploy.sh)

### Documentation Updates
- Updated all README files to use pnpm instead of yarn
- Updated installation and build instructions across packages:
  - packages/webamp/README.md
  - packages/webamp-modern/README.md
  - packages/webamp-docs/README.md
  - packages/ani-cursor/README.md
  - packages/webamp/demo/readme.md

### Lock File Migration
- Removed yarn.lock
- Generated pnpm-lock.yaml preserving exact dependency versions
- Moved resolutions from skin-database package.json to root pnpm overrides
- Created pnpm-workspace.yaml for optimized workspace configuration

### CI/CD Updates
- Updated all yarn commands to use pnpm equivalents
- Changed yarn workspace commands to pnpm --filter syntax
- Updated cache keys to use pnpm-lock.yaml instead of yarn.lock
- Added pnpm/action-setup for GitHub Actions

### Validation
- Tested builds for webamp, webamp-modern, ani-cursor, webamp-docs
- Tested installation and linting for skin-database
- Verified dependency resolution consistency
- Confirmed all scripts work with pnpm

All package versions remain identical to yarn.lock, ensuring no breaking changes.

* Fix GitHub Actions CI: Install pnpm before using cache

The GitHub Actions workflow was trying to cache pnpm before installing it.
Fixed by reordering steps in all jobs to:
1. Install pnpm first
2. Setup Node.js with pnpm cache
3. Install dependencies

This ensures pnpm is available when setting up the cache.

* Fix pnpm overrides configuration format

Move overrides from pnpm.overrides to top-level overrides in package.json
to match the format expected by pnpm lockfile. This resolves the
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH error in CI.

* Update CI to use pnpm version 9 to match lockfile format

* Add missing @types dependencies for ani-cursor

Add @types/jest and @types/node as devDependencies to ani-cursor package.
These were missing but referenced in tsconfig.json, causing TypeScript
compilation failures in CI with pnpm's stricter package isolation.

* Fix dependency isolation issues for pnpm migration

- Add strtok3 as direct dependency to webamp package (was transitive)
- Add missing Babel plugins that were accessible as transitive deps with yarn
- These packages need to be explicit dependencies for pnpm's stricter isolation

Addresses missing dependencies that caused CI build failures:
- Cannot find module 'strtok3'
- Cannot find package '@babel/plugin-proposal-nullish-coalescing-operator'
- Cannot find package '@babel/plugin-proposal-optional-chaining'

* Add @babel/preset-env to webamp devDependencies

- Fixes build-library failing due to missing Babel preset
- pnpm's stricter dependency isolation revealed this missing direct dependency
- Confirmed build-library now passes locally

* Lock changes

* Remove workspaces field from package.json

- pnpm uses pnpm-workspace.yaml instead of package.json workspaces field
- Fixes warning: 'The workspaces field in package.json is not supported by pnpm'
- Workspace configuration is already correctly defined in pnpm-workspace.yaml

* Does forcing a specific pnpm version help?

* Update pnpm version to 9.12.0 in CI workflows

- Fixes issues with pnpm v9.0 as mentioned in https://github.com/pnpm/pnpm/issues/6312
- Updates both ci.yml and code-size.yml workflows
- 9.12.0 matches the local version and is more stable
- Should resolve workspaces field warning and other pnpm issues

* Skip root-level Jest tests in CI due to configuration conflicts

- Root-level Jest config has compatibility issues with jest-environment-jsdom@29.7.0
- Different packages use different Jest versions causing testEnvironmentOptions errors
- Webamp package tests work fine with their specific Jest configuration
- This is the same issue we saw locally - pnpm's stricter isolation reveals these conflicts
- CI only needs webamp tests to pass for the migration validation

* Add missing Babel plugins for build-library

- Add @babel/plugin-proposal-object-rest-spread
- Add @babel/plugin-syntax-dynamic-import
- These were missing dependencies revealed by pnpm's stricter isolation
- Fixes build-library errors in CI

* Upgrade Jest to v29.7.0 to fix test environment issues

- Upgrade from Jest 27.5.1 to 29.7.0 to match webamp package version
- Add jest-environment-jsdom as direct dependency
- Fixes 'Cannot read properties of undefined (reading testEnvironmentOptions)' error
- pnpm's stricter isolation revealed version conflicts between packages
- Tests now run properly but some snapshots need updating due to format changes

* Re-enable Jest tests in CI

- Jest environment issues are now fixed with v29.7.0 upgrade
- Tests work properly with the updated configuration
- Some packages may have snapshot format changes but tests pass

* Clean up lock

* Clean up Yarn cruft

* Update snapshots

* Fix compressed size workflow for pnpm

- Add Node.js setup step (required for pnpm)
- Add pnpm install step to install dependencies before build
- Update checkout action from v2 to v4
- Ensure dependencies are available before running deploy script
2025-07-06 15:45:44 -07:00
Jordan Eldredge
bcaa8dfdc6 Try to speed up CI 2025-07-06 12:40:37 -07:00
Jordan Eldredge
9aa4f59db5 Turn moduleSideEffects back on 2025-07-06 12:30:38 -07:00
Jordan Eldredge
c78b1abe92 We know we have big chunks 2025-07-06 10:43:31 -07:00
Jordan Eldredge
d1f6ef0089 Fix reference to undefined variable 2025-07-06 10:43:06 -07:00
Jordan Eldredge
8eae420851 Format files 2025-07-06 10:36:55 -07:00
Jordan Eldredge
704112b333 Fix slow lint 2025-07-06 10:36:46 -07:00
Jordan Eldredge
274abd9090 Finish migration to inline action types 2025-07-06 10:32:33 -07:00
Jordan Eldredge
c6d0c2717c Clean up unused lock files 2025-07-06 10:02:41 -07:00
Jordan Eldredge
c0d4960dd3 Remove internet archive integration tests 2025-07-06 10:01:04 -07:00
Jordan Eldredge
48ef4eeff2 Inline action types 2025-07-06 09:48:33 -07:00
Jordan Eldredge
aa7ea12b41 Fix playlist scroll behavior 2025-07-05 18:35:43 -07:00
Jordan Eldredge
b508663db9 Clean up whitespace 2025-07-05 18:35:43 -07:00
Jordan Eldredge
eee17f0d25 Fix name 2025-07-05 18:35:43 -07:00
Jordan Eldredge
b1ec6460b2
Optimize ci performance (#1302)
* Improve rollup perf

* 🚀 Optimize CI performance with parallel jobs and caching

- Split monolithic build-and-test into 4 parallel jobs (setup, build, lint, test)
- Add dependency caching with actions/setup-node@v4 yarn cache
- Cache build artifacts between jobs to avoid rebuilding
- Upgrade to latest GitHub Actions (checkout@v4, setup-node@v4)
- Skip CI runs on documentation-only changes
- Optimize Jest with --maxWorkers=2 for better CI performance
- Add fail-safe caching for main-release job

Expected performance improvement: 40-50% faster CI runs

* Try to improve caching

* Revert "Try to improve caching"

This reverts commit 63d0abdca9.
2025-07-05 18:12:42 -07:00
Jordan Eldredge
833060e1ae Fix Jest 2025-07-05 16:53:03 -07:00
Jordan Eldredge
3e0417267a Avoid library build warnings 2025-07-05 16:32:55 -07:00
Jordan Eldredge
d4a841846c Switch back to streaming parsing of id3s 2025-07-05 16:17:03 -07:00
Jordan Eldredge
7b87c2302c Avoid ts warnings in vite build 2025-07-05 16:16:48 -07:00
Jordan Eldredge
b230cc66c5 Avoid commonjs warning in vite 2025-07-05 16:05:58 -07:00
Jordan Eldredge
1cb0991db7 Avoid trying to do parsing streaming while we wait for bug fix 2025-07-05 13:28:00 -07:00
Jordan Eldredge
1d39600284 Upgrade png optimizer 2025-07-04 21:46:40 -07:00
Jordan Eldredge
14c0d24a47 Avoid some memory leaks when disposing Webamp 2025-07-04 21:25:57 -07:00
Jordan Eldredge
e52900d4fc Add Milkdrop to docs example 2025-07-04 19:44:47 -07:00
Jordan Eldredge
7e159f2173 Move butterchurn loader docs to bundle size guide 2025-07-04 19:10:02 -07:00
Jordan Eldredge
bb98aba71a Prevent attempting to enter fullscreen mode on platforms where it's not supported 2025-07-04 18:28:42 -07:00
Jordan Eldredge
cfa67c4bc5 Update example to use new /butterchurn entrypoint 2025-07-04 17:29:31 -07:00
Jordan Eldredge
a86226bd6b Set viewport in examples 2025-07-04 16:59:08 -07:00
Jordan Eldredge
854e2c7998 Document webamp/butterchurn in changelog 2025-07-04 16:58:16 -07:00
Jordan Eldredge
f4321173df Improve butterchurn build 2025-07-04 16:43:13 -07:00
Jordan Eldredge
d060b9042b Throwing stuff at the wall, lol 2025-07-04 15:51:48 -07:00
Jordan Eldredge
50e6e21e2e Try adding Butterchurn bundle 2025-07-04 15:18:04 -07:00
Jordan Eldredge
e06d3b9b3e Add back polyfills 2025-07-04 11:02:02 -07:00
Jordan Eldredge
b5bf64a55d Inline music-metadata lazy loads to fix builds 2025-07-04 10:38:48 -07:00
Jordan Eldredge
1ba58c81e1 Aovid import types 2025-07-04 10:29:32 -07:00
Jordan Eldredge
9d0cdc6ed8 Ignore type error for now 2025-07-04 10:24:18 -07:00
Jordan Eldredge
90625614f0 Hide milkdrop context window if not enabled 2025-07-04 10:20:00 -07:00
Jordan Eldredge
b66667019c Fix types for metadata 2025-07-04 10:09:31 -07:00
Jordan Eldredge
e043a1b5b8 Try upgrading to music-metadata from music-metadata-browser 2025-07-04 10:06:13 -07:00
Jordan Eldredge
366e79525e Upgrade Redux devtools package 2025-06-29 22:59:10 -07:00
Jordan Eldredge
eb0898fe4e Add getPlayerMediaStatus instance method 2025-06-29 22:44:49 -07:00
Jordan Eldredge
3feae65585 Spelling 2025-06-29 21:34:31 -07:00
Jordan Eldredge
7ededf9bf5 Prevent default on scroll events Fixes #1301 2025-06-29 17:40:54 -07:00
Jordan Eldredge
067623811c Fix spelling 2025-06-29 17:27:03 -07:00
Jordan Eldredge
a2eb557599 Avoid warning when scrolling main window 2025-06-29 17:26:52 -07:00
Jordan Eldredge
2ea015c504 Avoid Redux dev tools warning 2025-06-29 17:26:25 -07:00
Jordan Eldredge
b63cec5cbd Fix recentering bug 2025-06-29 17:25:37 -07:00
Jordan Eldredge
a618b049eb Fix early resolution of renderWhenReady 2025-06-29 15:19:52 -07:00
Jordan Eldredge
4c07731d76 Fix more Winamp typos 2025-06-29 14:42:21 -07:00
Jordan Eldredge
ce483b8df5 Fix typos of Winamp when I meant Webamp 2025-06-29 14:22:19 -07:00
Jordan Eldredge
2be1e38bce Spelling typo fixes 2025-06-27 21:48:38 -07:00
Jordan Eldredge
c1d68b5946 Remove empty files 2025-06-25 15:23:30 -07:00
Jordan Eldredge
f4daf821c8 Add methods to toggle repeat and shuffle 2025-06-25 15:22:20 -07:00
Jordan Eldredge
afc8b038c8 Sketch of server rendered table 2025-06-25 15:14:08 -07:00
Jordan Eldredge
aa006df23e Docs improvements 2025-06-22 20:32:36 -07:00
Jordan Eldredge
6ad374934f Clarify how to get help 2025-06-22 17:36:51 -07:00
Jordan Eldredge
b5dc838b8a Build media session support into the NPM module 2025-06-20 08:43:04 -07:00
Jordan Eldredge
72b902e5f4 Add initial skin to multiple skins example 2025-06-19 21:14:45 -07:00
Jordan Eldredge
09c26754aa Bump to 2.1.2 2025-06-19 19:56:33 -07:00
Jordan Eldredge
333bb96c5d Improve songs and skins on docs page 2025-06-19 19:51:18 -07:00
Jordan Eldredge
469b136589 Work around setImmediate bug 2025-06-19 19:51:04 -07:00
Jordan Eldredge
a180155fb9 2.1.1 2025-06-19 14:10:35 -07:00
Jordan Eldredge
2d572d561e Revert "Make Milkdrop window opening on initialization work the same as it does for other windows"
This reverts commit 7ff8aab8af.
2025-06-19 14:03:22 -07:00
Jordan Eldredge
d5ea998198 Version 2.1.0 2025-06-19 13:54:45 -07:00
Jordan Eldredge
8b218e8e67 Remove onCurrentTrackDidChange 2025-06-19 13:52:51 -07:00
Jordan Eldredge
62c5af1dfb Add and document new instance methods 2025-06-19 13:44:37 -07:00
Jordan Eldredge
1fb4a4fb5b Document playlist mini visualizer 2025-06-19 13:20:28 -07:00
Jordan Eldredge
4ad4ea74eb Document HTML playlist 2025-06-19 13:09:10 -07:00
Jordan Eldredge
fd12770ad5 Define an interface for IMedia 2025-06-19 12:59:51 -07:00
Jordan Eldredge
6432753d20 Use willReadFrequently to potentially improve skin parsing perf 2025-06-19 12:33:07 -07:00
Jordan Eldredge
415a06f80f Repair Generate HTML Playlist feature 2025-06-19 12:24:56 -07:00
Jordan Eldredge
a0f5cd2358 EQ docs 2025-06-19 12:11:20 -07:00
Jordan Eldredge
23c34434f3 Milkdrop docs 2025-06-19 12:02:18 -07:00
Jordan Eldredge
5a312bd9c6 Lock update 2025-06-19 10:39:00 -07:00
Jordan Eldredge
7ff8aab8af Make Milkdrop window opening on initialization work the same as it does for other windows 2025-06-19 10:38:44 -07:00
Jordan Eldredge
f0f8e88dd0 Set new docs domain 2025-06-19 00:17:59 -07:00
Jordan Eldredge
dc59239a83 Organize and add more docs 2025-06-19 00:12:35 -07:00
Jordan Eldredge
91e6bd3902 Add docs site 2025-06-18 23:43:23 -07:00
Jordan Eldredge
359b3258ed Update examples to use version range that includes fixed release 2.0.1 2025-06-18 20:38:47 -07:00
Jordan Eldredge
624573a4d9 Bump version 2.0.1 to fix broken build artifacts 2025-06-18 20:30:24 -07:00
Jordan Eldredge
f860e72971 Clean up deps 2025-06-18 20:28:57 -07:00
Jordan Eldredge
d007dadd5a Update examples now that 2.0 is out 2025-06-18 18:01:56 -07:00
Jordan Eldredge
1dc8c15bbb Webpack is deprecated 2025-06-18 17:56:37 -07:00
Jordan Eldredge
978c0fcabd Version 2.0 release 2025-06-18 17:47:53 -07:00
Jordan Eldredge
199acfc23b Avoid building twice 2025-06-18 17:37:33 -07:00
Jordan Eldredge
361ce79faa Disable integration tests due to sandbox issues with running browser engine in CI 2025-06-18 17:23:26 -07:00
Jordan Eldredge
fba45357c0 Fix lints 2025-06-18 16:58:48 -07:00
Jordan Eldredge
c643c0cc95 Prettier 2025-06-18 16:56:46 -07:00
Jordan Eldredge
0bb70dee8e Make module the default export style for Webamp 2025-06-18 16:19:22 -07:00
Jordan Eldredge
a9ee896053 Remove helmet 2025-06-09 10:52:03 -07:00
Jordan Eldredge
74061b782e Consolidate Skin Museum client inside the skin-database package along with the server 2025-06-09 10:49:32 -07:00
Jordan Eldredge
9101dc94ce Restructure skin permalink route 2025-06-09 10:33:09 -07:00
Jordan Eldredge
b761f81c21 Fix modal rendering in nextjs 2025-06-06 10:34:15 -07:00
Jordan Eldredge
ac497964b0 Implement algolia logo 2025-06-06 10:04:44 -07:00
Jordan Eldredge
33ca103c10 Get ready for rollout of next.js version 2025-06-06 12:54:48 -04:00
Jordan Eldredge
186129ecb9 Fix aspect ration of og images 2025-06-06 11:54:41 -04:00
Jordan Eldredge
45189adcde Bring static assets over 2025-06-06 08:07:54 -07:00
Jordan Eldredge
0fd0cbab4a Trim readme 2025-06-06 08:02:58 -07:00
Jordan Eldredge
564309ecad Metadata for search results page 2025-06-06 08:02:43 -07:00
Jordan Eldredge
53335e8f80 Fix setimmediate bug (again) 2025-06-06 07:55:28 -07:00
Jordan Eldredge
bffcefa5f6 Metadata for individual skin page 2025-06-05 22:22:22 -07:00
Jordan Eldredge
7dd7f139a9 Add metadata 2025-06-05 22:22:22 -07:00
Jordan Eldredge
963f375f33 Upgrade React 2025-06-06 00:22:35 -04:00
Jordan Eldredge
e7ca8edf78 Hack loading SPA from Next 2025-06-05 18:57:38 -07:00
Jordan Eldredge
e8c677d97b Remove express in favor of next.js 2025-06-05 18:14:30 -07:00
Jordan Eldredge
77e022aa56 Fix tests snapshots 2025-06-05 17:30:15 -07:00
Jordan Eldredge
cdcf240a96 Add ecoystem file 2025-06-05 19:54:51 -04:00
Jordan Eldredge
b794ca333c Typescript fixes 2025-06-05 18:00:42 -04:00
Jordan Eldredge
12ec93d2d2 Fix typing of GOT_FEEDBACK action 2025-06-05 17:47:18 -04:00
Jordan Eldredge
1761e9101f Fiddle with shooter 2025-06-05 02:59:17 -04:00
Jordan Eldredge
c47498120c Optional SQLite search 2025-06-05 02:59:17 -04:00
Jordan Eldredge
6b4e227aad More stuff 2025-06-05 02:59:09 -04:00
Jordan Eldredge
71ca6d9230 Get rid of custom server for next.js 2025-06-04 23:47:10 -07:00
Jordan Eldredge
b64a007d0f Next.js sitemaps 2025-06-04 23:00:32 -07:00
Jordan Eldredge
9ccaa396dc Sitemap and cors for nextjs 2025-06-04 22:24:29 -07:00
Jordan Eldredge
5a3b08e621 Fix tests
Summary:

Test Plan:
2025-06-04 21:58:16 -07:00
Jordan Eldredge
3904636e8a Start adopting next.js 2025-06-04 21:07:45 -07:00
Jordan Eldredge
2a055064a1 Remove deprecated endpoints 2025-06-04 21:05:54 -07:00
Jordan Eldredge
71f754f9f9 Remove deprecated routes 2025-06-04 20:51:48 -07:00
Jordan Eldredge
fa9d55b9af Remove unused function 2025-06-04 20:49:42 -07:00
Jordan Eldredge
1cc0721cba Remove file uploads 2025-06-04 20:49:24 -07:00
Jordan Eldredge
f89dc00fa0 Mark app as use client 2025-06-04 16:37:40 -07:00
Jordan Eldredge
b7692aca80 Use hooks for WebampComponent 2025-06-04 16:37:28 -07:00
Jordan Eldredge
ec77debbd6 Use regular links for downloading 2025-06-04 16:37:06 -07:00
Jordan Eldredge
15b0312cb7 Switch back to Algolia with debounce 2025-04-07 16:48:29 -07:00
Jordan Eldredge
f90c72d53b Avoid throwing when processing uploads 2025-04-07 16:41:30 -07:00
Jordan Eldredge
e82b06b5a7 Use GraphQL search while we are out of credits 2025-03-25 20:46:57 -07:00
Jordan Eldredge
a5bb19c852 Fix 'failed to parse skin' bug 2025-03-11 16:11:52 -07:00
Jordan Eldredge
71600a9fca Export more types 2025-02-18 21:47:45 -08:00
Jordan Eldredge
f5065235b7 Spelling 2025-02-05 17:55:24 -08:00
Jordan Eldredge
f656aba7d9 Improve graphql error logging 2025-02-05 17:54:42 -08:00
Jordan Eldredge
9510f7b37f Improve logging of fetch failures 2025-02-03 23:37:48 -08:00
Jordan Eldredge
b2226e84c6 Sentry upgrade 2025-02-03 20:11:15 -08:00
Jordan Eldredge
7e61c5e351 Enable Sentry logging for skin museum 2025-02-03 19:19:20 -08:00
Jordan Eldredge
020ad85401 Go back to Algolia search 2025-02-02 10:45:35 -08:00
Jordan Eldredge
925760af1d Increase search limit 2025-01-25 15:51:19 -08:00
Jordan Eldredge
784caeac54 Remove unused 2025-01-25 14:49:31 -08:00
Jordan Eldredge
eb6b28b326 Sqlite fallback search 2025-01-25 14:31:05 -08:00
Jordan Eldredge
faee5410ac Add support for SQLite based search 2025-01-25 13:24:22 -08:00
Jordan Eldredge
68b8b22a49 Handle search errors with a message 2025-01-23 20:33:17 -08:00
Jordan Eldredge
9af4c7336c Upgrade Grats 2025-01-23 20:12:54 -08:00
Jordan Eldredge
b01cdc5876 Hack on openAi tags 2025-01-14 22:50:51 -08:00
Jordan Eldredge
afff5d63d2 Linting and typing fixups
Summary:

Test Plan:
2025-01-11 21:36:24 -08:00
Eris Lund
e242054ec6
Improved Winamp Visualizer by @x2nie (#1260)
* Improved Winamp Visualizer by @x2nie

This contains work started by @x2nie and continued by me.

There's a lot that's either broken and/or unfinished.

The main goal of this PR is to improve the visualizer of Webamp, bringing improvements to the Oscilloscope and Spectrum analyzer rendering.

Improving the Oscilloscope was achieved by taking the rendering code from the Equalizer and implanting it into the small visualizer box done by x2nie, improving the Spectrum Analyzer involved ditching the Web Audio API in favor of FFTNullsoft, providing a much more accurate representation of the audio.
The spectrum analyzer code (at least for the thicker bands) was redone that makes use of reverse engineered code from Winamp 2.65, more specifically how the bar chunking is achieved as well as how the peaks are calculated (this is broken).

* Fixed analyzer responding to data immediately
Various code comments explaining the purpose behind the new implementation
Peaks are no longer visible at the bottom

* Added support for windowshade mode (colors are broken)
Replaced old Visualizer in the Playlist Editor in favor of the new Visualizer

* Fixed analyzer exceeding bounds in unwindowshaded mode

* Removed paintFrameThin() since it's now no longer needed (processing happens in paintFrameWide())
Also removed paintWavSolid() since that too is now processed in paintWavLine
Removed variables no longer in use
Adjusted how the FFT data is first processed in Vis.tsx and how that affects the thin and wide modes (they should now be consistent in volume)

* Add proper windowshade mode visualizer support (adapts correctly to doublesize too!)
Proper color handling for the Spectrum Analyzer if in windowshade and double size mode
Removed comemnts/functions no longer in use

* Visualizer is now pushed down by 2 pixels if not in double size mode
Fixed "doubled" not being able to be used outside of Vis.tsx
Set up base for eventual additional parameters that can be passed to the visualizer
Consolidate paintWavDot into paintWavLine
Remove dispose()

* Fixed accidentally setting oscStyle to "dots" for testing (oops)

* New (non-working) parameter: "sa", dictates vis mode
Allowed "mode" to be modifiable
Adjusted frequency scaling of the FFT

* Maybe fix deploy issues?

* Replace rangeByAmplitude with a colorIndex function
Attempt at addressing a few comments from the PR review

* Missed a few variables that weren't in camelCase
Finetuned the data going into timeToFrequencyDomain

* Move FFT stuff into VisPainter.ts
Attempt at addressing more of the PR review

* Moved the FFT to be part of BarPaintHandler, instead of being global
Added checking for the state of the Main Window so that the Playlist Editor visualizer no longer bugs out like it did (incorrectly showing the small visualizer, or showing the full capacity of it)
Changed the global variable `i` to be `chunk` to avoid potential issues
Ensure `y` is scaled down (in the y axis) correctly when in windowshade mode
Skip rendering of the Oscilloscope in the windowshade mode to avoid it drawing out of bounds
Missed a few variables that werent in camelCase (again)

* Missed implementing the solid mode drawing a pixel instead of a filled line if x is 0 and if visualizer is small

* Readded drawing the visualizer background
Prevent saPeaks from going "out of bounds" when the main window is in windowshade mode

* Missed accounting for the Playlist Editor Visualizer w.r.t to the background if Main Window was not visible, in windowshade mode and not in double size

* Addressing comments of the recent review
Fixes FFT being corrupted when multiple instances of Webamp exist and are playing at the same time
Fixes multiple Webamp instances fighting over what the current state of the Main Window really is
Moved a lot of global mutable variables to instead be owned by BarPaintHandler and PaintWavHandler
Renamed visualizer functions since they now handle a lot of things

* Make canvas required

* Ensure bars are split only in "wide" bandwidth

* Some small FFTNullsoft cleanup

* Call ``painter.prepare()`` only when doublesize is engaged/disengaged

* Confirmed order of Visualization modes
VisPaintHandler is an abstract class (is that how you do it?)
Instead of logging to console about paintBarLine reusing code from "Fire mode", it's been replaced with a code comment

* move ``processFFT()`` out of ``Painter.prepare()``
It never had a place there anyway
2025-01-11 21:35:32 -08:00
Jordan Eldredge
9a12a61c08 Upgrade Grats and adopt new patterns
Summary:

Test Plan:
2024-12-15 22:39:16 -08:00
Jordan Eldredge
2a8219299f Stub out description generation 2024-12-15 22:26:24 -08:00
Jordan Eldredge
b06490dd74 Revert "Change polyfill (#1278)"
This reverts commit 67e6cdf89d.
2024-10-06 12:16:35 -07:00
Jordan Eldredge
6d3b7fc367 Remove tests badge (This is in the github ui now) 2024-10-06 11:07:48 -07:00
Jordan Eldredge
67e6cdf89d
Change polyfill (#1278) 2024-10-06 11:06:12 -07:00
Jordan Eldredge
bdd5f505e7
Upgrade jszip (#1276) 2024-10-02 22:21:51 -07:00
Jordan Eldredge
8083662f0d Fixes for webamp package 2024-10-02 21:31:56 -07:00
Jordan Eldredge
66fb476f36 Upgrade webpack in example 2024-10-02 21:25:58 -07:00
Jordan Eldredge
f25242bf00 Shorter hashes? 2024-10-02 19:38:01 -07:00
Jordan Eldredge
7ef3a309ed Publish from within directory 2024-10-02 18:15:35 -07:00
Jordan Eldredge
37134c7d31 Disable yarn cache 2024-10-02 17:21:36 -07:00
Jordan Eldredge
50c386d6b2 Bump setup-node 2024-10-02 17:19:47 -07:00
Jordan Eldredge
56a7402e4c Use yarn to update the version 2024-10-02 16:54:10 -07:00
Jordan Eldredge
7f284263fb We need scripts for optipng to work 2024-10-02 16:40:55 -07:00
Jordan Eldredge
38ee567f97 Unify action versions 2024-10-02 16:33:06 -07:00
Jordan Eldredge
2b5e6bc431 Update branch and tag name 2024-10-02 16:24:42 -07:00
Jordan Eldredge
bb45f513bb Don't ignore scripts 2024-10-02 09:11:49 -07:00
Jordan Eldredge
2396030248 Set main version 2024-10-02 09:07:20 -07:00
Jordan Eldredge
df80d1a375 0.0.0-next-87012d8d 2024-10-02 09:01:51 -07:00
431 changed files with 36202 additions and 207118 deletions

100
.eslintrc
View file

@ -4,15 +4,11 @@
"jsx": true,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
"experimentalObjectRestSpread": true
"jsx": true
}
},
"plugins": ["prettier"],
"plugins": ["prettier", "@typescript-eslint"],
"settings": {
"react": {
"version": "15.2"
},
"import/resolver": {
"node": {
"extensions": [".js", ".ts", ".tsx"]
@ -21,27 +17,85 @@
},
"env": {
"node": true,
"amd": true,
"es6": true,
"jest": true
},
"globals": {
"window": true,
"document": true,
"console": true,
"navigator": true,
"alert": true,
"Blob": true,
"fetch": true,
"FileReader": true,
"Element": true,
"AudioNode": true,
"MutationObserver": true,
"Image": true,
"location": true
},
"rules": {
"prettier/prettier": "error",
"no-constant-binary-expression": "error"
"no-constant-binary-expression": "error",
"array-callback-return": "error",
"no-template-curly-in-string": "error",
"no-promise-executor-return": "error",
"no-constructor-return": "error",
"no-unsafe-optional-chaining": "error",
"block-scoped-var": "warn",
"camelcase": "error",
"constructor-super": "error",
"dot-notation": "error",
"eqeqeq": ["error", "smart"],
"guard-for-in": "error",
"max-depth": ["warn", 4],
"new-cap": "error",
"no-caller": "error",
"no-const-assign": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-div-regex": "warn",
"no-dupe-args": "error",
"no-dupe-class-members": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-duplicate-imports": "error",
"no-else-return": "error",
"no-empty-character-class": "error",
"no-eval": "error",
"no-ex-assign": "error",
"no-extend-native": "warn",
"no-extra-boolean-cast": "error",
"no-extra-semi": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-implied-eval": "error",
"no-inner-declarations": "error",
"no-irregular-whitespace": "error",
"no-label-var": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-multi-str": "error",
"no-nested-ternary": "warn",
"no-new-object": "error",
"no-new-symbol": "error",
"no-new-wrappers": "error",
"no-obj-calls": "error",
"no-octal": "error",
"no-octal-escape": "error",
"no-proto": "error",
"no-redeclare": "error",
"no-shadow": "warn",
"no-this-before-super": "error",
"no-throw-literal": "error",
"no-undef-init": "error",
"no-unneeded-ternary": "error",
"no-unreachable": "error",
"no-unused-expressions": "error",
"no-useless-rename": "error",
"no-var": "error",
"no-with": "error",
"prefer-arrow-callback": "warn",
"prefer-const": "error",
"prefer-spread": "error",
"prefer-template": "warn",
"radix": "error",
"use-isnan": "error",
"valid-typeof": "error",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
]
}
}

View file

@ -1,84 +1,125 @@
name: CI
on: [push]
on:
push:
pull_request:
jobs:
build-and-test:
# Main CI job - using Turborepo for dependency management
ci:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
node-version: ${{ matrix.node-version }}
version: 9.12.0
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "pnpm"
- name: Install Dependencies
run: yarn install --frozen-lockfile --ignore-scripts
- name: Build
run: pnpm install --frozen-lockfile
- name: Build all packages
run: |
yarn workspace ani-cursor build
yarn workspace webamp build
yarn workspace webamp build-library
- name: Lint
run: |
yarn lint
yarn workspace webamp type-check
- name: Run Unit Tests
run: |
touch packages/skin-database/config.js
yarn test
yarn workspace webamp test
- name: Run Integration Tests
run: yarn workspace webamp integration-tests
npx turbo build build-library
env:
CI: true
- name: Upload Screenshot Diffs
if: failure()
uses: actions/upload-artifact@v4
with:
name: image_diffs
path: packages/webamp/js/__tests__/__image_snapshots__/__diff_output__/
- name: Generate New Screenshots
if: failure()
NODE_ENV: production
- name: Lint and type-check
run: |
yarn workspace webamp integration-tests -u
- name: Upload New Screenshots
if: failure()
uses: actions/upload-artifact@v4
npx turbo lint type-check
- name: Validate Grats generated files are up-to-date
run: ./scripts/validate-grats.sh
- name: Run tests
run: |
npx turbo test -- --maxWorkers=2
env:
NODE_ENV: test
- name: Cache build artifacts for release
uses: actions/cache@v4
with:
name: new_images
path: packages/webamp/js/__tests__/__image_snapshots__/
main-release:
name: Publish to NPM
path: |
packages/ani-cursor/dist
packages/winamp-eqf/built
packages/webamp/built
key: release-artifacts-${{ github.sha }}
# Release job - publish packages to NPM
release:
name: Publish packages to NPM
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.repository == 'captbaritone/webamp'
needs: [build-and-test]
needs: [ci]
permissions:
contents: read
id-token: write # Required for OIDC trusted publishing
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9.12.0
- uses: actions/setup-node@v4
with:
node-version: 20.x
registry-url: https://registry.npmjs.org/
cache: "yarn"
cache: "pnpm"
- name: Update npm to latest version
run: npm install -g npm@latest
- name: Install dependencies
run: yarn install --frozen-lockfile --ignore-scripts
- name: Build latest (main) version
if: github.ref == 'refs/heads/main'
run: pnpm install --frozen-lockfile
- name: Restore build artifacts
uses: actions/cache@v4
with:
path: |
packages/ani-cursor/dist
packages/winamp-eqf/built
packages/webamp/built
key: release-artifacts-${{ github.sha }}
fail-on-cache-miss: true
- name: Set version for all packages
if: github.ref == 'refs/heads/master'
run: |
yarn workspace webamp build-library
echo "Setting version to 0.0.0-next-${RELEASE_COMMIT_SHA::7}"
cd packages/webamp && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
cd ../ani-cursor && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
cd ../winamp-eqf && npm version 0.0.0-next-${RELEASE_COMMIT_SHA::7} --no-git-tag-version
env:
RELEASE_COMMIT_SHA: ${{ github.sha }}
- name: Build release version
- name: Set version for tagged release
if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
run: exit 1 # TODO: Script to update version number in webampLazy.tsx
- name: Publish to npm
if: github.ref == 'refs/heads/main' || github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
run: |
npm publish webamp ${TAG}
env:
TAG: ${{ github.ref == 'refs/heads/main' && '--tag=main' || ((contains(github.ref_name, '-rc.') && '--tag=dev') || '' )}}
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
VERSION=${GITHUB_REF_NAME#v}
echo "Setting version to $VERSION for tagged release"
cd packages/webamp && npm version $VERSION --no-git-tag-version
cd ../ani-cursor && npm version $VERSION --no-git-tag-version
cd ../winamp-eqf && npm version $VERSION --no-git-tag-version
# TODO: Update version number in webampLazy.tsx if needed
- name: Publish ani-cursor to npm
working-directory: ./packages/ani-cursor
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
run: |
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
npm publish --tag=next --ignore-scripts --provenance
else
npm publish --ignore-scripts --provenance
fi
- name: Publish winamp-eqf to npm
working-directory: ./packages/winamp-eqf
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
run: |
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
npm publish --tag=next --ignore-scripts --provenance
else
npm publish --ignore-scripts --provenance
fi
- name: Publish webamp to npm
working-directory: ./packages/webamp
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
# Use pre-built artifacts instead of rebuilding
run: |
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
npm publish --tag=next --ignore-scripts --provenance
else
npm publish --ignore-scripts --provenance
fi

View file

@ -4,13 +4,23 @@ on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: preactjs/compressed-size-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
build-script: "deploy"
pattern: "./packages/webamp/built/*bundle.min.js"
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9.12.0
- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "pnpm"
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- uses: preactjs/compressed-size-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
build-script: "deploy"
pattern: "./packages/webamp/built/*bundle.min.js"

View file

@ -1,29 +0,0 @@
name: "Internet Archive Integration Tests"
on:
workflow_dispatch:
# push:
# schedule:
# - cron: "0 8 * * *"
jobs:
ia-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Run Tests
run: |
cd packages/archive-org-webamp-integration-tests
yarn
node ./index.js
env:
CI: true
- uses: actions/upload-artifact@v1
if: failure()
with:
name: error
path: packages/webamp/experiments/archive-org-integration-tests/error.png

6
.gitignore vendored
View file

@ -1,5 +1,7 @@
node_modules
.vscode
.parcel-cache
.idea
dist
parcel-bundle-reports
# Turborepo cache
.turbo

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

View file

@ -1,3 +0,0 @@
{
"extends": "@parcel/config-default"
}

File diff suppressed because one or more lines are too long

View file

@ -1,5 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
yarn-path ".yarn/releases/yarn-1.22.10.cjs"

View file

@ -1,45 +1,31 @@
[![gzip size](https://img.badgesize.io/https:/unpkg.com/webamp/built/webamp.lazy-bundle.min.js?label=gzip&compression=gzip)](https://bundlephobia.com/result?p=webamp)
[![Tests](https://github.com/captbaritone/webamp/workflows/CI/badge.svg)](https://github.com/captbaritone/webamp/actions?query=branch%3Amaster+workflow%3ACI)
[![Discord](https://img.shields.io/discord/434058775012311061.svg)](https://webamp.org/chat)
# Webamp
A reimplementation of Winamp 2.9 in HTML5 and JavaScript with full skin support.
A reimplementation of Winamp in HTML5 and JavaScript with full skin support.
As seen on [TechCrunch], [Motherboard], [Gizmodo], Hacker News ([1], [2], [3], [4]), and [elsewhere](./packages/webamp/docs/press.md).
[![Screenshot of Webamp](https://raw.githubusercontent.com/captbaritone/webamp/master/packages/webamp/demo/images/preview.png)](https://webamp.org)
Check out this [Twitter thread](https://twitter.com/captbaritone/status/961274714013319168) for an illustrated list of features. Works in modern versions of Edge, Firefox, Safari and Chrome. IE is [not supported](http://caniuse.com/#feat=audio-api).
## Add Webamp to Your Site
## Read the docs
Here is the **most minimal** example of adding Webamp to a page:
```HTML
<div id="app"></div>
<script src="https://unpkg.com/webamp"></script>
<script>
const app = document.getElementById("app")
const webamp = new Webamp();
webamp.renderWhenReady(app);
</script>
```
For more examples, including how to add audio files, check out [`examples/` directory](./examples) and the [API documentation](./packages/webamp/docs/usage.md).
**The [Webamp Documentation](https://docs.webamp.org) site contains detailed instructions showing how to add Webamp to your site and customize it to meet your needs.**
## About This Repository
Webamp uses a [monorepo](https://en.wikipedia.org/wiki/Monorepo) approach, so in addition to the Webamp NPM module, this repository contains code for a few closely related projects and some pieces of Webamp which are published as standalone modules:
* [`packages/webamp`](https://github.com/captbaritone/webamp/tree/master/packages/webamp): The [Webamp NPM module](https://www.npmjs.com/package/webamp)
* [`packages/webamp/demo`](https://github.com/captbaritone/webamp/tree/master/packages/webamp/demo): The demo site which lives at [webamp.org](https://webamp.org)
* [`packages/ani-cursor`](https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor): An NPM module for rendering animiated `.ani` cursors as CSS animations
* [`packages/skin-database`](https://github.com/captbaritone/webamp/tree/master/packages/skin-database): The server component of https://skins.webamp.org which also runs our [Twitter bot](https://twitter.com/winampskins), and a Discord bot for our community chat
* [`packages/skin-museum-client`](https://github.com/captbaritone/webamp/tree/master/packages/skin-museum-client): The front-end component of https://skins.webamp.org.
* [`packages/winamp-eqf`](https://github.com/captbaritone/webamp/tree/master/packages/winamp-eqf): An NPM module for parsing and constructing Winamp equalizer preset files (`.eqf`)
* [`packages/archive-org-webamp-integration-tests`](https://github.com/captbaritone/webamp/tree/master/packages/archive-org-webamp-integration-tests): An integration that confirms that archive.org's Webamp integration is working as expected
* [`packages/webamp-modern`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-modern): A prototype exploring rendering "modern" Winamp skins in the browser
* [`examples`](https://github.com/captbaritone/webamp/tree/master/examples): A few examples showing how to use the NPM module
- [`packages/webamp`](https://github.com/captbaritone/webamp/tree/master/packages/webamp): The [Webamp NPM module](https://www.npmjs.com/package/webamp)
- [`packages/webamp/demo`](https://github.com/captbaritone/webamp/tree/master/packages/webamp/demo): The demo site which lives at [webamp.org](https://webamp.org)
- [`packages/webamp-docs`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-docs): The documentation site for Webamp the NPM library which lives at [docs.webamp.org](https://docs.webamp.org)
- [`packages/ani-cursor`](https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor): An NPM module for rendering animiated `.ani` cursors as CSS animations
- [`packages/skin-database`](https://github.com/captbaritone/webamp/tree/master/packages/skin-database): The server component of https://skins.webamp.org which also runs our [Twitter bot](https://twitter.com/winampskins), and a Discord bot for our community chat
- [`packages/winamp-eqf`](https://github.com/captbaritone/webamp/tree/master/packages/winamp-eqf): An NPM module for parsing and constructing Winamp equalizer preset files (`.eqf`)
- [`packages/webamp-modern`](https://github.com/captbaritone/webamp/tree/master/packages/webamp-modern): A prototype exploring rendering "modern" Winamp skins in the browser
- [`examples`](https://github.com/captbaritone/webamp/tree/master/examples): A few examples showing how to use the NPM module
## Community
@ -79,6 +65,52 @@ Nullsoft, the code within this project is released under the [MIT
License](LICENSE.txt). That being said, if you do anything interesting with
this code, please let me know. I'd love to see it.
## Development
This repository uses [Turborepo](https://turbo.build/) for efficient monorepo management. Turborepo provides intelligent caching and parallel execution of tasks across all packages.
### Quick Start
```bash
# Install dependencies
pnpm install
# Build all packages (automatically handles dependencies)
npx turbo build
# Build library bundles for packages that need them
npx turbo build-library
# Run all tests
npx turbo test
# Lint and type-check all packages
npx turbo lint type-check
# Work on a specific package and its dependencies
npx turbo dev --filter="webamp"
```
### Package Dependencies
The monorepo dependency graph is automatically managed by Turborepo:
- `ani-cursor` and `winamp-eqf` are standalone packages built with TypeScript
- `webamp` depends on both `ani-cursor` and `winamp-eqf` for workspace linking
- All packages are built in the correct topological order
- Builds are cached and only rebuild what has changed
### Available Tasks
- `build` - Main build output (Vite for demos, TypeScript compilation for libraries)
- `build-library` - Library bundles for NPM publishing (only applies to `webamp`)
- `test` - Run unit tests with Jest
- `type-check` - TypeScript type checking without emitting files
- `lint` - ESLint code quality checks
- `dev` - Development server (for packages that support it)
For more details on individual packages, see their respective README files.
[techcrunch]: https://techcrunch.com/2018/02/09/whip-the-llamas-ass-with-this-javascript-winamp-emulator/
[motherboard]: https://motherboard.vice.com/en_us/article/qvebbv/winamp-2-mp3-music-player-emulator
[gizmodo]: https://gizmodo.com/winamp-2-has-been-immortalized-in-html5-for-your-pleasu-1655373653

View file

@ -8,6 +8,8 @@ module.exports = {
"dist",
// TODO: Add these as we can...
"/packages/webamp/",
"/packages/ani-cursor/",
"/packages/winamp-eqf/",
// TODO: Fix config import so that this can work.
"/packages/webamp-modern/src/__tests__/integration*",
],

View file

@ -1,6 +0,0 @@
#!/bin/bash
yarn workspace ani-cursor build
yarn workspace webamp build
yarn workspace webamp build-library
yarn workspace webamp-modern build
mv packages/webamp-modern/build packages/webamp/dist/demo-site/modern

117
docs/typescript-checking.md Normal file
View file

@ -0,0 +1,117 @@
# TypeScript Checking Convention
This document describes the TypeScript checking convention established for the Webamp monorepo.
## Current Status
Each TypeScript-enabled package in the monorepo now has a consistent `type-check` script that performs type checking without emitting files.
**Progress: 5 out of 6 packages now passing! 🎉**
### Package Status
#### ✅ Passing Packages
- **webamp**: Clean TypeScript compilation
- **ani-cursor**: Clean TypeScript compilation
- **skin-database**: Clean TypeScript compilation (fixed JSZip types, Jest types, and Buffer compatibility issues)
- **webamp-docs**: Clean TypeScript compilation
- **winamp-eqf**: Clean TypeScript compilation (ES module with full type definitions)
#### ❌ Failing Packages (Need fixes)
- **webamp-modern**: 390+ TypeScript errors (conflicting type definitions, target issues)
## Convention
### Package-level Scripts
Each TypeScript package should have:
```json
{
"scripts": {
"type-check": "tsc --noEmit"
}
}
```
**Important:** Always use `tsc --noEmit` to avoid accidentally creating JavaScript files in source directories.
### Root-level Script
The root package.json contains a centralized script that runs type checking for all currently passing packages:
```json
{
"scripts": {
"type-check": "pnpm --filter webamp type-check && pnpm --filter ani-cursor type-check && pnpm --filter skin-database type-check && pnpm --filter webamp-docs type-check && pnpm --filter winamp-eqf type-check"
}
}
```
### CI Integration
The CI workflow (`.github/workflows/ci.yml`) runs the centralized type-check command:
```yaml
- name: Lint
run: |
pnpm lint
pnpm type-check
```
## Adding New Packages
When adding a new TypeScript package to the type-check convention:
1. Add the `type-check` script to the package's `package.json`
2. Ensure the package passes type checking: `pnpm --filter <package-name> type-check`
3. Add the package to the root `type-check` script
4. Test the full suite: `pnpm type-check`
## Fixing Failing Packages
### Common Issues
1. **Missing Jest types** (Fixed in `skin-database`):
- Install `@types/jest` and configure proper Jest setup
- Ensure test files are properly configured
2. **Missing package types** (Fixed in `skin-database`):
- Install missing dependencies like `jszip`, `react-redux`, `express`
- Install corresponding `@types/` packages where needed
- Note: Some packages like JSZip provide their own types
3. **Buffer compatibility issues** (Fixed in `skin-database`):
- Newer TypeScript versions require explicit casting for `fs.writeFileSync`
- Use `new Uint8Array(buffer)` instead of raw `Buffer` objects
4. **Conflicting type definitions** (`webamp-modern`):
- Multiple versions of `@types/node` causing conflicts
- Target configuration issues (ES5 vs ES2015+)
- Dependency type mismatches
### Recommended Fix Strategy
1. Start with packages that have fewer errors
2. Focus on one category of errors at a time
3. Update TypeScript compiler target if needed (many errors require ES2015+)
4. Ensure proper dependency management to avoid type conflicts
## Benefits
- **Consistency**: All packages use the same type-checking approach
- **CI Integration**: Automatic type checking prevents type errors from being merged
- **Developer Experience**: Simple `pnpm type-check` command for full project validation
- **Incremental**: Only includes packages that currently pass, allowing gradual improvement
## Future Work
- Fix remaining packages to include them in the type-check suite
- Consider stricter TypeScript configurations for better type safety
- Investigate automated type checking in development workflow

24
examples/lazy/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
examples/lazy/README.md Normal file
View file

@ -0,0 +1,5 @@
# `webamp/lazy` Example
Shows how it's possible to use Webamp with lazy loading and TypeScript. Uses [Vite](https://vitejs.dev/) for development and bundling.
Pay special attention to the versions used in `package.json` since some beta versions are required for this to work.

13
examples/lazy/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Webamp</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,22 @@
{
"name": "lazy",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.8.3",
"vite": "^7.0.4"
},
"dependencies": {
"butterchurn": "3.0.0-beta.5",
"butterchurn-presets": "3.0.0-beta.4",
"jszip": "^3.10.1",
"music-metadata": "^11.6.0",
"webamp": "^2.2.0"
}
}

57
examples/lazy/src/main.ts Normal file
View file

@ -0,0 +1,57 @@
import Webamp from "webamp/lazy";
const webamp = new Webamp({
initialTracks: [
{
metaData: {
artist: "DJ Mike Llama",
title: "Llama Whippin' Intro",
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://docs.webamp.org/docs/guides/cors
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
windowLayout: {
main: { position: { left: 0, top: 0 } },
equalizer: { position: { left: 0, top: 116 } },
playlist: {
position: { left: 0, top: 232 },
size: { extraHeight: 4, extraWidth: 0 },
},
milkdrop: {
position: { left: 275, top: 0 },
size: { extraHeight: 12, extraWidth: 7 },
},
},
requireJSZip: async () => {
const JSZip = await import("jszip");
return JSZip.default;
},
// @ts-ignore
requireMusicMetadata: async () => {
return await import("music-metadata");
},
__butterchurnOptions: {
// @ts-ignore
importButterchurn: () => import("butterchurn"),
// @ts-ignore
getPresets: async () => {
const butterchurnPresets = await import(
// @ts-ignore
"butterchurn-presets/dist/base.js"
);
// Convert the presets object
return Object.entries(butterchurnPresets.default).map(
([name, preset]) => {
return { name, butterchurnPresetObject: preset };
}
);
},
butterchurnOpen: true,
},
});
webamp.renderWhenReady(document.getElementById("app")!);

1
examples/lazy/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View file

@ -2,15 +2,15 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app" style="height: 100vh">
<!-- Webamp will attempt to center itself within this div -->
</div>
<script src="https://unpkg.com/webamp@1.4.2/built/webamp.bundle.min.js"></script>
<script>
const Webamp = window.Webamp;
<script type="module">
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
const webamp = new Webamp({
initialTracks: [
{
@ -20,7 +20,7 @@
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
// https://docs.webamp.org/docs/guides/cors
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
@ -35,7 +35,6 @@
],
});
// Returns a promise indicating when it's done loading.
webamp.renderWhenReady(document.getElementById("app"));
</script>
</body>

View file

@ -2,7 +2,7 @@
## API is still being finalized and may change when released
This example fetches the Webamp bundle from a free CDN, and fetches the audio file and skin from a free CDN as well, and loads Milkdrop visualizer.
This example fetches the Webamp bundle from a free CDN, and fetches the audio file from a free CDN as well, and loads Milkdrop visualizer.
You should be able to open this local html file in your browser and see Webamp working.

View file

@ -2,17 +2,20 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app" style="height: 100vh">
<!-- Webamp will attempt to center itself within this div -->
</div>
<script src="https://unpkg.com/webamp@1.5.0/built/webamp.bundle.min.js"></script>
<script src="https://unpkg.com/butterchurn@2.6.7/lib/butterchurn.min.js"></script>
<script src="https://unpkg.com/butterchurn-presets@2.4.7/lib/butterchurnPresets.min.js"></script>
<script>
const Webamp = window.Webamp;
<script type="module">
/**
* Starting in version 2.2.0, Webamp includes a `webamp/butterchurn`
* entrypoint which includes the Butterchurn library to enable the
* Milkdrop visualizer.
*/
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
const webamp = new Webamp({
initialTracks: [
{
@ -22,36 +25,11 @@
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
// https://docs.webamp.org/docs/guides/cors
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
__butterchurnOptions: {
importButterchurn: () => Promise.resolve(window.butterchurn),
getPresets: () => {
const presets = window.butterchurnPresets.getPresets();
return Object.keys(presets).map((name) => {
return {
name,
butterchurnPresetObject: presets[name],
};
});
},
butterchurnOpen: true,
},
windowLayout: {
main: { position: { top: 0, left: 0 } },
equalizer: { position: { top: 116, left: 0 } },
playlist: {
position: { top: 232, left: 0 },
size: { extraWidth: 0, extraHeight: 4 },
},
milkdrop: {
position: { top: 0, left: 275 },
size: { extraHeight: 12, extraWidth: 7 },
},
},
});
webamp.renderWhenReady(document.getElementById("app"));
</script>

View file

@ -2,15 +2,15 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app" style="height: 100vh">
<!-- Webamp will attempt to center itself within this div -->
</div>
<script src="https://unpkg.com/webamp@0.0.0-next-6d0ec37b/built/webamp.bundle.min.js"></script>
<script>
const Webamp = window.Webamp;
<script type="module">
import Webamp from "https://unpkg.com/webamp@^2";
const webamp = new Webamp({
windowLayout: {
main: {

View file

@ -0,0 +1,13 @@
# Multiple Milkdrop Presets Example
An example of overriding the default Milkdrop presets with a custom set of presets.
This example fetches the Webamp bundle from a free CDN, and fetches the audio file and skin from a free CDN as well.
You should be able to open this local html file in your browser and see Webamp working.
```
$ git clone git@github.com:captbaritone/webamp.git
$ cd webamp
$ open examples/multipleMilkdropPresets/index.html
```

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app" style="height: 100vh">
<!-- Webamp will attempt to center itself within this div -->
</div>
<script type="module">
// The `requireButterchurnPresets` config option is not yet available in a stable release.
// for now you need to install a pre-release version of Webamp to use it.
import Webamp from "https://unpkg.com/webamp@0.0.0-next-41cfbbb/butterchurn";
const webamp = new Webamp({
initialTracks: [
{
metaData: {
artist: "DJ Mike Llama",
title: "Llama Whippin' Intro",
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://docs.webamp.org/docs/guides/cors
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
requireButterchurnPresets() {
return [
{
name: "md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D/md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D.milk",
butterchurnPresetUrl:
"https://archive.org/cors/md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D/md_AdamFX_Enterz_Tha_Mash_With_Martin_stahlregen_AdamFX_In_Tha_Mash_Effectz_D.json",
},
];
},
});
// Returns a promise indicating when it's done loading.
webamp.renderWhenReady(document.getElementById("app"));
</script>
</body>
</html>

View file

@ -2,22 +2,27 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app" style="height: 100vh">
<!-- Webamp will attempt to center itself within this div -->
</div>
<script src="https://unpkg.com/webamp@1.4.2/built/webamp.bundle.min.js"></script>
<script>
const Webamp = window.Webamp;
<script type="module">
import Webamp from "https://unpkg.com/webamp@^2";
const webamp = new Webamp({
// Optional. An array of objects representing skins.
// These will appear in the "Options" menu under "Skins".
// Note: These URLs must be served with the correct CORs headers.
// NOTE: These URLs must be served with the correct CORs headers.
// https://docs.webamp.org/docs/guides/cors
//
// These will appear in the dropdown menu under "Skins".
availableSkins: [
{
url: "https://archive.org/cors/winampskin_Zelda_Amp/Zelda-Amp.wsz",
name: "Zelda Amp",
},
{
url: "https://archive.org/cors/winampskin_Green-Dimension-V2/Green-Dimension-V2.wsz",
name: "Green Dimension V2",
@ -27,6 +32,9 @@
name: "Mac OSX v1.5 (Aqua)",
},
],
initialSkin: {
url: "https://archive.org/cors/winampskin_Zelda_Amp/Zelda-Amp.wsz",
},
initialTracks: [
{
metaData: {

View file

@ -2,15 +2,15 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app" style="height: 100vh">
<!-- Webamp will attempt to center itself within this div -->
</div>
<script src="https://unpkg.com/webamp@1.4.2/built/webamp.bundle.min.js"></script>
<script>
const Webamp = window.Webamp;
<script type="module">
import Webamp from "https://unpkg.com/webamp@^2";
const webamp = new Webamp({
/**
* Here we list three tracks. Note that the `metaData` fields and
@ -26,7 +26,7 @@
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
// https://docs.webamp.org/docs/guides/cors
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
@ -37,7 +37,7 @@
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
// https://docs.webamp.org/docs/guides/cors
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Diablo_Swing_Orchestra_-_01_-_Heroines.mp3",
duration: 322.612245,
},
@ -48,7 +48,7 @@
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
// https://docs.webamp.org/docs/guides/cors
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Eclectek_-_02_-_We_Are_Going_To_Eclecfunk_Your_Ass.mp3",
duration: 190.093061,
},

View file

@ -1,15 +0,0 @@
# Minimal Example
This example includes Webamp in a Webpack bundle. The audio file and skin are fetched from a free CDN at run time.
**Note:** Currently Webamp is published to NPM as a single bundle which includes all of its dependencies. This means that no matter what you do, Webamp is going to bring along it's own React, Redux, JSZip, etc. If you have a use case where you would like Webamp to share some or all of these dependencies with your own application, please file an issue and I can look into it.
To try it out:
```
$ git clone git@github.com:captbaritone/webamp.git
$ cd webamp/examples/webpack/
$ npm install
$ npm run build
$ open index.html
```

View file

@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div id="app" style="height: 100vh"></div>
<script src="./bundle.js"></script>
</body>
</html>

View file

@ -1,17 +0,0 @@
import Webamp from "webamp";
new Webamp({
initialTracks: [
{
metaData: {
artist: "DJ Mike Llama",
title: "Llama Whippin' Intro",
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
}).renderWhenReady(document.getElementById("app"));

View file

@ -1,19 +0,0 @@
{
"name": "webamp-webpack-example",
"version": "0.0.0",
"description": "An example of using Webamp within a Webpack bundle",
"main": "index.js",
"scripts": {
"build": "webpack index.js -o bundle.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"webamp": "1.5.0"
},
"devDependencies": {
"webpack": "^4.29.6",
"webpack-cli": "^3.3.0"
},
"prettier": {}
}

View file

@ -1,17 +0,0 @@
# Minimal Lazy Loading Example
## API is still being finalized and may change when released
This example includes Webamp in a Webpack bundle. The audio file and skin are fetched from a free CDN at run time. Milkdrop and visualizer presets are lazy loaded after playing starts.
**Note:** Currently Webamp is published to NPM as a single bundle which includes all of its dependencies. This means that no matter what you do, Webamp is going to bring along it's own React, Redux, JSZip, etc. If you have a use case where you would like Webamp to share some or all of these dependencies with your own application, please file an issue and I can look into it.
To try it out:
```
$ git clone git@github.com:captbaritone/webamp.git
$ cd webamp/examples/webpackLazyLoad/
$ npm install
$ npm run build
$ open index.html
```

View file

@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div id="app" style="height: 100vh"></div>
<script src="./bundle.js"></script>
</body>
</html>

View file

@ -1,45 +0,0 @@
import Webamp from "webamp";
const webamp = new Webamp({
initialTracks: [
{
metaData: {
artist: "DJ Mike Llama",
title: "Llama Whippin' Intro",
},
// NOTE: Your audio file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
url: "https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82cfe0e37286dbbe0666072dc3190a83bc/mp3/llama-2.91.mp3",
duration: 5.322286,
},
],
__butterchurnOptions: {
importButterchurn: () => {
// Only load butterchurn when music starts playing to reduce initial page load
return import("butterchurn");
},
getPresets: async () => {
// Load presets from preset URL mapping on demand as they are used
const resp = await fetch(
// NOTE: Your preset file must be served from the same domain as your HTML
// file, or served with permissive CORS HTTP headers:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
"https://unpkg.com/butterchurn-presets-weekly@0.0.2/weeks/week1/presets.json"
);
const namesToPresetUrls = await resp.json();
return Object.keys(namesToPresetUrls).map((name) => {
return { name, butterchurnPresetUrl: namesToPresetUrls[name] };
});
},
butterchurnOpen: true,
},
__initialWindowLayout: {
main: { position: { x: 0, y: 0 } },
equalizer: { position: { x: 0, y: 116 } },
playlist: { position: { x: 0, y: 232 }, size: [0, 4] },
milkdrop: { position: { x: 275, y: 0 }, size: [7, 12] },
},
});
webamp.renderWhenReady(document.getElementById("app"));

View file

@ -1,19 +0,0 @@
{
"name": "webamp-webpack-lazy-example",
"version": "0.0.0",
"description": "An example of using Webamp within a Webpack bundle",
"main": "index.js",
"scripts": {
"build": "webpack index.js -o bundle.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"butterchurn": "^2.6.7",
"webamp": "1.5.0"
},
"devDependencies": {
"webpack": "^4.29.6",
"webpack-cli": "^3.3.0"
}
}

View file

@ -1,5 +1,5 @@
[build]
command = "yarn deploy"
command = "pnpm run deploy"
publish = "packages/webamp/dist/demo-site/"
# A short URL for listeners of https://changelog.com/podcast/291
@ -36,4 +36,4 @@ status = 301
force = true
[build.environment]
NODE_VERSION = "20.9.0"
NODE_VERSION = "22.11.0"

View file

@ -1,46 +1,52 @@
{
"name": "webamp-monorepo",
"private": true,
"workspaces": [
"packages/*",
"examples/*"
],
"packageManager": "pnpm@9.12.0",
"overrides": {
"graphql": "16.8.1"
},
"engines": {
"node": ">=16.0.0"
"node": ">=22.0.0"
},
"scripts": {
"test": "jest",
"lint": "eslint . --ext ts,tsx,js,jsx --rulesdir=packages/webamp-modern/tools/eslint-rules",
"deploy": "sh deploy.sh",
"test": "npx turbo test",
"test:integration": "npx turbo run integration-tests",
"test:all": "npx turbo run test integration-tests",
"test:unit": "jest",
"lint": "npx turbo lint",
"type-check": "pnpm --filter webamp type-check && pnpm --filter ani-cursor type-check && pnpm --filter skin-database type-check && pnpm --filter webamp-docs type-check && pnpm --filter winamp-eqf type-check",
"deploy": "npx turbo webamp#build webamp-modern#build --concurrency 1 && mv packages/webamp-modern/build packages/webamp/dist/demo-site/modern",
"format": "prettier --write '**/*.{js,ts,tsx}'"
},
"devDependencies": {
"@babel/preset-typescript": "^7.16.7",
"@parcel/optimizer-data-url": "2.7.0",
"@parcel/transformer-inline-string": "^2.8.2",
"@swc/core": "^1.3.24",
"@swc/jest": "^0.2.24",
"@typescript-eslint/parser": "^7.1.0",
"@types/jest": "^30.0.0",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.36.0",
"assert": "^2.0.0",
"eslint": "^8.57.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.16.0",
"eslint-plugin-react-hooks": "^4.3.0",
"events": "^3.3.0",
"jest": "^27.5.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^2.3.2",
"puppeteer": "^22.2.0",
"stream-browserify": "^3.0.0",
"typescript": "^5.3.3"
"turbo": "^2.5.4",
"typescript": "^5.6.2"
},
"prettier": {
"trailingComma": "es5"
},
"jest": {
"projects": [
"config/jest.*.js"
]
},
"dependencies": {}
"version": "0.0.0-next-87012d8d",
"pnpm": {
"patchedDependencies": {
"butterchurn@3.0.0-beta.5": "patches/butterchurn@3.0.0-beta.5.patch"
}
}
}

View file

@ -8,12 +8,6 @@ I wrote a blog post about this library which you can find [here](https://jordane
## Install
```bash
yarn add ani-cursor
```
or
```bash
npm install ani-cursor
```
@ -41,4 +35,4 @@ document.body.appendChild(h1);
applyCursor("#pizza", "https://archive.org/cors/tucows_169906_Pizza_cursor/pizza.ani");
```
Try the [Live Demo on CodeSandbox](https://codesandbox.io/s/jolly-thunder-9jkio?file=/src/index.js).
Try the [Live Demo on CodeSandbox](https://codesandbox.io/s/jolly-thunder-9jkio?file=/src/index.js).

View file

@ -1,6 +0,0 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-typescript",
],
};

View file

@ -2,14 +2,24 @@
"name": "ani-cursor",
"version": "0.0.5",
"description": "Render .ani cursors as CSS animations in the browser",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist/",
"src/**/*.ts"
],
"author": "Jordan Eldredge <jordan@jordaneldredge.com>",
"license": "MIT",
"engines": {
"node": ">=22.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/captbaritone/webamp.git",
@ -21,6 +31,8 @@
"homepage": "https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor",
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit",
"lint": "eslint src --ext ts,js",
"test": "jest",
"prepublish": "tsc"
},
@ -28,7 +40,9 @@
"@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.20.0",
"typescript": "^5.3.3"
"@swc/jest": "^0.2.24",
"@types/node": "^24.0.10",
"typescript": "^5.6.2"
},
"dependencies": {
"byte-data": "18.1.1",
@ -38,6 +52,13 @@
"modulePathIgnorePatterns": [
"dist"
],
"testEnvironment": "jsdom"
"testEnvironment": "jsdom",
"extensionsToTreatAsEsm": [".ts"],
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"transform": {
"^.+\\.(t|j)sx?$": ["@swc/jest"]
}
}
}

View file

@ -1,6 +1,6 @@
import fs from "fs";
import path from "path";
import { convertAniBinaryToCSS } from "../";
import { convertAniBinaryToCSS } from "../index.js";
const LONG_BASE_64 = /([A-Za-z0-9+/=]{50})[A-Za-z0-9+/=]+/g;

View file

@ -1,4 +1,4 @@
import { parseAni } from "./parser";
import { parseAni } from "./parser.js";
type AniCursorImage = {
frames: {

View file

@ -1,8 +1,10 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
"target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
"target": "ES2018" /* Specify ECMAScript target version */,
"lib": ["ES2018", "dom"] /* Specify library files to be included in the compilation. */,
"types": ["jest", "node"] /* Type declaration files to be included in compilation. */,
"module": "ES2020" /* Specify module code generation */,
"sourceMap": true /* Generates corresponding '.map' file. */,
"outDir": "./dist" /* Redirect output structure to the directory. */,
"downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */,
@ -24,7 +26,9 @@
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
/* Module Resolution Options */
"moduleResolution": "node" /* Use Node.js-style module resolution */,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
"allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export */,
"declaration": true,
"skipLibCheck": true /* Skip type checking of declaration files. */,

View file

@ -1,9 +0,0 @@
# Archive.org Webamp Integration Test
This package contains an automated test to ensure that the [Webamp](https://webamp.org) integration on https://archive.org is still working correectly. It's run twice a day via a GitHub Action, which can be found [here](../../.github/workflows/ia-integration-tests.yml).
## Run
```sh
yarn
node index.js
```

View file

@ -1,121 +0,0 @@
const puppeteer = require("puppeteer");
// Handle a few different cases since specialized pages use specialized classes.
const webampButtonSelector =
".js-webamp-use_skin_for_audio_items, .webamp-link";
// Create our own log function so we can mute it if we want
function log(message) {
console.log(message);
}
const TIMEOUT = 10000;
async function expectSelector(page, selector) {
log(`Waiting for selector ${selector}...`);
await page.waitForSelector(selector, { visible: true, timeout: TIMEOUT });
log(`Found selector ✅`);
}
async function testPage({ url, name, firstTrackText }) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
try {
log(`🏁 [${name}] ${url}`);
await page.goto(url);
await expectSelector(page, webampButtonSelector);
log("Going to click the Webamp button");
// For some reason `page.click` often fails with `Error: Node is either not
// visible or not an HTMLElement` so we use `page.evaluate` instead.
// https://stackoverflow.com/a/52336777
// await page.click(webampButtonSelector, { timeout: TIMEOUT });
await page.evaluate(() => {
document
.querySelector(".js-webamp-use_skin_for_audio_items, .webamp-link")
.click();
});
await expectSelector(page, "#webamp #main-window");
log("Looking for first track...");
const firstTrack = await page.$(".track-cell.current");
if (firstTrack == null) {
throw new Error("Could not find first track");
}
log("Getting text of first track...");
const actualFirstTrackText = await page.evaluate(
(_) => _.textContent,
firstTrack
);
if (!actualFirstTrackText.includes(firstTrackText)) {
throw new Error(
`Could not find track title '${firstTrackText}' in "${actualFirstTrackText}"`
);
}
log("Clicking play...");
await page.click("#webamp #main-window #play", { timeout: TIMEOUT });
await expectSelector(page, "#webamp #main-window.play");
log("✅ Success! Test passed.");
} catch (e) {
log(`🛑 Errored in [${name}]. Wrote screenshot to ./error.png`);
await page.screenshot({ path: "error.png", fullPage: true });
throw e;
} finally {
log("DONE");
await browser.close();
}
}
async function testPageAndRetry(options) {
let retries = 5;
while (retries--) {
try {
await testPage(options);
return;
} catch (e) {
console.error(e);
if (retries > 0) {
console.warn(`Retrying... ${retries} retries left.`);
}
}
}
throw new Error("Failed to pass even after 5 retries");
}
async function main() {
await testPageAndRetry({
name: "Popular",
url: "https://archive.org/details/gd73-06-10.sbd.hollister.174.sbeok.shnf",
firstTrackText: "Grateful Dead - Morning Dew",
});
await testPageAndRetry({
name: "Regular",
url: "https://archive.org/details/78_mambo-no.-5_perez-prado-and-his-orchestra-d.-perez-prado_gbia0009774b",
firstTrackText: "Mambo No. 5",
});
await testPageAndRetry({
name: "Samples Only",
url: "https://archive.org/details/lp_smokey-and-the-bandit-2-original-soundtrac_various-brenda-lee-burt-reynolds-don-willi",
firstTrackText: "Texas Bound And Flyin",
});
await testPageAndRetry({
name: "Stream Only",
url: "https://archive.org/details/cd_a-sweeter-music_sarah-cahill",
firstTrackText: "Be Kind to One Another",
});
await testPageAndRetry({
name: "Another",
url: "https://archive.org/details/78_house-of-the-rising-sun_josh-white-and-his-guitar_gbia0001628b",
firstTrackText: "House Of The Rising Sun",
});
}
(async function () {
try {
await main();
} catch (e) {
console.error(e);
// Ensure process returns an error exit code so that other tools know the test failed.
process.exit(1);
}
})();

View file

@ -1,11 +0,0 @@
{
"name": "archive-org-integration-tests",
"version": "1.0.0",
"description": "Automated tests to ensure the Internet Archive's Webamp integration is working",
"main": "index.js",
"license": "MIT",
"prettier": {},
"dependencies": {
"puppeteer": "^3.0.4"
}
}

View file

@ -1,24 +1,28 @@
module.exports = {
env: {
node: true,
es2021: true,
jest: true,
},
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 12,
sourceType: "module",
},
plugins: ["@typescript-eslint"],
extends: ["plugin:@typescript-eslint/recommended"],
rules: {
// "no-console": "warn",
// Disable rules that conflict with the project's style
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
// Override the base no-shadow rule since it conflicts with TypeScript
"no-shadow": "off",
// Relax rules for this project's existing style
camelcase: "off",
"dot-notation": "off",
eqeqeq: "off",
"no-undef-init": "off",
"no-return-await": "off",
"prefer-arrow-callback": "off",
"no-div-regex": "off",
"guard-for-in": "off",
"prefer-template": "off",
"no-else-return": "off",
"prefer-const": "off",
"new-cap": "off",
},
ignorePatterns: ["dist/**"],
};

View file

@ -7,4 +7,5 @@ dist/
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
.env
.env
.next

View file

@ -23,7 +23,7 @@ class DiscordWinstonTransport extends Transport {
let dataString = null;
try {
dataString = JSON.stringify(rest, null, 2);
} catch (e) {
} catch (_e) {
dataString = "COULD NOT STRINGIFY DATA";
}
await this._channel.send(`${message}

View file

@ -17,3 +17,4 @@ The discord bot allows us to:
## Server
This package also includes a GraphQL interface for exploring skins. It is not currently used by anything, but can be useful for inspecting the data.
sudo systemctl reload apache2

View file

@ -1,3 +1,3 @@
export const searchIndex = {
export const client = {
partialUpdateObjects: jest.fn(),
};

View file

@ -59,7 +59,7 @@ async function addModernSkinFromBuffer(
): Promise<Result> {
console.log("Write temporarty file.");
const tempFile = temp.path({ suffix: ".wal" });
fs.writeFileSync(tempFile, buffer);
fs.writeFileSync(tempFile, new Uint8Array(buffer));
console.log("Put skin to S3.");
await S3.putSkin(md5, buffer, "wal");
@ -83,7 +83,7 @@ async function addClassicSkinFromBuffer(
uploader: string
): Promise<Result> {
const tempFile = temp.path({ suffix: ".wsz" });
fs.writeFileSync(tempFile, buffer);
fs.writeFileSync(tempFile, new Uint8Array(buffer));
const tempScreenshotPath = temp.path({ suffix: ".png" });
const logLines: string[] = [];
@ -117,6 +117,7 @@ async function addClassicSkinFromBuffer(
await setHashesForSkin(skin);
// Disable while we figure out our quota
await Skins.updateSearchIndex(ctx, md5);
return { md5, status: "ADDED", skinType: "CLASSIC" };

View file

@ -1,5 +1,4 @@
import algoliasearch from "algoliasearch";
import { ALGOLIA_ACCOUNT, ALGOLIA_KEY, ALGOLIA_INDEX } from "./config";
import { algoliasearch } from "algoliasearch";
import { ALGOLIA_ACCOUNT, ALGOLIA_KEY } from "./config";
const client = algoliasearch(ALGOLIA_ACCOUNT, ALGOLIA_KEY);
export const searchIndex = client.initIndex(ALGOLIA_INDEX);
export const client = algoliasearch(ALGOLIA_ACCOUNT, ALGOLIA_KEY);

View file

@ -1,4 +1,4 @@
import { ApiAction } from "./app";
import { ApiAction } from "./types";
import Discord, { TextChannel } from "discord.js";
import * as Config from "../config";
import SkinModel from "../data/SkinModel";
@ -105,7 +105,7 @@ export default class DiscordEventHandler {
case "SYNCED_TO_ARCHIVE": {
const dest = await this.getChannel(Config.SKIN_UPLOADS_CHANNEL_ID);
const message = `Synced skins to archive.org. Success: ${action.successes.toLocaleString()} Errors: ${action.errors.toLocaleString()}.`;
const message = `Synced skins to archive.org. Success: ${action.successes.toLocaleString()} Errors: ${action.errors.toLocaleString()} Skipped: ${action.skips.toLocaleString()}.`;
await dest.send(message);
break;
@ -172,7 +172,8 @@ export default class DiscordEventHandler {
dest,
});
} else {
await DiscordUtils.sendAlreadyReviewed({ md5, dest });
// Too much nosie
// await DiscordUtils.sendAlreadyReviewed({ md5, dest });
}
}
}

View file

@ -1,31 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Query.fetch_skin_by_md5 (debug data) 1`] = `
Object {
"fetch_skin_by_md5": Object {
"archive_files": Array [
Object {
{
"fetch_skin_by_md5": {
"archive_files": [
{
"date": "2000-05-08T07:44:52.000Z",
"file_md5": "a_fake_file_md5",
"filename": null,
"filename": "a_fake_archive_file.bmp",
"is_directory": false,
"size": null,
"skin": Object {
"skin": {
"md5": "a_fake_md5",
},
"text_content": null,
"url": "https://zip-worker.jordan1320.workers.dev/zip/a_fake_md5/null",
"url": "https://zip-worker.jordan1320.workers.dev/zip/a_fake_md5.wsz/a_fake_archive_file.bmp",
},
],
"average_color": null,
"download_url": "https://r2.webampskins.org/skins/a_fake_md5.wsz",
"filename": "path.wsz",
"id": "Q2xhc3NpY1NraW5fX2FfZmFrZV9tZDU=",
"internet_archive_item": Object {
"internet_archive_item": {
"identifier": "a_fake_ia_identifier",
"metadata_url": "https://archive.org/metadata/a_fake_ia_identifier",
"raw_metadata_json": null,
"skin": Object {
"skin": {
"md5": "a_fake_md5",
},
"url": "https://archive.org/details/a_fake_ia_identifier",
@ -34,10 +34,10 @@ Object {
"museum_url": "https://skins.webamp.org/skin/a_fake_md5",
"nsfw": false,
"readme_text": null,
"reviews": Array [],
"reviews": [],
"screenshot_url": "https://r2.webampskins.org/screenshots/a_fake_md5.png",
"tweeted": false,
"tweets": Array [],
"tweets": [],
"webamp_url": "https://webamp.org?skinUrl=https://r2.webampskins.org/skins/a_fake_md5.wsz",
},
}

View file

@ -1,19 +1,17 @@
import { Application } from "express";
import { knex } from "../../db";
import request from "supertest"; // supertest is a framework that allows to easily test web apis
import { createApp } from "../app";
import SkinModel from "../../data/SkinModel";
import * as S3 from "../../s3";
import * as Auth from "../auth";
import { processUserUploads } from "../processUserUploads";
import UserContext from "../../data/UserContext";
import { searchIndex } from "../../algolia";
import { client } from "../../algolia";
import { createYogaInstance } from "../../app/graphql/yoga";
import { YogaServerInstance } from "graphql-yoga";
jest.mock("../../s3");
jest.mock("../../algolia");
jest.mock("../processUserUploads");
jest.mock("../auth");
let app: Application;
let yoga: YogaServerInstance<any, any>;
const handler = jest.fn();
const log = jest.fn();
const logError = jest.fn();
@ -23,26 +21,30 @@ let username: string | undefined;
beforeEach(async () => {
jest.clearAllMocks();
username = "<MOCKED>";
app = createApp({
yoga = createYogaInstance({
eventHandler: handler,
extraMiddleware: (req, res, next) => {
req.session.username = username;
next();
},
getUserContext: () => new UserContext(username),
logger: { log, logError },
});
await knex.migrate.latest();
await knex.seed.run();
});
afterAll(async () => {
await knex.destroy();
});
function gql(templateString: TemplateStringsArray): string {
return templateString[0];
}
async function graphQLRequest(query: string, variables?: any) {
const { body } = await request(app)
.post("/graphql")
.send({ query, variables: variables ?? {} });
const response = await yoga.fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
const body = await response.json();
if (body.errors && body.errors.length) {
for (const err of body.errors) {
console.warn(err.message);
@ -106,37 +108,6 @@ describe(".me", () => {
});
});
// TODO: The redirect_uri is different on github
test("/auth", async () => {
const { body } = await request(app).get("/auth").expect(302);
// TODO: The redirect_uri is different on github
// .expect(
// "Location",
// "https://discord.com/api/oauth2/authorize?client_id=%3CDUMMY_DISCORD_CLIENT_ID%3E&redirect_uri=https%3A%2F%2Fapi.webampskins.org%2Fauth%2Fdiscord&response_type=code&scope=identify%20guilds"
// );
expect(body).toEqual({});
});
describe("/auth/discord", () => {
test("valid code", async () => {
const response = await request(app)
.get("/auth/discord")
.query({ code: "<A_FAKE_CODE>" })
.expect(302);
// TODO: The location is different on github
// .expect("Location", "https://skins.webamp.org/review/");
// TODO: Assert that we get cookie headers. I think that will not work now
// because express does not think it's secure in a test env.
expect(Auth.auth).toHaveBeenCalledWith("<A_FAKE_CODE>");
expect(response.body).toEqual({});
});
test("missing code", async () => {
const { body } = await request(app).get("/auth/discord").expect(400);
expect(Auth.auth).not.toHaveBeenCalled();
expect(body).toEqual({ message: "Expected to get a code" });
});
});
describe("Query.skins", () => {
test("no query params", async () => {
const { data } = await graphQLRequest(
@ -156,35 +127,35 @@ describe("Query.skins", () => {
`
);
expect(data.skins).toMatchInlineSnapshot(`
Object {
{
"count": 6,
"nodes": Array [
Object {
"nodes": [
{
"filename": "tweeted.wsz",
"md5": "a_tweeted_md5",
"nsfw": false,
},
Object {
{
"filename": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
},
Object {
{
"filename": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
},
Object {
{
"filename": "approved.wsz",
"md5": "an_approved_md5",
"nsfw": false,
},
Object {
{
"filename": "rejected.wsz",
"md5": "a_rejected_md5",
"nsfw": false,
},
Object {
{
"filename": "nsfw.wsz",
"md5": "a_nsfw_md5",
"nsfw": true,
@ -212,15 +183,15 @@ describe("Query.skins", () => {
{ first: 2, offset: 1 }
);
expect(data.skins).toMatchInlineSnapshot(`
Object {
{
"count": 6,
"nodes": Array [
Object {
"nodes": [
{
"filename": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
},
Object {
{
"filename": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
@ -360,9 +331,10 @@ test("Mutation.mark_skin_nsfw", async () => {
type: "MARKED_SKIN_NSFW",
md5: "a_fake_md5",
});
expect(searchIndex.partialUpdateObjects).toHaveBeenCalledWith([
{ nsfw: true, objectID: "a_fake_md5" },
]);
expect(client.partialUpdateObjects).toHaveBeenCalledWith({
indexName: "test-index",
objects: [{ nsfw: true, objectID: "a_fake_md5" }],
});
expect(data).toEqual({ mark_skin_nsfw: true });
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");

View file

@ -1,381 +0,0 @@
import { Application } from "express";
import { knex } from "../../db";
import request from "supertest"; // supertest is a framework that allows to easily test web apis
import { createApp } from "../app";
import SkinModel from "../../data/SkinModel";
import * as S3 from "../../s3";
import * as Auth from "../auth";
import { processUserUploads } from "../processUserUploads";
import UserContext from "../../data/UserContext";
import { searchIndex } from "../../algolia";
jest.mock("../../s3");
jest.mock("../../algolia");
jest.mock("../processUserUploads");
jest.mock("../auth");
let app: Application;
const handler = jest.fn();
const log = jest.fn();
const logError = jest.fn();
let username: string | undefined;
beforeEach(async () => {
jest.clearAllMocks();
username = "<MOCKED>";
app = createApp({
eventHandler: handler,
extraMiddleware: (req, res, next) => {
req.session.username = username;
next();
},
logger: { log, logError },
});
await knex.migrate.latest();
await knex.seed.run();
});
describe("/authed", () => {
test("logged in ", async () => {
const { body } = await request(app).get("/authed").expect(200);
expect(body).toEqual({ username: "<MOCKED>" });
});
test("not logged in", async () => {
username = undefined;
const { body } = await request(app).get("/authed").expect(200);
expect(body).toEqual({ username: null });
});
});
test.skip("/auth", async () => {
const { body } = await request(app)
.get("/auth")
.expect(302)
.expect(
"Location",
"https://discord.com/api/oauth2/authorize?client_id=%3CDUMMY_DISCORD_CLIENT_ID%3E&redirect_uri=https%3A%2F%2Fapi.webampskins.org%2Fauth%2Fdiscord&response_type=code&scope=identify%20guilds"
);
expect(body).toEqual({});
});
describe.skip("/auth/discord", () => {
test("valid code", async () => {
const response = await request(app)
.get("/auth/discord")
.query({ code: "<A_FAKE_CODE>" })
.expect(302)
.expect("Location", "https://skins.webamp.org/review/");
// TODO: Assert that we get cookie headers. I think that will not work now
// because express does not think it's secure in a test env.
expect(Auth.auth).toHaveBeenCalledWith("<A_FAKE_CODE>");
expect(response.body).toEqual({});
});
test("missing code", async () => {
const { body } = await request(app).get("/auth/discord").expect(400);
expect(Auth.auth).not.toHaveBeenCalled();
expect(body).toEqual({ message: "Expected to get a code" });
});
});
describe("/skins/", () => {
test("no query params", async () => {
const { body } = await request(app).get("/skins/");
expect(body).toMatchInlineSnapshot(`
Object {
"skinCount": 6,
"skins": Array [
Object {
"fileName": "tweeted.wsz",
"md5": "a_tweeted_md5",
"nsfw": false,
},
Object {
"fileName": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
},
Object {
"fileName": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
},
Object {
"fileName": "approved.wsz",
"md5": "an_approved_md5",
"nsfw": false,
},
Object {
"fileName": "rejected.wsz",
"md5": "a_rejected_md5",
"nsfw": false,
},
Object {
"fileName": "nsfw.wsz",
"md5": "a_nsfw_md5",
"nsfw": true,
},
],
}
`);
});
test("first and offset", async () => {
const { body } = await request(app)
.get("/skins/")
.query({ first: 2, offset: 1 });
expect(body).toMatchInlineSnapshot(`
Object {
"skinCount": 6,
"skins": Array [
Object {
"fileName": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
},
Object {
"fileName": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
},
],
}
`);
});
});
// This is deprecated and fails in CI due to printing as localize date string.
test.skip("/skins/a_fake_md5/debug", async () => {
const { body } = await request(app)
.get("/skins/a_fake_md5/debug")
.expect(200);
expect(body).toMatchSnapshot();
});
test("/skins/a_fake_md5/report", async () => {
const { body } = await request(app)
.post("/skins/a_fake_md5/report")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "REVIEW_REQUESTED",
md5: "a_fake_md5",
});
expect(body).toEqual({}); // TODO: Where does the text response go?
});
test("/skins/a_fake_md5/approve", async () => {
const ctx = new UserContext();
const { body } = await request(app)
.post("/skins/a_fake_md5/approve")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "APPROVED_SKIN",
md5: "a_fake_md5",
});
expect(body).toEqual({ message: "The skin has been approved." });
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
expect(await skin?.getTweetStatus()).toEqual("APPROVED");
});
describe("/to_review", () => {
test("logged in ", async () => {
const { body } = await request(app).get("/to_review").expect(200);
expect(body).toEqual({
filename: expect.any(String),
md5: expect.any(String),
});
});
test("not logged in ", async () => {
username = undefined;
const { body } = await request(app).get("/to_review").expect(403);
expect(body).toEqual({ message: "You must be logged in" });
});
});
test("/skins/a_md5_that_does_not_exist/approve (404)", async () => {
const { body } = await request(app)
.post("/skins/a_md5_that_does_not_exist/approve")
.expect(404);
expect(body).toEqual({});
expect(handler).not.toHaveBeenCalled();
});
test("/skins/a_fake_md5/reject", async () => {
const ctx = new UserContext();
const { body } = await request(app)
.post("/skins/a_fake_md5/reject")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "REJECTED_SKIN",
md5: "a_fake_md5",
});
expect(body).toEqual({ message: "The skin has been rejected." }); // TODO: Where does the text response go?
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
expect(await skin?.getTweetStatus()).toEqual("REJECTED");
});
test("/skins/a_md5_that_does_not_exist/reject (404)", async () => {
const { body } = await request(app)
.post("/skins/a_md5_that_does_not_exist/reject")
.expect(404);
expect(body).toEqual({});
expect(handler).not.toHaveBeenCalled();
});
test("/skins/a_fake_md5/nsfw", async () => {
const ctx = new UserContext();
const { body } = await request(app)
.post("/skins/a_fake_md5/nsfw")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "MARKED_SKIN_NSFW",
md5: "a_fake_md5",
});
expect(searchIndex.partialUpdateObjects).toHaveBeenCalledWith([
{ nsfw: true, objectID: "a_fake_md5" },
]);
expect(body).toEqual({ message: "The skin has been marked as NSFW." });
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
expect(await skin?.getTweetStatus()).toEqual("NSFW");
});
// TODO: Actually upload some skins?
test("/skins/status", async () => {
const { body } = await request(app)
.post("/skins/status")
.send({ hashes: ["a_fake_md5", "a_missing_md5"] });
expect(body).toEqual({});
});
test("/approved", async () => {
const { body } = await request(app).get("/approved").expect(200);
expect(body).toEqual(["an_approved_md5", "a_tweeted_md5"]);
});
test("/skins/a_fake_md5", async () => {
let response = await request(app).get("/skins/a_fake_md5");
expect(response.body).toEqual({
fileName: "path.wsz",
md5: "a_fake_md5",
nsfw: false,
});
response = await request(app).get("/skins/a_nsfw_md5");
expect(response.body).toEqual({
fileName: "nsfw.wsz",
md5: "a_nsfw_md5",
nsfw: true,
});
await request(app).get("/skins/does_not_exist_md5").expect(404);
});
test("/skins/get_upload_urls", async () => {
const { body } = await request(app)
.post("/skins/get_upload_urls")
.send({
skins: {
"3b73bcd43c30b85d4cad3083e8ac9695": "a_fake_new_file.wsz",
"48bbdbbeb03d347e59b1eebda4d352d0":
"a_new_name_for_a_file_that_exists.wsz",
},
});
expect(S3.getSkinUploadUrl).toHaveBeenCalledWith(
"3b73bcd43c30b85d4cad3083e8ac9695",
expect.any(Number)
);
expect(body).toEqual({
"3b73bcd43c30b85d4cad3083e8ac9695": {
id: expect.any(Number),
url: "<MOCK_S3_UPLOAD_URL>",
},
});
});
test("An Upload Flow", async () => {
// Request an upload URL
const md5 = "3b73bcd43c30b85d4cad3083e8ac9695";
const filename = "a_fake_new_file.wsz";
const skins = { [md5]: filename };
const getUrlsResponse = await request(app)
.post("/skins/get_upload_urls")
.send({ skins });
const id = getUrlsResponse.body[md5].id;
expect(getUrlsResponse.body).toEqual({
[md5]: { id: expect.any(Number), url: "<MOCK_S3_UPLOAD_URL>" },
});
const requestedUpload = await knex("skin_uploads").where({ id }).first();
expect(requestedUpload).toEqual({
filename,
id,
skin_md5: md5,
status: "URL_REQUESTED",
});
// Report that we've uploaded the skin to S3 (we lie)
const uploadedResponse = await request(app)
.post(`/skins/${md5}/uploaded`)
.query({ id })
.send({ skins });
expect(uploadedResponse.body).toEqual({ done: true });
expect(processUserUploads).toHaveBeenCalled();
const reportedUpload = await knex("skin_uploads").where({ id }).first();
expect(reportedUpload).toEqual({
filename,
id,
skin_md5: md5,
status: "UPLOAD_REPORTED",
});
});
test("/stylegan.json", async () => {
const response = await request(app).get("/stylegan.json");
expect(response.body).toMatchInlineSnapshot(`
Array [
Object {
"fileName": "Zelda_Amp_3.wsz",
"md5": "48bbdbbeb03d347e59b1eebda4d352d0",
"nsfw": false,
"url": "https://r2.webampskins.org/screenshots/48bbdbbeb03d347e59b1eebda4d352d0.png",
},
Object {
"fileName": "path.wsz",
"md5": "a_fake_md5",
"nsfw": false,
"url": "https://r2.webampskins.org/screenshots/a_fake_md5.png",
},
Object {
"fileName": "nsfw.wsz",
"md5": "a_nsfw_md5",
"nsfw": true,
"url": "https://r2.webampskins.org/screenshots/a_nsfw_md5.png",
},
Object {
"fileName": "rejected.wsz",
"md5": "a_rejected_md5",
"nsfw": false,
"url": "https://r2.webampskins.org/screenshots/a_rejected_md5.png",
},
Object {
"fileName": "tweeted.wsz",
"md5": "a_tweeted_md5",
"nsfw": false,
"url": "https://r2.webampskins.org/screenshots/a_tweeted_md5.png",
},
Object {
"fileName": "approved.wsz",
"md5": "an_approved_md5",
"nsfw": false,
"url": "https://r2.webampskins.org/screenshots/an_approved_md5.png",
},
]
`);
});

View file

@ -1,208 +0,0 @@
import router from "./router";
import graphql from "./graphql";
import fileUpload from "express-fileupload";
import cors, { CorsOptions } from "cors";
import bodyParser from "body-parser";
import Sentry from "@sentry/node";
import expressSitemapXml from "express-sitemap-xml";
import * as Skins from "../data/skins";
import express, { Handler, RequestHandler, ErrorRequestHandler } from "express";
import UserContext from "../data/UserContext";
import cookieSession from "cookie-session";
import { SECRET } from "../config";
export type ApiAction =
| { type: "REVIEW_REQUESTED"; md5: string }
| { type: "REJECTED_SKIN"; md5: string }
| { type: "APPROVED_SKIN"; md5: string }
| { type: "MARKED_SKIN_NSFW"; md5: string }
| { type: "SKIN_UPLOADED"; md5: string }
| { type: "ERROR_PROCESSING_UPLOAD"; id: string; message: string }
| { type: "CLASSIC_SKIN_UPLOADED"; md5: string }
| { type: "MODERN_SKIN_UPLOADED"; md5: string }
| { type: "SKIN_UPLOAD_ERROR"; uploadId: string; message: string }
| {
type: "GOT_FEEDBACK";
message: string;
email?: string | null;
url?: string | null;
}
| { type: "SYNCED_TO_ARCHIVE"; successes: number; errors: number }
| { type: "STARTED_SYNC_TO_ARCHIVE"; count: number }
| {
type: "POPULAR_TWEET";
bracket: number;
url: string;
likes: number;
date: Date;
}
| { type: "TWEET_BOT_MILESTONE"; bracket: number; count: number };
export type EventHandler = (event: ApiAction) => void;
export type Logger = {
log(message: string, context: any): void;
logError(message: string, context: any): void;
};
// Add UserContext to req objects globally
declare global {
namespace Express {
interface Request {
ctx: UserContext;
notify(action: ApiAction): void;
log(message: string): void;
logError(message: string): void;
startTime: number;
session: {
username: string | undefined;
};
}
}
}
type Options = {
eventHandler?: EventHandler;
extraMiddleware?: Handler;
logger?: Logger;
};
export function createApp({ eventHandler, extraMiddleware, logger }: Options) {
const app = express();
if (Sentry) {
app.use(Sentry.Handlers.requestHandler() as RequestHandler);
}
app.use(function (req, res, next) {
req.startTime = Date.now();
next();
});
// https://expressjs.com/en/guide/behind-proxies.html
// This is needed in order to allow `cookieSession({secure: true})` cookies to be sent.
app.set("trust proxy", "loopback");
function use(handler: RequestHandler) {
app.use(handler);
}
const cookieHandler: RequestHandler = cookieSession({
secure: true,
sameSite: "none",
httpOnly: false,
name: "session",
secret: SECRET,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
// @ts-ignore Tests fail if this is missing, but prod is fine.
keys: "what",
});
app.use(cookieHandler);
if (extraMiddleware != null) {
app.use(extraMiddleware);
}
// Add UserContext to request
app.use((req, res, next) => {
req.ctx = new UserContext(req.session.username);
next();
// TODO: Dispose of context?
});
// Attach event handler
app.use((req, res, next) => {
req.notify = (action) => {
if (eventHandler) {
eventHandler(action);
}
};
next();
});
// Attach logger
app.use((req, res, next) => {
const context = {
url: req.url,
params: req.params,
query: req.query,
username: req.ctx.username,
};
req.log = (message) => {
if (logger != null) {
logger.log(message, context);
}
};
req.logError = (message) => {
if (logger != null) {
logger.logError(message, context);
}
};
next();
});
// Configure CORs
app.use(cors(corsOptions));
app.options("*", cors(corsOptions));
// Configure json output
app.set("json spaces", 2);
// parse application/json
app.use(bodyParser.json() as RequestHandler);
// Configure File Uploads
const limits = { fileSize: 50 * 1024 * 1024 };
app.use(fileUpload({ limits }));
// Configure sitemap
app.use(expressSitemapXml(getSitemapUrls, "https://skins.webamp.org"));
// Add routes
app.use("/", router);
app.use("/graphql", graphql);
// The error handler must be before any other error middleware and after all controllers
if (Sentry) {
app.use(Sentry.Handlers.errorHandler() as ErrorRequestHandler);
}
// Optional fallthrough error handler
app.use(function onError(err, _req, res, _next) {
console.error(err);
res.statusCode = 500;
res.json({ errorId: res.sentry, message: err.message });
});
return app;
}
async function getSitemapUrls() {
const md5s = await Skins.getAllClassicSkins();
const skinUrls = md5s.map(({ md5, fileName }) => `skin/${md5}/${fileName}`);
return ["/about", "/", "/upload", ...skinUrls];
}
const allowList = [
/https:\/\/skins\.webamp\.org/,
/https:\/\/api\.webamp\.org/,
/https:\/\/webamp\.org/,
/https:\/\/[^.]*\.csb\.app/,
/https:\/\/winamp-skin-museum\.pages\.dev/,
/http:\/\/localhost:3000/,
/http:\/\/localhost:3001/,
/netlify.app/,
/https:\/\/dustinbrett.com/,
];
const corsOptions: CorsOptions = {
credentials: true,
origin: function (origin, callback) {
if (!origin || allowList.some((regex) => regex.test(origin))) {
callback(null, true);
} else {
callback(
new Error(`Request from origin "${origin}" not allowed by CORS.`)
);
}
},
};

View file

@ -2,8 +2,7 @@ import { Int } from "grats";
import SkinModel from "../../data/SkinModel";
import { knex } from "../../db";
import ModernSkinResolver from "./resolvers/ModernSkinResolver";
import { Ctx } from ".";
import { Query } from "./resolvers/QueryResolver";
import UserContext from "../../data/UserContext.js";
/**
* A collection of "modern" Winamp skins
@ -31,7 +30,7 @@ export default class ModernSkinsConnection {
/**
* The list of skins
* @gqlField */
async nodes({ ctx }: Ctx): Promise<Array<ModernSkinResolver | null>> {
async nodes(ctx: UserContext): Promise<Array<ModernSkinResolver | null>> {
const skins = await this._getQuery()
.select()
.limit(this._first)
@ -44,16 +43,10 @@ export default class ModernSkinsConnection {
/**
* All modern skins in the database
* @gqlField */
* @gqlQueryField */
export async function modern_skins(
_: Query,
{
first = 10,
offset = 0,
}: {
first?: Int;
offset?: Int;
}
first: Int = 10,
offset: Int = 0
): Promise<ModernSkinsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");

View file

@ -5,8 +5,7 @@ import SkinResolver from "./resolvers/SkinResolver";
import LRU from "lru-cache";
import { Int } from "grats";
import { ISkin } from "./resolvers/CommonSkinResolver";
import { Ctx } from ".";
import { Query } from "./resolvers/QueryResolver";
import UserContext from "../../data/UserContext.js";
const options = {
max: 100,
@ -103,7 +102,7 @@ export default class SkinsConnection {
* The list of skins
* @gqlField
*/
async nodes({ ctx }: Ctx): Promise<Array<ISkin | null>> {
async nodes(ctx: UserContext): Promise<Array<ISkin | null>> {
if (this._sort === "MUSEUM") {
if (this._filter) {
throw new Error(
@ -171,21 +170,18 @@ Only the skins that have been tweeted
* All classic skins in the database
*
* **Note:** We don't currently support combining sorting and filtering.
* @gqlField */
export function skins(
_: Query,
{
first = 10,
offset = 0,
sort,
filter,
}: {
first?: Int;
offset?: Int;
sort?: SkinsSortOption | null;
filter?: SkinsFilterOption | null;
}
): SkinsConnection {
* @gqlQueryField */
export function skins({
first = 10,
offset = 0,
sort,
filter,
}: {
first?: Int;
offset?: Int;
sort?: SkinsSortOption | null;
filter?: SkinsFilterOption | null;
}): SkinsConnection {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}

View file

@ -1,7 +1,6 @@
import { Int } from "grats";
import TweetModel from "../../data/TweetModel";
import { knex } from "../../db";
import { Query } from "./resolvers/QueryResolver";
/** @gqlEnum */
export type TweetsSortOption = "LIKES" | "RETWEETS";
@ -51,20 +50,17 @@ export default class TweetsConnection {
/**
* Tweets tweeted by @winampskins
* @gqlField
* @gqlQueryField
*/
export async function tweets(
_: Query,
{
first = 10,
offset = 0,
sort,
}: {
first?: Int;
offset?: Int;
sort?: TweetsSortOption | null;
}
): Promise<TweetsConnection> {
export async function tweets({
first = 10,
offset = 0,
sort,
}: {
first?: Int;
offset?: Int;
sort?: TweetsSortOption | null;
}): Promise<TweetsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}

View file

@ -1,42 +1,9 @@
import { Router } from "express";
import { createHandler } from "graphql-http/lib/use/express";
// import DEFAULT_QUERY from "./defaultQuery";
import { getSchema } from "./schema";
import UserContext from "../../data/UserContext.js";
/** @gqlContext */
export type Ctx = Express.Request;
const router = Router();
router.use(
"/",
createHandler<Ctx>({
schema: getSchema(),
context: (req) => {
return req.raw;
},
/*
graphiql: {
defaultQuery: DEFAULT_QUERY,
},*/
// graphqlHTTP({
// schema: getSchema(),
// graphiql: {
// defaultQuery: DEFAULT_QUERY,
// },
// customFormatErrorFn: (error) => {
// console.error(error);
// return {
// message: error.message,
// locations: error.locations,
// stack: error.stack ? error.stack.split("\n") : [],
// path: error.path,
// };
// },
// extensions,
// }) as RequestHandler
})
);
export default router;
/** @gqlContext */
export function getUserContext(ctx: Ctx): UserContext {
return ctx.ctx;
}

View file

@ -0,0 +1,96 @@
import { Int } from "grats";
import { knex } from "../../../db";
import SkinModel from "../../../data/SkinModel";
import ClassicSkinResolver from "./ClassicSkinResolver";
import ModernSkinResolver from "./ModernSkinResolver";
import UserContext from "../../../data/UserContext";
import { ISkin } from "./CommonSkinResolver";
import { SkinRow } from "../../../types";
/**
* Connection for bulk download skin metadata
* @gqlType
*/
export class BulkDownloadConnection {
_offset: number;
_first: number;
constructor(first: number, offset: number) {
this._first = first;
this._offset = offset;
}
/**
* Total number of skins available for download
* @gqlField
*/
async totalCount(): Promise<Int> {
// Get count of both classic and modern skins
const [classicResult, modernResult] = await Promise.all([
knex("skins").where({ skin_type: 1 }).count("* as count"),
knex("skins").where({ skin_type: 2 }).count("* as count"),
]);
const classicCount = Number(classicResult[0].count);
const modernCount = Number(modernResult[0].count);
return classicCount + modernCount;
}
/**
* Estimated total size in bytes (approximation for progress indication)
* @gqlField
*/
async estimatedSizeBytes(): Promise<string> {
const totalCount = await this.totalCount();
// Rough estimate: average skin is ~56KB
return (totalCount * 56 * 1024).toString();
}
/**
* List of skin metadata for bulk download
* @gqlField
*/
async nodes(ctx: UserContext): Promise<Array<ISkin>> {
// Get skins ordered by skin_type (classic first, then modern) and id for consistency
const skins = await knex<SkinRow>("skins")
.select(["id", "md5", "skin_type", "emails"])
.orderBy([{ column: "skins.id", order: "asc" }])
.where({ skin_type: 1 })
.orWhere({ skin_type: 2 })
.where((builder) => {
builder.where({ skin_type: 1 }).orWhere({ skin_type: 2 });
})
.limit(this._first)
.offset(this._offset);
return skins.map((skinRow) => {
const skinModel = new SkinModel(ctx, skinRow);
if (skinRow.skin_type === 1) {
return new ClassicSkinResolver(skinModel);
} else if (skinRow.skin_type === 2) {
return new ModernSkinResolver(skinModel);
} else {
throw new Error("Expected classic or modern skin");
}
});
}
}
/**
* Get metadata for bulk downloading all skins in the museum
* @gqlQueryField
*/
export function bulkDownload({
first = 1000,
offset = 0,
}: {
first?: Int;
offset?: Int;
}): BulkDownloadConnection {
if (first > 10000) {
throw new Error("Maximum limit is 10000 for bulk download");
}
return new BulkDownloadConnection(first, offset);
}

View file

@ -1,7 +1,6 @@
import { ISkin } from "./CommonSkinResolver";
import { NodeResolver, toId } from "./NodeResolver";
import ReviewResolver from "./ReviewResolver";
import path from "path";
import { ID, Int } from "grats";
import SkinModel from "../../../data/SkinModel";
@ -24,11 +23,7 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
return toId(this.__typename, this.md5());
}
async filename(normalize_extension?: boolean): Promise<string> {
const filename = await this._model.getFileName();
if (normalize_extension) {
return path.parse(filename).name + ".wsz";
}
return filename;
return await this._model.getFileName(normalize_extension);
}
museum_url(): string {
@ -46,7 +41,7 @@ export default class ClassicSkinResolver implements NodeResolver, ISkin {
nsfw(): Promise<boolean> {
return this._model.getIsNsfw();
}
average_color(): string {
average_color(): string | null {
return this._model.getAverageColor();
}
/**

View file

@ -69,19 +69,34 @@ export function id(skin: ISkin): ID {
* has been uploaded under multiple names. Here we just pick one.
* @gqlField
*/
export function filename(
export async function filename(
skin: ISkin,
{
normalize_extension = false,
include_museum_id = false,
}: {
/**
* If true, the the correct file extension (.wsz or .wal) will be .
* Otherwise, the original user-uploaded file extension will be used.
*/
normalize_extension?: boolean;
/**
* If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
*/
include_museum_id?: boolean;
}
): Promise<string> {
return skin.filename(normalize_extension);
const baseFilename = await skin.filename(normalize_extension);
if (!include_museum_id) {
return baseFilename;
}
const museumId = skin._model.getId();
const segments = baseFilename.split(".");
const fileExtension = segments.pop();
return `${segments.join(".")}_[S${museumId}].${fileExtension}`;
}
/**

View file

@ -1,6 +1,5 @@
import { Int } from "grats";
import * as Skins from "../../../data/skins";
import { Query } from "./QueryResolver";
/**
* Statistics about the contents of the Museum's database.
@ -92,7 +91,7 @@ export default class DatabaseStatisticsResolver {
/**
* A namespace for statistics about the database
* @gqlField */
export function statistics(_: Query): DatabaseStatisticsResolver {
* @gqlQueryField */
export function statistics(): DatabaseStatisticsResolver {
return new DatabaseStatisticsResolver();
}

View file

@ -1,17 +1,13 @@
import { Ctx } from "..";
import { Mutation } from "./MutationResolver";
/**
* Send a message to the admin of the site. Currently this appears in Discord.
* @gqlField */
* @gqlMutationField */
export async function send_feedback(
_: Mutation,
{
message,
email,
url,
}: { message: string; email?: string | null; url?: string | null },
req: Ctx
req: Ctx,
message: string,
email?: string | null,
url?: string | null
): Promise<boolean> {
req.notify({
type: "GOT_FEEDBACK",

View file

@ -1,2 +0,0 @@
/** @gqlType Mutation */
export type Mutation = unknown;

View file

@ -1,8 +1,7 @@
import { ID } from "grats";
import { Query } from "./QueryResolver";
import { Ctx } from "..";
import SkinModel from "../../../data/SkinModel";
import SkinResolver from "./SkinResolver";
import UserContext from "../../../data/UserContext.js";
/**
* A globally unique object. The `id` here is intended only for use within
@ -34,12 +33,11 @@ export function fromId(base64Id: string): { graphqlType: string; id: string } {
* Get a globally unique object by its ID.
*
* https://graphql.org/learn/global-object-identification/
* @gqlField
* @gqlQueryField
*/
export async function node(
_: Query,
{ id }: { id: ID },
{ ctx }: Ctx
id: ID,
ctx: UserContext
): Promise<NodeResolver | null> {
const { graphqlType, id: localId } = fromId(id);
// TODO Use typeResolver

View file

@ -1,2 +0,0 @@
/** @gqlType Query */
export type Query = unknown;

View file

@ -1,7 +1,6 @@
import SkinModel from "../../../data/SkinModel";
import * as Skins from "../../../data/skins";
import { Ctx } from "..";
import { Mutation } from "./MutationResolver";
function requireAuthed(handler) {
return (args, req: Ctx) => {
@ -17,12 +16,8 @@ function requireAuthed(handler) {
* Reject skin for tweeting
*
* **Note:** Requires being logged in
* @gqlField */
export function reject_skin(
_: Mutation,
md5: string,
req: Ctx
): Promise<boolean> {
* @gqlMutationField */
export function reject_skin(md5: string, req: Ctx): Promise<boolean> {
return _reject_skin(md5, req);
}
@ -41,12 +36,8 @@ const _reject_skin = requireAuthed(async (md5: string, req: Ctx) => {
* Approve skin for tweeting
*
* **Note:** Requires being logged in
* @gqlField */
export function approve_skin(
_: Mutation,
md5: string,
req: Ctx
): Promise<boolean> {
* @gqlMutationField */
export function approve_skin(md5: string, req: Ctx): Promise<boolean> {
return _approve_skin(md5, req);
}
@ -65,12 +56,8 @@ const _approve_skin = requireAuthed(async (md5: string, req: Ctx) => {
* Mark a skin as NSFW
*
* **Note:** Requires being logged in
* @gqlField */
export function mark_skin_nsfw(
_: Mutation,
md5: string,
req: Ctx
): Promise<boolean> {
* @gqlMutationField */
export function mark_skin_nsfw(md5: string, req: Ctx): Promise<boolean> {
return _mark_skin_nsfw(md5, req);
}
@ -89,10 +76,9 @@ const _mark_skin_nsfw = requireAuthed(async (md5: string, req: Ctx) => {
* Request that an admin check if this skin is NSFW.
* Unlike other review mutation endpoints, this one does not require being logged
* in.
* @gqlField */
* @gqlMutationField */
export async function request_nsfw_review_for_skin(
_: Mutation,
{ md5 }: { md5: string },
md5: string,
req: Ctx
): Promise<boolean> {
req.log(`Reporting skin with hash "${md5}"`);

View file

@ -1,4 +1,4 @@
import { Ctx } from "..";
import UserContext from "../../../data/UserContext.js";
import { Rating, ReviewRow } from "../../../types";
import { ISkin } from "./CommonSkinResolver";
import SkinResolver from "./SkinResolver";
@ -17,7 +17,7 @@ export default class ReviewResolver {
* The skin that was reviewed
* @gqlField
*/
skin({ ctx }: Ctx): Promise<ISkin | null> {
skin(ctx: UserContext): Promise<ISkin | null> {
return SkinResolver.fromMd5(ctx, this._model.skin_md5);
}

View file

@ -1,17 +1,11 @@
import { Int } from "grats";
import { Ctx } from "..";
import SkinModel from "../../../data/SkinModel";
import UserContext from "../../../data/UserContext";
import ClassicSkinResolver from "./ClassicSkinResolver";
import { ISkin } from "./CommonSkinResolver";
import ModernSkinResolver from "./ModernSkinResolver";
import { Query } from "./QueryResolver";
import algoliasearch from "algoliasearch";
import * as Skins from "../../../data/skins";
// These keys are already in the web client, so they are not secret at all.
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
const index = client.initIndex("Skins");
import { knex } from "../../../db";
export default class SkinResolver {
constructor() {
@ -35,12 +29,11 @@ export default class SkinResolver {
/**
* Get a skin by its MD5 hash
* @gqlField
* @gqlQueryField
*/
export async function fetch_skin_by_md5(
_: Query,
{ md5 }: { md5: string },
{ ctx }: Ctx
md5: string,
ctx: UserContext
): Promise<ISkin | null> {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
@ -53,41 +46,79 @@ export async function fetch_skin_by_md5(
* Search the database using the Algolia search index used by the Museum.
*
* Useful for locating a particular skin.
* @gqlField
* @gqlQueryField
*/
export async function search_skins(
_: Query,
{
query,
first = 10,
offset = 0,
}: { query: string; first?: Int; offset?: Int },
{ ctx }: Ctx
query: string,
first: Int = 10,
offset: Int = 0,
ctx: UserContext
): Promise<Array<ISkin | null>> {
if (first > 1000) {
throw new Error("Can only query 1000 records via search.");
}
const results: { hits: { md5: string }[] } = await index.search(query, {
attributesToRetrieve: ["md5"],
length: first,
offset,
});
const skins = await knex("skin_search")
.select("skin_md5")
.leftJoin("skins", "skin_search.skin_md5", "skins.md5")
.where("skins.skin_type", "in", [1, 2])
.limit(first)
.offset(offset)
.whereRaw("skin_search MATCH ?", query);
return Promise.all(
results.hits.map(async (hit) => {
const model = await SkinModel.fromMd5Assert(ctx, hit.md5);
skins.map(async (hit) => {
const model = await SkinModel.fromMd5Assert(ctx, hit.skin_md5);
return SkinResolver.fromModel(model);
})
);
}
/**
* Search the database using SQLite's FTS (full text search) index.
*
* Useful for locating a particular skin.
* @gqlQueryField
*/
export async function search_classic_skins(
query: string,
first: Int = 10,
offset: Int = 0,
ctx: UserContext
): Promise<Array<ClassicSkinResolver | null>> {
if (first > 1000) {
throw new Error("Can only query 1000 records via search.");
}
// const skins = await knex("skin_search")
// .select("skin_search.skin_md5")
// .leftJoin("skins", "skin_search.skin_md5", "skins.md5")
// .leftJoin("skin_reviews", "skins.md5", "skin_reviews.skin_md5")
// .where("skins.skin_type", "=", 1)
// .orderByRaw("CASE WHEN skin_reviews.review = 'NSFW' THEN 1 ELSE 0 END")
// .limit(first)
// .offset(offset)
// .whereRaw("skin_search MATCH ?", query);
const skins = await knex("skin_search")
.select("skin_md5")
.leftJoin("skins", "skin_search.skin_md5", "skins.md5")
.where("skins.skin_type", "=", 1)
.limit(first)
.offset(offset)
.whereRaw("skin_search MATCH ?", query);
return Promise.all(
skins.map(async (hit) => {
const model = await SkinModel.fromMd5Assert(ctx, hit.skin_md5);
return new ClassicSkinResolver(model);
})
);
}
/**
* A random skin that needs to be reviewed
* @gqlField */
export async function skin_to_review(
_: Query,
{ ctx }: Ctx
): Promise<ISkin | null> {
* @gqlQueryField */
export async function skin_to_review(ctx: UserContext): Promise<ISkin | null> {
if (!ctx.authed()) {
return null;
}

View file

@ -1,31 +1,27 @@
import { Ctx } from "..";
import SkinModel from "../../../data/SkinModel";
import UserContext from "../../../data/UserContext";
import { knex } from "../../../db";
import { ISkin } from "./CommonSkinResolver";
import { Query } from "./QueryResolver";
import SkinResolver from "./SkinResolver";
/**
* Get the status of a batch of uploads by md5s
* @gqlField
* @gqlQueryField
* @deprecated Prefer `upload_statuses` instead, were we operate on ids.
*/
export async function upload_statuses_by_md5(
_: Query,
{ md5s }: { md5s: string[] },
{ ctx }: Ctx
md5s: string[],
ctx: UserContext
): Promise<Array<SkinUpload | null>> {
return _upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx);
}
/**
* Get the status of a batch of uploads by ids
* @gqlField */
* @gqlQueryField */
export async function upload_statuses(
_: Query,
{ ids }: { ids: string[] },
{ ctx }: Ctx
ids: string[],
ctx: UserContext
): Promise<Array<SkinUpload | null>> {
return _upload_statuses({ keyName: "id", keys: ids }, ctx);
}

View file

@ -4,7 +4,7 @@ import * as S3 from "../../../s3";
import * as Skins from "../../../data/skins";
import { processUserUploads } from "../../processUserUploads";
import { Ctx } from "..";
import { Mutation } from "./MutationResolver";
import UserContext from "../../../data/UserContext.js";
// We don't use a resolver here, just return the value directly.
/**
@ -46,8 +46,8 @@ class UploadMutationResolver {
* @gqlField
*/
async get_upload_urls(
{ files }: { files: UploadUrlRequest[] },
{ ctx }: Ctx
files: UploadUrlRequest[],
ctx: UserContext
): Promise<Array<UploadUrl | null>> {
const missing: UploadUrl[] = [];
await Parallel.each(
@ -70,7 +70,8 @@ class UploadMutationResolver {
* @gqlField
*/
async report_skin_uploaded(
{ id, md5 }: { id: string; md5: string },
id: string,
md5: string,
req: Ctx
): Promise<boolean> {
// TODO: Validate md5 and id;
@ -83,7 +84,7 @@ class UploadMutationResolver {
/**
* Mutations for the upload flow
* @gqlField */
export async function upload(_: Mutation): Promise<UploadMutationResolver> {
* @gqlMutationField */
export async function upload(): Promise<UploadMutationResolver> {
return new UploadMutationResolver();
}

View file

@ -1,18 +1,17 @@
import { Ctx } from "..";
import { Query } from "./QueryResolver";
import UserContext from "../../../data/UserContext.js";
/** @gqlType User */
export default class UserResolver {
/** @gqlField */
username({ ctx }: Ctx): string | null {
username(ctx: UserContext): string | null {
return ctx.username;
}
}
/**
* The currently authenticated user, if any.
* @gqlField
* @gqlQueryField
*/
export function me(_: Query): UserResolver | null {
export function me(): UserResolver | null {
return new UserResolver();
}

View file

@ -1,5 +1,6 @@
# Schema generated by Grats (https://grats.capt.dev)
# Do not manually edit. Regenerate by running `npx grats`.
"""
Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.
In all other cases, the position is non-null.
@ -92,6 +93,10 @@ interface Skin {
has been uploaded under multiple names. Here we just pick one.
"""
filename(
"""
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
"""
include_museum_id: Boolean! = false
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
@ -161,7 +166,17 @@ type ArchiveFile {
serverless Cloudflare function which tries to exctact the file on the fly.
It may not work for all files.
"""
url: String @semanticNonNull
url: String
}
"""Connection for bulk download skin metadata"""
type BulkDownloadConnection {
"""Estimated total size in bytes (approximation for progress indication)"""
estimatedSizeBytes: String @semanticNonNull
"""List of skin metadata for bulk download"""
nodes: [Skin!] @semanticNonNull
"""Total number of skins available for download"""
totalCount: Int @semanticNonNull
}
"""A classic Winamp skin"""
@ -177,6 +192,10 @@ type ClassicSkin implements Node & Skin {
has been uploaded under multiple names. Here we just pick one.
"""
filename(
"""
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
"""
include_museum_id: Boolean! = false
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
@ -307,6 +326,10 @@ type ModernSkin implements Node & Skin {
has been uploaded under multiple names. Here we just pick one.
"""
filename(
"""
If true, the museum ID will be appended to the filename to ensure filenames are globally unique.
"""
include_museum_id: Boolean! = false
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
@ -384,6 +407,8 @@ type Mutation {
}
type Query {
"""Get metadata for bulk downloading all skins in the museum"""
bulkDownload(first: Int! = 1000, offset: Int! = 0): BulkDownloadConnection @semanticNonNull
"""
Fetch archive file by it's MD5 hash
@ -411,6 +436,12 @@ type Query {
"""
node(id: ID!): Node
"""
Search the database using SQLite's FTS (full text search) index.
Useful for locating a particular skin.
"""
search_classic_skins(first: Int! = 10, offset: Int! = 0, query: String!): [ClassicSkin] @semanticNonNull
"""
Search the database using the Algolia search index used by the Museum.
Useful for locating a particular skin.

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import * as Skins from "../data/skins";
import S3 from "../s3";
import { addSkinFromBuffer } from "../addSkin";
import { EventHandler } from "./app";
import { EventHandler } from "./types";
import DiscordEventHandler from "./DiscordEventHandler";
async function* reportedUploads(): AsyncGenerator<
@ -37,9 +37,9 @@ const ONE_MINUTE_IN_MS = 1000 * 60;
function timeout<T>(p: Promise<T>, duration: number): Promise<T> {
return Promise.race([
p,
new Promise<never>((resolve, reject) =>
setTimeout(() => reject("timeout"), duration)
),
new Promise<never>((_resolve, reject) => {
setTimeout(() => reject("timeout"), duration);
}),
]);
}
@ -82,7 +82,6 @@ async function processGivenUserUploads(
message: e.message,
} as const;
eventHandler(action);
throw e;
console.error(e);
}
}

View file

@ -1,332 +0,0 @@
import { Router } from "express";
import asyncHandler from "express-async-handler";
import SkinModel from "../data/SkinModel";
import * as Skins from "../data/skins";
import {
DISCORD_CLIENT_ID,
DISCORD_REDIRECT_URL,
LOGIN_REDIRECT_URL,
} from "../config";
import S3 from "../s3";
import LRU from "lru-cache";
import { MuseumPage } from "../data/skins";
import { processUserUploads } from "./processUserUploads";
import { auth } from "./auth";
import * as Parallel from "async-parallel";
const router = Router();
const options = {
max: 100,
maxAge: 1000 * 60 * 60,
};
let skinCount: number | null = null;
const cache = new LRU<string, MuseumPage>(options);
// Purposefully REST
router.get(
"/auth/",
asyncHandler(async (req, res) => {
res.redirect(
302,
`https://discord.com/api/oauth2/authorize?client_id=${encodeURIComponent(
DISCORD_CLIENT_ID
)}&redirect_uri=${encodeURIComponent(
DISCORD_REDIRECT_URL
)}&response_type=code&scope=identify%20guilds`
);
})
);
// @deprecated Use GraphQL
router.get(
"/authed/",
asyncHandler(async (req, res) => {
res.json({ username: req.ctx.username });
})
);
// Purposefully REST
router.get(
"/auth/discord",
asyncHandler(async (req, res) => {
const code = req.query.code as string | undefined;
if (code == null) {
res.status(400).send({ message: "Expected to get a code" });
return;
}
const username = await auth(code);
if (username == null) {
res.status(400).send({ message: "Invalid code" });
return;
}
req.session.username = username;
// TODO: What about dev?
res.redirect(302, LOGIN_REDIRECT_URL);
})
);
// @deprecated Use GraphQL
router.get(
"/skins/",
asyncHandler(async (req, res) => {
if (skinCount == null) {
skinCount = await Skins.getClassicSkinCount();
}
const { offset = 0, first = 100 } = req.query;
const key = req.originalUrl;
const cached = cache.get(key);
if (cached != null) {
req.log(`Cache hit for ${key}`);
res.json({ skinCount, skins: cached });
return;
}
req.log(`Getting offset: ${offset}, first: ${first}`);
const start = Date.now();
const skins = await Skins.getMuseumPage({
offset: Number(offset),
first: Number(first),
});
req.log(`Query took ${(Date.now() - start) / 1000}`);
req.log(`Cache set for ${key}`);
cache.set(key, skins);
res.json({ skinCount, skins });
})
);
// @deprecated Use GraphQL
router.post(
"/skins/get_upload_urls",
asyncHandler(async (req, res) => {
const payload = req.body.skins as { [md5: string]: string };
const missing = {};
await Parallel.each(
Object.entries(payload),
async ([md5, filename]) => {
if (!(await SkinModel.exists(req.ctx, md5))) {
const id = await Skins.recordUserUploadRequest(md5, filename);
const url = S3.getSkinUploadUrl(md5, id);
missing[md5] = { id, url };
}
},
5
);
res.json(missing);
})
);
// @deprecated Use GraphQL
router.post(
"/feedback",
asyncHandler(async (req, res) => {
const payload = req.body as {
email?: string;
message: string;
url?: string;
};
req.notify({
type: "GOT_FEEDBACK",
url: payload.url,
message: payload.message,
email: payload.email,
});
res.json({ message: "sent" });
})
);
// @deprecate Use GraphQL
router.post(
"/skins/status",
asyncHandler(async (req, res) => {
const statuses = await Skins.getUploadStatuses(req.body.hashes);
res.json(statuses);
})
);
// @deprecated Use GraphQL
router.get(
"/skins/:md5",
asyncHandler(async (req, res) => {
const { md5 } = req.params;
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
req.log(`Details for hash "${md5}" NOT FOUND`);
res.status(404).json();
return;
}
res.json({
md5: skin.getMd5(),
nsfw: await skin.getIsNsfw(),
fileName: await skin.getFileName(),
});
})
);
// @deprecated Use GraphQL
router.get(
"/skins/:md5/metadata",
asyncHandler(async (req, res) => {
const { md5 } = req.params;
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
req.log(`Details for hash "${md5}" NOT FOUND`);
res.status(404).json();
return;
}
const [nsfw, fileName, readme] = await Promise.all([
skin.getIsNsfw(),
skin.getFileName(),
skin.getReadme(),
]);
res.json({
md5: skin.getMd5(),
nsfw,
fileName,
readme,
});
})
);
// @deprecated Use GraphQL
router.get(
"/skins/:md5/debug",
asyncHandler(async (req, res) => {
const { md5 } = req.params;
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
req.log(`Details for hash "${md5}" NOT FOUND`);
res.status(404).json();
return;
}
res.json(await skin.debug());
})
);
function requireAuthed(req, res, next) {
if (!req.ctx.authed()) {
res.status(403);
res.send({ message: "You must be logged in" });
} else {
next();
}
}
// @deprecated Use GraphQL
router.get(
"/to_review",
requireAuthed,
asyncHandler(async (req, res) => {
const { filename, md5 } = await Skins.getSkinToReview();
res.json({ filename, md5 });
})
);
// @deprecated Use GraphQL
router.post(
"/skins/:md5/reject",
requireAuthed,
asyncHandler(async (req, res) => {
const { md5 } = req.params;
req.log(`Rejecting skin with hash "${md5}"`);
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
res.status(404).send("Skin not found");
return;
}
await Skins.reject(req.ctx, md5);
req.notify({ type: "REJECTED_SKIN", md5 });
res.send({ message: "The skin has been rejected." });
})
);
// @deprecated Use GraphQL
router.post(
"/skins/:md5/approve",
requireAuthed,
asyncHandler(async (req, res) => {
const { md5 } = req.params;
req.log(`Approving skin with hash "${md5}"`);
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
res.status(404).send("Skin not found");
return;
}
await Skins.approve(req.ctx, md5);
req.notify({ type: "APPROVED_SKIN", md5 });
res.send({ message: "The skin has been approved." });
})
);
// @deprecated Use GraphQL
// Unlike /report, this marks the skin NSFW right away without sending to
// Discord. Because of this, it requires auth.
router.post(
"/skins/:md5/nsfw",
requireAuthed,
asyncHandler(async (req, res) => {
const { md5 } = req.params;
req.log(`Approving skin with hash "${md5}"`);
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
res.status(404).send("Skin not found");
return;
}
await Skins.markAsNSFW(req.ctx, md5);
req.notify({ type: "MARKED_SKIN_NSFW", md5 });
res.send({ message: "The skin has been marked as NSFW." });
})
);
// @deprecated Use GraphQL
router.post(
"/skins/:md5/report",
asyncHandler(async (req, res) => {
const { md5 } = req.params;
req.log(`Reporting skin with hash "${md5}"`);
// Blow up if there is no skin with this hash
await SkinModel.fromMd5Assert(req.ctx, md5);
req.notify({ type: "REVIEW_REQUESTED", md5 });
res.send("The skin has been reported and will be reviewed shortly.");
})
);
// @deprecated Use GraphQL
// User reports that they uploaded a skin
router.post(
"/skins/:md5/uploaded",
asyncHandler(async (req, res) => {
const { md5 } = req.params;
const id = req.query.id as string;
if (id == null) {
throw new Error("Missing upload id");
}
// TODO: Validate md5 and id;
await Skins.recordUserUploadComplete(md5, id);
// Don't await, just kick off the task.
processUserUploads(req.notify);
res.json({ done: true });
})
);
// @deprecated Use GraphQL
router.get(
"/approved",
asyncHandler(async (req, res) => {
const approved = await Skins.getAllApproved();
res.json(approved);
})
);
// @deprecated Special purpose URL
router.get(
"/stylegan.json",
asyncHandler(async (req, res) => {
const images = await Skins.getAllClassicScreenshotUrls();
res.json(images);
})
);
export default router;

View file

@ -1,38 +0,0 @@
// import Sentry from "@sentry/node";
import dotenv from "dotenv";
dotenv.config();
import { createApp } from "./app";
import DiscordEventHandler from "./DiscordEventHandler";
const port = process.env.PORT ? Number(process.env.PORT) : 3001;
const handler = new DiscordEventHandler();
// GO!
const app = createApp({
eventHandler: (action) => handler.handle(action),
logger: {
log: (message, context) => console.log(message, context),
logError: (message, context) => console.error(message, context),
},
});
app.listen(port, () => {
console.log(
`Winamp Skin Museum database API app listening on http://localhost:${port}`
);
console.log(`Explore: http://localhost:${port}/graphql`);
});
// Initialize Sentry after we start listening. Any crash at start time will appear in the console and we'll notice.
/*
Sentry.init({
dsn:
"https://0e6bc841b4f744b2953a1fe5981effe6@o68382.ingest.sentry.io/5508241",
// We recommend adjusting this value in production, or using tracesSampler
// for finer control
tracesSampleRate: 1.0,
});
*/

View file

@ -0,0 +1,54 @@
import UserContext from "../data/UserContext";
export type ApiAction =
| { type: "REVIEW_REQUESTED"; md5: string }
| { type: "REJECTED_SKIN"; md5: string }
| { type: "APPROVED_SKIN"; md5: string }
| { type: "MARKED_SKIN_NSFW"; md5: string }
| { type: "SKIN_UPLOADED"; md5: string }
| { type: "ERROR_PROCESSING_UPLOAD"; id: string; message: string }
| { type: "CLASSIC_SKIN_UPLOADED"; md5: string }
| { type: "MODERN_SKIN_UPLOADED"; md5: string }
| { type: "SKIN_UPLOAD_ERROR"; uploadId: string; message: string }
| {
type: "GOT_FEEDBACK";
message: string;
email?: string | null;
url?: string | null;
}
| {
type: "SYNCED_TO_ARCHIVE";
successes: number;
errors: number;
skips: number;
}
| { type: "STARTED_SYNC_TO_ARCHIVE"; count: number }
| {
type: "POPULAR_TWEET";
bracket: number;
url: string;
likes: number;
date: Date;
}
| { type: "TWEET_BOT_MILESTONE"; bracket: number; count: number };
export type EventHandler = (event: ApiAction) => void;
export type Logger = {
log(message: string, context: any): void;
logError(message: string, context: any): void;
};
// Add UserContext to req objects globally
declare global {
namespace Express {
interface Request {
ctx: UserContext;
notify(action: ApiAction): void;
log(message: string): void;
logError(message: string): void;
session: {
username: string | undefined;
};
}
}
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

View file

@ -0,0 +1,5 @@
import App from "../App";
export default function Layout() {
return <App />;
}

View file

@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { generateSkinPageMetadata } from "../skinMetadata";
export async function generateMetadata({ params }): Promise<Metadata> {
const { hash, fileName: _fileName } = await params;
return generateSkinPageMetadata(hash);
}
export default function Page() {
return null;
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

View file

@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { generateSkinPageMetadata } from "./skinMetadata";
export async function generateMetadata({ params }): Promise<Metadata> {
const { hash } = await params;
return generateSkinPageMetadata(hash);
}
export default function Page() {
return null;
}

View file

@ -0,0 +1,50 @@
import { Metadata } from "next";
import SkinModel from "../../../../data/SkinModel";
import UserContext from "../../../../data/UserContext";
export async function generateSkinPageMetadata(
hash: string
): Promise<Metadata> {
const skin = await SkinModel.fromMd5Assert(new UserContext(), hash);
const fileName = await skin.getFileName();
const readme = await skin.getReadme();
const imageUrl = `https://skin-museum-og-captbaritone-webamp.vercel.app/api/og?md5=${hash}`;
const images = [
{
alt: `Screenshot of the Winamp skin ${fileName}.`,
url: imageUrl,
width: 1200,
height: 600,
},
];
const title = `${fileName} - Winamp Skin Museum`;
const description =
readme == null
? `The Winamp Skin "${fileName}" in the Winamp Skin Museum. Explore skins, view details, and interact with previews.`
: readme.slice(0, 300);
return {
title,
description,
alternates: {
canonical: skin.getMuseumUrl(),
},
openGraph: {
title,
description,
images,
type: "website",
siteName: "Winamp Skin Museum",
},
twitter: {
card: "summary_large_image",
site: "@winampskins",
title,
description,
creator: "@captbaritone",
images,
},
};
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

View file

@ -0,0 +1,330 @@
"use client";
import {
Smartphone,
Info,
Grid3x3,
Menu,
MessageSquare,
Upload,
Github,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
import {
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
unstable_ViewTransition as ViewTransition,
useState,
useEffect,
useRef,
} from "react";
export default function BottomMenuBar() {
const [isHamburgerOpen, setIsHamburgerOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const toggleHamburger = () => {
setIsHamburgerOpen(!isHamburgerOpen);
};
// Close hamburger menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
isHamburgerOpen
) {
setIsHamburgerOpen(false);
}
}
if (isHamburgerOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isHamburgerOpen]);
return (
<>
{/* Hamburger Menu Overlay */}
{isHamburgerOpen && (
<div
style={{
position: "fixed",
bottom: "4.5rem",
left: "50%",
transform: "translateX(-50%)",
width: "100%",
maxWidth: MOBILE_MAX_WIDTH,
backgroundColor: "rgba(26, 26, 26, 0.98)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderBottom: "none",
boxShadow: "0 -4px 12px rgba(0, 0, 0, 0.3)",
zIndex: 999,
}}
>
<div ref={menuRef}>
<HamburgerMenuItem
href="/scroll/about"
icon={<Info size={20} />}
label="About"
onClick={() => {
setIsHamburgerOpen(false);
}}
/>
<HamburgerMenuItem
href="/scroll/feedback"
icon={<MessageSquare size={20} />}
label="Feedback"
onClick={() => {
setIsHamburgerOpen(false);
}}
/>
<HamburgerMenuItem
href="https://github.com/captbaritone/webamp/"
icon={<Github size={20} />}
label="GitHub"
onClick={() => {
setIsHamburgerOpen(false);
}}
external
/>
</div>
</div>
)}
{/* Bottom Menu Bar */}
<div
style={{
position: "sticky",
bottom: 0,
left: 0,
width: "100%",
backgroundColor: "rgba(26, 26, 26, 0.95)",
backdropFilter: "blur(10px)",
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
padding: "0.75rem 0",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 1000,
}}
>
<div
style={{
width: "100%",
maxWidth: MOBILE_MAX_WIDTH, // Match the scroll page max width
display: "flex",
justifyContent: "space-evenly",
alignItems: "center",
}}
>
<MenuButton
href="/scroll"
icon={<Grid3x3 size={24} />}
label="Grid"
isActive={pathname === "/scroll"}
/>
<MenuButton
href="/scroll/skin"
icon={<Smartphone size={24} />}
label="Feed"
isActive={pathname.startsWith("/scroll/skin")}
/>
<MenuButton
href="/upload"
icon={<Upload size={24} />}
label="Upload"
isActive={pathname === "/upload"}
/>
<MenuButton
icon={<Menu size={24} />}
label="Menu"
onClick={toggleHamburger}
isButton
isActive={false}
/>
</div>
</div>
</>
);
}
type MenuButtonProps = {
href?: string;
icon: React.ReactNode;
label: string;
isButton?: boolean;
isActive?: boolean;
onClick?: () => void;
};
function MenuButton({
href,
icon,
label,
isButton = false,
isActive = false,
onClick,
}: MenuButtonProps) {
const touchTargetSize = "3.0rem";
const containerStyle = {
display: "flex",
flexDirection: "column" as const,
alignItems: "center",
justifyContent: "center",
gap: "0.25rem",
color: "#ccc",
textDecoration: "none",
cursor: "pointer",
transition: "color 0.2s ease",
background: "none",
border: "none",
padding: 0,
position: "relative" as const,
width: touchTargetSize,
minWidth: touchTargetSize,
};
const handleMouseEnter = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.color = "#fff";
};
const handleMouseLeave = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.color = "#ccc";
};
const content = (
<>
{/* Active indicator line */}
{isActive && (
<ViewTransition name="footer-menu-active">
<div
style={{
position: "absolute",
top: "-0.75rem",
left: 0,
width: touchTargetSize,
height: "1px",
backgroundColor: "#fff",
}}
/>
</ViewTransition>
)}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{icon}
</div>
<span
style={{
fontSize: "0.65rem",
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
fontWeight: 500,
}}
></span>
</>
);
if (isButton) {
return (
<button
style={containerStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={onClick}
>
{content}
</button>
);
}
return (
<Link
href={href!}
title={label}
style={containerStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{content}
</Link>
);
}
type HamburgerMenuItemProps = {
href: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
external?: boolean;
};
function HamburgerMenuItem({
href,
icon,
label,
onClick,
external = false,
}: HamburgerMenuItemProps) {
const content = (
<div
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
padding: "1rem 1.5rem",
color: "#ccc",
textDecoration: "none",
cursor: "pointer",
transition: "background-color 0.2s ease, color 0.2s ease",
borderBottom: "1px solid rgba(255, 255, 255, 0.05)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.05)";
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "#ccc";
}}
>
{icon}
<span
style={{
fontSize: "0.9rem",
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
fontWeight: 500,
}}
>
{label}
</span>
</div>
);
if (external) {
return (
<a href={href} onClick={onClick} style={{ textDecoration: "none" }}>
{content}
</a>
);
}
return (
<Link href={href} onClick={onClick} style={{ textDecoration: "none" }}>
{content}
</Link>
);
}

View file

@ -0,0 +1,103 @@
"use server";
import { knex } from "../../../db";
import { markAsNSFW } from "../../../data/skins";
import UserContext from "../../../data/UserContext";
export async function logUserEvent(sessionId: string, event: UserEvent) {
const timestamp = Date.now();
console.log("Logging user event:", {
sessionId,
timestamp,
event,
});
await knex("user_log_events").insert({
session_id: sessionId,
timestamp: timestamp,
metadata: JSON.stringify(event),
});
// If this is a NSFW report, call the existing infrastructure
if (event.type === "skin_flag_nsfw") {
// Create an anonymous user context for the report
const ctx = new UserContext();
await markAsNSFW(ctx, event.skinMd5);
}
}
export type UserEvent =
| {
type: "session_start";
}
| {
type: "session_end";
reason: "unmount" | "before_unload";
}
/**
* @deprecated
*/
| {
type: "skin_view";
skinMd5: string;
}
| {
type: "skin_view_start";
skinMd5: string;
}
| {
type: "skin_view_end";
skinMd5: string;
durationMs: number;
}
| {
type: "skins_fetch_start";
offset: number;
}
| {
type: "skins_fetch_success";
offset: number;
}
| {
type: "skins_fetch_failure";
offset: number;
errorMessage: string;
}
| {
type: "readme_expand";
skinMd5: string;
}
| {
type: "skin_download";
skinMd5: string;
}
| {
type: "skin_like";
skinMd5: string;
liked: boolean;
}
| {
type: "skin_flag_nsfw";
skinMd5: string;
}
| {
type: "share_open";
skinMd5: string;
}
| {
type: "share_success";
skinMd5: string;
}
| {
type: "share_failure";
skinMd5: string;
errorMessage: string;
}
| {
type: "menu_click";
menuItem: string;
}
| {
type: "scroll_hint_shown";
};

View file

@ -0,0 +1,344 @@
"use client";
import React, {
useState,
useMemo,
useCallback,
useRef,
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
unstable_ViewTransition as ViewTransition,
useEffect,
} from "react";
import Link from "next/link";
import { FixedSizeGrid as Grid } from "react-window";
import { useWindowSize } from "../../../legacy-client/src/hooks";
import {
SCREENSHOT_WIDTH,
SKIN_RATIO,
MOBILE_MAX_WIDTH,
} from "../../../legacy-client/src/constants";
import { getMuseumPageSkins, GridSkin } from "./getMuseumPageSkins";
import { searchSkins as performAlgoliaSearch } from "./algoliaClient";
// Simple utility to get screenshot URL (avoiding server-side import)
function getScreenshotUrl(md5: string): string {
return `https://r2.webampskins.org/screenshots/${md5}.png`;
}
type CellData = {
skins: GridSkin[];
columnCount: number;
width: number;
height: number;
loadMoreSkins: (startIndex: number) => Promise<void>;
};
function Cell({
columnIndex,
rowIndex,
style,
data,
}: {
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
data: CellData;
}) {
const { skins, width, height, columnCount } = data;
const index = rowIndex * columnCount + columnIndex;
data.loadMoreSkins(index);
const skin = skins[index];
if (!skin) {
return null;
}
return (
<div style={style}>
<div style={{ width, height, position: "relative" }}>
<Link href={`/scroll/skin/${skin.md5}`}>
<ViewTransition name={`skin-${skin.md5}`}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</ViewTransition>
</Link>
{skin.nsfw && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "14px",
}}
>
NSFW
</div>
)}
</div>
</div>
);
}
type SkinTableProps = {
initialSkins: GridSkin[];
initialTotal: number;
};
export default function SkinTable({
initialSkins,
initialTotal,
}: SkinTableProps) {
const { windowWidth, windowHeight } = useWindowSize();
// Search input state - separate input value from actual search query
const [inputValue, setInputValue] = useState(() => {
const params = new URLSearchParams(window.location.search);
return params.get("q") || "";
});
// State for browsing mode
const [browseSkins, setBrowseSkins] = useState<GridSkin[]>(initialSkins);
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([0]));
const isLoadingRef = useRef(false);
// State for search mode
const [searchSkins, setSearchSkins] = useState<GridSkin[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);
const [_, setSearchIsPending] = useState(false);
// Debounce timer ref
// Determine which mode we're in based on actual search query, not input
const isSearchMode = inputValue.trim().length > 0;
const skins = isSearchMode ? searchSkins : browseSkins;
const total = isSearchMode ? searchSkins.length : initialTotal;
// Handle search input change
const handleSearchChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
setInputValue(query);
};
useEffect(() => {
const query = inputValue;
const newUrl = query.trim() === "" ? "/scroll/" : `/scroll/?q=${query}`;
// window.document.title = `${skins[visibleSkinIndex].fileName} - Winamp Skin Museum`;
window.history.replaceState(
{ ...window.history.state, as: newUrl, url: newUrl },
"",
newUrl
);
// If query is empty, clear results immediately
if (!query || query.trim().length === 0) {
setSearchSkins([]);
setSearchError(null);
return;
}
async function fetchResults() {
try {
setSearchIsPending(true);
const result = await performAlgoliaSearch(query);
const hits = result.hits as Array<{
objectID: string;
fileName: string;
nsfw?: boolean;
}>;
const searchResults: GridSkin[] = hits.map((hit) => ({
md5: hit.objectID,
screenshotUrl: getScreenshotUrl(hit.objectID),
fileName: hit.fileName,
nsfw: hit.nsfw ?? false,
}));
setSearchSkins(searchResults);
} catch (err) {
console.error("Search failed:", err);
setSearchError("Search failed. Please try again.");
setSearchSkins([]);
} finally {
setSearchIsPending(false);
}
}
fetchResults();
}, [inputValue]);
const columnCount = Math.round(windowWidth / (SCREENSHOT_WIDTH * 0.9));
const columnWidth = windowWidth / columnCount;
const rowHeight = columnWidth * SKIN_RATIO;
const pageSize = 50; // Number of skins to load per page
const loadMoreSkins = useCallback(
async (startIndex: number) => {
// Don't load more in search mode
if (isSearchMode) {
return;
}
const pageNumber = Math.floor(startIndex / pageSize);
// Don't reload if we already have this page
if (loadedPages.has(pageNumber) || isLoadingRef.current) {
return;
}
isLoadingRef.current = true;
try {
const offset = pageNumber * pageSize;
const newSkins = await getMuseumPageSkins(offset, pageSize);
setBrowseSkins((prev) => [...prev, ...newSkins]);
setLoadedPages((prev) => new Set([...prev, pageNumber]));
} catch (error) {
console.error("Failed to load skins:", error);
} finally {
isLoadingRef.current = false;
}
},
[loadedPages, pageSize, isSearchMode]
);
const itemKey = useCallback(
({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => {
const index = rowIndex * columnCount + columnIndex;
const skin = skins[index];
return skin ? skin.md5 : `empty-cell-${columnIndex}-${rowIndex}`;
},
[columnCount, skins]
);
const gridRef = React.useRef<any>(null);
const itemRef = React.useRef<number>(0);
const onScroll = useMemo(() => {
const half = Math.round(columnCount / 2);
return (scrollData: { scrollTop: number }) => {
itemRef.current =
Math.round(scrollData.scrollTop / rowHeight) * columnCount + half;
};
}, [columnCount, rowHeight]);
const itemData: CellData = useMemo(
() => ({
skins,
columnCount,
width: columnWidth,
height: rowHeight,
loadMoreSkins,
}),
[skins, columnCount, columnWidth, rowHeight, loadMoreSkins]
);
return (
<div id="infinite-skins">
{/* Floating Search Bar */}
<div
style={{
position: "fixed",
bottom: "4.25rem",
left: "50%",
transform: "translateX(-50%)",
width: "calc(100% - 2rem)",
maxWidth: MOBILE_MAX_WIDTH,
padding: "0 1rem",
zIndex: 998,
}}
>
<div style={{ position: "relative" }}>
<input
type="search"
value={inputValue}
onChange={handleSearchChange}
placeholder="Search skins..."
style={{
width: "100%",
padding: "0.75rem 1rem",
paddingRight: "1rem",
fontSize: "1rem",
backgroundColor: "rgba(26, 26, 26, 0.55)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "9999px",
color: "#fff",
outline: "none",
fontFamily: "inherit",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
transition: "padding-right 0.2s ease",
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.65)";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.3)";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.55)";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.2)";
}}
/>
</div>
</div>
{/* Error State */}
{isSearchMode && searchError && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: windowHeight,
color: "#ff6b6b",
}}
>
{searchError}
</div>
)}
{/* Empty Results */}
{isSearchMode && !searchError && skins.length === 0 && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: windowHeight,
color: "#ccc",
}}
>
No results found for &quot;{inputValue}&quot;
</div>
)}
{/* Grid - show when browsing or when we have results (even while pending) */}
{(!isSearchMode || (!searchError && skins.length > 0)) && (
<Grid
ref={gridRef}
itemKey={itemKey}
itemData={itemData}
columnCount={columnCount}
columnWidth={columnWidth}
height={windowHeight}
rowCount={Math.ceil(total / columnCount)}
rowHeight={rowHeight}
width={windowWidth}
overscanRowsCount={5}
onScroll={onScroll}
style={{ overflowY: "scroll" }}
>
{Cell}
</Grid>
)}
</div>
);
}

View file

@ -0,0 +1,198 @@
"use client";
import {
useState,
useLayoutEffect,
useEffect,
useRef,
useCallback,
memo,
} from "react";
import { FixedSizeGrid as Grid } from "react-window";
import { useRouter } from "next/navigation";
import { ClientSkin } from "./SkinScroller";
import {
SCREENSHOT_WIDTH,
SKIN_RATIO,
} from "../../../legacy-client/src/constants";
type Props = {
initialSkins: ClientSkin[];
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
sessionId: string;
};
type CellProps = {
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
data: {
skins: ClientSkin[];
columnCount: number;
requestSkinsIfNeeded: (index: number) => void;
};
};
// Extract Cell as a separate component so we can use hooks
const GridCell = memo(({ columnIndex, rowIndex, style, data }: CellProps) => {
const { skins, columnCount, requestSkinsIfNeeded } = data;
const router = useRouter();
const index = rowIndex * columnCount + columnIndex;
const skin = skins[index];
// Request more skins if this cell needs data
useEffect(() => {
if (!skin) {
requestSkinsIfNeeded(index);
}
}, [skin, index, requestSkinsIfNeeded]);
if (!skin) {
return <div style={style} />;
}
return (
<div
style={{
...style,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a1a",
padding: "2px",
boxSizing: "border-box",
cursor: "pointer",
}}
onClick={() => {
router.push(`/scroll/skin/${skin.md5}`);
}}
>
<div style={{ width: "100%", height: "100%", position: "relative" }}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
width: "100%",
height: "100%",
display: "block",
imageRendering: "pixelated",
objectFit: "cover",
}}
/>
</div>
</div>
);
});
GridCell.displayName = "GridCell";
// Calculate grid dimensions based on window width
// Skins will be scaled to fill horizontally across multiple columns
function getGridDimensions(windowWidth: number) {
const scale = 1.0; // Can be adjusted for different sizes
const columnCount = Math.max(
1,
Math.floor(windowWidth / (SCREENSHOT_WIDTH * scale))
);
const columnWidth = windowWidth / columnCount;
const rowHeight = columnWidth * SKIN_RATIO;
return { columnWidth, rowHeight, columnCount };
}
export default function InfiniteScrollGrid({
initialSkins,
getSkins,
sessionId,
}: Props) {
const [skins, setSkins] = useState<ClientSkin[]>(initialSkins);
const [fetching, setFetching] = useState(false);
const [windowWidth, setWindowWidth] = useState(0);
const [windowHeight, setWindowHeight] = useState(0);
const gridRef = useRef<Grid>(null);
const requestedIndicesRef = useRef<Set<number>>(new Set());
// Track window size
useLayoutEffect(() => {
function updateSize() {
setWindowWidth(window.innerWidth);
setWindowHeight(window.innerHeight);
}
updateSize();
window.addEventListener("resize", updateSize);
return () => window.removeEventListener("resize", updateSize);
}, []);
// Scroll to top when window width changes (column count changes)
useEffect(() => {
if (gridRef.current && windowWidth > 0) {
gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: 0 });
}
}, [windowWidth]);
// Function to request more skins when a cell needs data
const requestSkinsIfNeeded = useCallback(
(index: number) => {
// Only fetch if this index is beyond our current data
if (index >= skins.length) {
// Calculate which batch this index belongs to
const batchSize = 50; // Fetch in batches
const batchStart = Math.floor(skins.length / batchSize) * batchSize;
// Only fetch if we haven't already requested this batch
if (!requestedIndicesRef.current.has(batchStart) && !fetching) {
requestedIndicesRef.current.add(batchStart);
setFetching(true);
getSkins(sessionId, batchStart)
.then((newSkins) => {
setSkins((prevSkins) => [...prevSkins, ...newSkins]);
setFetching(false);
})
.catch(() => {
requestedIndicesRef.current.delete(batchStart);
setFetching(false);
});
}
}
},
[skins.length, fetching, sessionId, getSkins]
);
const { columnWidth, rowHeight, columnCount } =
getGridDimensions(windowWidth);
if (windowWidth === 0 || windowHeight === 0) {
return null; // Don't render until we have window dimensions
}
const rowCount = Math.ceil(skins.length / columnCount);
const itemData = {
skins,
columnCount,
requestSkinsIfNeeded,
};
return (
<div style={{ backgroundColor: "#1a1a1a" }}>
<Grid
ref={gridRef}
columnCount={columnCount}
columnWidth={columnWidth}
height={windowHeight}
rowCount={rowCount}
rowHeight={rowHeight}
width={windowWidth}
itemData={itemData}
overscanRowCount={2}
style={{
scrollbarWidth: "none", // Firefox
msOverflowStyle: "none", // IE and Edge
}}
className="hide-scrollbar"
>
{GridCell}
</Grid>
</div>
);
}

View file

@ -0,0 +1,233 @@
"use client";
import { useState, ReactNode } from "react";
import { Heart, Share2, Flag, Download } from "lucide-react";
import { ClientSkin } from "./SkinScroller";
import { logUserEvent } from "./Events";
type Props = {
skin: ClientSkin;
sessionId: string;
};
export default function SkinActionIcons({ skin, sessionId }: Props) {
return (
<div
style={{
position: "absolute",
right: "1rem",
bottom: "2rem",
display: "flex",
flexDirection: "column",
gap: "1.5rem",
paddingBottom: "1rem",
}}
>
<LikeButton skin={skin} sessionId={sessionId} />
<ShareButton skin={skin} sessionId={sessionId} />
<FlagButton skin={skin} sessionId={sessionId} />
<DownloadButton skin={skin} sessionId={sessionId} />
</div>
);
}
// Implementation details below
type ButtonProps = {
onClick: () => void;
disabled?: boolean;
opacity?: number;
"aria-label": string;
children: ReactNode;
};
function Button({
onClick,
disabled = false,
opacity = 1,
"aria-label": ariaLabel,
children,
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
background: "none",
border: "none",
cursor: disabled ? "default" : "pointer",
padding: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.25rem",
opacity,
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))",
}}
aria-label={ariaLabel}
>
{children}
</button>
);
}
type LikeButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function LikeButton({ skin, sessionId }: LikeButtonProps) {
const [isLiked, setIsLiked] = useState(false);
const [likeCount, setLikeCount] = useState(skin.likeCount);
const handleLike = async () => {
const newLikedState = !isLiked;
setIsLiked(newLikedState);
// Optimistically update the like count
setLikeCount((prevCount) =>
newLikedState ? prevCount + 1 : prevCount - 1
);
logUserEvent(sessionId, {
type: "skin_like",
skinMd5: skin.md5,
liked: newLikedState,
});
};
return (
<Button onClick={handleLike} aria-label="Like">
<Heart
size={32}
color="white"
fill={isLiked ? "white" : "none"}
strokeWidth={2}
/>
{likeCount > 0 && (
<span
style={{
color: "white",
fontSize: "0.75rem",
fontWeight: "bold",
}}
>
{likeCount}
</span>
)}
</Button>
);
}
type ShareButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function ShareButton({ skin, sessionId }: ShareButtonProps) {
const handleShare = async () => {
if (navigator.share) {
try {
logUserEvent(sessionId, {
type: "share_open",
skinMd5: skin.md5,
});
await navigator.share({
title: skin.fileName,
text: `Check out this Winamp skin: ${skin.fileName}`,
url: skin.shareUrl,
});
logUserEvent(sessionId, {
type: "share_success",
skinMd5: skin.md5,
});
} catch (error) {
// User cancelled or share failed
if (error instanceof Error && error.name !== "AbortError") {
console.error("Share failed:", error);
logUserEvent(sessionId, {
type: "share_failure",
skinMd5: skin.md5,
errorMessage: error.message,
});
}
}
} else {
// Fallback: copy to clipboard
await navigator.clipboard.writeText(skin.shareUrl);
logUserEvent(sessionId, {
type: "share_success",
skinMd5: skin.md5,
});
alert("Share link copied to clipboard!");
}
};
return (
<Button onClick={handleShare} aria-label="Share">
<Share2 size={32} color="white" strokeWidth={2} />
</Button>
);
}
type FlagButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function FlagButton({ skin, sessionId }: FlagButtonProps) {
const [isFlagged, setIsFlagged] = useState(skin.nsfw);
const handleFlagNsfw = async () => {
if (isFlagged) return; // Only allow flagging once
setIsFlagged(true);
logUserEvent(sessionId, {
type: "skin_flag_nsfw",
skinMd5: skin.md5,
});
};
return (
<Button
onClick={handleFlagNsfw}
disabled={isFlagged}
opacity={isFlagged ? 0.5 : 1}
aria-label="Flag as NSFW"
>
<Flag
size={32}
color="white"
fill={isFlagged ? "white" : "none"}
strokeWidth={2}
/>
</Button>
);
}
type DownloadButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function DownloadButton({ skin, sessionId }: DownloadButtonProps) {
const handleDownload = async () => {
logUserEvent(sessionId, {
type: "skin_download",
skinMd5: skin.md5,
});
// Trigger download
window.location.href = skin.downloadUrl;
};
return (
<Button onClick={handleDownload} aria-label="Download">
<Download size={32} color="white" strokeWidth={2} />
</Button>
);
}

View file

@ -0,0 +1,97 @@
"use client";
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
import { unstable_ViewTransition as ViewTransition } from "react";
import { ClientSkin } from "./SkinScroller";
import SkinActionIcons from "./SkinActionIcons";
import WebampComponent from "./Webamp";
type Props = {
skin: ClientSkin;
index: number;
sessionId: string;
focused: boolean;
};
export default function SkinPage({ skin, index, sessionId, focused }: Props) {
const showWebamp = focused;
return (
<div
key={skin.md5}
skin-md5={skin.md5}
skin-index={index}
className="scroller"
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
width: "100%",
height: "100vh",
scrollSnapAlign: "start",
scrollSnapStop: "always",
position: "relative",
paddingTop: "2rem", // Space for top shadow
paddingBottom: "5rem", // Space for bottom menu bar
boxSizing: "border-box",
}}
>
<div style={{ position: "relative", flexShrink: 0 }}>
<ViewTransition name={`skin-${skin.md5}`}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
position: "relative",
width: "100%",
aspectRatio: "275 / 348",
imageRendering: "pixelated",
}}
/>
{showWebamp && (
<WebampComponent
skinUrl={skin.skinUrl}
closeModal={() => {}}
loaded={() => {}}
/>
)}
</ViewTransition>
<SkinActionIcons skin={skin} sessionId={sessionId} />
</div>
<div
style={{
color: "white",
paddingLeft: "0.5rem",
paddingTop: "0.5rem",
flexShrink: 0,
}}
>
<h2
style={{
marginBottom: 0,
fontSize: "0.9rem",
paddingBottom: "0",
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
color: "#ccc",
wordBreak: "break-all",
}}
>
{skin.fileName}
</h2>
<p
style={{
marginTop: "0.5rem",
fontSize: "0.75rem",
paddingTop: "0",
color: "#999",
fontFamily: 'monospace, "Courier New", Courier, monospace',
overflow: "hidden",
}}
>
{skin.readmeStart}
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,216 @@
"use client";
import { useState, useLayoutEffect, useEffect } from "react";
import SkinPage from "./SkinPage";
import { logUserEvent } from "./Events";
import { useScrollHint } from "./useScrollHint";
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
export type ClientSkin = {
screenshotUrl: string;
skinUrl: string;
fileName: string;
md5: string;
readmeStart: string;
downloadUrl: string;
shareUrl: string;
nsfw: boolean;
likeCount: number;
};
type Props = {
initialSkins: ClientSkin[];
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
sessionId: string;
};
export default function SkinScroller({
initialSkins,
getSkins,
sessionId,
}: Props) {
const [skins, setSkins] = useState<ClientSkin[]>(initialSkins);
const [visibleSkinIndex, setVisibleSkinIndex] = useState(0);
const [fetching, setFetching] = useState(false);
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [hasEverScrolled, setHasEverScrolled] = useState(false);
// Track if user has ever scrolled to another skin
useEffect(() => {
if (visibleSkinIndex > 0) {
setHasEverScrolled(true);
}
}, [visibleSkinIndex]);
// Show scroll hint only if user has never scrolled to another skin
useScrollHint({
containerRef,
enabled: visibleSkinIndex === 0 && !hasEverScrolled,
onHintShown: () => {
logUserEvent(sessionId, {
type: "scroll_hint_shown",
});
},
});
useLayoutEffect(() => {
if (containerRef == null) {
return;
}
// Use IntersectionObserver for cross-browser compatibility (iOS doesn't support scrollsnapchange)
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// When an element becomes mostly visible (> 50% intersecting)
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
const index = parseInt(
entry.target.getAttribute("skin-index") || "0",
10
);
setVisibleSkinIndex(index);
}
});
},
{
root: containerRef,
threshold: 0.5, // Trigger when 50% of the element is visible
}
);
// Observe all skin page elements
const skinElements = containerRef.querySelectorAll("[skin-index]");
skinElements.forEach((element) => observer.observe(element));
return () => {
observer.disconnect();
};
}, [containerRef, skins.length]);
useEffect(() => {
logUserEvent(sessionId, {
type: "session_start",
});
function beforeUnload() {
logUserEvent(sessionId, {
type: "session_end",
reason: "before_unload",
});
}
addEventListener("beforeunload", beforeUnload);
return () => {
removeEventListener("beforeunload", beforeUnload);
logUserEvent(sessionId, {
type: "session_end",
reason: "unmount",
});
};
}, []);
useEffect(() => {
// We want the URL and title to update as you scroll, but
// we can't trigger a NextJS navigation since that would remount the
// component. So, here we replicate the metadata behavior of the route.
const skinMd5 = skins[visibleSkinIndex].md5;
const newUrl = `/scroll/skin/${skinMd5}`;
window.document.title = `${skins[visibleSkinIndex].fileName} - Winamp Skin Museum`;
window.history.replaceState(
{ ...window.history.state, as: newUrl, url: newUrl },
"",
newUrl
);
logUserEvent(sessionId, {
type: "skin_view_start",
skinMd5,
});
const startTime = Date.now();
return () => {
const durationMs = Date.now() - startTime;
logUserEvent(sessionId, {
type: "skin_view_end",
skinMd5: skins[visibleSkinIndex].md5,
durationMs,
});
};
}, [visibleSkinIndex, skins, fetching]);
useLayoutEffect(() => {
if (fetching) {
return;
}
if (visibleSkinIndex + 5 >= skins.length) {
setFetching(true);
console.log("Fetching more skins...");
logUserEvent(sessionId, {
type: "skins_fetch_start",
offset: skins.length,
});
getSkins(sessionId, skins.length)
.then((newSkins) => {
logUserEvent(sessionId, {
type: "skins_fetch_success",
offset: skins.length,
});
setSkins([...skins, ...newSkins]);
setFetching(false);
})
.catch((error) => {
logUserEvent(sessionId, {
type: "skins_fetch_failure",
offset: skins.length,
errorMessage: error.message,
});
setFetching(false);
});
}
}, [visibleSkinIndex, skins, fetching]);
return (
<>
<div
ref={setContainerRef}
style={{
maxWidth: MOBILE_MAX_WIDTH, // 9:16 aspect ratio for scroll, full width for grid
margin: "0 auto",
height: "100vh",
// width: "100%",
overflowY: "scroll",
scrollSnapType: "y mandatory",
scrollbarWidth: "none", // Firefox
msOverflowStyle: "none", // IE and Edge
WebkitOverflowScrolling: "touch", // Smooth scrolling on iOS
}}
className="hide-scrollbar"
>
{skins.map((skin, i) => {
return (
<SkinPage
key={skin.md5}
skin={skin}
index={i}
focused={i === visibleSkinIndex}
sessionId={sessionId}
/>
);
})}
</div>
{/* Top shadow overlay */}
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "4rem",
background:
"linear-gradient(180deg, rgba(26, 26, 26, 0.8) 0%, rgba(26, 26, 26, 0.4) 50%, rgba(26, 26, 26, 0) 100%)",
pointerEvents: "none",
zIndex: 500,
}}
/>
</>
);
}

View file

@ -0,0 +1,182 @@
import { ReactNode, CSSProperties } from "react";
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
type StaticPageProps = {
children: ReactNode;
};
export default function StaticPage({ children }: StaticPageProps) {
return (
<div
style={{
minHeight: "100vh",
backgroundColor: "#1a1a1a",
paddingBottom: "5rem", // Space for bottom menu bar
boxSizing: "border-box",
}}
>
<div
style={{
maxWidth: MOBILE_MAX_WIDTH,
margin: "0 auto",
padding: "2rem 1.5rem",
color: "#fff",
lineHeight: "1.6",
}}
>
{children}
</div>
</div>
);
}
// Styled heading components
export function Heading({ children }: { children: ReactNode }) {
return (
<h1
style={{
fontSize: "2rem",
marginBottom: "1.5rem",
fontWeight: 600,
}}
>
{children}
</h1>
);
}
export function Subheading({ children }: { children: ReactNode }) {
return (
<h2
style={{
fontSize: "1.5rem",
marginTop: "2rem",
marginBottom: "1rem",
fontWeight: 600,
}}
>
{children}
</h2>
);
}
// Styled link component
export function Link({
href,
children,
...props
}: {
href: string;
children: ReactNode;
target?: string;
rel?: string;
}) {
return (
<a
href={href}
style={{
color: "#6b9eff",
textDecoration: "underline",
}}
{...props}
>
{children}
</a>
);
}
// Styled paragraph component
export function Paragraph({ children }: { children: ReactNode }) {
return <p style={{ marginBottom: "1rem" }}>{children}</p>;
}
// Styled form components
export function Label({ children }: { children: ReactNode }) {
return (
<label
style={{
display: "block",
marginBottom: "0.5rem",
fontWeight: 500,
}}
>
{children}
</label>
);
}
export function Input({
style,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { style?: CSSProperties }) {
return (
<input
style={{
width: "100%",
padding: "0.75rem",
fontSize: "1rem",
backgroundColor: "#2a2a2a",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "4px",
color: "#fff",
fontFamily: "inherit",
boxSizing: "border-box",
...style,
}}
{...props}
/>
);
}
export function Textarea({
style,
...props
}: React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
style?: CSSProperties;
}) {
return (
<textarea
style={{
width: "100%",
padding: "0.75rem",
fontSize: "1rem",
backgroundColor: "#2a2a2a",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "4px",
color: "#fff",
fontFamily: "inherit",
display: "block",
resize: "vertical",
boxSizing: "border-box",
...style,
}}
{...props}
/>
);
}
export function Button({
style,
disabled,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { style?: CSSProperties }) {
return (
<button
style={{
padding: "0.75rem 2rem",
fontSize: "1rem",
fontWeight: 500,
backgroundColor: "#6b9eff",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
transition: "opacity 0.2s",
...style,
}}
disabled={disabled}
{...props}
/>
);
}

Some files were not shown because too many files have changed in this diff Show more