ArnieRamesh's picture
viewer: update static synchronized POV player
a674d64 verified
const DEFAULT_INDEX_URL = "https://huggingface.co/datasets/ArnieRamesh/CounterStrike-1K/resolve/main/space_viewer/rounds.json";
const SYNC_TOLERANCE_S = 0.14;
const state = {
index: null,
rounds: [],
filteredRounds: [],
activeRound: null,
videos: new Map(),
cards: new Map(),
audioPov: -1,
playing: false,
internalPause: false,
seeking: false,
raf: 0,
};
const el = {
roundMeta: document.getElementById("roundMeta"),
subsetFilter: document.getElementById("subsetFilter"),
splitFilter: document.getElementById("splitFilter"),
mapFilter: document.getElementById("mapFilter"),
matchFilter: document.getElementById("matchFilter"),
eventFilter: document.getElementById("eventFilter"),
minDuration: document.getElementById("minDuration"),
queryFilter: document.getElementById("queryFilter"),
roundSelect: document.getElementById("roundSelect"),
audioSelect: document.getElementById("audioSelect"),
speedSelect: document.getElementById("speedSelect"),
playPause: document.getElementById("playPause"),
timeSlider: document.getElementById("timeSlider"),
timeNow: document.getElementById("timeNow"),
timeTotal: document.getElementById("timeTotal"),
statusText: document.getElementById("statusText"),
videoGrid: document.getElementById("videoGrid"),
};
function formatTime(seconds) {
const safe = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
const mins = Math.floor(safe / 60);
const secs = safe - mins * 60;
return `${mins}:${secs.toFixed(1).padStart(4, "0")}`;
}
function fileUrl(path) {
const params = new URLSearchParams(window.location.search);
const assetBase = params.get("asset_base");
if (assetBase) {
return `${assetBase.replace(/\/$/, "")}/${path}`;
}
const repo = state.index?.dataset_repo || "ArnieRamesh/CounterStrike-1K";
const revision = state.index?.dataset_revision || "main";
return `https://huggingface.co/datasets/${repo}/resolve/${revision}/${path}`;
}
function indexUrl() {
const params = new URLSearchParams(window.location.search);
const override = params.get("index");
if (override) {
return override;
}
if (["localhost", "127.0.0.1"].includes(window.location.hostname)) {
return "../CounterStrike-1K/space_viewer/rounds.json";
}
return DEFAULT_INDEX_URL;
}
function masterVideo() {
return state.videos.get(state.audioPov) || state.videos.get(0) || null;
}
function setStatus(text) {
el.statusText.textContent = text;
}
function setControlsEnabled(enabled) {
const hasIndex = state.rounds.length > 0;
el.subsetFilter.disabled = !hasIndex;
el.splitFilter.disabled = !hasIndex;
el.mapFilter.disabled = !hasIndex;
el.matchFilter.disabled = !hasIndex;
el.minDuration.disabled = !hasIndex;
el.queryFilter.disabled = !hasIndex;
el.eventFilter.disabled = !hasIndex || !hasEventFields();
el.roundSelect.disabled = !enabled;
el.audioSelect.disabled = !enabled;
el.speedSelect.disabled = !enabled;
el.playPause.disabled = !enabled;
el.timeSlider.disabled = !enabled;
}
function setAudioPov(povIdx) {
state.audioPov = povIdx;
for (const [idx, video] of state.videos.entries()) {
video.muted = idx !== povIdx;
video.volume = idx === povIdx ? 1 : 0;
}
for (const [idx, card] of state.cards.entries()) {
card.classList.toggle("activeAudio", idx === povIdx);
}
}
function currentDuration() {
const fromIndex = Number(state.activeRound?.duration_s || 0);
const fromVideos = Array.from(state.videos.values())
.map((video) => video.duration)
.filter(Number.isFinite);
return Math.max(fromIndex, ...fromVideos, 0);
}
function setAllPlaybackRate(rate) {
for (const video of state.videos.values()) {
video.playbackRate = rate;
}
}
function setAllCurrentTime(seconds) {
const target = Math.max(0, Math.min(seconds, currentDuration()));
for (const video of state.videos.values()) {
const duration = Number.isFinite(video.duration) ? video.duration : target;
video.currentTime = Math.min(target, duration);
}
el.timeSlider.value = String(target);
el.timeNow.textContent = formatTime(target);
}
function syncFromMaster() {
const master = masterVideo();
if (!master || !state.playing || state.seeking) {
return;
}
const target = master.currentTime;
for (const video of state.videos.values()) {
if (video === master || video.readyState < 1) {
continue;
}
if (Math.abs(video.currentTime - target) > SYNC_TOLERANCE_S) {
video.currentTime = Math.min(target, Number.isFinite(video.duration) ? video.duration : target);
}
}
}
function tick() {
const master = masterVideo();
if (master) {
const now = master.currentTime;
el.timeSlider.value = String(now);
el.timeNow.textContent = formatTime(now);
syncFromMaster();
}
state.raf = requestAnimationFrame(tick);
}
async function playAll() {
const master = masterVideo();
if (!master) {
return;
}
setAllCurrentTime(master.currentTime);
state.playing = true;
el.playPause.textContent = "Pause";
setStatus("Playing");
const plays = [];
for (const video of state.videos.values()) {
plays.push(video.play());
}
const results = await Promise.allSettled(plays);
if (results.some((result) => result.status === "rejected")) {
pauseAll();
setStatus("Playback blocked");
}
}
function pauseAll(status = "Paused") {
state.internalPause = true;
for (const video of state.videos.values()) {
video.pause();
}
state.internalPause = false;
state.playing = false;
el.playPause.textContent = "Play";
setStatus(status);
}
function uniqueSorted(values) {
return [...new Set(values.filter((value) => value !== null && value !== undefined && String(value) !== ""))]
.map(String)
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}
function fillSelect(select, values, allLabel) {
const previous = select.value;
select.innerHTML = "";
const all = document.createElement("option");
all.value = "";
all.textContent = allLabel;
select.append(all);
for (const value of values) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.append(option);
}
if ([...select.options].some((option) => option.value === previous)) {
select.value = previous;
}
}
function hasEventFields() {
return state.rounds.some((round) =>
["is_pistol_round", "bomb_planted", "bomb_defused", "bomb_exploded", "has_awp_any_pov"]
.some((field) => Object.prototype.hasOwnProperty.call(round, field))
);
}
function renderFilterOptions() {
fillSelect(
el.subsetFilter,
uniqueSorted(state.rounds.flatMap((round) => Array.isArray(round.subset_names) ? round.subset_names : [])),
"All subsets",
);
fillSelect(el.splitFilter, uniqueSorted(state.rounds.map((round) => round.split)), "All splits");
fillSelect(el.mapFilter, uniqueSorted(state.rounds.map((round) => round.map_slug)), "All maps");
fillSelect(el.matchFilter, uniqueSorted(state.rounds.map((round) => `match_${round.match_id}`)), "All matches");
}
function roundSearchText(round) {
const povKeys = Array.isArray(round.povs)
? round.povs.map((pov) => pov.sample_key).join(" ")
: "";
return [
round.round_id,
`match_${round.match_id}`,
round.match_id,
round.map_slug,
round.split,
String(round.round_idx),
povKeys,
].join(" ").toLowerCase();
}
function roundMatchesFilters(round) {
const subset = el.subsetFilter.value;
if (subset && !(Array.isArray(round.subset_names) && round.subset_names.includes(subset))) {
return false;
}
if (el.splitFilter.value && String(round.split) !== el.splitFilter.value) {
return false;
}
if (el.mapFilter.value && String(round.map_slug) !== el.mapFilter.value) {
return false;
}
if (el.matchFilter.value && `match_${round.match_id}` !== el.matchFilter.value) {
return false;
}
if (el.eventFilter.value && !round[el.eventFilter.value]) {
return false;
}
const minDuration = Number(el.minDuration.value);
if (Number.isFinite(minDuration) && minDuration > 0 && Number(round.duration_s || 0) < minDuration) {
return false;
}
const query = el.queryFilter.value.trim().toLowerCase();
if (query && !roundSearchText(round).includes(query)) {
return false;
}
return true;
}
function renderRoundOptions() {
el.roundSelect.innerHTML = "";
state.filteredRounds.forEach((round, idx) => {
const option = document.createElement("option");
option.value = String(idx);
const subsets = Array.isArray(round.subset_names) && round.subset_names.length
? ` | ${round.subset_names.slice(0, 2).join(",")}`
: "";
option.textContent = `${round.map_slug} | match_${round.match_id} r${String(round.round_idx).padStart(3, "0")} | ${formatTime(round.duration_s)}${subsets}`;
el.roundSelect.append(option);
});
}
function renderAudioOptions() {
el.audioSelect.innerHTML = "";
const muted = document.createElement("option");
muted.value = "-1";
muted.textContent = "Muted";
el.audioSelect.append(muted);
for (let idx = 0; idx < 10; idx += 1) {
const option = document.createElement("option");
option.value = String(idx);
option.textContent = `POV ${String(idx).padStart(2, "0")}`;
el.audioSelect.append(option);
}
}
function makeVideoCard(pov) {
const card = document.createElement("article");
card.className = "povCard";
const header = document.createElement("div");
header.className = "povHeader";
const label = document.createElement("strong");
label.textContent = `POV ${String(pov.pov_idx).padStart(2, "0")}`;
const side = document.createElement("span");
side.className = pov.team_side === "CT" ? "sideCT" : pov.team_side === "T" ? "sideT" : "";
side.textContent = pov.team_side || "";
header.append(label, side);
const shell = document.createElement("div");
shell.className = "videoShell";
const video = document.createElement("video");
video.preload = "metadata";
video.playsInline = true;
video.controls = false;
video.muted = true;
video.src = fileUrl(pov.overlay_path);
video.addEventListener("loadedmetadata", () => {
const duration = currentDuration();
el.timeSlider.max = String(duration);
el.timeTotal.textContent = formatTime(duration);
if (state.playing) {
syncFromMaster();
}
});
video.addEventListener("pause", () => {
if (!state.internalPause && state.playing && !state.seeking && !video.ended) {
pauseAll("Paused");
}
});
video.addEventListener("waiting", () => {
if (state.playing) {
syncFromMaster();
setStatus("Buffering");
}
});
video.addEventListener("playing", () => {
if (state.playing) {
syncFromMaster();
setStatus("Playing");
}
});
video.addEventListener("error", () => {
card.classList.add("error");
setStatus(`Missing POV ${String(pov.pov_idx).padStart(2, "0")}`);
});
shell.append(video);
card.append(header, shell);
return { card, video };
}
function loadRound(round) {
if (!round) {
pauseAll("No matching rounds");
state.activeRound = null;
state.videos.clear();
state.cards.clear();
el.videoGrid.innerHTML = "";
el.roundMeta.textContent = "No matching rounds";
el.timeSlider.value = "0";
el.timeSlider.max = "0";
el.timeNow.textContent = formatTime(0);
el.timeTotal.textContent = formatTime(0);
setControlsEnabled(false);
return;
}
pauseAll("Loading");
state.activeRound = round;
state.videos.clear();
state.cards.clear();
el.videoGrid.innerHTML = "";
el.timeSlider.value = "0";
el.timeSlider.max = String(round.duration_s || 0);
el.timeNow.textContent = formatTime(0);
el.timeTotal.textContent = formatTime(round.duration_s || 0);
el.roundMeta.textContent = `${round.map_slug} | match_${round.match_id} | round ${String(round.round_idx).padStart(3, "0")}`;
const povs = [...round.povs].sort((a, b) => a.pov_idx - b.pov_idx);
for (const pov of povs) {
const { card, video } = makeVideoCard(pov);
state.videos.set(pov.pov_idx, video);
state.cards.set(pov.pov_idx, card);
el.videoGrid.append(card);
}
setAudioPov(Number(el.audioSelect.value));
setAllPlaybackRate(Number(el.speedSelect.value));
setControlsEnabled(true);
setStatus("Ready");
}
function applyFilters() {
const activeRoundId = state.activeRound?.round_id || "";
state.filteredRounds = state.rounds.filter(roundMatchesFilters);
renderRoundOptions();
const activeIdx = state.filteredRounds.findIndex((round) => round.round_id === activeRoundId);
if (!state.filteredRounds.length) {
loadRound(null);
return;
}
const nextIdx = activeIdx >= 0 ? activeIdx : 0;
el.roundSelect.value = String(nextIdx);
loadRound(state.filteredRounds[nextIdx]);
}
function bindEvents() {
el.roundSelect.addEventListener("change", () => {
loadRound(state.filteredRounds[Number(el.roundSelect.value)]);
});
for (const filter of [
el.subsetFilter,
el.splitFilter,
el.mapFilter,
el.matchFilter,
el.eventFilter,
el.minDuration,
el.queryFilter,
]) {
filter.addEventListener("input", applyFilters);
filter.addEventListener("change", applyFilters);
}
el.audioSelect.addEventListener("change", () => {
setAudioPov(Number(el.audioSelect.value));
});
el.speedSelect.addEventListener("change", () => {
setAllPlaybackRate(Number(el.speedSelect.value));
});
el.playPause.addEventListener("click", () => {
if (state.playing) {
pauseAll();
} else {
playAll();
}
});
el.timeSlider.addEventListener("input", () => {
state.seeking = true;
setAllCurrentTime(Number(el.timeSlider.value));
});
el.timeSlider.addEventListener("change", () => {
setAllCurrentTime(Number(el.timeSlider.value));
state.seeking = false;
});
}
async function init() {
bindEvents();
renderAudioOptions();
try {
const response = await fetch(indexUrl(), { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
state.index = await response.json();
state.rounds = Array.isArray(state.index.rounds) ? state.index.rounds : [];
if (!state.rounds.length) {
throw new Error("empty round index");
}
renderFilterOptions();
state.filteredRounds = state.rounds.slice();
renderRoundOptions();
loadRound(state.filteredRounds[0]);
tick();
} catch (error) {
setControlsEnabled(false);
el.roundMeta.textContent = "Round index unavailable";
setStatus(error instanceof Error ? error.message : "Failed");
}
}
init();