A little more possibility
Find your next favorite. Great games and useful apps. All in one place, all free to explore.
+
-
For you VR experiences Flat-screen apps Fits your Frame
Filter by source All sources
+
All VR Flat-screen Installs on the Frame
@@ -1260,9 +1275,9 @@
×
- Your app shelves
A world of apps. Choose where you discover games and apps. We bring them together for you.
+ Sources Where Discover looks for apps. Turn a source off to hide its apps.
-
+
@@ -3158,46 +3173,80 @@ loadOwned();
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
-const storeInitial = name => [...String(name || "?")][0].toUpperCase();
+const storeInitial = name => [...String(name || "?")][0].toUpperCase(); // the small icon's stand-in
const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/ /g, " ");
const storeOpenSource = o => o.open_source === true || /^(MIT|Apache|[AL]?GPL|BSD|MPL|ISC|CC0|Unlicense)/i.test(o.license || "");
function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } }
function storeImage(path, alt, eager=false) { return /^\/source-image\/[\w-]+$/.test(path || "") ? `
` : ""; }
+// Artwork: the banner or first screenshot. Under it (and instead of it, when there's none, or it
+// doesn't load) the app's icon on a blurred wash of itself, or failing that a VR or app glyph.
+const GLYPH_VR = `
`;
+const GLYPH_APP = `
`;
function storeArt(o, cls="", eager=false, path=null, alt=null) {
const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0];
- return `
${esc(storeInitial(o.name))} ${storeImage(image, alt || `${o.name} artwork`, eager)}`;
+ const icon = !path && /^\/source-image\/[\w-]+$/.test(o.artwork?.icon || "") ? o.artwork.icon : "";
+ const under = path ? "" : icon
+ ? `
`
+ : `
${o.vr ? GLYPH_VR : GLYPH_APP} `;
+ return `
${under}${storeImage(image, alt || `${o.name} artwork`, eager)} `;
}
function storeIcon(o) { return `
${esc(storeInitial(o.name))}${storeImage(o.artwork?.icon, "")} `; }
-document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true);
+document.addEventListener("error", e => {
+ if (!e.target.matches?.(".store-art img, .store-icon img")) return;
+ const art = e.target.closest(".store-art");
+ e.target.remove();
+ // Nothing left to show: the glyph rather than an empty wash.
+ if (art && !art.querySelector("img") && !art.querySelector(".art-glyph")) art.insertAdjacentHTML("afterbegin", `
${GLYPH_APP} `);
+}, true);
function sourceMark(s) { const name=storeName(s); return `
${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))} `; }
-function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); }
+function sourceDescription(s) {
+ const kinds = {fdroid:"Free and open-source Android apps", itch:"Games from independent developers", github:"Releases straight from the developers",
+ sidequest:"VR games and apps. Its terms don't allow searching it from here, so browse it on its website."};
+ return s.description || kinds[s.kind] || "Apps from this repository";
+}
async function loadSources() {
try {
const {sources} = await api("/api/sources"); sourceState.sources = sources;
- $("sourceFilter").innerHTML = `
All sources ` + sources.filter(s=>s.enabled).map(s=>`
${esc(storeName(s.name))} `).join("");
- $("sourceFilter").value = sourceState.source;
- $("sourcesList").innerHTML = sources.map(s=>`
${sourceMark(s.name)}
${esc(storeName(s.name))} ${esc(sourceDescription(s))}
${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")} ${s.builtin === false ? `
Remove ` : ""}
`).join("") || `
Your sources will appear here when they are available.
`;
+ $("sourcesList").innerHTML = sources.map(s=>`
${sourceMark(s.name)}
${esc(storeName(s.name))} ${esc(sourceDescription(s))}
${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")} ${s.builtin === false ? `
Remove ` : ""}
${s.page_only ? `
Website ↗ ` : `
`}
`).join("") || `
Your sources will appear here when they are available.
`;
} catch (e) { $("sourceError").textContent = e.message; }
}
+// Will it run? Short enough for a card; the full sentence is in its tooltip and the details.
+const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["Won't run", "blocked"] };
+function storeRuns(o) {
+ const [text, cls] = RUNS[o.verdict?.tone] || RUNS.unknown;
+ return `
${text} `;
+}
+const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app";
function storeCard(app) {
const i=sourceState.apps.indexOf(app), o=app.offers[0];
- return `
${storeArt(o)}${storeIcon(o)}${esc(app.name)} ${esc(storeText(app.summary) || "A new addition to your Frame")} `;
+ return `
${storeArt(o)}${storeIcon(o)}${esc(app.name)} ${esc(storeText(app.summary) || "From " + storeName(o.source_name))} `;
}
-function storeRow(title, subtitle, apps) { return apps.length ? `
${esc(title)} ${esc(subtitle)}
${apps.slice(0,5).map(storeCard).join("")}
` : ""; }
-function storeEmpty() {
- return `
A little further to explore. No apps found this time. Try a different name, clear your filters, or explore another source.
Explore all apps `;
+function storeRow(title, subtitle, apps) { return apps.length ? `
${esc(title)} ${subtitle ? `
${esc(subtitle)}
` : ""}
${apps.slice(0,5).map(storeCard).join("")}
` : ""; }
+function storeEmpty(message) {
+ return `
${message ? "Couldn't load apps" : "No apps found"} ${message ? esc(message) : "Try another name, or clear the filters."}
${message ? "Try again" : "Clear filters"} `;
}
function renderStore() {
const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable && !sourceState.source;
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`;
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
if (!browsing) { $("searchGrid").innerHTML=`
${apps.map(storeCard).join("")}
`; return; }
- const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0];
- const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works'));
- const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked'));
- const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0));
- const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||'')));
- $("searchGrid").innerHTML=`
${storeArt(o,"",true)}Step into something new ${esc(featured.name)} ${esc(storeText(featured.summary))} Explore ${esc(featured.name)} ↗ ` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent);
+ // The featured app: something that runs, with real artwork, and preferably VR. It isn't repeated below.
+ const runs=a=>a.offers[0].verdict?.tone!=='blocked', art=a=>a.offers[0].artwork?.banner;
+ const featured=apps.find(a=>a.offers[0].vr && art(a) && runs(a)) || apps.find(a=>art(a) && runs(a)) || apps.find(runs) || apps[0], o=featured.offers[0];
+ const rest=apps.filter(a=>a!==featured);
+ const tested=rest.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works'));
+ const vr=tested.length ? tested : rest.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked'));
+ // Rows don't repeat each other either: each takes the next apps not shown yet.
+ const shown=new Set(), take=list=>{ const out=list.filter(a=>!shown.has(a)).slice(0,5); out.forEach(a=>shown.add(a)); return out; };
+ const vrRow=take(vr);
+ const likely={works:0,ready:1,unknown:2,blocked:3}, rank=a=>likely[a.offers[0].verdict?.tone] ?? 2;
+ const popular=take([...rest].sort((a,b)=>rank(a)-rank(b) || (b.offers[0].popularity||0)-(a.offers[0].popularity||0)));
+ const recent=take(rest.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||''))));
+ const canInstall=o.downloadable && runs(featured);
+ $("searchGrid").innerHTML=`
${storeArt(o,"",true)}
Featured${o.vr ? " in VR" : ""} ${esc(featured.name)} ${esc(storeText(featured.summary))} ${storeRuns(o)} ${canInstall ? `Install on the Frame ` : ""}Details
` +
+ storeRow(tested.length ? "VR that works on the Frame" : "VR games and experiences", tested.length ? "Checked on a Frame" : "Made for headsets; most aren't checked on the Frame yet", vrRow) +
+ storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular" : "More to try", "", popular) +
+ storeRow("Recently updated", "New versions from their developers", recent);
}
function storeChips() { $("searchFilters").querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",b.dataset.filter==='all' ? sourceState.vr===null && !sourceState.installable : b.dataset.filter==='installable' ? sourceState.installable : sourceState.vr===(b.dataset.filter==='vr'))); }
async function searchSources(quiet) {
@@ -3217,20 +3266,20 @@ async function searchSources(quiet) {
result.sources.filter(s=>s.status==='limited').forEach(s=>notes.push(`${s.error}.`));
const stale=result.sources.filter(s=>s.stale).map(s=>storeName(s.name));
if(stale.length)notes.push(`Showing saved listings from ${stale.join(" and ")} while ${stale.length===1 ? "it refreshes" : "they refresh"}.`);
- $("sourceStatus").innerHTML=notes.map(esc).join(" ")+(result.elsewhere||[]).map(x=>`
Browse ${esc(storeName(x.name))} ↗ `).join("");
- $("sourceStatus").hidden=!notes.length && !(result.elsewhere||[]).length;
+ $("sourceStatus").textContent=notes.join(" ");
+ $("sourceStatus").hidden=!notes.length;
clearTimeout(sourceState.retry);
if(loading.length)sourceState.retry=setTimeout(()=>{ if(request===sourceState.request)searchSources(true); },6000);
renderStore();await loadSources();
- } catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML=storeEmpty();$("sourceStatus").hidden=false;$("sourceStatus").textContent="We couldn't reach the app shelves. Try again in a moment."; } }
+ } catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML=storeEmpty("Couldn't reach the app sources. Try again in a moment.");$("sourceStatus").hidden=true; } }
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
}
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
-function detailBadges(o) { return `
${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"} ${o.verdict?.tone==='works' ? '
✓ Works on Frame ' : ''}
Free ${storeOpenSource(o) ? '
Open source ' : ''}`; }
-function renderDetail() {
+function detailBadges(o) { return `
${storeKind(o)} ${storeRuns(o)}
Free ${storeOpenSource(o) ? '
Open source ' : ''}`; }
+function renderAppDetail() {
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
- $("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`
${storeIcon(o)}
${esc(o.name)} ${esc(o.developer || o.author || 'From '+storeName(o.source_name))}
${detailBadges(o)}
${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}
${images.length ? `
${images.length} screenshots · scroll to explore ← →
` : ''}
About this app ${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
${page ? `
Meet the creators ↗
` : ''}
Technical details Package ${esc(o.package||"Unknown")} Version ${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")}) Android API ${esc(o.min_sdk??"Unknown")} Architecture ${esc(o.abis?.join(', ')||"Unknown / no native code")} Updated ${esc(o.updated||"Unknown")} Compatibility ${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'} Free
No purchase needed.
${esc(o.verdict?.label||'Not yet checked on the Frame')}
Install
Get it from ${d.app.offers.map((v,j)=>`${sourceMark(v.source_name)} ${esc(storeName(v.source_name))}${j===0?' · Recommended':''} ${v.verified===true?'✓ Verified download':'Verification not provided'} ${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''} `).join('')}
`;
+ $("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`
${storeIcon(o)}
${esc(o.name)} ${esc(o.developer || o.author || 'From '+storeName(o.source_name))}
${detailBadges(o)}
${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}
${images.length ? `
${images.length} screenshots · scroll to explore ← →
` : ''}
About this app ${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
${page ? `
The developer's page ↗
` : ''}
Technical details Package ${esc(o.package||"Unknown")} Version ${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")}) Android API ${esc(o.min_sdk??"Unknown")} Architecture ${esc(o.abis?.join(', ')||"Unknown / no native code")} Updated ${esc(o.updated||"Unknown")} Compatibility ${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'} Free
No purchase needed.
${esc(o.verdict?.label||'Not yet checked on the Frame')}
Install
Get it from ${d.app.offers.map((v,j)=>`${sourceMark(v.source_name)} ${esc(storeName(v.source_name))}${j===0?' · Recommended':''} ${v.verified===true?'✓ Verified download':'Verification not provided'} ${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''} `).join('')}
`;
updateInstall();
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
}
@@ -3238,16 +3287,17 @@ function updateInstall() {
const o=currentOffer();if(!o || !$("detailInstall"))return;
const job=sourceState.jobs.get(`${o.source}:${o.id}`), blocked=o.verdict?.tone==='blocked', page=sourcePage(o.page), b=$("detailInstall");
b.disabled=!!(job && !job.done) || !!job?.addingData || (blocked && !job?.result) || (!o.downloadable && !page);
- b.textContent=job?.result?.game_data ? (job.addingData ? 'Adding game data…' : 'Add game data') : job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on Frame') : 'Get from the creator ↗';
+ b.textContent=job?.result?.game_data ? (job.addingData ? 'Adding game data…' : 'Add game data') : job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on the Frame') : 'Get from the creator ↗';
$("detailProgress").hidden=!job || job.done;
if(job?.percent!=null)$("detailProgress").value=job.percent;else $("detailProgress").removeAttribute("value");
- $("detailInstallNote").textContent=job?.result?.game_data ? (job.dataError || 'Installed. It also needs its game data: open it once on the Frame, then choose Add game data.') : job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Keep exploring. We’ll take care of the installation.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.');
+ $("detailInstallNote").textContent=job?.result?.game_data ? (job.dataError || 'Installed. It also needs its game data: open it once on the Frame, then choose Add game data.') : job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Installing. You can close this and keep browsing.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.');
$("detailOffers").querySelectorAll('input').forEach(input=>input.disabled=!!job&&!job.done);
}
-async function openStoreApp(index) {
+async function openStoreApp(index, install=false) {
const app=sourceState.apps[index];if(!app)return;
sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0};
- renderDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
+ renderAppDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
+ if(install)installStoreApp(); // the hero's Install: the details show its progress
await loadOfferDetails();
}
async function loadOfferDetails() {
@@ -3256,7 +3306,7 @@ async function loadOfferDetails() {
const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id}));
if(request!==sourceState.detailRequest || d!==sourceState.detail)return;
const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]};
- d.app.offers[d.choice]={...o,...detail,artwork};renderDetail();
+ d.app.offers[d.choice]={...o,...detail,artwork};renderAppDetail();
} catch(e) { if(d===sourceState.detail)$("detailInstallNote").textContent="Some app details couldn't be refreshed. You can still view this listing."; }
}
async function installStoreApp() {
@@ -3284,11 +3334,10 @@ async function changeSource(body) {
let storeSearchTimer;
$("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);};
$("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer);searchSources();};
-$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();};
$("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();};
-$("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}};
+$("searchGrid").onclick=e=>{const h=e.target.closest('[data-hero-install]');if(h){openStoreApp(+h.dataset.heroInstall,true);return;}const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}};
$("detailBody").onclick=e=>{if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
-$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderDetail();loadOfferDetails();}};
+$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}};
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
@@ -3707,7 +3756,7 @@ $("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/ap
// ---- Panel switcher: our UI over SteamVR's panel API ----
let panelSeq = 0;
-async function loadPanels() {
+async function loadPanelSwitcher() {
const seq = ++panelSeq;
try {
const s = await api("/api/panels", {action: "list"});
@@ -3718,7 +3767,7 @@ async function loadPanels() {
: '
No open panels.
';
} catch (e) { if (seq === panelSeq) failed($("panelList"), e); }
}
-$("panelsRefresh").onclick = loadPanels;
+$("panelsRefresh").onclick = loadPanelSwitcher;
$("panelsOpen").onclick = e => act("Open panel switcher in headset",
() => api("/api/panels", {action: "open"}), e.currentTarget);
$("panelList").onclick = e => {
@@ -3819,7 +3868,7 @@ function showPage() {
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
const section = !PAGES.includes(id) && id && $(id);
if (section) section.scrollIntoView(); else scrollToY(0);
- if (page === "tools") { loadMacView(); loadPanels(); }
+ if (page === "tools") { loadMacView(); loadPanelSwitcher(); }
}
window.addEventListener("hashchange", showPage);
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).