@mundogamernetwork/shared-ui 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ <template>
2
+ <div class="widget-root" :style="{ '--accent': alertPixel?.background_color || '#FDB215' }">
3
+ <!-- Canvas -->
4
+ <div class="widget-canvas-wrap">
5
+ <div ref="canvasEl" class="widget-canvas" :style="canvasStyle">
6
+ <div
7
+ v-for="pixel in visiblePixels"
8
+ :key="pixel.id"
9
+ class="widget-pixel"
10
+ :class="{ 'widget-pixel--new': newPixelIds.has(pixel.id) }"
11
+ :style="pixelStyle(pixel)"
12
+ >
13
+ <img v-if="pixel.image_url" :src="pixel.image_url" class="widget-pixel__img" alt="" />
14
+ </div>
15
+ </div>
16
+ </div>
17
+
18
+ <!-- Alert overlay -->
19
+ <transition name="alert-slide">
20
+ <div v-if="alertPixel" :key="alertKey" class="widget-alert">
21
+ <div class="widget-alert__avatar">
22
+ <img v-if="alertPixel.buyer_avatar" :src="alertPixel.buyer_avatar" alt="" />
23
+ <span v-else>{{ (alertPixel.buyer_name || '?')[0].toUpperCase() }}</span>
24
+ </div>
25
+ <div class="widget-alert__info">
26
+ <strong>{{ alertPixel.buyer_name || 'Anonymous' }}</strong>
27
+ <span v-if="alertPixel.tier_name"> · {{ alertPixel.tier_name }}</span>
28
+ <span> · {{ alertPixel.pixel_count || 1 }} pixel{{ (alertPixel.pixel_count || 1) > 1 ? 's' : '' }}</span>
29
+ <p v-if="alertPixel.message" class="widget-alert__msg">{{ alertPixel.message }}</p>
30
+ </div>
31
+ </div>
32
+ </transition>
33
+ </div>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import { fetchPublicWall, getWallSupporters, getWallEventsUrl } from '../../../services/indieWallService'
38
+
39
+ definePageMeta({ layout: 'blank' })
40
+
41
+ const route = useRoute()
42
+ const wallId = route.params.id as string
43
+ const token = (route.query.token as string) || ''
44
+
45
+ const CELL_SIZE = 12
46
+
47
+ interface Pixel {
48
+ id: number; x: number; y: number
49
+ width?: number; height?: number; pixel_count?: number
50
+ background_color?: string; image_url?: string
51
+ title?: string; message?: string
52
+ buyer_name?: string; buyer_avatar?: string
53
+ tier_name?: string; status?: boolean
54
+ }
55
+
56
+ interface Wall {
57
+ id: number; width: number; height: number; name?: string
58
+ }
59
+
60
+ const wall = ref<Wall | null>(null)
61
+ const pixels = ref<Pixel[]>([])
62
+ const newPixelIds = ref(new Set<number>())
63
+ const alertPixel = ref<Pixel | null>(null)
64
+ const alertKey = ref(0)
65
+ const canvasEl = ref<HTMLDivElement | null>(null)
66
+
67
+ let alertTimer: ReturnType<typeof setTimeout> | null = null
68
+ let audioCtx: AudioContext | null = null
69
+ let es: EventSource | null = null
70
+
71
+ const visiblePixels = computed(() => pixels.value.filter(p => p.status !== false))
72
+
73
+ const canvasStyle = computed(() => {
74
+ if (!wall.value) return {}
75
+ return {
76
+ position: 'relative' as const,
77
+ width: `${wall.value.width * CELL_SIZE}px`,
78
+ height: `${wall.value.height * CELL_SIZE}px`,
79
+ }
80
+ })
81
+
82
+ function pixelStyle(pixel: Pixel) {
83
+ const w = pixel.width ?? 1
84
+ const h = pixel.height ?? 1
85
+ return {
86
+ position: 'absolute' as const,
87
+ left: `${pixel.x * CELL_SIZE}px`,
88
+ top: `${pixel.y * CELL_SIZE}px`,
89
+ width: `${w * CELL_SIZE}px`,
90
+ height: `${h * CELL_SIZE}px`,
91
+ backgroundColor: pixel.image_url ? 'transparent' : (pixel.background_color || '#FDB215'),
92
+ overflow: 'hidden',
93
+ }
94
+ }
95
+
96
+ function unlockAudio() {
97
+ if (!audioCtx) audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
98
+ if (audioCtx.state === 'suspended') audioCtx.resume()
99
+ }
100
+
101
+ let audioUnlockInterval: ReturnType<typeof setInterval> | null = null
102
+
103
+ function playChime() {
104
+ if (!audioCtx) return
105
+ try {
106
+ const osc = audioCtx.createOscillator()
107
+ const gain = audioCtx.createGain()
108
+ osc.connect(gain)
109
+ gain.connect(audioCtx.destination)
110
+ osc.type = 'sine'
111
+ const t = audioCtx.currentTime
112
+ osc.frequency.setValueAtTime(880, t)
113
+ osc.frequency.linearRampToValueAtTime(1320, t + 0.12)
114
+ gain.gain.setValueAtTime(0.4, t)
115
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.8)
116
+ osc.start(t)
117
+ osc.stop(t + 0.8)
118
+ } catch {}
119
+ }
120
+
121
+ function showAlert(pixel: Pixel) {
122
+ alertPixel.value = pixel
123
+ alertKey.value++
124
+ if (alertTimer) clearTimeout(alertTimer)
125
+ alertTimer = setTimeout(() => { alertPixel.value = null }, 7000)
126
+ playChime()
127
+ }
128
+
129
+ function addNewPixel(data: any) {
130
+ const pixel: Pixel = {
131
+ id: data.pixel_id || Date.now(),
132
+ x: data.x, y: data.y,
133
+ pixel_count: data.pixel_count,
134
+ width: data.pixel_count, height: 1,
135
+ background_color: data.background_color,
136
+ image_url: data.image_url,
137
+ message: data.message,
138
+ buyer_name: data.buyer_name,
139
+ tier_name: data.tier_name,
140
+ status: true,
141
+ }
142
+ pixels.value.push(pixel)
143
+ newPixelIds.value = new Set([...newPixelIds.value, pixel.id])
144
+ setTimeout(() => {
145
+ const next = new Set(newPixelIds.value)
146
+ next.delete(pixel.id)
147
+ newPixelIds.value = next
148
+ }, 3000)
149
+ showAlert(pixel)
150
+ }
151
+
152
+ function connectSSE() {
153
+ if (!token) return
154
+ es = new EventSource(getWallEventsUrl(wallId, token))
155
+ es.addEventListener('purchase', (e: MessageEvent) => {
156
+ try { addNewPixel(JSON.parse(e.data)) } catch {}
157
+ })
158
+ es.onerror = () => {
159
+ es?.close()
160
+ setTimeout(connectSSE, 5000)
161
+ }
162
+ }
163
+
164
+ onMounted(async () => {
165
+ // OBS's embedded Chromium runs every Browser Source with autoplay unlocked
166
+ // by default, so resume() succeeds here with no user gesture. This widget
167
+ // runs unattended during a live stream — a click/keydown would never fire.
168
+ unlockAudio()
169
+ audioUnlockInterval = setInterval(() => {
170
+ if (!audioCtx) { unlockAudio(); return }
171
+ if (audioCtx.state === 'running') {
172
+ if (audioUnlockInterval) clearInterval(audioUnlockInterval)
173
+ return
174
+ }
175
+ audioCtx.resume().catch(() => {})
176
+ }, 3000)
177
+
178
+ try {
179
+ const [wallRes, pixelRes] = await Promise.all([
180
+ fetchPublicWall(wallId),
181
+ getWallSupporters(wallId, { per_page: 'all' }),
182
+ ])
183
+ wall.value = (wallRes as any)?.data ?? wallRes
184
+ pixels.value = ((pixelRes as any)?.data ?? []).filter((p: Pixel) => p.status !== false)
185
+ } catch {}
186
+ connectSSE()
187
+ })
188
+
189
+ onBeforeUnmount(() => {
190
+ es?.close()
191
+ if (alertTimer) clearTimeout(alertTimer)
192
+ if (audioUnlockInterval) clearInterval(audioUnlockInterval)
193
+ })
194
+ </script>
195
+
196
+ <style scoped lang="scss">
197
+ .widget-root {
198
+ background: transparent;
199
+ width: 100vw;
200
+ height: 100vh;
201
+ overflow: hidden;
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ position: relative;
206
+ }
207
+
208
+ .widget-canvas-wrap {
209
+ overflow: auto;
210
+ max-width: 100vw;
211
+ max-height: 100vh;
212
+ }
213
+
214
+ .widget-canvas {
215
+ background: #0d0d0d;
216
+ background-image:
217
+ linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
218
+ linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
219
+ background-size: 12px 12px;
220
+ }
221
+
222
+ .widget-pixel {
223
+ box-sizing: border-box;
224
+
225
+ &__img {
226
+ width: 100%; height: 100%;
227
+ object-fit: cover; display: block;
228
+ }
229
+
230
+ &--new {
231
+ animation: pixel-pop 0.4s ease-out, pixel-glow 2.5s ease-out forwards;
232
+ }
233
+ }
234
+
235
+ @keyframes pixel-pop {
236
+ 0% { transform: scale(0.5); opacity: 0.6; }
237
+ 60% { transform: scale(1.15); }
238
+ 100% { transform: scale(1); opacity: 1; }
239
+ }
240
+
241
+ @keyframes pixel-glow {
242
+ 0% { box-shadow: 0 0 10px 3px var(--accent, #64D8FF); }
243
+ 80% { box-shadow: 0 0 4px 1px color-mix(in srgb, var(--accent, #64D8FF) 50%, transparent); }
244
+ 100% { box-shadow: none; }
245
+ }
246
+
247
+ .widget-alert {
248
+ position: fixed;
249
+ bottom: 24px;
250
+ left: 50%;
251
+ transform: translateX(-50%);
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 12px;
255
+ background: rgba(13,13,13,0.92);
256
+ border: 1px solid var(--accent, #64D8FF);
257
+ padding: 12px 20px;
258
+ max-width: 400px;
259
+ z-index: 100;
260
+ box-shadow: 0 0 20px rgba(100, 216, 255, 0.2);
261
+
262
+ &__avatar {
263
+ width: 44px; height: 44px;
264
+ background: var(--accent, #64D8FF);
265
+ flex-shrink: 0;
266
+ display: flex; align-items: center; justify-content: center;
267
+ font-weight: 700; color: #000; font-size: 18px;
268
+ overflow: hidden;
269
+
270
+ img { width: 100%; height: 100%; object-fit: cover; }
271
+ }
272
+
273
+ &__info {
274
+ flex: 1;
275
+ color: #fff;
276
+ font-size: 14px;
277
+ line-height: 1.4;
278
+
279
+ strong { color: var(--accent, #64D8FF); }
280
+ }
281
+
282
+ &__msg {
283
+ margin: 4px 0 0;
284
+ font-size: 12px;
285
+ color: #aaa;
286
+ font-style: italic;
287
+ }
288
+ }
289
+
290
+ .alert-slide-enter-active,
291
+ .alert-slide-leave-active { transition: all 0.3s ease; }
292
+ .alert-slide-enter-from,
293
+ .alert-slide-leave-to { opacity: 0; transform: translateX(-50%) translateY(20px); }
294
+ </style>