A little more possibility
Find your next favorite.
Great games and useful apps. All in one place, all free to explore.
+
-
+
@@ -1416,9 +1428,9 @@
@@ -2194,7 +2206,7 @@ async function capture() {
let url = null;
try {
let url_ = "/api/screenshot?view=headset";
- var shotPanel = null;
+ let shotPanel = null;
if (view === "flat") {
shotPanel = deskPanel() || (await loadPanels(), deskPanel());
if (!shotPanel) throw new Error(noPanelWhy());
@@ -3495,22 +3507,40 @@ $("modRemove").onclick = () => modAction("uninstall", "Remove UEVR");
$("modClose").onclick = () => $("modDlg").close();
// ---- Discover apps ----
-const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
+const sourceState = { vr: null, installable: false, 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";
+}
// GitHub's optional access token: only whether one is set is ever shown, never its value.
function sourceToken(s) {
const t=s.token;if(!t)return "";
@@ -3520,37 +3550,53 @@ function sourceToken(s) {
async function loadSources() {
try {
const {sources} = await api("/api/sources"); sourceState.sources = sources;
- $("sourceFilter").innerHTML = `
` + sources.filter(s=>s.enabled).map(s=>`
`).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.login ? " · Uses a login" : ""}${sourceToken(s)}${s.builtin === false ? `
` : ""}
`).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.login ? " · Uses a login" : ""}${sourceToken(s)}${s.builtin === false ? `
` : ""}
${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: ["May not 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 `
`;
+ return `
`;
}
-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.
`;
+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."}
`;
}
function renderStore() {
- const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable && !sourceState.source;
+ const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable;
$("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=`
` + 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 ? `` : ""}
` +
+ 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) {
const request=++sourceState.request;
$("searchGrid").setAttribute("aria-busy","true");
if(!quiet)$("searchGrid").innerHTML=`
${Array.from({length:8},()=>'
').join("")}
`;
- const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable)});
+ const params=new URLSearchParams({q:$("sourceQ").value,installable:String(sourceState.installable)});
if(sourceState.vr!==null)params.set("vr",String(sourceState.vr));
try {
const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
@@ -3563,16 +3609,16 @@ 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 detailBadges(o) { return `
${storeKind(o)}${storeRuns(o)}
Free${storeOpenSource(o) ? '
Open source' : ''}`; }
// The store's app page. (Not renderDetail: the Devices page declares that further down,
// and a later function declaration silently replaces an earlier one; issue #68.)
// True when the page rendered; false when it shows the error instead.
@@ -3588,7 +3634,7 @@ function renderAppDetail() {
function renderAppDetailBody() {
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'}
`;
+ $("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'}
`;
updateInstall();
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
}
@@ -3596,17 +3642,23 @@ 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};
const shown=renderAppDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
- if(shown)await loadOfferDetails();
+ if(shown) {
+ // The hero's Install starts in the details, which show its progress. A running or
+ // finished job only opens its details; a failed install can be tried again.
+ const o=currentOffer(), job=sourceState.jobs.get(`${o.source}:${o.id}`);
+ if(install&&(!job||job.done&&!job.result))installStoreApp();
+ await loadOfferDetails();
+ }
}
async function loadOfferDetails() {
const d=sourceState.detail,o=currentOffer(), request=++sourceState.detailRequest;
@@ -3637,15 +3689,14 @@ async function installStoreApp() {
}
async function changeSource(body) {
$("sourceError").textContent="Saving your sources…";
- try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;sourceState.source='';await loadSources();await searchSources(); }
+ try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;await loadSources();await searchSources(); }
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
}
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;storeChips();searchSources();}};
$("detailBody").onclick=e=>{if(e.target.closest('[data-close]'))return $("appDetail").close();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;renderAppDetail();loadOfferDetails();}};
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
@@ -3657,7 +3708,7 @@ async function addSource(body) {
let error="";
const result=await runJob('Add repository',`repo:${body.url}`,()=>api('/api/sources',body).catch(e=>{error=e.message;throw e;}),j=>{error=j.error||"";});
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
- if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
+ if(result){$("addSource").reset();await loadSources();await searchSources();}
}
// A login typed into the address moves into the login fields, on the page too, before anything
// is sent: the address is shown and logged, the login never. It stays there if adding fails.