Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,45 @@ Explore upcoming features and priorities on our [Roadmap](https://roadmap.sh/r/o
## FAQ

For answers to common questions, issues, and to see a list of recommended models, visit our [FAQ Page](FAQ.md).

## Video Tiles Wall

Rotating grid of YouTube videos sourced from a CSV. Autoplays, mutes, loops, and minimizes YouTube UI overlays.

### Features
- Shows 4, 6, or 8 tiles at once (defaults to 6)
- Autoplay + mute + loop, with minimal overlays (modest branding, controls off)
- Swaps to a fresh set every N seconds (defaults to 60)
- Clean, modern "card" look with caption from your CSV's `exerciseName`
- Load `videos.csv` automatically, or upload your own CSV via the header

### Getting started
1. Open `index.html` in a browser via a local server (recommended) or drop it into any static host.
- Quick local server: `python3 -m http.server 5173` then visit `http://localhost:5173/`
2. Use the header controls to set Tiles (4/6/8) and Swap (sec).
3. Provide your CSV (auto-loads `videos.csv` if present). You can also click "Sample CSV" to download the template.

### CSV format
- Required columns:
- `exerciseName` — shown as the card caption
- `videoUrl` — YouTube URL or ID (`https://youtu.be/...`, `https://www.youtube.com/watch?v=...`, `.../embed/...`, or bare ID)
- Flexible alternatives supported for URL: `url`, `youtube`, `youtubeUrl`, `link`, `Video`, `video`

Example `videos.csv`:

```csv
exerciseName,videoUrl
Sprint Intervals,https://www.youtube.com/watch?v=2J7f9xQ1Q0o
Upper Body Strength,https://youtu.be/dXy3NjHt88U
Core Burner,https://www.youtube.com/watch?v=AnYl6Nk9GOA
Yoga Flow,https://www.youtube.com/watch?v=v7AYKMP6rOE
HIIT Ladder,https://www.youtube.com/watch?v=ml6cT4AZdqI
Mobility Reset,https://youtu.be/3-fYKgJE2pg
Glute Activation,https://www.youtube.com/watch?v=EMAg9GdGDG8
Cool Down,https://youtu.be/p7cj4IAmu_8
```

### Notes
- Autoplay policies require videos to be muted to start automatically; this app sets `mute=1`.
- YouTube overlays cannot be completely removed, but the app uses `youtube-nocookie.com`, `controls=0`, `modestbranding=1`, etc., to hide as much UI as possible.
- The app rotates through your entire list without repeats until the deck is reshuffled.
263 changes: 263 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/* Config */
const DEFAULT_TILE_COUNT = 6;
const DEFAULT_SWAP_SECONDS = 60;
const LOCAL_STORAGE_KEYS = {
tileCount: "videoWall.tileCount",
swapSeconds: "videoWall.swapSeconds",
};

/* State */
let allVideos = []; // { exerciseName, videoId, rawUrl }
let shuffledIndices = [];
let shuffledCursor = 0;
let swapTimer = null;

/* Elements */
const gridEl = document.getElementById("grid");
const tilesSelectEl = document.getElementById("tilesSelect");
const swapSecondsEl = document.getElementById("swapSeconds");
const csvFileEl = document.getElementById("csvFile");
const statusEl = document.getElementById("status");

/* Utils */
function setStatus(text) {
statusEl.textContent = text || "";
}

function saveSetting(key, value) {
try { localStorage.setItem(key, String(value)); } catch {}
}
function loadSetting(key, fallback) {
try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; }
}

function getTileCount() {
const count = parseInt(tilesSelectEl.value, 10);
return Number.isFinite(count) ? count : DEFAULT_TILE_COUNT;
}

function getSwapSeconds() {
const secs = parseInt(swapSecondsEl.value, 10);
return Number.isFinite(secs) && secs >= 5 ? secs : DEFAULT_SWAP_SECONDS;
}

function pickColumnsForTiles(tiles) {
if (tiles <= 4) return 2; // 2x2
if (tiles <= 6) return 3; // 3x2
return 4; // 4x2 for 8
}

function shuffleArray(arr) {
for (let i = arr.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}

function rebuildShuffledIndices() {
shuffledIndices = shuffleArray(Array.from({ length: allVideos.length }, (_, i) => i));
shuffledCursor = 0;
}

function getNextBatch(count) {
if (allVideos.length === 0) return [];
if (count >= allVideos.length) {
// If asking for more than available, just reshuffle and slice
rebuildShuffledIndices();
return shuffledIndices.slice(0, Math.min(count, allVideos.length)).map(i => allVideos[i]);
}

const result = [];
while (result.length < count) {
if (shuffledCursor >= shuffledIndices.length) {
rebuildShuffledIndices();
}
const idx = shuffledIndices[shuffledCursor++];
result.push(allVideos[idx]);
}
return result;
}

function extractYouTubeId(url) {
if (!url || typeof url !== "string") return null;
try {
const u = new URL(url);
// patterns: youtu.be/<id>, youtube.com/watch?v=<id>, youtube.com/embed/<id>
if (u.hostname.includes("youtu.be")) {
return u.pathname.split("/").filter(Boolean)[0] || null;
}
if (u.hostname.includes("youtube.com") || u.hostname.includes("youtube-nocookie.com")) {
if (u.pathname.startsWith("/watch")) {
return u.searchParams.get("v");
}
if (u.pathname.startsWith("/embed/")) {
return u.pathname.split("/").filter(Boolean)[1] || null;
}
// shorts
if (u.pathname.startsWith("/shorts/")) {
return u.pathname.split("/").filter(Boolean)[1] || null;
}
}
} catch {}
// Fallback: if the input looks like a bare ID
if (/^[a-zA-Z0-9_-]{6,15}$/.test(url)) return url;
return null;
}

function buildYouTubeEmbedSrc(videoId) {
const params = new URLSearchParams({
autoplay: "1",
mute: "1",
playsinline: "1",
loop: "1",
playlist: videoId, // required for single-video looping
rel: "0",
modestbranding: "1",
controls: "0",
iv_load_policy: "3",
fs: "0",
showinfo: "0",
enablejsapi: "0",
disablekb: "1"
});
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`;
}

function clearGrid() {
gridEl.innerHTML = "";
}

function applyGridColumns(tiles) {
gridEl.classList.remove("cols-2", "cols-3", "cols-4");
const cols = pickColumnsForTiles(tiles);
gridEl.classList.add(`cols-${cols}`);
}

function renderTiles(tiles) {
applyGridColumns(tiles);
clearGrid();

const batch = getNextBatch(tiles);
if (batch.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "Load a CSV with columns: exerciseName, videoUrl";
gridEl.appendChild(empty);
return;
}

for (const item of batch) {
const card = document.createElement("div");
card.className = "card";

const wrapper = document.createElement("div");
wrapper.className = "video-wrapper";

const iframe = document.createElement("iframe");
iframe.src = buildYouTubeEmbedSrc(item.videoId);
iframe.allow = "autoplay; encrypted-media; picture-in-picture";
iframe.referrerPolicy = "strict-origin-when-cross-origin";
iframe.loading = "lazy";

const caption = document.createElement("div");
caption.className = "caption";
caption.textContent = item.exerciseName || "";

wrapper.appendChild(iframe);
card.appendChild(wrapper);
card.appendChild(caption);
gridEl.appendChild(card);
}
}

function stopSwapTimer() {
if (swapTimer) {
clearInterval(swapTimer);
swapTimer = null;
}
}

function startSwapTimer() {
stopSwapTimer();
const secs = getSwapSeconds();
if (!Number.isFinite(secs) || secs < 5) return;
swapTimer = setInterval(() => {
renderTiles(getTileCount());
}, secs * 1000);
}

function normalizeRow(row) {
const exerciseName = row.exerciseName ?? row.ExerciseName ?? row.exercise_name ?? row.name ?? "";
const url = row.videoUrl ?? row.url ?? row.youtube ?? row.youtubeUrl ?? row.link ?? row.Video ?? row.video ?? "";
const videoId = extractYouTubeId(url);
if (!videoId) return null;
return { exerciseName: String(exerciseName || "").trim(), videoId, rawUrl: url };
}

function ingestRows(rows) {
const items = [];
for (const row of rows) {
const obj = normalizeRow(row);
if (obj) items.push(obj);
}
allVideos = items;
setStatus(`${allVideos.length} videos loaded`);
rebuildShuffledIndices();
renderTiles(getTileCount());
startSwapTimer();
}

function parseCsvText(text) {
const parsed = Papa.parse(text, { header: true, skipEmptyLines: true, dynamicTyping: false });
if (parsed.errors && parsed.errors.length) {
console.warn("CSV parse errors:", parsed.errors);
}
ingestRows(parsed.data || []);
}

async function tryLoadDefaultCsv() {
try {
const res = await fetch("./videos.csv", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
parseCsvText(text);
} catch (e) {
setStatus("No videos.csv found. Use Load CSV to select a file.");
}
}

function initSettings() {
// Load saved settings
const savedTiles = parseInt(loadSetting(LOCAL_STORAGE_KEYS.tileCount, DEFAULT_TILE_COUNT), 10);
const savedSecs = parseInt(loadSetting(LOCAL_STORAGE_KEYS.swapSeconds, DEFAULT_SWAP_SECONDS), 10);
tilesSelectEl.value = [4,6,8].includes(savedTiles) ? String(savedTiles) : String(DEFAULT_TILE_COUNT);
swapSecondsEl.value = Number.isFinite(savedSecs) ? String(savedSecs) : String(DEFAULT_SWAP_SECONDS);

tilesSelectEl.addEventListener("change", () => {
saveSetting(LOCAL_STORAGE_KEYS.tileCount, getTileCount());
renderTiles(getTileCount());
});
swapSecondsEl.addEventListener("change", () => {
saveSetting(LOCAL_STORAGE_KEYS.swapSeconds, getSwapSeconds());
startSwapTimer();
});
}

function initCsvInput() {
csvFileEl.addEventListener("change", async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
setStatus("Loading CSV…");
const text = await file.text();
parseCsvText(text);
});
}

function init() {
initSettings();
initCsvInput();
tryLoadDefaultCsv();
}

window.addEventListener("DOMContentLoaded", init);
53 changes: 53 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Video Tiles Wall</title>
<meta name="description" content="Rotating grid of YouTube videos sourced from CSV" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./styles.css" />

<!-- PapaParse for CSV parsing -->
<script src="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js" defer></script>
<script src="./app.js" defer></script>
</head>
<body>
<header class="app-header">
<div class="brand">
<div class="dot"></div>
<h1>Video Tiles</h1>
</div>
<div class="controls">
<label class="control">
<span>Tiles</span>
<select id="tilesSelect">
<option value="4">4</option>
<option value="6" selected>6</option>
<option value="8">8</option>
</select>
</label>
<label class="control">
<span>Swap (sec)</span>
<input id="swapSeconds" type="number" min="5" step="5" value="60" />
</label>
<label class="control file">
<span>Load CSV</span>
<input id="csvFile" type="file" accept=".csv" />
</label>
<a class="control link" id="downloadSample" href="./videos.csv" download>Sample CSV</a>
<span class="status" id="status"></span>
</div>
</header>

<main>
<div id="grid" class="grid cols-3"></div>
</main>

<noscript>
This app requires JavaScript to load and rotate videos.
</noscript>
</body>
</html>
Loading