diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index b8edaf7d9e..1bae0db08d 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -186,7 +186,32 @@ let newsTickerBounds = null; // { x, y, w, h } for click detection let newsTickerHovered = false; // Hover state for visual feedback let newsFetchPromise = null; // Track fetch to avoid duplicate requests -// ๐ @handle autocomplete +// ๏ฟฝ R8dio mini-player system (for laer-klokken) +const R8DIO_STREAM_URL = "https://s3.radio.co/s7cd1ffe2f/listen"; +const R8DIO_STREAM_ID = "chat-r8dio-stream"; +const R8DIO_METADATA_URL = "https://public.radio.co/stations/s7cd1ffe2f/status"; +let r8dioEnabled = false; // Whether r8dio player is shown +let r8dioPlaying = false; +let r8dioLoading = false; +let r8dioError = null; +let r8dioVolume = 0.5; +let r8dioTrack = ""; // Current track/program title +let r8dioLastMetadataFetch = 0; +let r8dioFrequencyData = []; +let r8dioWaveformData = []; +let r8dioBars = []; // Visualization bars +let r8dioNoAnalyserCount = 0; +let r8dioAnimPhase = 0; +let r8dioPlayerBounds = null; // { x, y, w, h } for click detection +let r8dioPlayBtnBounds = null; // Play/pause button bounds +let r8dioVolSliderBounds = null; // Volume slider bounds +let r8dioHovered = false; // Hover over player area +let r8dioPlayHovered = false; // Hover over play button +let r8dioVolDragging = false; // Dragging volume +const R8DIO_BAR_COUNT = 16; // Fewer bars for compact display +const R8DIO_METADATA_INTERVAL = 15000; + +// @handle autocomplete let handleAutocomplete = null; async function boot( @@ -1747,11 +1772,20 @@ function paint( needsPaint(); } - // ๐ฐ News ticker (top right, always visible) - hide when modal is open + // News ticker (top right, always visible) - hide when modal is open if (!client.connecting && !modalPainting && !messageCopyModal) { paintNewsTicker({ ink, screen, text, hud }, theme); needsPaint(); } + + // ๐ป R8dio mini-player (laer-klokken only) + if (options?.r8dioPlayer && !client.connecting && !modalPainting && !messageCopyModal) { + r8dioEnabled = true; + paintR8dioPlayer({ ink, screen, help, hud }, theme); + needsPaint(); + } else { + r8dioEnabled = false; + } // ๐ Paint @handle autocomplete dropdown (last, so it renders on top of everything) if (handleAutocomplete?.visible && input?.canType) { @@ -1823,6 +1857,33 @@ function act( } } + // ๐ป R8dio mini-player interaction + if (r8dioEnabled && pen && !linkConfirmModal && !messageCopyModal && !modalPainting) { + // Update hover states + if (e.is("move") || e.is("draw")) { + // Player area hover + if (r8dioPlayerBounds) { + r8dioHovered = pen.x >= r8dioPlayerBounds.x && pen.x < r8dioPlayerBounds.x + r8dioPlayerBounds.w && + pen.y >= r8dioPlayerBounds.y && pen.y < r8dioPlayerBounds.y + r8dioPlayerBounds.h; + } + // Play button hover (entire bar is clickable, but button gets special hover) + if (r8dioPlayBtnBounds) { + r8dioPlayHovered = pen.x >= r8dioPlayBtnBounds.x && pen.x < r8dioPlayBtnBounds.x + r8dioPlayBtnBounds.w && + pen.y >= r8dioPlayBtnBounds.y && pen.y < r8dioPlayBtnBounds.y + r8dioPlayBtnBounds.h; + } + } + + // Touch anywhere on bar toggles playback + if (e.is("touch") && r8dioPlayerBounds) { + const inBar = pen.x >= r8dioPlayerBounds.x && pen.x < r8dioPlayerBounds.x + r8dioPlayerBounds.w && + pen.y >= r8dioPlayerBounds.y && pen.y < r8dioPlayerBounds.y + r8dioPlayerBounds.h; + if (inBar) { + beep(); + toggleR8dioPlayback(send); + } + } + } + // ๐ Link confirmation modal intercepts all events if (linkConfirmModal) { const { yesBtn, noBtn, action } = linkConfirmModal; @@ -2842,7 +2903,7 @@ function act( } } -function sim({ api }) { +function sim({ api, num, send, net }) { if (input) { // ๐ Sync autocomplete skipHistory and skipEnter flags with TextInput // Only skip Enter when autocomplete is truly visible with selectable items AND keyboard is open @@ -2857,17 +2918,149 @@ function sim({ api }) { input.sim(api); // ๐ฌ Chat } ellipsisTicker?.update(api.clock?.time()); + + // ๐ป R8dio player simulation + if (r8dioEnabled) { + r8dioAnimPhase += 0.05; + + // Request frequency/waveform data when playing + if (r8dioPlaying && send) { + send({ type: "stream:frequencies", content: { id: R8DIO_STREAM_ID } }); + if (r8dioNoAnalyserCount >= 10) { + send({ type: "stream:waveform", content: { id: R8DIO_STREAM_ID } }); + } + } + + // Fetch metadata periodically + const now = Date.now(); + if (r8dioPlaying && now - r8dioLastMetadataFetch > R8DIO_METADATA_INTERVAL) { + fetchR8dioMetadata(net); + } + + // Update bars + const lerp = num?.lerp || ((a, b, t) => a + (b - a) * t); + for (let i = 0; i < R8DIO_BAR_COUNT; i++) { + if (!r8dioBars[i]) r8dioBars[i] = { height: 0, targetHeight: 0 }; + + if (r8dioPlaying && r8dioFrequencyData.length > 0) { + const dataIndex = Math.floor((i / R8DIO_BAR_COUNT) * r8dioFrequencyData.length); + r8dioBars[i].targetHeight = (r8dioFrequencyData[dataIndex] || 0) / 255; + r8dioNoAnalyserCount = 0; + } else if (r8dioPlaying && r8dioWaveformData.length > 0) { + const dataIndex = Math.floor((i / R8DIO_BAR_COUNT) * r8dioWaveformData.length); + const sample = r8dioWaveformData[dataIndex] || 128; + r8dioBars[i].targetHeight = Math.abs(sample - 128) / 128; + } else if (r8dioPlaying) { + // Fake animation while playing without analyser data + const wave = Math.sin(r8dioAnimPhase + i * 0.3) * 0.3 + 0.4; + const noise = Math.random() * 0.2; + r8dioBars[i].targetHeight = wave + noise; + } else { + r8dioBars[i].targetHeight = 0; + } + + r8dioBars[i].height = lerp(r8dioBars[i].height, r8dioBars[i].targetHeight, 0.15); + } + } +} + +// ๐ป Handle BIOS messages for r8dio streaming +function receive({ type, content }) { + if (!r8dioEnabled) return; + + if (type === "stream:playing" && content.id === R8DIO_STREAM_ID) { + r8dioPlaying = true; + r8dioLoading = false; + r8dioError = null; + } + + if (type === "stream:paused" && content.id === R8DIO_STREAM_ID) { + r8dioPlaying = false; + } + + if (type === "stream:stopped" && content.id === R8DIO_STREAM_ID) { + r8dioPlaying = false; + r8dioLoading = false; + } + + if (type === "stream:error" && content.id === R8DIO_STREAM_ID) { + r8dioPlaying = false; + r8dioLoading = false; + r8dioError = content.error; + } + + if (type === "stream:frequencies-data" && content.id === R8DIO_STREAM_ID) { + const data = content.data || []; + if (data.length > 0 && data.some(v => v > 0)) { + r8dioFrequencyData = data; + r8dioNoAnalyserCount = 0; + } else { + r8dioNoAnalyserCount++; + r8dioFrequencyData = []; + } + } + + if (type === "stream:waveform-data" && content.id === R8DIO_STREAM_ID) { + r8dioWaveformData = content.data || []; + } } // function leave() { // chat?.kill(); // } -export { boot, paint, act, sim }; +export { boot, paint, act, sim, receive }; // ๐ Library // (Useful functions used throughout the piece) +// ๐ป Fetch r8dio track metadata +async function fetchR8dioMetadata(net) { + r8dioLastMetadataFetch = Date.now(); + try { + const response = await fetch(R8DIO_METADATA_URL); + if (response.ok) { + const data = await response.json(); + if (data.current_track && data.current_track.title) { + r8dioTrack = data.current_track.title; + } + } + } catch (err) { + // Silently fail - metadata is optional + console.log("๐ป Could not fetch r8dio metadata:", err.message); + } +} + +// ๐ป R8dio playback control +function toggleR8dioPlayback(send) { + if (r8dioLoading) return; + + if (r8dioPlaying) { + // Pause + send({ type: "stream:pause", content: { id: R8DIO_STREAM_ID } }); + } else { + // Play + r8dioLoading = true; + r8dioError = null; + send({ + type: "stream:play", + content: { + id: R8DIO_STREAM_ID, + url: R8DIO_STREAM_URL, + volume: r8dioVolume + } + }); + } +} + +// ๐ป R8dio volume control +function setR8dioVolume(vol, send) { + r8dioVolume = Math.max(0, Math.min(1, vol)); + if (r8dioPlaying && send) { + send({ type: "stream:volume", content: { id: R8DIO_STREAM_ID, volume: r8dioVolume } }); + } +} + function boundScroll() { if (scroll < 0) scroll = 0; if (scroll > totalScrollHeight - chatHeight + 5) { @@ -3023,40 +3216,79 @@ function openYoutubeModal(videoId) { left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.92); + background: rgba(0, 0, 0, 0.75); z-index: 9999; display: flex; align-items: center; justify-content: center; flex-direction: column; animation: ytFadeIn 0.2s ease-out; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: none; } @keyframes ytFadeIn { from { opacity: 0; } to { opacity: 1; } } #youtube-modal-content { - width: min(80vw, 720px); + width: min(90vw, 720px); + max-width: calc(100vw - 32px); aspect-ratio: 16/9; background: #000; border-radius: 8px; overflow: hidden; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + cursor: default; + position: relative; } #youtube-modal-content iframe { width: 100%; height: 100%; border: none; + pointer-events: auto; + } + #youtube-modal-close { + position: absolute; + top: -40px; + right: 0; + width: 36px; + height: 36px; + background: rgba(255, 255, 255, 0.9); + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 24px; + line-height: 36px; + text-align: center; + color: #333; + z-index: 10000; + transition: background 0.15s, transform 0.15s; + } + #youtube-modal-close:hover { + background: #fff; + transform: scale(1.1); + } + #youtube-modal-close:active { + transform: scale(0.95); + } + #youtube-modal-tap-hint { + color: rgba(255, 255, 255, 0.6); + font-size: 14px; + margin-top: 16px; + font-family: system-ui, sans-serif; } -