tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
19
fork
atom
overview
issues
pulls
pipelines
Compare changes
Choose any two refs to compare.
base:
various-fixes
updated-blentos
update-docs
timer-card-tiny-fix
theme-colors
switch-map
switch-grid-layout
statusphere-fix
small-fixes
signup
show-login-error
section-settings
section-fix-undo
remove-extra-buttons
refactor-cards
record-visualizer-card
qr-codes
profile-stuff-2
profile-stuff
product-hunt
polijn/main
pages
npmx
next
new-og-image-wip
move-qr-click
mobile-editing
map
main-favicon
main
mail-icon
lastfm
kickstarter-card
invalid-handle-fix
improve-saving
improve-oauth
improve-link-card
improve-fluid-text
image-fixes
hide-friends
github-contribs
gifs-heypster
funding
fuck-another-fix
floating-button
fixes
fix-xss
fix-timer-stuff
fix-signup-pds
fix-package-manager
fix-invalid-site.standard.documents
fix-formatting
fix-favicon
fix-build
event-card
edit-profile
drawing-card
custom-domains-editing
custom-domains
copy-page
card-label
card-command-bar-v2
card-command-bar
button
bluesky-post-nsfw-labels
bluesky-post-card
bluesky-feed-card
apple-music-playlist
no tags found
compare:
various-fixes
updated-blentos
update-docs
timer-card-tiny-fix
theme-colors
switch-map
switch-grid-layout
statusphere-fix
small-fixes
signup
show-login-error
section-settings
section-fix-undo
remove-extra-buttons
refactor-cards
record-visualizer-card
qr-codes
profile-stuff-2
profile-stuff
product-hunt
polijn/main
pages
npmx
next
new-og-image-wip
move-qr-click
mobile-editing
map
main-favicon
main
mail-icon
lastfm
kickstarter-card
invalid-handle-fix
improve-saving
improve-oauth
improve-link-card
improve-fluid-text
image-fixes
hide-friends
github-contribs
gifs-heypster
funding
fuck-another-fix
floating-button
fixes
fix-xss
fix-timer-stuff
fix-signup-pds
fix-package-manager
fix-invalid-site.standard.documents
fix-formatting
fix-favicon
fix-build
event-card
edit-profile
drawing-card
custom-domains-editing
custom-domains
copy-page
card-label
card-command-bar-v2
card-command-bar
button
bluesky-post-nsfw-labels
bluesky-post-card
bluesky-feed-card
apple-music-playlist
no tags found
go
+451
-2
7 changed files
expand all
collapse all
unified
split
.gitignore
package.json
pnpm-lock.yaml
src
lib
cards
index.ts
visual
RecordVisualizerCard
RecordVisualizerCard.svelte
RecordVisualizerSettings.svelte
index.ts
+1
-1
.gitignore
···
22
22
vite.config.js.timestamp-*
23
23
vite.config.ts.timestamp-*
24
24
25
25
-
react-grid-layout
25
25
+
references
+1
package.json
···
79
79
"mapbox-gl": "^3.18.1",
80
80
"marked": "^17.0.1",
81
81
"perfect-freehand": "^1.2.2",
82
82
+
"pixi.js": "^8.16.0",
82
83
"plyr": "^3.8.4",
83
84
"qr-code-styling": "^1.8.6",
84
85
"react-grid-layout": "^2.2.2",
+69
-1
pnpm-lock.yaml
···
128
128
perfect-freehand:
129
129
specifier: ^1.2.2
130
130
version: 1.2.2
131
131
+
pixi.js:
132
132
+
specifier: ^8.16.0
133
133
+
version: 8.16.0
131
134
plyr:
132
135
specifier: ^3.8.4
133
136
version: 3.8.4
···
978
981
peerDependencies:
979
982
svelte: ^4 || ^5
980
983
984
984
+
'@pixi/colord@2.9.6':
985
985
+
resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==, tarball: https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz}
986
986
+
981
987
'@polka/url@1.0.0-next.29':
982
988
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
983
989
···
1508
1514
'@types/cookie@0.6.0':
1509
1515
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
1510
1516
1517
1517
+
'@types/earcut@3.0.0':
1518
1518
+
resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==, tarball: https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz}
1519
1519
+
1511
1520
'@types/estree@1.0.8':
1512
1521
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
1513
1522
···
1624
1633
'@webgpu/types@0.1.69':
1625
1634
resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==}
1626
1635
1636
1636
+
'@xmldom/xmldom@0.8.11':
1637
1637
+
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==, tarball: https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz}
1638
1638
+
engines: {node: '>=10.0.0'}
1639
1639
+
1627
1640
acorn-jsx@5.3.2:
1628
1641
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
1629
1642
peerDependencies:
···
1853
1866
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
1854
1867
1855
1868
earcut@3.0.2:
1856
1856
-
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
1869
1869
+
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==, tarball: https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz}
1857
1870
1858
1871
emoji-picker-element@1.28.1:
1859
1872
resolution: {integrity: sha512-8c64IPish2PWoV9oYCo2pvuPHwIv+uK9bO0dfpPyMupDAvaWL9ZvYhWNTAR+2sx7BhfRjciImqP6CIUgNX+DMg==}
···
1964
1977
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1965
1978
engines: {node: '>=0.10.0'}
1966
1979
1980
1980
+
eventemitter3@5.0.4:
1981
1981
+
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz}
1982
1982
+
1967
1983
exsolve@1.0.8:
1968
1984
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
1969
1985
···
2017
2033
geojson-vt@4.0.2:
2018
2034
resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==}
2019
2035
2036
2036
+
gifuct-js@2.1.2:
2037
2037
+
resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==, tarball: https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz}
2038
2038
+
2020
2039
gl-matrix@3.4.4:
2021
2040
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
2022
2041
···
2102
2121
isexe@2.0.0:
2103
2122
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
2104
2123
2124
2124
+
ismobilejs@1.1.1:
2125
2125
+
resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==, tarball: https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz}
2126
2126
+
2105
2127
iso-datestring-validator@2.2.2:
2106
2128
resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==}
2107
2129
···
2109
2131
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
2110
2132
hasBin: true
2111
2133
2134
2134
+
js-binary-schema-parser@2.0.3:
2135
2135
+
resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==, tarball: https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz}
2136
2136
+
2112
2137
js-tokens@4.0.0:
2113
2138
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz}
2114
2139
···
2385
2410
parse-css-color@0.2.1:
2386
2411
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
2387
2412
2413
2413
+
parse-svg-path@0.1.2:
2414
2414
+
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==, tarball: https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz}
2415
2415
+
2388
2416
parse5-htmlparser2-tree-adapter@7.1.0:
2389
2417
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
2390
2418
···
2422
2450
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
2423
2451
engines: {node: '>=12'}
2424
2452
2453
2453
+
pixi.js@8.16.0:
2454
2454
+
resolution: {integrity: sha512-gu2xw3sZGAn3cWBtk0HqTQT+v19YAfiaYXwUGgWoJl5NKz4cEZJUgWrwkmdfDszGyYBAGqOvJNbd2M9+vzLLMg==, tarball: https://registry.npmjs.org/pixi.js/-/pixi.js-8.16.0.tgz}
2455
2455
+
2425
2456
pkg-types@1.3.1:
2426
2457
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
2427
2458
···
2897
2928
tiny-inflate@1.0.3:
2898
2929
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
2899
2930
2931
2931
+
tiny-lru@11.4.7:
2932
2932
+
resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==, tarball: https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz}
2933
2933
+
engines: {node: '>=12'}
2934
2934
+
2900
2935
tinyglobby@0.2.15:
2901
2936
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
2902
2937
engines: {node: '>=12.0.0'}
···
3750
3785
number-flow: 0.5.9
3751
3786
svelte: 5.48.0
3752
3787
3788
3788
+
'@pixi/colord@2.9.6': {}
3789
3789
+
3753
3790
'@polka/url@1.0.0-next.29': {}
3754
3791
3755
3792
'@poppinss/colors@4.1.6':
···
4224
4261
4225
4262
'@types/cookie@0.6.0': {}
4226
4263
4264
4264
+
'@types/earcut@3.0.0': {}
4265
4265
+
4227
4266
'@types/estree@1.0.8': {}
4228
4267
4229
4268
'@types/geojson-vt@3.2.5':
···
4373
4412
4374
4413
'@webgpu/types@0.1.69': {}
4375
4414
4415
4415
+
'@xmldom/xmldom@0.8.11': {}
4416
4416
+
4376
4417
acorn-jsx@5.3.2(acorn@8.15.0):
4377
4418
dependencies:
4378
4419
acorn: 8.15.0
···
4783
4824
4784
4825
esutils@2.0.3: {}
4785
4826
4827
4827
+
eventemitter3@5.0.4: {}
4828
4828
+
4786
4829
exsolve@1.0.8: {}
4787
4830
4788
4831
fast-deep-equal@3.1.3: {}
···
4822
4865
4823
4866
geojson-vt@4.0.2: {}
4824
4867
4868
4868
+
gifuct-js@2.1.2:
4869
4869
+
dependencies:
4870
4870
+
js-binary-schema-parser: 2.0.3
4871
4871
+
4825
4872
gl-matrix@3.4.4: {}
4826
4873
4827
4874
glob-parent@6.0.2:
···
4891
4938
4892
4939
isexe@2.0.0: {}
4893
4940
4941
4941
+
ismobilejs@1.1.1: {}
4942
4942
+
4894
4943
iso-datestring-validator@2.2.2: {}
4895
4944
4896
4945
jiti@2.6.1: {}
4897
4946
4947
4947
+
js-binary-schema-parser@2.0.3: {}
4948
4948
+
4898
4949
js-tokens@4.0.0: {}
4899
4950
4900
4951
js-yaml@4.1.1:
···
5165
5216
color-name: 1.1.4
5166
5217
hex-rgb: 4.3.0
5167
5218
5219
5219
+
parse-svg-path@0.1.2: {}
5220
5220
+
5168
5221
parse5-htmlparser2-tree-adapter@7.1.0:
5169
5222
dependencies:
5170
5223
domhandler: 5.0.3
···
5196
5249
5197
5250
picomatch@4.0.3: {}
5198
5251
5252
5252
+
pixi.js@8.16.0:
5253
5253
+
dependencies:
5254
5254
+
'@pixi/colord': 2.9.6
5255
5255
+
'@types/earcut': 3.0.0
5256
5256
+
'@webgpu/types': 0.1.69
5257
5257
+
'@xmldom/xmldom': 0.8.11
5258
5258
+
earcut: 3.0.2
5259
5259
+
eventemitter3: 5.0.4
5260
5260
+
gifuct-js: 2.1.2
5261
5261
+
ismobilejs: 1.1.1
5262
5262
+
parse-svg-path: 0.1.2
5263
5263
+
tiny-lru: 11.4.7
5264
5264
+
5199
5265
pkg-types@1.3.1:
5200
5266
dependencies:
5201
5267
confbox: 0.1.8
···
5710
5776
5711
5777
tiny-inflate@1.0.3: {}
5712
5778
5779
5779
+
tiny-lru@11.4.7: {}
5780
5780
+
5713
5781
tinyglobby@0.2.15:
5714
5782
dependencies:
5715
5783
fdir: 6.5.0(picomatch@4.0.3)
+2
src/lib/cards/index.ts
···
29
29
import { EventCardDefinition } from './social/EventCard';
30
30
import { VCardCardDefinition } from './social/VCardCard';
31
31
import { DrawCardDefinition } from './visual/DrawCard';
32
32
+
import { RecordVisualizerCardDefinition } from './visual/RecordVisualizerCard';
32
33
import { TimerCardDefinition } from './utilities/TimerCard';
33
34
import { ClockCardDefinition } from './utilities/ClockCard';
34
35
import { CountdownCardDefinition } from './utilities/CountdownCard';
···
75
76
EventCardDefinition,
76
77
VCardCardDefinition,
77
78
DrawCardDefinition,
79
79
+
RecordVisualizerCardDefinition,
78
80
TimerCardDefinition,
79
81
ClockCardDefinition,
80
82
CountdownCardDefinition,
+277
src/lib/cards/visual/RecordVisualizerCard/RecordVisualizerCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import { browser } from '$app/environment';
4
4
+
import * as PIXI from 'pixi.js';
5
5
+
import type { ContentComponentProps } from '../../types';
6
6
+
7
7
+
let { item }: ContentComponentProps = $props();
8
8
+
9
9
+
type RecordVisualizerCardData = {
10
10
+
emoji?: string;
11
11
+
collection?: string;
12
12
+
direction?: 'down' | 'up';
13
13
+
speed?: number;
14
14
+
};
15
15
+
16
16
+
let cardData = $derived(item.cardData as RecordVisualizerCardData);
17
17
+
18
18
+
let emoji = $derived(cardData.emoji || '๐');
19
19
+
let collection = $derived(cardData.collection || 'app.bsky.feed.like');
20
20
+
let direction = $derived(cardData.direction || 'down');
21
21
+
let speed = $derived(Math.max(0.5, Math.min(2, cardData.speed || 1)));
22
22
+
23
23
+
let containerEl: HTMLDivElement | null = null;
24
24
+
let canvasEl: HTMLCanvasElement | null = null;
25
25
+
let app: PIXI.Application | null = null;
26
26
+
let ws: WebSocket | null = null;
27
27
+
let prevCollection = $state<string | null>(null);
28
28
+
let prevEmoji = $state<string | null>(null);
29
29
+
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
30
30
+
31
31
+
const RECONNECT_DEBOUNCE = 1000;
32
32
+
const MAX_PARTICLES = 10000;
33
33
+
34
34
+
// Particle system
35
35
+
interface ParticleSprite extends PIXI.Sprite {
36
36
+
speedX: number;
37
37
+
speedY: number;
38
38
+
age: number;
39
39
+
maxAge: number;
40
40
+
initialSize: number;
41
41
+
}
42
42
+
43
43
+
let particles: ParticleSprite[] = [];
44
44
+
let particlePool: ParticleSprite[] = [];
45
45
+
let particleContainer: PIXI.Container | null = null;
46
46
+
let emojiTexture: PIXI.Texture | null = null;
47
47
+
48
48
+
function createEmojiTexture(emojiChar: string): PIXI.Texture {
49
49
+
const canvas = document.createElement('canvas');
50
50
+
const size = 64;
51
51
+
canvas.width = size;
52
52
+
canvas.height = size;
53
53
+
const ctx = canvas.getContext('2d')!;
54
54
+
ctx.font = `${size * 0.8}px serif`;
55
55
+
ctx.textAlign = 'center';
56
56
+
ctx.textBaseline = 'middle';
57
57
+
ctx.fillText(emojiChar, size / 2, size / 2);
58
58
+
return PIXI.Texture.from(canvas);
59
59
+
}
60
60
+
61
61
+
function spawnParticle() {
62
62
+
if (!app || !particleContainer || !emojiTexture) return;
63
63
+
64
64
+
let particle: ParticleSprite;
65
65
+
if (particlePool.length > 0) {
66
66
+
particle = particlePool.pop()!;
67
67
+
particle.texture = emojiTexture;
68
68
+
} else if (particles.length < MAX_PARTICLES) {
69
69
+
particle = new PIXI.Sprite(emojiTexture) as ParticleSprite;
70
70
+
particle.anchor.set(0.5, 0.5);
71
71
+
particleContainer.addChild(particle);
72
72
+
} else {
73
73
+
return;
74
74
+
}
75
75
+
76
76
+
const w = app.screen.width;
77
77
+
const h = app.screen.height;
78
78
+
79
79
+
// Parallax: random scale from 0.3 (far/small) to 1.0 (near/large)
80
80
+
const scale = Math.random() * 0.7 + 0.3;
81
81
+
const baseSize = (Math.random() * 30 + 15) * scale;
82
82
+
83
83
+
particle.visible = true;
84
84
+
particle.x = Math.random() * w;
85
85
+
particle.y = direction === 'down' ? -baseSize : h + baseSize;
86
86
+
particle.width = particle.height = baseSize;
87
87
+
particle.alpha = 0.4 + scale * 0.6;
88
88
+
particle.rotation = (Math.random() - 0.5) * 0.3;
89
89
+
particle.zIndex = Math.round(scale * 10);
90
90
+
91
91
+
// Speed based on scale (smaller = slower for parallax)
92
92
+
const baseSpeed = 80 * speed;
93
93
+
const effectiveSpeed = baseSpeed * scale;
94
94
+
particle.speedX = (Math.random() - 0.5) * 20;
95
95
+
particle.speedY = direction === 'down' ? effectiveSpeed : -effectiveSpeed;
96
96
+
97
97
+
particle.age = 0;
98
98
+
particle.maxAge = (h + baseSize * 2) / effectiveSpeed + 2;
99
99
+
particle.initialSize = baseSize;
100
100
+
101
101
+
particles.push(particle);
102
102
+
}
103
103
+
104
104
+
function removeParticle(particle: ParticleSprite) {
105
105
+
const index = particles.indexOf(particle);
106
106
+
if (index !== -1) {
107
107
+
particle.visible = false;
108
108
+
particles.splice(index, 1);
109
109
+
particlePool.push(particle);
110
110
+
}
111
111
+
}
112
112
+
113
113
+
function updateParticles(deltaTime: number) {
114
114
+
if (!app) return;
115
115
+
const h = app.screen.height;
116
116
+
117
117
+
for (let i = particles.length - 1; i >= 0; i--) {
118
118
+
const particle = particles[i];
119
119
+
particle.x += particle.speedX * deltaTime;
120
120
+
particle.y += particle.speedY * deltaTime;
121
121
+
particle.age += deltaTime;
122
122
+
123
123
+
// Remove if off screen or too old
124
124
+
const isOffScreen =
125
125
+
direction === 'down'
126
126
+
? particle.y > h + particle.initialSize
127
127
+
: particle.y < -particle.initialSize;
128
128
+
129
129
+
if (particle.age >= particle.maxAge || isOffScreen) {
130
130
+
removeParticle(particle);
131
131
+
}
132
132
+
}
133
133
+
}
134
134
+
135
135
+
async function initPixi() {
136
136
+
if (!browser || !containerEl || !canvasEl) return;
137
137
+
138
138
+
// Clean up existing app
139
139
+
if (app) {
140
140
+
app.destroy(true, { children: true, texture: true });
141
141
+
app = null;
142
142
+
}
143
143
+
144
144
+
particles = [];
145
145
+
particlePool = [];
146
146
+
147
147
+
app = new PIXI.Application();
148
148
+
await app.init({
149
149
+
canvas: canvasEl,
150
150
+
width: containerEl.clientWidth,
151
151
+
height: containerEl.clientHeight,
152
152
+
backgroundAlpha: 0,
153
153
+
antialias: true,
154
154
+
resolution: window.devicePixelRatio || 1,
155
155
+
autoDensity: true
156
156
+
});
157
157
+
158
158
+
particleContainer = new PIXI.Container();
159
159
+
particleContainer.sortableChildren = true;
160
160
+
app.stage.addChild(particleContainer);
161
161
+
162
162
+
emojiTexture = createEmojiTexture(emoji);
163
163
+
164
164
+
app.ticker.add((ticker) => {
165
165
+
updateParticles(ticker.deltaMS * 0.001);
166
166
+
});
167
167
+
168
168
+
// Handle resize
169
169
+
const resizeObserver = new ResizeObserver(() => {
170
170
+
if (app && containerEl) {
171
171
+
app.renderer.resize(containerEl.clientWidth, containerEl.clientHeight);
172
172
+
}
173
173
+
});
174
174
+
resizeObserver.observe(containerEl);
175
175
+
176
176
+
return () => {
177
177
+
resizeObserver.disconnect();
178
178
+
};
179
179
+
}
180
180
+
181
181
+
function connectWebSocket() {
182
182
+
if (!browser) return;
183
183
+
184
184
+
if (ws) {
185
185
+
ws.close();
186
186
+
}
187
187
+
188
188
+
const wsUrl = `wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=${encodeURIComponent(collection)}`;
189
189
+
190
190
+
try {
191
191
+
ws = new WebSocket(wsUrl);
192
192
+
193
193
+
ws.onmessage = (event) => {
194
194
+
try {
195
195
+
const data = JSON.parse(event.data);
196
196
+
if (data.kind === 'commit' && data.commit?.operation === 'create') {
197
197
+
spawnParticle();
198
198
+
}
199
199
+
} catch {
200
200
+
// Ignore parse errors
201
201
+
}
202
202
+
};
203
203
+
204
204
+
ws.onerror = () => {
205
205
+
// Silently handle errors
206
206
+
};
207
207
+
208
208
+
ws.onclose = () => {
209
209
+
setTimeout(() => {
210
210
+
if (containerEl) {
211
211
+
connectWebSocket();
212
212
+
}
213
213
+
}, 5000);
214
214
+
};
215
215
+
} catch {
216
216
+
// Failed to create WebSocket
217
217
+
}
218
218
+
}
219
219
+
220
220
+
onMount(() => {
221
221
+
let cleanupResize: (() => void) | undefined;
222
222
+
223
223
+
initPixi().then((cleanup) => {
224
224
+
cleanupResize = cleanup;
225
225
+
});
226
226
+
connectWebSocket();
227
227
+
228
228
+
return () => {
229
229
+
if (ws) {
230
230
+
ws.close();
231
231
+
ws = null;
232
232
+
}
233
233
+
if (reconnectTimeout) {
234
234
+
clearTimeout(reconnectTimeout);
235
235
+
}
236
236
+
if (app) {
237
237
+
app.destroy(true, { children: true, texture: true });
238
238
+
app = null;
239
239
+
}
240
240
+
cleanupResize?.();
241
241
+
};
242
242
+
});
243
243
+
244
244
+
// Reconnect when collection changes (debounced)
245
245
+
$effect(() => {
246
246
+
const currentCollection = collection;
247
247
+
248
248
+
if (prevCollection !== null && prevCollection !== currentCollection) {
249
249
+
if (reconnectTimeout) {
250
250
+
clearTimeout(reconnectTimeout);
251
251
+
}
252
252
+
reconnectTimeout = setTimeout(() => {
253
253
+
if (ws) {
254
254
+
ws.close();
255
255
+
}
256
256
+
connectWebSocket();
257
257
+
}, RECONNECT_DEBOUNCE);
258
258
+
}
259
259
+
260
260
+
prevCollection = currentCollection;
261
261
+
});
262
262
+
263
263
+
// Update emoji texture when emoji changes
264
264
+
$effect(() => {
265
265
+
const currentEmoji = emoji;
266
266
+
267
267
+
if (prevEmoji !== null && prevEmoji !== currentEmoji && app) {
268
268
+
emojiTexture = createEmojiTexture(currentEmoji);
269
269
+
}
270
270
+
271
271
+
prevEmoji = currentEmoji;
272
272
+
});
273
273
+
</script>
274
274
+
275
275
+
<div bind:this={containerEl} class="h-full w-full overflow-hidden">
276
276
+
<canvas bind:this={canvasEl} class="h-full w-full"></canvas>
277
277
+
</div>
+71
src/lib/cards/visual/RecordVisualizerCard/RecordVisualizerSettings.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { Item } from '$lib/types';
3
3
+
import type { SettingsComponentProps } from '../../types';
4
4
+
import { Input, Label } from '@foxui/core';
5
5
+
6
6
+
let { item = $bindable<Item>() }: SettingsComponentProps = $props();
7
7
+
8
8
+
type RecordVisualizerCardData = {
9
9
+
emoji?: string;
10
10
+
collection?: string;
11
11
+
direction?: 'down' | 'up';
12
12
+
speed?: number;
13
13
+
};
14
14
+
15
15
+
let cardData = $derived(item.cardData as RecordVisualizerCardData);
16
16
+
17
17
+
// Initialize defaults if not set
18
18
+
if (item.cardData.emoji === undefined) {
19
19
+
item.cardData.emoji = '๐';
20
20
+
}
21
21
+
if (item.cardData.collection === undefined) {
22
22
+
item.cardData.collection = 'app.bsky.feed.like';
23
23
+
}
24
24
+
if (item.cardData.direction === undefined) {
25
25
+
item.cardData.direction = 'down';
26
26
+
}
27
27
+
if (item.cardData.speed === undefined) {
28
28
+
item.cardData.speed = 1;
29
29
+
}
30
30
+
</script>
31
31
+
32
32
+
<div class="flex flex-col gap-3">
33
33
+
<div>
34
34
+
<Label class="mb-1 text-xs">Emoji</Label>
35
35
+
<Input bind:value={item.cardData.emoji} placeholder="๐" class="w-full" />
36
36
+
</div>
37
37
+
38
38
+
<div>
39
39
+
<Label class="mb-1 text-xs">Collection</Label>
40
40
+
<Input bind:value={item.cardData.collection} placeholder="app.bsky.feed.like" class="w-full" />
41
41
+
</div>
42
42
+
43
43
+
<div>
44
44
+
<Label class="mb-1 text-xs">Direction</Label>
45
45
+
<select
46
46
+
value={cardData.direction ?? 'down'}
47
47
+
onchange={(e) => {
48
48
+
item.cardData.direction = (e.target as HTMLSelectElement).value;
49
49
+
}}
50
50
+
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-600 w-full rounded-md border px-3 py-2 text-sm"
51
51
+
>
52
52
+
<option value="down">Down</option>
53
53
+
<option value="up">Up</option>
54
54
+
</select>
55
55
+
</div>
56
56
+
57
57
+
<div>
58
58
+
<Label class="mb-1 text-xs">Speed ({cardData.speed?.toFixed(1) ?? '1.0'}x)</Label>
59
59
+
<input
60
60
+
type="range"
61
61
+
min="0.5"
62
62
+
max="2"
63
63
+
step="0.1"
64
64
+
value={cardData.speed ?? 1}
65
65
+
oninput={(e) => {
66
66
+
item.cardData.speed = parseFloat(e.currentTarget.value);
67
67
+
}}
68
68
+
class="bg-base-200 dark:bg-base-700 h-2 w-full cursor-pointer appearance-none rounded-lg"
69
69
+
/>
70
70
+
</div>
71
71
+
</div>
+30
src/lib/cards/visual/RecordVisualizerCard/index.ts
···
1
1
+
import type { CardDefinition } from '../../types';
2
2
+
import RecordVisualizerCard from './RecordVisualizerCard.svelte';
3
3
+
import RecordVisualizerSettings from './RecordVisualizerSettings.svelte';
4
4
+
5
5
+
export const RecordVisualizerCardDefinition = {
6
6
+
type: 'record-visualizer',
7
7
+
contentComponent: RecordVisualizerCard,
8
8
+
createNew: (card) => {
9
9
+
card.cardType = 'record-visualizer';
10
10
+
card.cardData = {
11
11
+
emoji: '๐',
12
12
+
collection: 'app.bsky.feed.like',
13
13
+
direction: 'down',
14
14
+
speed: 1
15
15
+
};
16
16
+
card.w = 2;
17
17
+
card.h = 2;
18
18
+
card.mobileW = 4;
19
19
+
card.mobileH = 4;
20
20
+
},
21
21
+
settingsComponent: RecordVisualizerSettings,
22
22
+
minW: 1,
23
23
+
minH: 2,
24
24
+
canHaveLabel: true,
25
25
+
26
26
+
keywords: ['emoji', 'particles', 'animation', 'bluesky', 'atproto', 'live', 'realtime', 'stream'],
27
27
+
groups: ['Visual'],
28
28
+
name: 'Record Visualizer',
29
29
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" /></svg>`
30
30
+
} as CardDefinition & { type: 'record-visualizer' };