# Project export: StayTuned

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: Cal Hacks 12.0
- Tagline: StayTuned turns any article into an audio + subtitle stream over looping gameplay so ADHD minds stay engaged and finish reading.
- Devpost: https://devpost.com/software/staytuned
- GitHub: https://github.com/louis-cheung/StayTuned
- Video: https://www.youtube.com/embed/Q5urvVXJes4?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — louis-cheung (2 commits)

## Devpost submission (written by the team)

No Devpost description available.

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 9 recognized source files, 19 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (10 of 10)

```
background.js
content.js
manifest.json
options.html
options.js
popup.html
popup.js
reader.html
reader.js
styles.css
```

### Dependencies

No dependency index available.

### Recent commits (newest first)

- Add files via upload
- Add files via upload

## Key source files (fetched from GitHub, selected and truncated for size)

### background.js

```javascript

chrome.runtime.onMessage.addListener((msg, sender, sendResponse)=>{
  if (msg.type === "PING") sendResponse({pong:true});
  return false;
});

```

### options.js

```javascript
const els = {
  elKey: document.getElementById('elKey'),
  elVoice: document.getElementById('elVoice'),
  save: document.getElementById('save')
};

chrome.storage.sync.get(["adf_el_key","adf_el_voice"], (cfg)=>{
  if (cfg.adf_el_key) els.elKey.value = cfg.adf_el_key;
  if (cfg.adf_el_voice) els.elVoice.value = cfg.adf_el_voice;
});

els.save.addEventListener('click', ()=>{
  chrome.storage.sync.set({
    adf_el_key: els.elKey.value.trim(),
    adf_el_voice: els.elVoice.value.trim()
  }, ()=> alert("Saved!"));
});

```

### content.js

```javascript
try { console.log("[AdFocus] content.js injected on", location.href); } catch(e){}

function extractFullText(charCap){
  const wikiMain = document.querySelector('#mw-content-text');
  const main = wikiMain || document.querySelector('main') || document.body;
  const ps = Array.from(main.querySelectorAll('p, article p, section p'));
  let text = ps.map(p => (p.innerText || "").trim()).filter(t => t && t.length > 40).join("\n\n");
  if (!text || text.length < 500) {
    text = ((document.body.innerText || document.documentElement.innerText) || "").trim();
  }
  text = text.replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
  if (charCap && text.length > charCap) text = text.slice(0, charCap);
  return { url: location.href, title: document.title, text };
}

chrome.runtime.onMessage.addListener((msg, sender, sendResponse)=>{
  if (msg.type === "EXTRACT_FULL") {
    const cap = msg.cap || 4000;
    sendResponse(extractFullText(cap));
  }
});

```

### styles.css

```css

html, body { margin:0; padding:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.controls { position: absolute; top: 10px; right: 10px; display:flex; gap:8px; z-index:3; }
button { padding:8px 12px; border-radius:10px; border:1px solid #ccc; background:#fff; cursor:pointer; }
#bg { position: fixed; inset:0; width:100%; height:100%; object-fit: cover; z-index: 0; }
#stage { position: fixed; inset:0; width:100%; height:100%; z-index: 1; }
.subtitle { position: fixed; left:5%; right:5%; bottom: 6%; z-index: 2; padding: 10px 14px; background: rgba(0,0,0,0.45); color:#fff; font-size: clamp(18px, 2.6vw, 34px); border-radius: 12px; line-height: 1.3; text-align: center; }
.titleCard { position: fixed; top:8%; left:5%; right:5%; z-index: 2; padding: 10px 14px; background: rgba(0,0,0,0.35); color:#fff; font-weight: 600; font-size: clamp(18px, 2.2vw, 28px); border-radius: 10px; text-align: left; }
footer { position: fixed; bottom: 12px; left: 12px; color: #eee; z-index: 2; font-size: 12px; opacity: 0.8; }
hr { border: none; border-top: 1px solid #e7e7e7; margin: 10px 0; }

```

### options.html

```html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>AdFocus Options</title>
    <style>
      body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 16px; max-width: 600px; }
      h1 { font-size: 20px; }
      label { display:block; margin-top: 14px; font-size: 12px; color:#444; }
      input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 8px; }
      small { color:#666; }
      button { margin-top:12px; padding:8px 12px; border-radius: 8px; border:1px solid #ccc; background:#fff; cursor:pointer; }
      .note { background:#f6f6f9; border:1px dashed #cdd; padding:8px; border-radius:8px; margin-top:8px; }
      code { background:#f0f0f0; padding: 0 6px; border-radius: 6px; }
    </style>
  </head>
  <body>
    <h1>AdFocus Options</h1>
    <div class="note">
      Enter your <b>ElevenLabs</b> credentials.
    </div>

    <label>ElevenLabs API Key</label>
    <input id="elKey" placeholder="eleven_api_key" />
    <label>ElevenLabs Voice ID</label>
    <input id="elVoice" placeholder="voice-id" />

    <button id="save">Save</button>
    <script src="options.js"></script>
  </body>
</html>

```

### popup.html

```html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>AdFocus Reader</title>
    <style>
      body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 14px; width: 320px; }
      h1 { font-size: 18px; margin: 0 0 8px; }
      label { display:block; margin-top:10px; font-size: 12px; color:#444; }
      button { margin-top: 10px; padding:8px 12px; border-radius: 8px; border:1px solid #ccc; background:#fff; cursor:pointer; }
      .row { display:flex; gap:8px; align-items:center; }
      input[type="number"] { width:100%; padding:8px; border:1px solid #ccc; border-radius:8px; }
      small { color:#666; }
      .note { background:#f6f6f9; border:1px dashed #cdd; padding:8px; border-radius:8px; margin-top:8px; }
      a { text-decoration: none; }
      const win = await chrome.windows.create({
      url: readerUrl,
      type: "popup",
      width: 1024,
      height: 700
      });
    </style>
  </head>
  <body>
    <h1>AdFocus Reader</h1>
    <div class="note">
      <div><b>Mode:</b> Entire Reading (ElevenLabs TTS + gameplay + subtitles)</div>
      <div>Configure ElevenLabs in <a href="#" id="open-options">Options</a>.</div>
    </div>

    <label>Approx. Words Per Minute</label>
    <div class="row"><input type="number" id="wpm" min="100" max="240" value="160" /></div>

    <label>Max characters (entire reading cap for demo)</label>
    <div class="row"><input type="number" id="charcap" min="200" max="12000" value="4000" /></div>

    <button id="start">Start Entire Reading</button>
    <hr>
    <small>Tip: open a Wikipedia or news article tab, then click Start.</small>

    <script src="popup.js"></script>
  </body>
</html>

```

### popup.js

```javascript
document.getElementById('open-options').addEventListener('click', (e)=>{
  e.preventDefault();
  chrome.runtime.openOptionsPage();
});

const els = {
  wpm: document.getElementById('wpm'),
  charcap: document.getElementById('charcap'),
  start: document.getElementById('start')
};

// restore
chrome.storage.sync.get(["adf_wpm","adf_charcap"], (cfg)=>{
  if (cfg.adf_wpm) els.wpm.value = cfg.adf_wpm;
  if (cfg.adf_charcap) els.charcap.value = cfg.adf_charcap;
});

els.start.addEventListener('click', async ()=>{
  const wpm = parseInt(els.wpm.value || "220", 10);
  const charcap = parseInt(els.charcap.value || "4000", 10);
  chrome.storage.sync.set({ adf_wpm:wpm, adf_charcap:charcap });

  const [tab] = await chrome.tabs.query({active:true, currentWindow:true});
  const url = tab?.url || "";
  if (!/^https?:\/\//i.test(url)) {
    alert("Please run this on a normal web page (http/https), not a Chrome page.");
    return;
  }

  // ensure content script is present
  try {
    await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["src/content.js"] });
  } catch(_) {}

  // extract
  let payload;
  try {
    payload = await chrome.tabs.sendMessage(tab.id, { type:"EXTRACT_FULL", cap: charcap });
  } catch (e) {
    console.error("Extraction error:", e);
    alert("Could not extract text from this page. Try a different article (e.g., Wikipedia).");
    return;
  }
  if (!payload || !payload.text || payload.text.trim().length < 50) {
    alert("No readable text was extracted from this page. Try a different article.");
    return;
  }

  // stash payload
  await chrome.storage.local.set({ adf_reader_payload: payload });

   // open reader (provider is fixed to elevenlabs)
  const readerUrl = chrome.runtime.getURL("src/reader.html");
  const params = new URLSearchParams({ provider: "elevenlabs", wpm });

  // Phone-like portrait window (~18:9)
  const win = await chrome.windows.create({
    url: `${readerUrl}?${params}`,
    type: "popup",
    width: 450,
    height: 900,
    focused: true
  });

  // (Optional) park it near the right edge of the primary display
  try {
    const left = Math.max(0, (window.screen.availWidth || 1280) - (450 + 20)); // 20px margin
    await chrome.windows.update(win.id, { left, top: 60 });
  } catch (e) {
    console.warn("Window position update failed (safe to ignore):", e);
  }
});


```

### reader.html

```html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>AdFocus Reader</title>
    <style>
      html, body {
        margin: 0; padding: 0; overflow: hidden;
        background: #000; color: #fff;
        font-family: system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif;
      }

      /* Video + canvas behind everything */
      #bg, #stage {
        position: fixed; inset: 0;
        width: 100%; height: 100%;
        object-fit: cover; z-index: 0;
      }
      #stage { pointer-events: none; z-index: 1; }

      /* Hide the title bar */
      #titleCard { display: none !important; }

      /* Centered, bold, high-contrast subtitles (meme-style) */
      #subtitle {
        position: fixed; top: 50%; left: 50%;
        transform: translate(-50%, -50%);
        width: min(90vw, 800px);
        text-align: center;
        font-weight: 900;
        font-size: clamp(22px, 3.2vw, 40px);
        line-height: 1.25;
        color: #fff; z-index: 2;
        text-shadow:
          -2px -2px 0 #000,
           2px -2px 0 #000,
          -2px  2px 0 #000,
           2px  2px 0 #000,
           0px  0px 8px rgba(0,0,0,.65);
        letter-spacing: .3px;
        /* text-transform: uppercase; */
        /* Optional extra crispness: */
        /* -webkit-text-stroke: 0.5px rgba(0,0,0,.35); */
      }

      /* Center “Play” button */
      #play {
        position: fixed; top: 50%; left: 50%;
        transform: translate(-50%, -50%);
        background: rgba(0,0,0,.7); color: #fff;
        border: 0; border-radius: 50%;
        width: 100px; height: 100px; font-size: 32px;
        display: flex; align-items: center; justify-content: center;
        cursor: pointer; box-shadow: 0 0 30px rgba(255,255,255,.2);
        transition: opacity .25s ease, transform .25s ease;
        z-index: 3;
      }
      #play:hover { background: rgba(255,255,255,.15); transform: translate(-50%, -50%) scale(1.08); }
      #play.fade-out { opacity: 0; transform: translate(-50%, -50%) scale(.92); pointer-events: none; }

      footer { position: fixed; bottom: 10px; right: 15px; font-size: 12px; opacity: .6; z-index: 2; }
    </style>
  </head>
  <body>
    <video id="bg" src="../assets/gameplay.mov" loop muted playsinline></video>
    <canvas id="stage" width="1280" height="720"></canvas>
    <div id="titleCard"></div>
    <div id="subtitle"></div>
    <footer>AdFocus demo</footer>

    <button id="play">▶️</button>

    <script src="reader.js"></script>
  </body>
</html>

```

### reader.js

```javascript
// src/reader.js (subtitles + optional ElevenLabs; 220 WPM; pause/resume)

// --- DOM refs ---
const bg = document.getElementById('bg');
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const titleCard = document.getElementById('titleCard');
const subtitleEl = document.getElementById('subtitle');

// Toggle to skip ElevenLabs calls (no credits)
const SUBTITLES_ONLY = true;

// --- Playback state ---
let playbackState = {
  isPlaying: false,
  startOffset: 0,
  startWallTime: null,
  totalDuration: 0,
  tickHandle: null
};
let incoming = null;
let settings = { wpm: 220 };

// --- Params + speed factor ---
const qp = new URLSearchParams(location.search);
settings.wpm = parseInt(qp.get("wpm") || "220", 10);
const BASE_WPM = 160;
const speedFactor = settings.wpm / BASE_WPM;

// --- Load payload helper (you were calling this but it was missing) ---
async function loadPayload() {
  const { adf_reader_payload } = await chrome.storage.local.get("adf_reader_payload");
  if (adf_reader_payload && adf_reader_payload.text) {
    incoming = adf_reader_payload;
    titleCard.textContent = incoming.title || "Reading";
    await chrome.storage.local.remove("adf_reader_payload");
  }
}
loadPayload();

// --- Chunking: random 2–3 words ---
function chunkTextRandom(text, minWords = 2, maxWords = 3) {
  const words = String(text).replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
  const chunks = [];
  let i = 0;
  while (i < words.length) {
    const sz = Math.min(
      maxWords,
      Math.max(minWords, Math.floor(Math.random() * (maxWords - minWords + 1)) + minWords),
      words.length - i
    );
    chunks.push(words.slice(i, i + sz).join(" "));
    i += sz;
  }
  return chunks;
}

// --- Cue building ---
function makeCues(chunks, wpm) {
  const wps = Math.max(1, wpm) / 60; // words/sec
  const cues = [];
  let t = 0;
  for (const c of chunks) {
    const wc = c.split(/\s+/).filter(Boolean).length;
    const dur = Math.max(0.6, wc / wps); // a small floor so 2-word flashes aren’t too tiny
    cues.push({ start: t, end: t + dur, text: c });
    t += dur;
  }
  return { cues, total: t };
}

function drawOverlay(currentText) {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  subtitleEl.textContent = currentText || "";
}

// --- ElevenLabs TTS (kept for when you flip SUBTITLES_ONLY=false) ---
async function ttsElevenLabs(text, apiKey, voiceId) {
  const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
    method: "POST",
    headers: {
      "xi-api-key": apiKey,
      "Content-Type": "application/json",
      "Accept": "audio/mpeg"
    },
    body: JSON.stringify({
      text,
      model_id: "eleven_multilingual_v2",
      voice_settings: { stability: 0.3, similarity_boost: 0.7 },
      output_format: "mp3_44100_128"
    })
  });
  if (!res.ok) throw new Error(`ElevenLabs TTS failed (${res.status}): ${await res.text()}`);
  return await res.arrayBuffer();
}

// --- Audio graph ---
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const mixDest = audioCtx.createMediaStreamDestination();
const playbackBus = audioCtx.createGain();
playbackBus.connect(audioCtx.destination);
playbackBus.connect(mixDest);

// --- Globals used by the ticker ---
let currentCues = null;
let lastCueIdx = -1;

// --- Main play sequence (build buffers, schedule, and start ticker) ---
async function playSequence(chunks, creds) {
  await audioCtx.resume();
  bg.playbackRate = 1.15; // optional: make gameplay snappier
  bg.play();

  const wps = settings.wpm / 60;
  const buffers = [];
  const bufferDurations = [];
  const isReal = [];

  for (const c of chunks) {
    let arrBuf = null;
    if (!SUBTITLES_ONLY && creds.elKey && creds.elVoice) {
      try {
        arrBuf = await ttsElevenLabs(c, creds.elKey, creds.elVoice);
      } catch (e) {
        console.warn("[AdFocus] TTS error; continuing with silence:", e);
      }
    }

    if (arrBuf) {
      const buf = await audioCtx.decodeAudioData(arrBuf.slice(0));
      buffers.push(buf);
      bufferDurations.push(buf.duration);
      isReal.push(true);
    } else {
      const silentSec = Math.max(1, c.split(/\s+/).filter(Boolean).length / wps);
      const silent = audioCtx.createBuffer(1, Math.round(audioCtx.sampleRate * silentSec), audioCtx.sampleRate);
      buffers.push(silent);
      bufferDurations.push(silent.duration);
      isReal.push(false);
    }
  }

  // Build preliminary cues from WPM; we’ll align to actual/planned durations below
  const cues = makeCues(chunks, settings.wpm);

  // Schedule audio buffers and align cue times
  let now = audioCtx.currentTime + 0.2;
  for (let i = 0; i < buffers.length; i++) {
    const src = audioCtx.createBufferSource();
    src.buffer = buffers[i];

    // Speed up real audio to match WPM; silent buffers already reflect WPM
    if (isReal[i]) {
      src.playbackRate.value = speedFactor;
    }

    src.connect(playbackBus);
    src.start(now);

    const baseDur = bufferDurations[i] ?? (buffers[i].duration || (cues.cues[i].end - cues.cues[i].start));
    const plannedDur = isReal[i] ? (baseDur / speedFactor) : baseDur;

    cues.cues[i].start = (i === 0) ? 0 : cues.cues[i - 1].end;
    cues.cues[i].end = cues.cues[i].start + plannedDur;
    now += plannedDur;
  }

  currentCues = cues;
  playbackState.totalDuration = cues.total;
  playbackState.startOffset = 0;
  playbackState.startWallTime = performance.now() / 1000;
  playbackState.isPlaying = true;
  lastCueIdx = -1;
  requestAnimationFrame(tick);
}

// --- Subtitle ticker (driven by wall clock + state) ---
function tick() {
  const t = Math.min(
    (performance.now() / 1000) - playbackState.startWallTime + playbackState.startOffset,
    playbackState.totalDuration
  );

  if (!currentCues) return;

  let currentCueIdx = 0;
  while (currentCueIdx < currentCues.cues.length && t >= currentCues.cues[currentCueIdx].end) {
    currentCueIdx++;
  }

  const current = currentCues.cues[c
[truncated — 2762 more characters]
```