Design a custom clip player for either platform using Meld's built-in AI.
This block contains everything Meld's AI needs. The AI will ask which platform you stream on, then your channel name, before building the widget. One prompt, either platform.
I want to build a custom clips player overlay for my stream. The player pulls top clips from either Kick or Twitch and plays them back-to-back. A data layer script is included at the bottom of this message — it handles all the clip fetching, URL resolution, and preloading. Your job is to build the UI on top of it.
## STEP ZERO — CRITICAL, DO THIS FIRST
Before ANYTHING else — before acknowledging the task, before describing what you'll build, before asking about style — your FIRST message to me must be exactly this single question and nothing else:
"Which platform? (Kick or Twitch)"
Wait for my answer. Once I answer Kick or Twitch, ask ONE more question and nothing else:
"What's your <platform> channel name?"
A real channel name is a non-empty string of letters, numbers, or underscores (no spaces, no URLs, no "skip", no "idk"). If I try to skip or answer something unrelated, re-ask until I give a real name.
Once I answer, confirm both back to me:
"Got it — building a <platform> clips player for `<channel>`."
THEN proceed to the styling questions. You must also:
- In the data layer, replace `PLATFORM_PLACEHOLDER` with `"kick"` or `"twitch"` (lowercase, quoted).
- Replace `CHANNEL_PLACEHOLDER` with the channel name I gave you (quoted).
If you generate the final widget with either placeholder string still in it, that is a failure — re-generate it.
## How the data layer works
It exposes a global `window.clipsPlayer` object (identical API regardless of platform):
window.clipsPlayer.ready — boolean, true once clips are loaded
window.clipsPlayer.platform — "kick" or "twitch"
window.clipsPlayer.channel — the channel name
window.clipsPlayer.clips — array of clip objects (shape below)
window.clipsPlayer.currentIndex — index of the currently playing clip
window.clipsPlayer.currentClip() — returns the currently playing clip object
Each clip has this shape (fields with no equivalent on a given platform are empty strings or 0):
{
id: "unique clip id",
title: "clip title",
creatorUsername: "person who made the clip",
creatorAvatar: "avatar URL or empty",
viewCount: 12345,
durationMs: 28000
}
It fires these events on window:
"clips:ready" — clips are loaded, first clip starts playing
"clips:clipchange" — a new clip started. e.detail = { clip, index }
"clips:ended" — a clip finished (fires right before clips:clipchange for the next one)
To control playback:
window.clipsPlayer.next() — skip to next clip
window.clipsPlayer.prev() — go to previous clip
window.clipsPlayer.pause()
window.clipsPlayer.play()
window.clipsPlayer.setVolume(0..1)
The data layer includes a hidden `<video id="clips-video-player">` element that's already playing the clip. Your overlay renders ON TOP of it — title cards, creator attribution, progress bars, transition effects, whatever. DO NOT create your own video element.
## After Step Zero — ask me these styling questions, ONE OR TWO at a time
1. What's the overall vibe? (examples: minimal/invisible, retro TV/VHS, cinematic with film grain, news ticker, arcade, vaporwave, cyberpunk HUD, esports broadcast, late-night talk show)
2. What info should overlay on the clip?
- creator username + avatar
- clip title
- view count
- "clip X of Y" counter
- nothing (just the clip plays clean)
3. Where should the overlay sit? (bottom bar, top ribbon, corner card, full-frame cinema bars, fade-on-start only)
4. Transitions between clips — hard cut, crossfade, glitch, VHS static, swipe, film-projector-style?
5. Should the overlay be always visible, or fade in at clip start and fade out after a few seconds?
6. Any specific fonts? (Google Fonts are fine.)
7. Any branding elements — logo, color palette, background music bed?
## Rules for the final widget
- DO NOT re-implement the clip fetching. Use `window.clipsPlayer` only.
- DO NOT create a second `<video>` element. Style/overlay the existing one.
- Re-render overlay content on the "clips:clipchange" event.
- Background must be transparent outside the video — this runs as an OBS/Meld browser source.
- Handle long titles and creator names gracefully (ellipsis or marquee scroll).
- Prefer vanilla JS and CSS. No frameworks unless I ask.
- Include the data layer script below VERBATIM (with PLATFORM_PLACEHOLDER and CHANNEL_PLACEHOLDER replaced). Do not modify anything else.
## Data layer (include verbatim after placeholder substitution)
```html
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<video id="clips-video-player" width="1920" height="1080" autoplay playsinline style="width:100%;height:100%;object-fit:contain;background:transparent;"></video>
<video id="clips-preload-player" muted playsinline style="display:none;"></video>
<script>
(function() {
const PLATFORM = PLATFORM_PLACEHOLDER; // ← "kick" or "twitch"
const CHANNEL = CHANNEL_PLACEHOLDER; // ← channel name
const TWITCH_ENDPOINT = "https://mrboostlive.com/twitch/clips/channel.php"; // server-side Twitch bridge
const urlParams = new URLSearchParams(window.location.search);
const volume = parseFloat(urlParams.get('volume') || '50') / 100;
window.clipsPlayer = {
ready: false,
platform: PLATFORM,
channel: CHANNEL,
clips: [],
currentIndex: 0,
currentClip() { return this.clips[this.currentIndex] || null; }
};
// ---- Kick fetcher (direct client-side) ----
async function fetchKickClips(cursor = 0) {
const base = `https://kick.com/api/v2/channels/${CHANNEL}/clips?sort=view&time=all`;
const res = await fetch(`${base}&cursor=${cursor}`);
if (!res.ok) throw new Error(`Kick HTTP ${res.status}`);
const data = await res.json();
const batch = (data.clips || []).map(c => ({
id: c.id,
url: c.video_url,
title: c.title || '',
creatorUsername: c.creator?.username || '',
creatorAvatar: c.creator?.profile_picture || '',
viewCount: c.views || 0,
durationMs: (c.duration || 0) * 1000
}));
if (data.nextCursor) batch.push(...await fetchKickClips(data.nextCursor));
return batch;
}
// ---- Twitch fetcher (proxied through PHP bridge) ----
// Twitch clip URLs require a signed token per playback, so the PHP page handles
// the clip list. We fetch the page's clip metadata via a companion JSON endpoint.
async function fetchTwitchClips() {
const res = await fetch(`${TWITCH_ENDPOINT}?channel=${encodeURIComponent(CHANNEL)}&format=json`);
if (!res.ok) throw new Error(`Twitch bridge HTTP ${res.status}`);
const data = await res.json();
return (data.clips || []).map(c => ({
id: c.id,
url: c.url || '', // signed playback URL (already resolved by the bridge)
title: c.title || '',
creatorUsername: c.creator_name || '',
creatorAvatar: c.creator_avatar || '',
viewCount: c.view_count || 0,
durationMs: (c.duration || 0) * 1000
}));
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
const mainPlayer = document.getElementById('clips-video-player');
const preloadPlayer = document.getElementById('clips-preload-player');
mainPlayer.volume = volume;
function loadInto(player, src) {
if (!src) return;
if (src.endsWith('.m3u8')) {
if (window.Hls && Hls.isSupported()) {
if (player._hls) player._hls.destroy();
const hls = new Hls();
hls.loadSource(src);
hls.attachMedia(player);
player._hls = hls;
} else if (player.canPlayType('application/vnd.apple.mpegurl')) {
player.src = src;
}
} else {
player.src = src;
}
}
function playCurrent() {
const clip = window.clipsPlayer.clips[window.clipsPlayer.currentIndex];
if (!clip) return;
loadInto(mainPlayer, clip.url);
mainPlayer.play().catch(() => {});
const nextClip = window.clipsPlayer.clips[(window.clipsPlayer.currentIndex + 1) % window.clipsPlayer.clips.length];
if (nextClip) loadInto(preloadPlayer, nextClip.url);
window.dispatchEvent(new CustomEvent('clips:clipchange', { detail: { clip, index: window.clipsPlayer.currentIndex } }));
}
mainPlayer.addEventListener('ended', () => {
window.dispatchEvent(new CustomEvent('clips:ended'));
window.clipsPlayer.currentIndex = (window.clipsPlayer.currentIndex + 1) % window.clipsPlayer.clips.length;
playCurrent();
});
// When a clip fails to load (e.g., expired Twitch signed URL), skip it
mainPlayer.addEventListener('error', () => {
if (!window.clipsPlayer.ready) return;
window.clipsPlayer.currentIndex = (window.clipsPlayer.currentIndex + 1) % window.clipsPlayer.clips.length;
playCurrent();
});
window.clipsPlayer.next = function() { this.currentIndex = (this.currentIndex + 1) % this.clips.length; playCurrent(); };
window.clipsPlayer.prev = function() { this.currentIndex = (this.currentIndex - 1 + this.clips.length) % this.clips.length; playCurrent(); };
window.clipsPlayer.pause = function() { mainPlayer.pause(); };
window.clipsPlayer.play = function() { mainPlayer.play().catch(() => {}); };
window.clipsPlayer.setVolume = function(v) { mainPlayer.volume = Math.max(0, Math.min(1, v)); };
(async () => {
try {
const all = PLATFORM === 'twitch' ? await fetchTwitchClips() : await fetchKickClips();
if (!all.length) { console.error(`No clips found for ${PLATFORM}:${CHANNEL}`); return; }
window.clipsPlayer.clips = shuffle(all);
window.clipsPlayer.currentIndex = 0;
window.clipsPlayer.ready = true;
window.dispatchEvent(new Event('clips:ready'));
playCurrent();
} catch (e) { console.error('Clip fetch failed:', e); }
})();
})();
</script>
```
window.clipsPlayer and listens for clips:clipchange events.