Compare commits

...

78 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
148 changed files with 8200 additions and 796 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

@ -50,6 +50,9 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.repository == 'captbaritone/webamp'
needs: [ci]
permissions:
contents: read
id-token: write # Required for OIDC trusted publishing
steps:
- uses: actions/checkout@v4
- name: Install pnpm
@ -61,6 +64,8 @@ jobs:
node-version: 20.x
registry-url: https://registry.npmjs.org/
cache: "pnpm"
- name: Update npm to latest version
run: npm install -g npm@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Restore build artifacts
@ -81,31 +86,40 @@ jobs:
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
run: |
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')
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
run: |
npm publish ${TAG} --ignore-scripts
env:
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
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')
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
run: |
npm publish ${TAG} --ignore-scripts
env:
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
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')
if: github.ref == 'refs/heads/master' || (github.ref_type == 'tag' && startsWith(github.ref_name, 'v'))
# Use pre-built artifacts instead of rebuilding
run: |
npm publish ${TAG} --ignore-scripts
env:
TAG: ${{ github.ref == 'refs/heads/master' && '--tag=next' || ''}}
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
npm publish --tag=next --ignore-scripts --provenance
else
npm publish --ignore-scripts --provenance
fi

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules
.vscode
.idea
dist
# Turborepo cache

View file

@ -20,9 +20,9 @@ Webamp uses a [monorepo](https://en.wikipedia.org/wiki/Monorepo) approach, so in
- [`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/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/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

View file

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

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

@ -10,7 +10,7 @@
<!-- Webamp will attempt to center itself within this div -->
</div>
<script type="module">
import Webamp from "https://unpkg.com/webamp@^2";
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
const webamp = new Webamp({
initialTracks: [
{

View file

@ -11,12 +11,11 @@
</div>
<script type="module">
/**
* Webamp includes a `webamp/butterchurn` entrypoint which includes the Butterchurn
* library to enable the Milkdrop visualizer. It hasn't been included in a
* stable version yet, until it is, you can use by installing
* `webamp@next`.
* 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@0.0.0-next-7cdc35b/butterchurn";
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
const webamp = new Webamp({
initialTracks: [
{

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

@ -1,5 +1,5 @@
[build]
command = "pnpm deploy"
command = "pnpm run deploy"
publish = "packages/webamp/dist/demo-site/"
# A short URL for listeners of https://changelog.com/podcast/291

View file

@ -13,9 +13,9 @@
"test:integration": "npx turbo run integration-tests",
"test:all": "npx turbo run test integration-tests",
"test:unit": "jest",
"lint": "eslint . --ext ts,tsx,js,jsx --rulesdir=packages/webamp-modern/tools/eslint-rules",
"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": "sh deploy.sh",
"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": {
@ -43,5 +43,10 @@
"prettier": {
"trailingComma": "es5"
},
"version": "0.0.0-next-87012d8d"
"version": "0.0.0-next-87012d8d",
"pnpm": {
"patchedDependencies": {
"butterchurn@3.0.0-beta.5": "patches/butterchurn@3.0.0-beta.5.patch"
}
}
}

View file

@ -32,6 +32,7 @@
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit",
"lint": "eslint src --ext ts,js",
"test": "jest",
"prepublish": "tsc"
},

View file

@ -1,32 +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", // Allow require() in JS files
"@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: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
// 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

@ -118,7 +118,7 @@ async function addClassicSkinFromBuffer(
await setHashesForSkin(skin);
// Disable while we figure out our quota
// await Skins.updateSearchIndex(ctx, md5);
await Skins.updateSearchIndex(ctx, md5);
return { md5, status: "ADDED", skinType: "CLASSIC" };
}

View file

@ -1,8 +1,3 @@
import { Router } from "express";
import { createYoga, YogaInitialContext } from "graphql-yoga";
// import DEFAULT_QUERY from "./defaultQuery";
import { getSchema } from "./schema";
import UserContext from "../../data/UserContext.js";
/** @gqlContext */
@ -12,18 +7,3 @@ export type Ctx = Express.Request;
export function getUserContext(ctx: Ctx): UserContext {
return ctx.ctx;
}
const router = Router();
const yoga = createYoga({
schema: getSchema(),
context: (ctx: YogaInitialContext) => {
// @ts-expect-error
return ctx.req;
},
});
// Bind GraphQL Yoga to the graphql endpoint to avoid rendering the playground on any path
router.use("", yoga);
export default router;

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,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.
@ -164,6 +169,16 @@ type ArchiveFile {
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"""
type ClassicSkin implements Node & Skin {
"""List of files contained within the skin's .wsz archive"""
@ -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

View file

@ -2,8 +2,10 @@
* Executable schema generated by Grats (https://grats.capt.dev)
* Do not manually edit. Regenerate by running `npx grats`.
*/
import { defaultFieldResolver, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLBoolean, GraphQLInt, GraphQLInterfaceType, GraphQLList, GraphQLNonNull, GraphQLID, GraphQLEnumType, GraphQLInputObjectType } from "graphql";
import { getUserContext as getUserContext } from "./index";
import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLList, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLString, defaultFieldResolver, GraphQLNonNull, GraphQLInterfaceType, GraphQLBoolean, GraphQLID, GraphQLEnumType, GraphQLInputObjectType } from "graphql";
import { getUserContext } from "./index";
import { bulkDownload as queryBulkDownloadResolver } from "./resolvers/BulkDownloadConnection";
import { fetch_archive_file_by_md5 as queryFetch_archive_file_by_md5Resolver } from "./../../data/ArchiveFileModel";
import { fetch_internet_archive_item_by_identifier as queryFetch_internet_archive_item_by_identifierResolver } from "./../../data/IaItemModel";
import { fetch_skin_by_md5 as queryFetch_skin_by_md5Resolver, search_classic_skins as querySearch_classic_skinsResolver, search_skins as querySearch_skinsResolver, skin_to_review as querySkin_to_reviewResolver } from "./resolvers/SkinResolver";
@ -26,6 +28,75 @@ async function assertNonNull<T>(value: T | Promise<T>): Promise<T> {
return awaited;
}
export function getSchema(): GraphQLSchema {
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
name: "ArchiveFile",
description: "A file found within a Winamp Skin's .wsz archive",
fields() {
return {
date: {
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
name: "date",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getIsoDate());
}
},
file_md5: {
description: "The md5 hash of the file within the archive",
name: "file_md5",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileMd5());
}
},
filename: {
description: "Filename of the file within the archive",
name: "filename",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileName());
}
},
is_directory: {
description: "Is the file a directory?",
name: "is_directory",
type: GraphQLBoolean,
resolve(source) {
return assertNonNull(source.getIsDirectory());
}
},
size: {
description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories",
name: "size",
type: GraphQLInt,
resolve(source) {
return source.getFileSize();
}
},
skin: {
description: "The skin in which this file was found",
name: "skin",
type: SkinType
},
text_content: {
description: "The content of the file, if it's a text file",
name: "text_content",
type: GraphQLString,
resolve(source) {
return source.getTextContent();
}
},
url: {
description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.",
name: "url",
type: GraphQLString,
resolve(source) {
return source.getUrl();
}
}
};
}
});
const InternetArchiveItemType: GraphQLObjectType = new GraphQLObjectType({
name: "InternetArchiveItem",
fields() {
@ -187,9 +258,13 @@ export function getSchema(): GraphQLSchema {
name: "filename",
type: GraphQLString,
args: {
include_museum_id: {
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
},
normalize_extension: {
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
name: "normalize_extension",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
}
@ -253,70 +328,33 @@ export function getSchema(): GraphQLSchema {
};
}
});
const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({
name: "ArchiveFile",
description: "A file found within a Winamp Skin's .wsz archive",
const BulkDownloadConnectionType: GraphQLObjectType = new GraphQLObjectType({
name: "BulkDownloadConnection",
description: "Connection for bulk download skin metadata",
fields() {
return {
date: {
description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).",
name: "date",
estimatedSizeBytes: {
description: "Estimated total size in bytes (approximation for progress indication)",
name: "estimatedSizeBytes",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getIsoDate());
resolve(source, args, context, info) {
return assertNonNull(defaultFieldResolver(source, args, context, info));
}
},
file_md5: {
description: "The md5 hash of the file within the archive",
name: "file_md5",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileMd5());
nodes: {
description: "List of skin metadata for bulk download",
name: "nodes",
type: new GraphQLList(new GraphQLNonNull(SkinType)),
resolve(source, _args, context) {
return assertNonNull(source.nodes(getUserContext(context)));
}
},
filename: {
description: "Filename of the file within the archive",
name: "filename",
type: GraphQLString,
resolve(source) {
return assertNonNull(source.getFileName());
}
},
is_directory: {
description: "Is the file a directory?",
name: "is_directory",
type: GraphQLBoolean,
resolve(source) {
return assertNonNull(source.getIsDirectory());
}
},
size: {
description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories",
name: "size",
totalCount: {
description: "Total number of skins available for download",
name: "totalCount",
type: GraphQLInt,
resolve(source) {
return source.getFileSize();
}
},
skin: {
description: "The skin in which this file was found",
name: "skin",
type: SkinType
},
text_content: {
description: "The content of the file, if it's a text file",
name: "text_content",
type: GraphQLString,
resolve(source) {
return source.getTextContent();
}
},
url: {
description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.",
name: "url",
type: GraphQLString,
resolve(source) {
return source.getUrl();
resolve(source, args, context, info) {
return assertNonNull(defaultFieldResolver(source, args, context, info));
}
}
};
@ -382,9 +420,13 @@ export function getSchema(): GraphQLSchema {
name: "filename",
type: GraphQLString,
args: {
include_museum_id: {
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
},
normalize_extension: {
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
name: "normalize_extension",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
}
@ -545,9 +587,13 @@ export function getSchema(): GraphQLSchema {
name: "filename",
type: GraphQLString,
args: {
include_museum_id: {
description: "If true, the museum ID will be appended to the filename to ensure filenames are globally unique.",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
},
normalize_extension: {
description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.",
name: "normalize_extension",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false
}
@ -903,13 +949,30 @@ export function getSchema(): GraphQLSchema {
name: "Query",
fields() {
return {
bulkDownload: {
description: "Get metadata for bulk downloading all skins in the museum",
name: "bulkDownload",
type: BulkDownloadConnectionType,
args: {
first: {
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 1000
},
offset: {
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
}
},
resolve(_source, args) {
return assertNonNull(queryBulkDownloadResolver(args));
}
},
fetch_archive_file_by_md5: {
description: "Fetch archive file by it's MD5 hash\n\nGet information about a file found within a skin's wsz/wal/zip archive.",
name: "fetch_archive_file_by_md5",
type: ArchiveFileType,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -923,7 +986,6 @@ export function getSchema(): GraphQLSchema {
type: InternetArchiveItemType,
args: {
identifier: {
name: "identifier",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -937,7 +999,6 @@ export function getSchema(): GraphQLSchema {
type: SkinType,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -951,7 +1012,6 @@ export function getSchema(): GraphQLSchema {
type: TweetType,
args: {
url: {
name: "url",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -973,12 +1033,10 @@ export function getSchema(): GraphQLSchema {
type: ModernSkinsConnectionType,
args: {
first: {
name: "first",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 10
},
offset: {
name: "offset",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
}
@ -993,7 +1051,6 @@ export function getSchema(): GraphQLSchema {
type: NodeType,
args: {
id: {
name: "id",
type: new GraphQLNonNull(GraphQLID)
}
},
@ -1007,17 +1064,14 @@ export function getSchema(): GraphQLSchema {
type: new GraphQLList(ClassicSkinType),
args: {
first: {
name: "first",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 10
},
offset: {
name: "offset",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
},
query: {
name: "query",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1031,17 +1085,14 @@ export function getSchema(): GraphQLSchema {
type: new GraphQLList(SkinType),
args: {
first: {
name: "first",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 10
},
offset: {
name: "offset",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
},
query: {
name: "query",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1063,21 +1114,17 @@ export function getSchema(): GraphQLSchema {
type: SkinsConnectionType,
args: {
filter: {
name: "filter",
type: SkinsFilterOptionType
},
first: {
name: "first",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 10
},
offset: {
name: "offset",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
},
sort: {
name: "sort",
type: SkinsSortOptionType
}
},
@ -1099,17 +1146,14 @@ export function getSchema(): GraphQLSchema {
type: TweetsConnectionType,
args: {
first: {
name: "first",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 10
},
offset: {
name: "offset",
type: new GraphQLNonNull(GraphQLInt),
defaultValue: 0
},
sort: {
name: "sort",
type: TweetsSortOptionType
}
},
@ -1123,7 +1167,6 @@ export function getSchema(): GraphQLSchema {
type: new GraphQLList(SkinUploadType),
args: {
ids: {
name: "ids",
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
}
},
@ -1138,7 +1181,6 @@ export function getSchema(): GraphQLSchema {
type: new GraphQLList(SkinUploadType),
args: {
md5s: {
name: "md5s",
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString)))
}
},
@ -1205,7 +1247,6 @@ export function getSchema(): GraphQLSchema {
type: new GraphQLList(UploadUrlType),
args: {
files: {
name: "files",
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(UploadUrlRequestType)))
}
},
@ -1219,11 +1260,9 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
id: {
name: "id",
type: new GraphQLNonNull(GraphQLString)
},
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1244,7 +1283,6 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1258,7 +1296,6 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1272,7 +1309,6 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1286,7 +1322,6 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
md5: {
name: "md5",
type: new GraphQLNonNull(GraphQLString)
}
},
@ -1300,15 +1335,12 @@ export function getSchema(): GraphQLSchema {
type: GraphQLBoolean,
args: {
email: {
name: "email",
type: GraphQLString
},
message: {
name: "message",
type: new GraphQLNonNull(GraphQLString)
},
url: {
name: "url",
type: GraphQLString
}
},
@ -1328,8 +1360,19 @@ export function getSchema(): GraphQLSchema {
}
});
return new GraphQLSchema({
directives: [...specifiedDirectives, new GraphQLDirective({
name: "semanticNonNull",
locations: [DirectiveLocation.FIELD_DEFINITION],
description: "Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.\nIn all other cases, the position is non-null.\n\nTools doing code generation may use this information to generate the position as non-null if field errors are handled out of band:\n\n```graphql\ntype User {\n # email is semantically non-null and can be generated as non-null by error-handling clients.\n email: String @semanticNonNull\n}\n```\n\nThe `levels` argument indicates what levels are semantically non null in case of lists:\n\n```graphql\ntype User {\n # friends is semantically non null\n friends: [User] @semanticNonNull # same as @semanticNonNull(levels: [0])\n\n # every friends[k] is semantically non null\n friends: [User] @semanticNonNull(levels: [1])\n\n # friends as well as every friends[k] is semantically non null\n friends: [User] @semanticNonNull(levels: [0, 1])\n}\n```\n\n`levels` are zero indexed.\nPassing a negative level or a level greater than the list dimension is an error.",
args: {
levels: {
type: new GraphQLList(GraphQLInt),
defaultValue: [0]
}
}
})],
query: QueryType,
mutation: MutationType,
types: [RatingType, SkinUploadStatusType, SkinsFilterOptionType, SkinsSortOptionType, TweetsSortOptionType, NodeType, SkinType, UploadUrlRequestType, ArchiveFileType, ClassicSkinType, DatabaseStatisticsType, InternetArchiveItemType, ModernSkinType, ModernSkinsConnectionType, MutationType, QueryType, ReviewType, SkinUploadType, SkinsConnectionType, TweetType, TweetsConnectionType, UploadMutationsType, UploadUrlType, UserType]
types: [RatingType, SkinUploadStatusType, SkinsFilterOptionType, SkinsSortOptionType, TweetsSortOptionType, NodeType, SkinType, UploadUrlRequestType, ArchiveFileType, BulkDownloadConnectionType, ClassicSkinType, DatabaseStatisticsType, InternetArchiveItemType, ModernSkinType, ModernSkinsConnectionType, MutationType, QueryType, ReviewType, SkinUploadType, SkinsConnectionType, TweetType, TweetsConnectionType, UploadMutationsType, UploadUrlType, UserType]
});
}

View file

@ -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);
}),
]);
}

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

@ -1,4 +1,3 @@
import App from "../../../App";
import type { Metadata } from "next";
import { generateSkinPageMetadata } from "../skinMetadata";
@ -8,5 +7,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
}
export default function Page() {
return <App />;
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

@ -1,4 +1,3 @@
import App from "../../App";
import type { Metadata } from "next";
import { generateSkinPageMetadata } from "./skinMetadata";
@ -8,5 +7,5 @@ export async function generateMetadata({ params }): Promise<Metadata> {
}
export default function Page() {
return <App />;
return null;
}

View file

@ -1,6 +1,6 @@
import { Metadata } from "next";
import SkinModel from "../../../data/SkinModel";
import UserContext from "../../../data/UserContext";
import SkinModel from "../../../../data/SkinModel";
import UserContext from "../../../../data/UserContext";
export async function generateSkinPageMetadata(
hash: string

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}
/>
);
}

View file

@ -0,0 +1,208 @@
import React, { useEffect, useRef } from "react";
import {
SCREENSHOT_HEIGHT,
SCREENSHOT_WIDTH,
} from "../../../legacy-client/src/constants";
type Props = {
skinUrl: string;
closeModal: () => void;
loaded: () => void;
};
export default function WebampComponent({
skinUrl,
closeModal,
loaded,
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const outerRef = useRef<HTMLDivElement | null>(null);
// @ts-ignore
const webampRef = useRef<import("webamp").default | null>(null);
useEffect(() => {
let disposed = false;
let cleanup = () => {};
async function loadWebamp() {
// @ts-ignore
const { default: Webamp } = await import("webamp");
if (disposed) return;
const webamp = new Webamp({
initialSkin: { url: skinUrl },
initialTracks,
enableHotkeys: true,
zIndex: 1001,
});
webampRef.current = webamp;
cleanup = () => webamp.dispose();
webamp.onClose(closeModal);
// ref.current!.style.opacity = "0";
await webamp.renderInto(ref.current!);
const { width } = outerRef.current!.getBoundingClientRect();
const zoom = width / SCREENSHOT_WIDTH;
document
.getElementById("webamp")
?.style.setProperty("zoom", String(zoom));
ref.current!.style.opacity = "1";
if (!disposed) loaded();
}
loadWebamp();
return () => {
disposed = true;
cleanup();
};
}, [skinUrl, closeModal, loaded]);
return (
<div
ref={outerRef}
style={{
top: 0,
position: "absolute",
width: "100%",
height: "100%",
}}
>
<div
className="webamp-container"
style={{
width: SCREENSHOT_WIDTH,
height: SCREENSHOT_HEIGHT,
position: "relative",
opacity: 0,
transition: "opacity 1s linear",
}}
ref={ref}
/>
</div>
);
}
const album = "netBloc Vol. 24: tiuqottigeloot";
const initialTracks = [
{
metaData: {
artist: "DJ Mike Llama",
title: "Llama Whippin' Intro",
},
url: "/llama.mp3",
duration: 5.322286,
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Diablo_Swing_Orchestra_-_01_-_Heroines.mp3",
duration: 322.612245,
metaData: {
title: "Heroines",
artist: "Diablo Swing Orchestra",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Eclectek_-_02_-_We_Are_Going_To_Eclecfunk_Your_Ass.mp3",
duration: 190.093061,
metaData: {
title: "We Are Going To Eclecfunk Your Ass",
artist: "Eclectek",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Auto-Pilot_-_03_-_Seventeen.mp3",
duration: 214.622041,
metaData: {
title: "Seventeen",
artist: "Auto-Pilot",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Muha_-_04_-_Microphone.mp3",
duration: 181.838367,
metaData: {
title: "Microphone",
artist: "Muha",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Just_Plain_Ant_-_05_-_Stumble.mp3",
duration: 86.047347,
metaData: {
title: "Stumble",
artist: "Just Plain Ant",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Sleaze_-_06_-_God_Damn.mp3",
duration: 226.795102,
metaData: {
title: "God Damn",
artist: "Sleaze",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Juanitos_-_07_-_Hola_Hola_Bossa_Nova.mp3",
duration: 207.072653,
metaData: {
title: "Hola Hola Bossa Nova",
artist: "Juanitos",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Entertainment_for_the_Braindead_-_08_-_Resolutions_Chris_Summer_Remix.mp3",
duration: 314.331429,
metaData: {
title: "Resolutions (Chris Summer Remix)",
artist: "Entertainment for the Braindead",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Nobara_Hayakawa_-_09_-_Trail.mp3",
duration: 204.042449,
metaData: {
title: "Trail",
artist: "Nobara Hayakawa",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/Paper_Navy_-_10_-_Tongue_Tied.mp3",
duration: 201.116735,
metaData: {
title: "Tongue Tied",
artist: "Paper Navy",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/60_Tigres_-_11_-_Garage.mp3",
duration: 245.394286,
metaData: {
title: "Garage",
artist: "60 Tigres",
album,
},
},
{
url: "https://raw.githubusercontent.com/captbaritone/webamp-music/4b556fbf/CM_aka_Creative_-_12_-_The_Cycle_Featuring_Mista_Mista.mp3",
duration: 221.44,
metaData: {
title: "The Cycle (Featuring Mista Mista)",
artist: "CM aka Creative",
album,
},
},
];

View file

@ -0,0 +1,67 @@
import StaticPage, {
Heading,
Subheading,
Link,
Paragraph,
} from "../StaticPage";
export default function AboutPage() {
return (
<StaticPage>
<Heading>About</Heading>
<Paragraph>
The Winamp Skin Museum is an attempt to build a <i>fast</i>,{" "}
<i>searchable</i>, and <i>shareable</i>, interface for the collection of
Winamp Skins amassed on the{" "}
<Link
href="https://archive.org/details/winampskins"
target="_blank"
rel="noopener noreferrer"
>
Internet Archive
</Link>
.
</Paragraph>
<Subheading>Features:</Subheading>
<ul style={{ marginBottom: "1.5rem", paddingLeft: "1.5rem" }}>
<li style={{ marginBottom: "0.5rem" }}>
<strong>Infinite scroll</strong> preview images
</li>
<li style={{ marginBottom: "0.5rem" }}>
<strong>Experience</strong> skins with integrated{" "}
<Link
href="https://webamp.org"
target="_blank"
rel="noopener noreferrer"
>
Webamp
</Link>
</li>
<li style={{ marginBottom: "0.5rem" }}>
<strong>Fast search</strong> of indexed readme.txt texts
</li>
</ul>
<Paragraph>
Made by <Link href="https://jordaneldredge.com">Jordan Eldredge</Link>
</Paragraph>
<hr
style={{
border: "none",
borderTop: "1px solid rgba(255, 255, 255, 0.2)",
margin: "2rem 0",
}}
/>
<Paragraph>
Want Winamp on your Windows PC, but with supported updates & new
features?{" "}
<Link
href="https://getwacup.com/"
target="_blank"
rel="noopener noreferrer"
>
Try WACUP
</Link>
</Paragraph>
</StaticPage>
);
}

View file

@ -0,0 +1,18 @@
import { algoliasearch } from "algoliasearch";
// Using the legacy hardcoded credentials for client-side search
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
export async function searchSkins(query: string) {
const result = await client.searchSingleIndex({
indexName: "Skins",
searchParams: {
query,
attributesToRetrieve: ["objectID", "fileName", "nsfw"],
attributesToHighlight: [],
hitsPerPage: 1000,
typoTolerance: "min",
},
});
return result;
}

View file

@ -0,0 +1,117 @@
"use client";
import { useState, useCallback } from "react";
import { usePathname } from "next/navigation";
import StaticPage, {
Heading,
Paragraph,
Label,
Input,
Textarea,
Button,
} from "../StaticPage";
async function sendFeedback(variables: {
message: string;
email: string;
url: string;
}) {
const mutation = `
mutation GiveFeedback($message: String!, $email: String, $url: String) {
send_feedback(message: $message, email: $email, url: $url)
}
`;
const response = await fetch("/api/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: mutation,
variables,
}),
});
if (!response.ok) {
throw new Error("Failed to send feedback");
}
return response.json();
}
export default function FeedbackPage() {
const pathname = usePathname();
const [message, setMessage] = useState("");
const [email, setEmail] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const send = useCallback(async () => {
if (message.trim().length === 0) {
alert("Please add a message before sending.");
return;
}
const body = {
message,
email,
url: "https://skins.webamp.org" + pathname,
};
setSending(true);
try {
await sendFeedback(body);
setSent(true);
} catch (_) {
alert("Failed to send feedback. Please try again.");
setSending(false);
}
}, [message, email, pathname]);
if (sent) {
return (
<StaticPage>
<Heading>Sent!</Heading>
<Paragraph>
Thanks for your feedback. I appreciate you taking the time to share
your thoughts.
</Paragraph>
</StaticPage>
);
}
return (
<StaticPage>
<Heading>Feedback</Heading>
<p style={{ marginBottom: "1.5rem" }}>
Let me know what you think about the Winamp Skin Museum. Bug reports,
feature suggestions, personal anecdotes, or criticism are all welcome.
</p>
<div style={{ marginBottom: "1.5rem" }}>
<Label>Message</Label>
<Textarea
disabled={sending}
value={message}
onChange={(e) => setMessage(e.target.value)}
style={{ minHeight: 150 }}
placeholder="Your thoughts here..."
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<Label>Email (optional)</Label>
<Input
disabled={sending}
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
/>
</div>
<div style={{ textAlign: "right" }}>
<Button onClick={send} disabled={sending}>
{sending ? "Sending..." : "Send"}
</Button>
</div>
</StaticPage>
);
}

View file

@ -0,0 +1,48 @@
import UserContext from "../../../data/UserContext";
import SessionModel from "../../../data/SessionModel";
import { ClientSkin } from "./SkinScroller";
import { getScrollPage } from "../../../data/skins";
import SkinModel from "../../../data/SkinModel";
// Ensure each page load gets a new session
export const dynamic = "force-dynamic";
export async function getClientSkins(sessionId: string): Promise<ClientSkin[]> {
"use server";
const ctx = new UserContext();
const page = await getScrollPage(sessionId);
return await Promise.all(
page.map(async (item) => {
return getSkinForSession(ctx, sessionId, item.md5);
})
);
}
export async function getSkinForSession(
ctx: UserContext,
sessionId: string,
md5: string
): Promise<ClientSkin> {
const model = await SkinModel.fromMd5Assert(ctx, md5);
const readmeText = await model.getReadme();
const fileName = await model.getFileName();
const tweet = await model.getTweet();
const likeCount = tweet ? tweet.getLikes() : 0;
SessionModel.addSkin(sessionId, md5);
return {
screenshotUrl: model.getScreenshotUrl(),
skinUrl: model.getSkinUrl(),
md5,
// TODO: Normalize to .wsz
fileName: fileName,
readmeStart: readmeText ? readmeText.slice(0, 200) : "",
downloadUrl: model.getSkinUrl(),
shareUrl: `https://skins.webamp.org/scroll/skin/${md5}`,
nsfw: await model.getIsNsfw(),
likeCount: likeCount,
};
}

View file

@ -0,0 +1,26 @@
"use server";
import { getMuseumPage, getScreenshotUrl } from "../../../data/skins";
export type GridSkin = {
md5: string;
screenshotUrl: string;
fileName: string;
nsfw: boolean;
};
export async function getMuseumPageSkins(
offset: number,
limit: number
): Promise<GridSkin[]> {
const page = await getMuseumPage({ offset, first: limit });
const skins = page.map((item) => ({
md5: item.md5,
screenshotUrl: getScreenshotUrl(item.md5),
fileName: item.fileName,
nsfw: item.nsfw,
}));
return skins;
}

View file

@ -0,0 +1,26 @@
"use client";
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
import { unstable_ViewTransition as ViewTransition, ReactNode } from "react";
import BottomMenuBar from "./BottomMenuBar";
import "./scroll.css";
type LayoutProps = {
children: ReactNode;
};
export default function Layout({ children }: LayoutProps) {
return (
<div
style={{
position: "relative",
height: "100vh",
}}
>
{children}
<ViewTransition>
<BottomMenuBar />
</ViewTransition>
</div>
);
}

View file

@ -0,0 +1,12 @@
import React from "react";
import Grid from "./Grid";
import { getMuseumPageSkins } from "./getMuseumPageSkins";
import * as Skins from "../../..//data/skins";
export default async function SkinTable() {
const [initialSkins, skinCount] = await Promise.all([
getMuseumPageSkins(0, 50),
Skins.getClassicSkinCount(),
]);
return <Grid initialSkins={initialSkins} initialTotal={skinCount} />;
}

View file

@ -0,0 +1,22 @@
body {
margin: 0; /* Remove default margin */
height: 100vh; /* Set body height to viewport height */
background-color: #1a1a1a; /* Dark charcoal instead of pure black */
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
}
input,
button {
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
}
.scroller::-webkit-scrollbar,
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
.scroller,
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}

View file

@ -0,0 +1,33 @@
import { Metadata } from "next";
import SessionModel from "../../../../../data/SessionModel";
import UserContext from "../../../../../data/UserContext";
import { getClientSkins, getSkinForSession } from "../../getClientSkins";
import SkinScroller from "../../SkinScroller";
import { generateSkinPageMetadata } from "../../../../(legacy)/skin/[hash]/skinMetadata";
export async function generateMetadata({ params }): Promise<Metadata> {
const { md5 } = await params;
return generateSkinPageMetadata(md5);
}
// Ensure each page load gets a new session
export const dynamic = "force-dynamic";
export default async function Skin({ params }) {
const { md5 } = await params;
// Create the session in the database
const sessionId = await SessionModel.create();
const ctx = new UserContext();
const linked = await getSkinForSession(ctx, sessionId, md5);
const initialSkins = await getClientSkins(sessionId);
return (
<SkinScroller
initialSkins={[linked, ...initialSkins]}
getSkins={getClientSkins}
sessionId={sessionId}
/>
);
}

View file

@ -0,0 +1,24 @@
import SessionModel from "../../../../data/SessionModel";
import { getClientSkins } from "../getClientSkins";
import SkinScroller from "../SkinScroller";
// Ensure each page load gets a new session
export const dynamic = "force-dynamic";
/**
* A tik-tok style scroll page where we display one skin at a time in full screen
*/
export default async function ScrollPage() {
// Create the session in the database
const sessionId = await SessionModel.create();
const initialSkins = await getClientSkins(sessionId);
return (
<SkinScroller
initialSkins={initialSkins}
getSkins={getClientSkins}
sessionId={sessionId}
/>
);
}

View file

@ -0,0 +1,99 @@
import { useEffect } from "react";
type UseScrollHintOptions = {
containerRef: HTMLDivElement | null;
enabled: boolean;
delayMs?: number;
scrollAmount?: number;
animationDuration?: number;
onHintShown?: () => void;
};
/**
* A hook that provides a gentle scroll hint animation to encourage user interaction.
* After a delay, if the user hasn't scrolled, it will scroll down slightly and bounce back.
*/
export function useScrollHint({
containerRef,
enabled,
delayMs = 2000,
scrollAmount = 80,
animationDuration = 1000,
onHintShown,
}: UseScrollHintOptions) {
useEffect(() => {
if (containerRef == null || !enabled) {
return;
}
const hintTimer = setTimeout(() => {
if (!enabled || containerRef.scrollTop !== 0) {
return;
}
const startScrollTop = containerRef.scrollTop;
const startTime = Date.now();
// Temporarily disable scroll snap for smooth animation
const originalScrollSnapType = containerRef.style.scrollSnapType;
containerRef.style.scrollSnapType = "none";
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / animationDuration, 1);
// Bouncy easing function - overshoots and bounces back
const easeOutBounce = (t: number) => {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) {
return n1 * t * t;
} else if (t < 2 / d1) {
return n1 * (t -= 1.5 / d1) * t + 0.75;
} else if (t < 2.5 / d1) {
return n1 * (t -= 2.25 / d1) * t + 0.9375;
} else {
return n1 * (t -= 2.625 / d1) * t + 0.984375;
}
};
// Create a bounce effect: scroll down quickly, then bounce back
let offset;
if (progress < 0.4) {
// First 40%: scroll down quickly
const t = progress / 0.4;
offset = scrollAmount * t * t; // Quadratic ease-in
} else {
// Last 60%: bounce back with overshoot
const t = (progress - 0.4) / 0.6;
offset = scrollAmount * (1 - easeOutBounce(t));
}
containerRef.scrollTop = startScrollTop + offset;
if (progress < 1) {
requestAnimationFrame(animate);
} else {
// Ensure we end exactly where we started
containerRef.scrollTop = startScrollTop;
// Re-enable scroll snap
containerRef.style.scrollSnapType = originalScrollSnapType;
}
};
animate();
onHintShown?.();
}, delayMs);
return () => {
clearTimeout(hintTimer);
};
}, [
containerRef,
enabled,
delayMs,
scrollAmount,
animationDuration,
onHintShown,
]);
}

View file

@ -111,8 +111,8 @@ function SkinTableUnbound({
}
return skin ? skin.hash : `unfectched-index-${requestToken}`;
}
const gridRef = React.useRef();
const itemRef = React.useRef();
const gridRef = React.useRef<any>(null);
const itemRef = React.useRef<number>(0);
React.useLayoutEffect(() => {
if (gridRef.current == null) {
return;

View file

@ -0,0 +1,483 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { fetchGraphql, gql } from "../../legacy-client/src/utils";
interface BulkDownloadSkin {
md5: string;
filename: string;
download_url: string;
__typename: string;
}
interface DownloadProgress {
totalSkins: number;
completedSkins: number;
failedSkins: number;
estimatedSizeBytes: string;
activeDownloads: Array<{
filename: string;
md5: string;
status: "downloading" | "failed";
error?: string;
}>;
}
interface DirectoryHandle {
name: string;
getDirectoryHandle: (
name: string,
options?: { create?: boolean }
) => Promise<DirectoryHandle>;
getFileHandle: (
name: string,
options?: { create?: boolean }
) => Promise<FileSystemFileHandle>;
}
declare global {
interface Window {
showDirectoryPicker?: () => Promise<DirectoryHandle>;
}
}
const BULK_DOWNLOAD_QUERY = gql`
query BulkDownload($offset: Int!, $first: Int!) {
bulkDownload(offset: $offset, first: $first) {
totalCount
estimatedSizeBytes
nodes {
__typename
md5
filename(normalize_extension: true, include_museum_id: true)
download_url
}
}
}
`;
const MAX_CONCURRENT_DOWNLOADS = 6;
const CHUNK_SIZE = 1000;
export default function BulkDownloadPage() {
const [directoryHandle, setDirectoryHandle] =
useState<DirectoryHandle | null>(null);
const [progress, setProgress] = useState<DownloadProgress>({
totalSkins: 0,
completedSkins: 0,
failedSkins: 0,
estimatedSizeBytes: "0",
activeDownloads: [],
});
const [isDownloading, setIsDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSupported] = useState(
typeof window !== "undefined" && "showDirectoryPicker" in window
);
const abortController = useRef<AbortController | null>(null);
const downloadSkin = useCallback(
async (
skin: BulkDownloadSkin,
directoryHandle: DirectoryHandle,
signal: AbortSignal
): Promise<void> => {
const { filename, download_url, md5 } = skin;
// Get the target directory and file path
const targetDirectory = await getDirectoryForSkin(
filename,
directoryHandle
);
// Check if file already exists
try {
await targetDirectory.getFileHandle(filename);
// File exists, skip download
console.log(`Skipping ${filename} - already exists`);
setProgress((prev) => ({
...prev,
completedSkins: prev.completedSkins + 1,
}));
return;
} catch (_) {
// File doesn't exist, continue with download
}
// Add to active downloads
setProgress((prev) => ({
...prev,
activeDownloads: [
...prev.activeDownloads,
{
filename,
md5,
status: "downloading" as const,
},
],
}));
try {
const response = await fetch(download_url, { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// We don't need individual progress tracking anymore
// const contentLength = parseInt(response.headers.get("content-length") || "0", 10);
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
// Use the targetDirectory and finalFilename we calculated earlier
const fileHandle = await targetDirectory.getFileHandle(filename, {
create: true,
});
const writable = await fileHandle.createWritable();
// Track total bytes for this file (not needed for individual progress)
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writable.write(value);
}
await writable.close();
// Mark as completed and immediately remove from active downloads
setProgress((prev) => ({
...prev,
completedSkins: prev.completedSkins + 1,
activeDownloads: prev.activeDownloads.filter((d) => d.md5 !== md5),
}));
} catch (writeError) {
await writable.abort("Failed to write file");
throw writeError;
}
} catch (error: any) {
if (error.name === "AbortError") {
console.log(`Download aborted: ${filename}`);
throw error; // Re-throw abort errors
}
// Mark as failed and schedule removal
setProgress((prev) => ({
...prev,
failedSkins: prev.failedSkins + 1,
activeDownloads: prev.activeDownloads.map((d) =>
d.md5 === md5
? {
...d,
status: "failed" as const,
error: error.message,
}
: d
),
}));
// Remove failed download after 3 seconds
setTimeout(() => {
setProgress((prev) => ({
...prev,
activeDownloads: prev.activeDownloads.filter((d) => d.md5 !== md5),
}));
}, 3000);
console.error(`Failed to download ${filename}:`, error);
}
},
[getDirectoryForSkin]
);
// Load initial metadata when component mounts
useEffect(() => {
async function loadInitialData() {
try {
const { totalCount, estimatedSizeBytes } = await fetchSkins(0, 1);
setProgress((prev) => ({
...prev,
totalSkins: totalCount,
estimatedSizeBytes,
}));
} catch (error: any) {
console.error("Failed to load initial data:", error);
setError("Failed to load skin count information");
}
}
loadInitialData();
}, [fetchSkins]);
const selectDirectoryAndStart = useCallback(async () => {
// First, select directory if not already selected
if (!directoryHandle) {
if (!window.showDirectoryPicker) {
setError(
"File System Access API is not supported in this browser. Please use Chrome or Edge."
);
return;
}
try {
const handle = await window.showDirectoryPicker();
setDirectoryHandle(handle);
setError(null);
// Now start the download with the new directory
await startDownloadWithDirectory(handle as FileSystemDirectoryHandle);
} catch (err: any) {
if (err.name !== "AbortError") {
setError(`Failed to select directory: ${err.message}`);
}
}
} else {
// Directory already selected, just start download
await startDownloadWithDirectory(
directoryHandle as FileSystemDirectoryHandle
);
}
}, [directoryHandle]);
const startDownloadWithDirectory = useCallback(
async (handle: FileSystemDirectoryHandle) => {
setIsDownloading(true);
setError(null);
// setStartTime(Date.now());
abortController.current = new AbortController();
try {
// Get initial metadata
const { totalCount, estimatedSizeBytes } = await fetchSkins(0, 1);
setProgress({
totalSkins: totalCount,
completedSkins: 0,
failedSkins: 0,
estimatedSizeBytes,
activeDownloads: [],
});
let offset = 0;
const activePromises = new Set<Promise<void>>();
while (offset < totalCount && !abortController.current.signal.aborted) {
console.log(`Fetching batch: offset=${offset}, chunk=${CHUNK_SIZE}`);
try {
const { skins } = await fetchSkins(offset, CHUNK_SIZE);
console.log(`Retrieved ${skins.length} skins in this batch`);
if (skins.length === 0) {
console.log("No more skins to fetch, breaking");
break;
}
for (const skin of skins) {
// eslint-disable-next-line max-depth
if (abortController.current.signal.aborted) break;
await waitForAvailableSlot(
activePromises,
abortController.current.signal
);
// eslint-disable-next-line max-depth
if (abortController.current.signal.aborted) break;
const downloadPromise = downloadSkin(
skin,
handle,
abortController.current.signal
).finally(() => {
activePromises.delete(downloadPromise);
});
activePromises.add(downloadPromise);
}
offset += skins.length;
console.log(`Completed batch, new offset: ${offset}/${totalCount}`);
} catch (error: any) {
console.error(`Failed to fetch batch at offset ${offset}:`, error);
setError(`Failed to fetch skins: ${error.message}`);
break;
}
}
// Wait for all remaining downloads to complete
await Promise.allSettled(activePromises);
console.log("All downloads completed!");
} catch (error: any) {
if (error.name !== "AbortError") {
setError(`Download failed: ${error.message}`);
}
} finally {
setIsDownloading(false);
}
},
[fetchSkins, downloadSkin]
);
const stopDownload = useCallback(() => {
if (abortController.current) {
abortController.current.abort("User Canceled");
}
setIsDownloading(false);
// setStartTime(null);
}, []);
const progressPercent =
progress.totalSkins > 0
? ((progress.completedSkins + progress.failedSkins) /
progress.totalSkins) *
100
: 0;
if (!isSupported) {
return <h1>Your browser does not support filesystem access.</h1>;
}
const gb = Math.round(
parseInt(progress.estimatedSizeBytes || "0", 10) / (1024 * 1024 * 1024)
);
return (
<div>
<div>
<div>
<div>
<h1>Bulk Download All Skins</h1>
<p>Download the entire Winamp Skin Museum collection.</p>
<ul>
<li>
Will download {progress.totalSkins.toLocaleString()} files (~
{gb}
GB) into the selected directory
</li>
<li>
Files will be organized into directories (aa-zz, 0-9) based on
filename prefix
</li>
<li>
Supports resuming from previously interrupted bulk download
</li>
</ul>
</div>
</div>
{error && (
<div>
<div>{error}</div>
</div>
)}
{/* Download Controls */}
<div>
{isDownloading ? (
<button onClick={stopDownload}>Stop Download</button>
) : (
<button onClick={selectDirectoryAndStart}>
{directoryHandle
? "Start Download"
: "Select Directory & Start Download"}
</button>
)}
</div>
{/* Progress Section */}
{(isDownloading || progress.completedSkins > 0) && (
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>
Downloaded{" "}
{(
progress.completedSkins + progress.failedSkins
).toLocaleString()}{" "}
of {progress.totalSkins.toLocaleString()} skins
</span>
<span>{Math.round(progressPercent)}% complete</span>
</div>
<div
style={{
border: "1px solid black",
}}
>
<div
style={{
background: "black",
transition: "all 300ms",
height: "18px",
width: `${progressPercent}%`,
}}
/>
</div>
</div>
)}
</div>
</div>
);
}
async function getDirectoryForSkin(
filename: string,
rootHandle: DirectoryHandle
) {
// Create directory based on first two characters of filename (case insensitive)
const firstChar = filename.charAt(0).toLowerCase();
const secondChar =
filename.length > 1 ? filename.charAt(1).toLowerCase() : "";
let dirName: string;
if (/[a-z]/.test(firstChar)) {
// For letters, use two-character prefix if second char is alphanumeric
if (/[a-z0-9]/.test(secondChar)) {
dirName = firstChar + secondChar;
} else {
// Fallback to single letter + 'x' for special characters
dirName = firstChar + "x";
}
} else {
// For numbers/symbols, use "0-9"
dirName = "0-9";
}
try {
return await rootHandle.getDirectoryHandle(dirName, { create: true });
} catch (err) {
console.warn(`Failed to create directory ${dirName}, using root:`, err);
return rootHandle;
}
}
async function fetchSkins(
offset: number,
first: number
): Promise<{
skins: BulkDownloadSkin[];
totalCount: number;
estimatedSizeBytes: string;
}> {
const { bulkDownload } = await fetchGraphql(BULK_DOWNLOAD_QUERY, {
offset,
first,
});
return {
skins: bulkDownload.nodes,
totalCount: bulkDownload.totalCount,
estimatedSizeBytes: bulkDownload.estimatedSizeBytes,
};
}
// Helper function to wait for an available download slot
async function waitForAvailableSlot(
activePromises: Set<Promise<void>>,
signal: AbortSignal
) {
while (activePromises.size >= MAX_CONCURRENT_DOWNLOADS && !signal.aborted) {
await Promise.race(activePromises);
}
}

View file

@ -10,8 +10,8 @@ const { handleRequest } = createYogaInstance({
return new UserContext();
},
logger: {
log: (message: string, context: Record<string, any>) => {
console.log(message, context);
log: (_message: string, _context: Record<string, any>) => {
// console.log(message, context);
},
logError: (message: string, context: Record<string, any>) => {
console.error(message, context);

View file

@ -1,4 +1,4 @@
import App from "../App";
import App from "./App";
import type { Metadata } from "next";
const DESCRIPTION =

View file

@ -35,11 +35,14 @@ import { setHashesForSkin } from "./skinHash";
import * as S3 from "./s3";
import { generateDescription } from "./services/openAi";
import KeyValue from "./data/KeyValue";
import { postToBluesky } from "./tasks/bluesky";
import { computeSkinRankings } from "./tasks/computeScrollRanking";
async function withHandler(
cb: (handler: DiscordEventHandler) => Promise<void>
) {
const handler = new DiscordEventHandler();
await handler._clientPromise; // Ensure client is initialized
try {
await cb(handler);
} finally {
@ -81,21 +84,30 @@ program
.argument("[md5]", "md5 of the skin to share")
.option("-t, --twitter", "Share on Twitter")
.option("-i, --instagram", "Share on Instagram")
.option("-b, --bluesky", "Share on Bluesky")
.option("-m, --mastodon", "Share on Mastodon")
.action(async (md5, { twitter, instagram, mastodon }) => {
if (!twitter && !instagram && !mastodon) {
throw new Error("Expected at least one of --twitter or --instagram");
}
.action(async (md5, { twitter, instagram, mastodon, bluesky }) => {
await withDiscordClient(async (client) => {
if (twitter) {
await tweet(client, md5);
return;
}
if (instagram) {
await insta(client, md5);
return;
}
if (mastodon) {
await postToMastodon(client, md5);
return;
}
if (bluesky) {
await postToBluesky(client, md5);
return;
}
throw new Error(
"Expected at least one of --twitter, --instagram, --mastodon, --bluesky"
);
});
});
@ -161,7 +173,7 @@ program
console.log("====================================");
}
if (purge) {
// cat purge | xargs -I {} yarn cli skin --purge {}
// cat purge | xargs -I {} pnpm cli skin --purge {}
await Skins.deleteSkin(md5);
const purgedArr: string[] = (await KeyValue.get("purged")) || [];
const purged = new Set(purgedArr);
@ -298,6 +310,14 @@ program
console.table([await Skins.getStats()]);
});
program
.command("compute-scroll-ranking")
.description("Analyze user event data and compute skin ranking scores.")
.action(async () => {
const rankings = await computeSkinRankings();
console.log(JSON.stringify(rankings, null, 2));
});
program
.command("process-uploads")
.description("Process any unprocessed user uploads.")
@ -438,7 +458,7 @@ program
);
const md5s = rows.map((row) => row.md5);
console.log(md5s.length);
console.log(await Skins.updateSearchIndexs(ctx, md5s));
console.log(await Skins.updateSearchIndexes(ctx, md5s));
}
if (refreshContentHash) {
const ctx = new UserContext();

View file

@ -29,6 +29,8 @@ export const INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID");
// Used for session encryption
export const SECRET = env("SECRET");
export const NODE_ENV = env("NODE_ENV") || "production";
export const BLUESKY_PASSWORD = env("BLUESKY_PASSWORD");
export const BLUESKY_USERNAME = env("BLUESKY_USERNAME");
function env(key: string): string {
const value = process.env[key];

View file

@ -0,0 +1,29 @@
import { knex } from "../db";
import { randomUUID } from "crypto";
export default class SessionModel {
static async create(): Promise<string> {
const sessionId = randomUUID();
const startTime = Date.now();
await knex("session").insert({
id: sessionId,
start_time: startTime,
});
return sessionId;
}
static async addSkin(sessionId: string, skinMd5: string): Promise<void> {
await knex("session_skin").insert({
session_id: sessionId,
skin_md5: skinMd5,
});
}
static async getIncludedSkinCount(sessionId: string): Promise<number> {
const result = await knex("session_skin")
.where({ session_id: sessionId })
.count("* as count")
.first();
return result ? (result.count as number) : 0;
}
}

View file

@ -158,7 +158,7 @@ export default class SkinModel {
return "UNREVIEWED";
}
async getFileName(): Promise<string> {
async getFileName(normalizeExtension?: boolean): Promise<string> {
const files = await this.getFiles();
if (files.length === 0) {
throw new Error(`Could not find file for skin with md5 ${this.getMd5()}`);
@ -167,6 +167,9 @@ export default class SkinModel {
if (!filename.match(/\.(zip)|(wsz)|(wal)$/i)) {
throw new Error("Expected filename to end with zip, wsz or wal.");
}
if (normalizeExtension) {
return path.parse(filename).name + ".wsz";
}
return filename;
}
@ -193,9 +196,6 @@ export default class SkinModel {
const filename = file.getFileName();
const isReadme = IS_README.test(filename);
const isNotReadme = IS_NOT_README.test(filename);
console.log({ filename, isReadme, isNotReadme, md5: file.getFileMd5() });
return isReadme && !isNotReadme;
});
@ -232,8 +232,8 @@ export default class SkinModel {
}
}
getAverageColor(): string {
return this.row.average_color;
getAverageColor(): string | null {
return this.row.average_color ?? null;
}
getBuffer = mem(async (): Promise<Buffer> => {

View file

@ -144,6 +144,17 @@ export async function markAsPostedToMastodon(
);
}
export async function markAsPostedToBlueSky(
md5: string,
postId: string,
url: string
): Promise<void> {
await knex("bluesky_posts").insert(
{ skin_md5: md5, post_id: postId, url },
[]
);
}
// TODO: Also path actor
export async function markAsNSFW(ctx: UserContext, md5: string): Promise<void> {
const index = { objectID: md5, nsfw: true };
@ -243,7 +254,7 @@ async function getSearchIndexes(
);
}
export async function updateSearchIndexs(
export async function updateSearchIndexes(
ctx: UserContext,
md5s: string[]
): Promise<any> {
@ -265,7 +276,7 @@ export async function updateSearchIndex(
ctx: UserContext,
md5: string
): Promise<any | null> {
return updateSearchIndexs(ctx, [md5]);
return updateSearchIndexes(ctx, [md5]);
}
export async function hideSkin(md5: string): Promise<void> {
@ -400,6 +411,7 @@ export async function getErroredUpload(): Promise<{
.where("status", "ERRORED")
.where("skin_md5", "!=", "c7df44bde6eb3671bde5a03e6d03ce1e")
.where("skin_md5", "!=", "fedc564eb2ce0a4ec5518b93983240ef")
.where("skin_md5", "!=", "a418fd00583006b6e79cf0b251c43771")
.first(["skin_md5", "id", "filename"]);
return found || null;
}
@ -550,6 +562,31 @@ export async function getSkinToPostToMastodon(): Promise<string | null> {
return skin.md5;
}
export async function getSkinToPostToBluesky(): Promise<string | null> {
// TODO: This does not account for skins that have been both approved and rejected
const postables = await knex("skins")
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
.leftJoin("bluesky_posts", "bluesky_posts.skin_md5", "=", "skins.md5")
.leftJoin("tweets", "tweets.skin_md5", "=", "skins.md5")
.leftJoin("refreshes", "refreshes.skin_md5", "=", "skins.md5")
.where({
"bluesky_posts.id": null,
skin_type: 1,
"skin_reviews.review": "APPROVED",
"refreshes.error": null,
})
.where("likes", ">", 10)
.groupBy("skins.md5")
.orderByRaw("random()")
.limit(1);
const skin = postables[0];
if (skin == null) {
return null;
}
return skin.md5;
}
export async function getUnreviewedSkinCount(): Promise<number> {
const rows = await knex("skins")
.where({ skin_type: 1 })
@ -682,6 +719,53 @@ export type MuseumPage = Array<{
nsfw: boolean;
}>;
export type ScrollPage = Array<{
md5: string;
}>;
const PAGE_SIZE = 50;
const FRESHNESS_PERCENTAGE = 0.2; // 20% random skins
const randomCount = Math.floor(PAGE_SIZE * FRESHNESS_PERCENTAGE);
const curatedCount = PAGE_SIZE - randomCount;
export async function getScrollPage(sessionId: string): Promise<ScrollPage> {
const skins = await knex.raw(
`
SELECT
museum_sort_order.skin_md5
FROM
museum_sort_order
WHERE museum_sort_order.skin_md5 NOT IN (SELECT skin_md5 from session_skin WHERE session_id = ?)
LIMIT ?`,
[sessionId, curatedCount]
);
const randomSkins = await knex.raw(
`
SELECT
skins.md5 as skin_md5
FROM
skins
LEFT JOIN session_skin ON session_skin.skin_md5 = skins.md5 AND session_skin.session_id = ?
LEFT JOIN skin_reviews ON skin_reviews.skin_md5 = skins.md5 AND skin_reviews.review = 'NSFW'
WHERE
skins.skin_type = 1 AND session_skin.skin_md5 IS NULL AND skin_reviews.skin_md5 IS NULL
ORDER BY RANDOM()
LIMIT ?`,
[sessionId, randomCount]
);
// Note: Technically we could get duplicates if a random skin is also in museum_sort_order,
// but in practice this is rare and acceptable for the use case.
const allSkins = skins.concat(randomSkins);
// Shuffle the results
allSkins.sort(() => Math.random() - 0.5);
return allSkins.map(({ skin_md5 }) => {
return { md5: skin_md5 };
});
}
export async function getMuseumPage({
offset,
first,

View file

@ -1,28 +0,0 @@
// To toggle between blue and green deployments:
// sudo vim /etc/apache2/sites-enabled/api.webamp.org-le-ssl.conf
// Update port number
// sudo systemctl reload apache2
module.exports = {
apps: [
{
name: "skin-database-blue",
script: "yarn",
interpreter: "bash",
args: "start",
env: {
NODE_ENV: "production",
PORT: 3001,
},
},
{
name: "skin-database-green",
script: "yarn",
interpreter: "bash",
args: "start",
env: {
NODE_ENV: "production",
PORT: 3002,
},
},
],
};

View file

@ -31,3 +31,5 @@ process.env.INSTAGRAM_ACCESS_TOKEN = "<DUMMY>";
process.env.INSTAGRAM_ACCOUNT_ID = "<DUMMY>";
process.env.MASTODON_ACCESS_TOKEN = "<DUMMY>";
process.env.SECRET = "<DUMMY>";
process.env.BLUESKY_PASSWORD = "<DUMMY>";
process.env.BLUESKY_USERNAME = "<DUMMY>";

View file

@ -1,18 +1,21 @@
"use client";
import React, { useCallback } from "react";
import { connect } from "react-redux";
import { connect, useSelector } from "react-redux";
import About from "./About";
import Feedback from "./Feedback";
import Header from "./Header";
import Overlay from "./Overlay";
import SkinTable from "./SkinTable";
import FocusedSkin from "./FocusedSkin";
import { useSelector } from "react-redux";
import * as Selectors from "./redux/selectors";
import * as Actions from "./redux/actionCreators";
import { ABOUT_PAGE, REVIEW_PAGE } from "./constants";
import {
ABOUT_PAGE,
REVIEW_PAGE,
SCREENSHOT_WIDTH,
SKIN_RATIO,
} from "./constants";
import { useWindowSize, useScrollbarWidth, useActionCreator } from "./hooks";
import { SCREENSHOT_WIDTH, SKIN_RATIO } from "./constants";
import UploadGrid from "./upload/UploadGrid";
import Metadata from "./components/Metadata";
import SkinReadme from "./SkinReadme";
@ -78,6 +81,7 @@ function App(props) {
windowWidth={windowWidthWithScrollabar}
/>
)}
{/* eslint-disable-next-line no-nested-ternary -- legacy code */}
{props.showFeedbackForm ? (
<Overlay>
<Feedback />

View file

@ -1,9 +1,7 @@
import React, { useState } from "react";
import React, { useState, useCallback } from "react";
import { getUrl } from "./redux/selectors";
import * as Actions from "./redux/actionCreators";
import { useActionCreator } from "./hooks";
import { useCallback } from "react";
import { useSelector } from "react-redux";
import { fetchGraphql, gql } from "./utils";

View file

@ -1,4 +1,5 @@
import * as Utils from "./utils";
import { gql } from "./utils";
import { algoliasearch } from "algoliasearch";
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
@ -6,14 +7,15 @@ const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
// Fallback search that uses SQLite. Useful for when we've exceeded the Algolia
// search quota.
export async function graphqlSearch(query) {
const queryText = Utils.gql`
query SearchQuery($query: String!) {
search_classic_skins(query: $query, first: 500) {
filename
md5
nsfw
}
}`;
const queryText = gql`
query SearchQuery($query: String!) {
search_classic_skins(query: $query, first: 500) {
filename(normalize_extension: true)
md5
nsfw
}
}
`;
const data = await Utils.fetchGraphql(queryText, { query });
const hits = data.search_classic_skins.map((skin) => {
return {

View file

@ -3,7 +3,7 @@ import React, { useLayoutEffect, useState } from "react";
function DownloadText({ text, children, ...restProps }) {
const [url, setUrl] = useState(null);
useLayoutEffect(() => {
var blob = new Blob([text], {
let blob = new Blob([text], {
type: "text/plain;charset=utf-8",
});
const url = URL.createObjectURL(blob);

View file

@ -1,6 +1,7 @@
export const SCREENSHOT_WIDTH = 275;
export const SCREENSHOT_HEIGHT = 348;
export const SKIN_RATIO = SCREENSHOT_HEIGHT / SCREENSHOT_WIDTH;
export const MOBILE_MAX_WIDTH = "56.25vh"; // 9:16 aspect ratio (100vh * 9/16) for TikTok-style scroll
export const ABOUT_PAGE = "ABOUT_PAGE";
export const UPLOAD_PAGE = "UPLOAD_PAGE";
export const REVIEW_PAGE = "REVIEW_PAGE";
@ -32,9 +33,8 @@ export const SKIN_CDN = R2_CDN;
// export const SCREENSHOT_CDN = "https://cdn.webampskins.org";
// export const SKIN_CDN = "https://cdn.webampskins.org";
// Note: This is a Cloudflare proxy for api.webamp.org which
// provides some additional caching.
export const API_URL = "https://api.webampskins.org";
// Sites have been unified, we can point to ourselves now
export const API_URL = "https://skins.webamp.org";
// export const API_URL = "https://dev.webamp.org";
export const HEADING_HEIGHT = 46;
export const CHUNK_SIZE = 300;

View file

@ -1,7 +1,7 @@
import SparkMD5 from "spark-md5";
export function hashFile(file) {
var blobSlice =
let blobSlice =
File.prototype.slice ||
File.prototype.mozSlice ||
File.prototype.webkitSlice;
@ -26,7 +26,7 @@ export function hashFile(file) {
fileReader.onerror = reject;
function loadNext() {
var start = currentChunk * chunkSize,
let start = currentChunk * chunkSize,
end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));

View file

@ -218,7 +218,7 @@ const unloadedSkinEpic = (actions, _states) =>
count
nodes {
md5
filename
filename(normalize_extension: true)
nsfw
}
}
@ -462,7 +462,10 @@ const urlEpic = (actions, state) => {
const newUrl = proposedUrl.toString();
window.history.replaceState({}, Selectors.getPageTitle(state), newUrl);
// Avoid clobbering URL for beta site
if (!window.location.href.includes("/scroll")) {
window.history.replaceState({}, Selectors.getPageTitle(state), newUrl);
}
}),
ignoreElements()
);
@ -481,7 +484,7 @@ const skinDataEpic = (actions, state) => {
const QUERY = gql`
query IndividualSkin($md5: String!) {
fetch_skin_by_md5(md5: $md5) {
filename
filename(normalize_extension: true)
nsfw
}
}

View file

@ -19,6 +19,7 @@ function Row({ name, loading, right, complete }) {
position: "absolute",
left: 0,
top: 0,
// eslint-disable-next-line no-nested-ternary -- legacy code
width: loading ? `90%` : complete ? `100%` : `0%`,
transitionProperty: "all",
// TODO: Try to learn how long it really takes

View file

@ -22,7 +22,9 @@ export async function upload(fileObj) {
console.warn(
`Request to ${uploadUrl} returned 503, going to retry again in 5 seconds. ${retries} retries left...`
);
await new Promise((resolve) => setTimeout(resolve, 5000));
await new Promise((resolve) => {
setTimeout(resolve, 5000);
});
continue;
}

View file

@ -13,7 +13,7 @@ export function museumUrlFromHash(hash) {
}
export function getWindowSize() {
var w = window,
let w = window,
d = document,
e = d.documentElement,
g = d.getElementsByTagName("body")[0],

View file

@ -0,0 +1,16 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<any> {
await knex.raw(
`CREATE TABLE "bluesky_posts" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
"skin_md5" TEXT NOT NULL,
post_id text NOT NULL UNIQUE,
url text NOT NULL UNIQUE
);`
);
}
export async function down(knex: Knex): Promise<any> {
await knex.raw(`DROP TABLE "bluesky_posts"`);
}

View file

@ -0,0 +1,21 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.raw(`
CREATE TABLE user_log_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
timestamp INTEGER NOT NULL,
metadata TEXT NOT NULL
);
CREATE INDEX idx_session_id ON user_log_events(session_id);
CREATE INDEX idx_timestamp ON user_log_events(timestamp);
`);
}
export async function down(knex: Knex): Promise<void> {
await knex.raw(`
DROP TABLE IF EXISTS user_log_events;
`);
}

View file

@ -0,0 +1,36 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.raw(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
start_time INTEGER NOT NULL
);
`);
await knex.raw(`
CREATE TABLE session_skin (
session_id TEXT NOT NULL,
skin_md5 TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES session(id)
);
`);
await knex.raw(`
CREATE INDEX idx_session_skin_session_id ON session_skin(session_id);
`);
await knex.raw(`
CREATE INDEX idx_session_skin_skin_md5 ON session_skin(skin_md5);
`);
}
export async function down(knex: Knex): Promise<void> {
await knex.raw(`
DROP TABLE IF EXISTS session_skin;
`);
await knex.raw(`
DROP TABLE IF EXISTS session;
`);
}

View file

@ -1,6 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
// !! WARN !!
// Dangerously allow production builds to successfully complete even if
// your project has type errors.
// !! WARN !!
ignoreBuildErrors: true,
},
serverExternalPackages: ["knex", "imagemin-optipng", "discord.js"],
experimental: {
viewTransition: true,
},
};
module.exports = nextConfig;

View file

@ -4,6 +4,7 @@
"main": "index.js",
"license": "MIT",
"dependencies": {
"@atproto/api": "^0.17.2",
"@next/third-parties": "^15.3.3",
"@sentry/node": "^5.27.3",
"@sentry/tracing": "^5.27.3",
@ -24,9 +25,10 @@
"jszip": "^3.10.1",
"knex": "^0.21.1",
"lru-cache": "^6.0.0",
"lucide-react": "^0.553.0",
"mastodon-api": "^1.3.0",
"md5": "^2.2.1",
"next": "^15.3.3",
"next": "^15.3.6",
"node-fetch": "^2.6.7",
"openai": "^4.68.0",
"polygon-clipping": "^0.15.3",
@ -46,7 +48,8 @@
"ts-node": "^10.5.0",
"twit": "^2.2.11",
"winston": "^3.2.1",
"yargs": "^13.2.4"
"yargs": "^13.2.4",
"webamp": "workspace:*"
},
"scripts": {
"lint": "eslint .",
@ -73,9 +76,10 @@
"@types/jest": "^30.0.0",
"@types/lru-cache": "^5.1.0",
"@types/node-fetch": "^2.5.7",
"@types/react": "^19.1.0",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.36.0",
"grats": "^0.0.31",
"grats": "0.0.0-main-e655d1ae",
"typescript": "^5.6.2"
},
"jest": {

View file

@ -0,0 +1,304 @@
/**
* Simple Blue/Green Deployment Script
* This script handles deploying to the inactive instance and switching traffic
*
* Usage: npx tsx scripts/deploy.ts
*/
import { execSync } from "child_process";
import { readFileSync, copyFileSync } from "fs";
import * as readline from "readline";
// ANSI color codes
const colors = {
red: "\x1b[0;31m",
green: "\x1b[0;32m",
blue: "\x1b[0;34m",
cyan: "\x1b[0;36m",
yellow: "\x1b[1;33m",
bold: "\x1b[1m",
reset: "\x1b[0m",
} as const;
// Configuration
const APACHE_CONFIG = "/etc/apache2/sites-enabled/api.webamp.org-le-ssl.conf";
const BLUE_PORT = 3001;
const GREEN_PORT = 3002;
type Color = "blue" | "green";
interface DeploymentState {
currentColor: Color;
currentPort: number;
newColor: Color;
newPort: number;
}
function log(message: string, color?: keyof typeof colors): void {
if (color) {
console.log(`${colors[color]}${message}${colors.reset}`);
} else {
console.log(message);
}
}
function logBlank(): void {
console.log();
}
function exec(command: string, description: string): void {
try {
execSync(command, { stdio: "inherit", shell: "/bin/bash" });
} catch (error) {
log(`✗ Failed to ${description}`, "red");
throw error;
}
}
function execSilent(command: string): string {
return execSync(command, { encoding: "utf8" });
}
function detectCurrentDeployment(): DeploymentState {
log("→ Detecting current active deployment...", "cyan");
const apacheConfig = readFileSync(APACHE_CONFIG, "utf8");
const isBlueActive = apacheConfig.includes(`localhost:${BLUE_PORT}`);
if (isBlueActive) {
log(
` Current active: ${colors.blue}blue${colors.reset} (port ${BLUE_PORT})`
);
log(
` Deploying to: ${colors.green}green${colors.reset} (port ${GREEN_PORT})`
);
logBlank();
return {
currentColor: "blue",
currentPort: BLUE_PORT,
newColor: "green",
newPort: GREEN_PORT,
};
} else {
log(
` Current active: ${colors.green}green${colors.reset} (port ${GREEN_PORT})`
);
log(
` Deploying to: ${colors.blue}blue${colors.reset} (port ${BLUE_PORT})`
);
logBlank();
return {
currentColor: "green",
currentPort: GREEN_PORT,
newColor: "blue",
newPort: BLUE_PORT,
};
}
}
async function promptForConfirmation(
newPort: number,
newColor: Color
): Promise<boolean> {
const colorCode = newColor === "blue" ? colors.blue : colors.green;
log("========================================", "cyan");
log(" MANUAL VALIDATION REQUIRED", "yellow");
log("========================================", "cyan");
logBlank();
log(` Test the new ${colorCode}${newColor}${colors.reset} deployment at:`);
log(` ${colors.bold}https://${newColor}.api.webamp.org${colors.reset}`);
logBlank();
log(` You can test it with:`);
log(
` ${colors.cyan}curl -I https://${newColor}.api.webamp.org${colors.reset}`
);
logBlank();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(" Does everything look good? (yes/no): ", (answer) => {
rl.close();
logBlank();
resolve(answer.toLowerCase() === "yes");
});
});
}
function switchApacheConfig(state: DeploymentState): void {
const colorCode = state.newColor === "blue" ? colors.blue : colors.green;
log(
`→ Switching production to ${colorCode}${state.newColor}${colors.reset}...`,
"cyan"
);
log(" Updating Apache configuration...", "cyan");
// Backup current config
const backupPath = `${APACHE_CONFIG}.backup`;
copyFileSync(APACHE_CONFIG, backupPath);
// Update the port in Apache config
exec(
`sudo sed -i 's/localhost:${state.currentPort}/localhost:${state.newPort}/g' "${APACHE_CONFIG}"`,
"update Apache configuration"
);
// Reload Apache
exec("sudo systemctl reload apache2", "reload Apache");
log("✓ Apache configuration updated and reloaded", "cyan");
logBlank();
// Verify the change
const updatedConfig = readFileSync(APACHE_CONFIG, "utf8");
if (!updatedConfig.includes(`localhost:${state.newPort}`)) {
throw new Error("Configuration update verification failed");
}
}
function restoreBackup(): void {
log(" Restoring backup...", "yellow");
const backupPath = `${APACHE_CONFIG}.backup`;
exec(`sudo cp "${backupPath}" "${APACHE_CONFIG}"`, "restore backup");
exec("sudo systemctl reload apache2", "reload Apache");
log("✓ Backup restored", "yellow");
}
async function main(): Promise<void> {
try {
log("========================================", "cyan");
log(" Blue/Green Deployment Script", "cyan");
log("========================================", "cyan");
logBlank();
// Step 1: Detect current deployment
const state = detectCurrentDeployment();
// Step 2: Pull from GitHub
log("→ Pulling latest code from GitHub...", "cyan");
exec("git pull --rebase origin master", "pull from GitHub");
log("✓ Code updated", "cyan");
logBlank();
// Step 3: Ensure correct Node version
log("→ Ensuring correct Node version...", "cyan");
exec(
"source ~/.nvm/nvm.sh && nvm install",
"ensure correct Node version from .nvmrc"
);
log("✓ Node version verified", "cyan");
logBlank();
// Step 4: Install dependencies
log("→ Installing dependencies...", "cyan");
exec(
"source ~/.nvm/nvm.sh && nvm exec yarn install --frozen-lockfile",
"install dependencies"
);
log("✓ Dependencies installed", "cyan");
logBlank();
// Step 5: Build the site
log("→ Building the site...", "cyan");
exec("source ~/.nvm/nvm.sh && nvm exec yarn build", "build the site");
log("✓ Build complete", "cyan");
logBlank();
// Step 5: Deploy to inactive instance
const newColorCode = state.newColor === "blue" ? colors.blue : colors.green;
log(
`→ Restarting ${newColorCode}${state.newColor}${colors.reset} instance...`,
"cyan"
);
exec(
`pm2 restart skin-database-${state.newColor}`,
`restart ${state.newColor} instance`
);
log(
`${newColorCode}${state.newColor}${colors.reset} instance restarted`,
"cyan"
);
logBlank();
// Wait for the service to start
log("→ Waiting for service to be ready...", "cyan");
await new Promise((resolve) => {
setTimeout(resolve, 5000);
});
// Check if the service is running
const pm2List = execSilent("pm2 list");
const isRunning =
pm2List.includes(`skin-database-${state.newColor}`) &&
pm2List.includes("online");
if (isRunning) {
log(
`${newColorCode}${state.newColor}${colors.reset} instance is running`,
"cyan"
);
} else {
log(
`${newColorCode}${state.newColor}${colors.reset} instance failed to start!`,
"red"
);
log(` Check PM2 logs: pm2 logs skin-database-${state.newColor}`, "red");
process.exit(1);
}
logBlank();
// Step 6: Manual validation prompt
const confirmed = await promptForConfirmation(
state.newPort,
state.newColor
);
if (!confirmed) {
log("✗ Deployment cancelled!", "red");
log(
` The ${newColorCode}${state.newColor}${colors.reset} instance is running but not active in production.`
);
log(
` You can rollback by restarting: pm2 restart skin-database-${state.newColor}`
);
process.exit(1);
}
// Step 7: Switch Apache configuration
switchApacheConfig(state);
// Success message
const currentColorCode =
state.currentColor === "blue" ? colors.blue : colors.green;
log("========================================", "cyan");
log(" DEPLOYMENT SUCCESSFUL!", "cyan");
log("========================================", "cyan");
logBlank();
log(
` Active deployment: ${newColorCode}${state.newColor}${colors.reset} (port ${state.newPort})`
);
log(
` Previous deployment: ${currentColorCode}${state.currentColor}${colors.reset} (port ${state.currentPort}) - still running as backup`
);
logBlank();
log("Note: If you need to rollback:", "yellow");
log(` 1. Edit: ${APACHE_CONFIG}`);
log(` 2. Change port back to ${state.currentPort}`);
log(` 3. Run: sudo systemctl reload apache2`);
} catch (error) {
if (
error instanceof Error &&
error.message === "Configuration update verification failed"
) {
log("✗ Configuration update failed!", "red");
restoreBackup();
}
process.exit(1);
}
}
// Run the deployment
main();

View file

@ -7,10 +7,10 @@ source "$NVM_DIR/nvm.sh"
nvm use 20
# Install dependencies
yarn install --frozen-lockfile
pnpm install --frozen-lockfile
# Build the site
yarn run build
pnpm run build
# Reload processes via PM2
pm2 reload ecosystem.config.js

View file

@ -1,5 +1,16 @@
import fetch from "node-fetch";
import { exec } from "../utils";
import { execFile } from "../utils";
import path from "path";
// Path to the ia command in the virtual environment
const IA_COMMAND = path.join(__dirname, "../.venv/bin/ia");
// Environment variables for the virtual environment
const getVenvEnv = () => ({
...process.env,
PATH: `${path.join(__dirname, "../.venv/bin")}:${process.env.PATH}`,
VIRTUAL_ENV: path.join(__dirname, "../.venv"),
});
export async function fetchMetadata(identifier: string): Promise<any> {
const r = await fetch(`https://archive.org/metadata/${identifier}`);
@ -11,9 +22,8 @@ export async function fetchMetadata(identifier: string): Promise<any> {
}
export async function fetchTasks(identifier: string): Promise<any> {
const command = `ia tasks ${identifier}`;
const result = await exec(command, {
encoding: "utf8",
const result = await execFile(IA_COMMAND, ["tasks", identifier], {
env: getVenvEnv(),
});
return result.stdout
.trim()
@ -25,12 +35,31 @@ export async function uploadFile(
identifier: string,
filepath: string
): Promise<any> {
const command = `ia upload ${identifier} "${filepath}"`;
await exec(command, { encoding: "utf8" });
await execFile(IA_COMMAND, ["upload", identifier, filepath], {
env: getVenvEnv(),
});
}
export async function uploadFiles(
identifier: string,
filepaths: string[],
metadata?: { [key: string]: string }
): Promise<any> {
const args = ["upload", identifier, ...filepaths];
if (metadata) {
Object.entries(metadata).forEach(([key, value]) => {
args.push(`--metadata=${key}:${value}`);
});
}
await execFile(IA_COMMAND, args, { env: getVenvEnv() });
}
export async function identifierExists(identifier: string): Promise<boolean> {
const result = await exec(`ia metadata ${identifier}`);
const result = await execFile(IA_COMMAND, ["metadata", identifier], {
env: getVenvEnv(),
});
const data = JSON.parse(result.stdout);
return Object.keys(data).length > 0;
}
@ -40,7 +69,10 @@ export async function setMetadata(
data: { [key: string]: string }
) {
const pairs = Object.entries(data).map(([key, value]) => `${key}:${value}`);
const args = pairs.map((pair) => `--modify="${pair}"`);
const command = `ia metadata ${identifier} ${args.join(" ")}`;
await exec(command);
const args = [
"metadata",
identifier,
...pairs.map((pair) => `--modify=${pair}`),
];
await execFile(IA_COMMAND, args, { env: getVenvEnv() });
}

View file

@ -0,0 +1,172 @@
import * as Skins from "../data/skins";
import {
AppBskyEmbedImages,
AppBskyFeedPost,
AtpAgent,
BlobRef,
RichText,
} from "@atproto/api";
import {
TWEET_BOT_CHANNEL_ID,
BLUESKY_USERNAME,
BLUESKY_PASSWORD,
} from "../config";
import { Client } from "discord.js";
import sharp from "sharp";
import SkinModel from "../data/SkinModel";
import UserContext from "../data/UserContext";
import { withBufferAsTempFile } from "../utils";
import fs from "fs";
const agent = new AtpAgent({ service: "https://bsky.social" });
export async function postToBluesky(
discordClient: Client,
md5: string | null
): Promise<void> {
if (md5 == null) {
md5 = await Skins.getSkinToPostToBluesky();
}
if (md5 == null) {
console.error("No skins to post to Bluesky");
return;
}
const url = await post(md5);
console.log("Going to post to discord");
const tweetBotChannel = await discordClient.channels.fetch(
TWEET_BOT_CHANNEL_ID
);
// @ts-ignore
await tweetBotChannel.send(url);
console.log("Posted to discord");
}
async function post(md5: string): Promise<string> {
const ctx = new UserContext();
const skin = await SkinModel.fromMd5Assert(ctx, md5);
const screenshot = await Skins.getScreenshotBuffer(md5);
const { width, height } = await sharp(screenshot).metadata();
const image = await sharp(screenshot)
.resize(width * 2, height * 2, {
kernel: sharp.kernel.nearest,
})
.toBuffer();
const name = await skin.getFileName();
const url = skin.getMuseumUrl();
const screenshotFileName = await skin.getScreenshotFileName();
const status = `${name}\n`; // TODO: Should we add hashtags?
await agent.login({
identifier: BLUESKY_USERNAME!,
password: BLUESKY_PASSWORD!,
});
const blob = await withBufferAsTempFile(
image,
screenshotFileName,
async (filePath) => {
return uploadImageFromFilePath(agent, filePath);
}
);
const postData = await buildPost(
agent,
status,
buildImageEmbed(blob, width * 2, height * 2)
);
const postResp = await agent.post(postData);
console.log(postResp);
const postId = postResp.cid;
const postUrl = postResp.uri;
await Skins.markAsPostedToBlueSky(md5, postId, postUrl);
const prefix = "Try on the ";
const suffix = "Winamp Skin Museum";
agent.post({
text: prefix + suffix,
createdAt: new Date().toISOString(),
facets: [
{
$type: "app.bsky.richtext.facet",
index: {
byteStart: prefix.length,
byteEnd: prefix.length + suffix.length,
},
features: [
{
$type: "app.bsky.richtext.facet#link",
uri: url,
},
],
},
],
reply: {
root: postResp,
parent: postResp,
},
$type: "app.bsky.feed.post",
});
// return permalink;
return postUrl;
}
/** Build the embed data for an image. */
function buildImageEmbed(
imgBlob: BlobRef,
width: number,
height: number
): AppBskyEmbedImages.Main {
const image = {
image: imgBlob,
aspectRatio: { width, height },
alt: "",
};
return {
$type: "app.bsky.embed.images",
images: [image],
};
}
/** Build the post data for an image. */
async function buildPost(
agent: AtpAgent,
rawText: string,
imageEmbed: AppBskyEmbedImages.Main
): Promise<AppBskyFeedPost.Record> {
const rt = new RichText({ text: rawText });
await rt.detectFacets(agent);
const { text, facets } = rt;
return {
text,
facets,
$type: "app.bsky.feed.post",
createdAt: new Date().toISOString(),
embed: {
$type: "app.bsky.embed.recordWithMedia",
...imageEmbed,
},
};
}
/** Upload an image from a URL to Bluesky. */
async function uploadImageFromFilePath(
agent: AtpAgent,
filePath: string
): Promise<BlobRef> {
const imageBuff = fs.readFileSync(filePath);
const imgU8 = new Uint8Array(imageBuff);
const dstResp = await agent.uploadBlob(imgU8);
if (!dstResp.success) {
throw new Error("Failed to upload image");
}
return dstResp.data.blob;
}

View file

@ -0,0 +1,181 @@
import { knex } from "../db";
import type { UserEvent } from "../app/(modern)/scroll/Events";
const WEIGHTS = {
viewDurationPerSecond: 0.1,
like: 10,
download: 5,
share: 15,
readmeExpand: 2,
};
interface SessionAggregate {
skinViewDurations: Map<string, number>;
skinsLiked: Set<string>;
skinsDownloaded: Set<string>;
readmesExpanded: Set<string>;
sharesSucceeded: Set<string>;
}
interface SkinRanking {
skinMd5: string;
totalViewDurationMs: number;
viewCount: number;
averageViewDurationMs: number;
likeCount: number;
downloadCount: number;
shareCount: number;
readmeExpandCount: number;
rankingScore: number;
}
async function main() {
try {
const rankings = await computeSkinRankings();
console.log(JSON.stringify(rankings, null, 2));
} catch (error) {
console.error("Error during aggregation:", error);
throw error;
} finally {
await knex.destroy();
}
}
if (require.main === module) {
main();
}
export async function computeSkinRankings(): Promise<SkinRanking[]> {
const sessionMap = await buildSessionAggregates();
const skinDataMap = new Map<
string,
{
viewDurations: number[];
likes: number;
downloads: number;
shares: number;
readmeExpands: number;
}
>();
function getSkinData(skinMd5: string) {
if (!skinDataMap.has(skinMd5)) {
skinDataMap.set(skinMd5, {
viewDurations: [],
likes: 0,
downloads: 0,
shares: 0,
readmeExpands: 0,
});
}
return skinDataMap.get(skinMd5)!;
}
for (const session of sessionMap.values()) {
for (const [skinMd5, duration] of session.skinViewDurations) {
getSkinData(skinMd5).viewDurations.push(duration);
}
for (const skinMd5 of session.skinsLiked) {
getSkinData(skinMd5).likes++;
}
for (const skinMd5 of session.skinsDownloaded) {
getSkinData(skinMd5).downloads++;
}
for (const skinMd5 of session.sharesSucceeded) {
getSkinData(skinMd5).shares++;
}
for (const skinMd5 of session.readmesExpanded) {
getSkinData(skinMd5).readmeExpands++;
}
}
const rankings: SkinRanking[] = [];
for (const [skinMd5, data] of skinDataMap) {
const totalViewDurationMs = data.viewDurations.reduce(
(sum, duration) => sum + duration,
0
);
const viewCount = data.viewDurations.length;
const averageViewDurationMs =
viewCount > 0 ? totalViewDurationMs / viewCount : 0;
const rankingScore =
(averageViewDurationMs / 1000) * WEIGHTS.viewDurationPerSecond +
data.likes * WEIGHTS.like +
data.downloads * WEIGHTS.download +
data.shares * WEIGHTS.share +
data.readmeExpands * WEIGHTS.readmeExpand;
rankings.push({
skinMd5,
totalViewDurationMs,
viewCount,
averageViewDurationMs,
likeCount: data.likes,
downloadCount: data.downloads,
shareCount: data.shares,
readmeExpandCount: data.readmeExpands,
rankingScore,
});
}
rankings.sort((a, b) => b.rankingScore - a.rankingScore);
return rankings;
}
async function buildSessionAggregates(): Promise<
Map<string, SessionAggregate>
> {
const events = await knex("user_log_events")
.select("session_id", "timestamp", "metadata")
.orderBy("timestamp", "asc");
const sessionMap = new Map<string, SessionAggregate>();
function getSession(sessionId: string): SessionAggregate {
if (!sessionMap.has(sessionId)) {
sessionMap.set(sessionId, {
skinViewDurations: new Map(),
skinsLiked: new Set(),
skinsDownloaded: new Set(),
readmesExpanded: new Set(),
sharesSucceeded: new Set(),
});
}
return sessionMap.get(sessionId)!;
}
for (const row of events) {
const event: UserEvent = JSON.parse(row.metadata);
const session = getSession(row.session_id);
switch (event.type) {
case "skin_view_end":
session.skinViewDurations.set(event.skinMd5, event.durationMs);
break;
case "readme_expand":
session.readmesExpanded.add(event.skinMd5);
break;
case "skin_download":
session.skinsDownloaded.add(event.skinMd5);
break;
case "skin_like":
if (event.liked) {
session.skinsLiked.add(event.skinMd5);
} else {
session.skinsLiked.delete(event.skinMd5);
}
break;
case "share_success":
session.sharesSucceeded.add(event.skinMd5);
break;
}
}
return sessionMap;
}
export { buildSessionAggregates };
export type { SessionAggregate };

View file

@ -7,6 +7,7 @@ import * as Skins from "../data/skins";
import * as CloudFlare from "../CloudFlare";
import SkinModel from "../data/SkinModel";
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-require-imports
const Shooter = require("../shooter");
export async function screenshot(skin: SkinModel, shooter: typeof Shooter) {

View file

@ -8,7 +8,7 @@ import SkinModel from "../data/SkinModel";
import * as Parallel from "async-parallel";
import IaItemModel from "../data/IaItemModel";
import DiscordEventHandler from "../api/DiscordEventHandler";
import { exec } from "../utils";
import { execFile } from "../utils";
import * as IAService from "../services/internetArchive";
export async function findItemsMissingImages(): Promise<string[]> {
@ -192,7 +192,7 @@ async function getNewIdentifier(filename: string): Promise<string> {
}
export async function archive(skin: SkinModel): Promise<string> {
const filename = await skin.getFileName();
const filename = await skin.getFileName(true);
const screenshotFilename = await skin.getScreenshotFileName();
const title = `Winamp Skin: ${filename}`;
@ -207,8 +207,36 @@ export async function archive(skin: SkinModel): Promise<string> {
console.log(`Going to try to upload with identifier "${identifier}"...`);
const command = `ia upload ${identifier} "${skinFile}" "${screenshotFile}" --metadata="collection:winampskins" --metadata="skintype:wsz" --metadata="mediatype:software" --metadata="title:${title}"`;
await exec(command, { encoding: "utf8" });
// Path to the ia command in the virtual environment
const IA_COMMAND = path.join(__dirname, "../.venv/bin/ia");
// Environment variables for the virtual environment
const venvEnv = {
...process.env,
PATH: `${path.join(__dirname, "../.venv/bin")}:${process.env.PATH}`,
VIRTUAL_ENV: path.join(__dirname, "../.venv"),
};
const metadata = {
collection: "winampskins",
skintype: "wsz",
mediatype: "software",
title: title,
};
// Build arguments array for ia upload command
const args = [
"upload",
identifier,
skinFile,
screenshotFile,
`--metadata=collection:${metadata.collection}`,
`--metadata=skintype:${metadata.skintype}`,
`--metadata=mediatype:${metadata.mediatype}`,
`--metadata=title:${metadata.title}`,
];
await execFile(IA_COMMAND, args, { env: venvEnv });
await knex("ia_items").insert({ skin_md5: skin.getMd5(), identifier });
return identifier;
}

View file

@ -23,11 +23,7 @@
"outDir": "dist",
"strictNullChecks": true,
"skipLibCheck": true,
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"strict": false,
"noEmit": true,
"incremental": true,
@ -37,13 +33,9 @@
},
"include": [
"./**/*",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/types/app/(legacy)/about/page.tsx"
],
"lib": [
"es2015"
],
"exclude": [
"node_modules",
"dist"
]
"lib": ["es2015"],
"exclude": ["node_modules", "dist"]
}

View file

@ -19,7 +19,7 @@ export type SkinRow = {
skin_type: number;
emails: string;
// readme_text: string;
average_color: string;
average_color?: string;
};
export type TweetRow = {

View file

@ -6,6 +6,7 @@ import child_process from "child_process";
import path from "path";
export const exec = util.promisify(child_process.exec);
export const execFile = util.promisify(child_process.execFile);
const temp = _temp.track();

View file

@ -5,9 +5,7 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
First, run the development server:
```bash
npm run dev
# or
yarn dev
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

View file

@ -11,8 +11,8 @@
"dependencies": {
"@vercel/og": "^0.0.20",
"next": "13.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"vercel": "^28.4.17"

View file

@ -98,11 +98,10 @@ async function searchImage(query) {
});
}
function HeaderGrid({ skins, title }) {
function HeaderGrid({ skins, _title }) {
return (
<div
style={{
background: "black",
color: "white",
width: "100%",
height: "100%",
@ -110,7 +109,6 @@ function HeaderGrid({ skins, title }) {
flexDirection: "column",
textAlign: "center",
alignItems: "center",
justifyContent: "center",
justifyContent: "space-between",
background: "linear-gradient(45deg,#000,#191927 66%,#000)",
}}

View file

@ -2,7 +2,6 @@ export default function Frame({ children }) {
return (
<div
style={{
background: "black",
color: "white",
width: "100%",
height: "100%",
@ -10,7 +9,6 @@ export default function Frame({ children }) {
flexDirection: "column",
textAlign: "center",
alignItems: "center",
justifyContent: "center",
justifyContent: "space-between",
background: "linear-gradient(45deg,#000,#191927 66%,#000)",
}}

View file

@ -15,7 +15,7 @@ npm install --save webamp
From here you can import Webamp in your JavaScript code:
```js
import Webamp from "webamp";
import Webamp from "webamp/butterchurn";
// ... use Webamp here
```
@ -25,19 +25,7 @@ ES modules can be imported via URL directly inside a `<script type="module">` ta
```html
<script type="module">
import Webamp from "https://unpkg.com/webamp@^2";
// ... use Webamp here
</script>
```
## Include via a script tag
This will make the Webamp constructor available as a `window` property: `window.Webamp` keep in mind that you will need to use the `type="module"` attribute on the script tag,
```html
<script src="https://unpkg.com/webamp@^2" type="module"></script>
<script>
const Webamp = window.Webamp;
import Webamp from "https://unpkg.com/webamp@^2/butterchurn";
// ... use Webamp here
</script>
```

View file

@ -12,6 +12,8 @@ Create a DOM element somewhere in your HTML document. This will be used by Webam
:::tip
**Webamp will not actually insert itself as a child of this element.** It will will insert itself as a child of the body element, and will attempt to center itself within this element. This is needed to allow the various Webamp windows to dragged around the page unencumbered.
If you want Webamp to be a child of a specific element, use the [`renderInto(domNode)`](./06_API/03_instance-methods.md#renderintodomnode-htmlelement-promisevoid) method instead _(available in unreleased version)_. Note that the target element must have a non-static CSS position (e.g., `position: relative`) for this to work correctly.
:::
## Initialize Webamp instance

View file

@ -2,7 +2,7 @@
Webamp uses [Butterchurn](https://butterchurnviz.com/) to provide a Milkdrop visualizer. Butterchurn is a JavaScript port of the original Milkdrop visualizer, and it can run in any modern web browser.
The [next release](../12_changelog.md#unreleased) of Webamp will includes a `webamp/butterchurn` entrypoint that includes Butterchurn, so you can use it to create a Webamp instance with Milkdrop visualizer enabled.
Starting with [v2.2.0](../12_changelog.md#220) Webamp includes a `webamp/butterchurn` entrypoint that includes Butterchurn, so you can use it to create a Webamp instance with Milkdrop visualizer enabled. See "Minimal Milkdrop" in [examples](../04_examples.md) for more details.
## Hotkeys

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