diff --git a/assets/apiClient.js b/assets/apiClient.js index 9ca5eb6..8b27f20 100644 --- a/assets/apiClient.js +++ b/assets/apiClient.js @@ -1,158 +1,158 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -// this file also gets injected into extension sandbox - -// @ts-check - -class DirectoryListing { - /** - * @param {string} name - * @param {string} mode - */ - constructor(name, mode) { - this.name = name; - this.mode = mode; - } - - isDir() { - return this.mode === "d" && !ignoredFileNames.includes(this.name); - } - - isFile() { - return this.mode === "-" && !ignoredFileNames.includes(this.name); - } -} - -// @ts-ignore -const baseURL = window.location.origin == "null" ? "http://127.0.0.1:8080" : window.location.origin; -const ignoredFileNames = [".", ".."]; -class ApiClient { - /** - * @param {string} path - * @param {(string | string[])?} [args] - * @param {(string | string[])?} [env] - * @param {string} [cwd] - * @returns {Promise?>} only returns true, throws error if not success code - */ - static async launchApp(path, args = null, env = null, cwd = null) { - let params = new URLSearchParams({ - "pipe": "1", - "path": path - }); - - if (typeof args === "string") { - // @ts-ignore - params.append("args", args.replaceAll(" ", "\\ ")); - } else if (Array.isArray(args)) { - // @ts-ignore - params.append("args", args.map(arg => arg.replaceAll(" ", "\\ ")).join(" ")); - } - - if (env != null) { - // @ts-ignore - params.append("env", Object.entries(env).map(([key, val]) => - `${key}=${val}`.replaceAll(" ", "\\ ") - ).join(" ")); - } - - if (cwd != null) { - params.append("cwd", cwd); - } - - let uri = baseURL + "/hbldr?" + params.toString(); - - let response = await fetch(uri); - if (response.status !== 200) { - throw new Error("Failed to launch app, status code: " + response.status); - } - - return response.body; - } - - /** - * @param {string} path - * @returns {Promise} - */ - static async fsListDir(path) { - if (!path.endsWith("/")) { - path += "/"; - } - - let response = await fetch(baseURL + "/fs" + path + "?fmt=json"); - if (!response.ok) { - return null; - } - let data = await response.json(); - data.sort((x, y) => x.mode == y.mode ? - x.name.localeCompare(y.name) : - y.mode.localeCompare(x.mode)); - - return data.filter(entry => !ignoredFileNames.includes(entry.name)).map(entry => - new DirectoryListing(entry.name, entry.mode)); - } - - /** - * - * @param {string} path - * @returns {Promise?>} - */ - static async fsGetFileStream(path) { - if (path.endsWith("/") || !path.startsWith("/")) { - return null; - } - - let response = await fetch(baseURL + "/fs" + path); - if (!response.ok) { - return null; - } - return response.body; - } - - /** - * - * @param {string} path - * @returns {Promise} - */ - static async fsGetFileText(path) { - if (path.endsWith("/") || !path.startsWith("/")) { - return null; - } - - let response = await fetch(baseURL + "/fs" + path); - if (!response.ok) { - return null; - } - return await response.text(); - } - - static async getVersion() { - try { - const response = await fetch(baseURL + '/version'); - if (response.ok) { - return await response.json(); - } - } catch (error) { - } - return { - api: 0, - tag: '', - date: '', - time: '' - }; - } -} +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +// this file also gets injected into extension sandbox + +// @ts-check + +class DirectoryListing { + /** + * @param {string} name + * @param {string} mode + */ + constructor(name, mode) { + this.name = name; + this.mode = mode; + } + + isDir() { + return this.mode === "d" && !ignoredFileNames.includes(this.name); + } + + isFile() { + return this.mode === "-" && !ignoredFileNames.includes(this.name); + } +} + +// @ts-ignore +const baseURL = window.location.origin == "null" ? "http://127.0.0.1:8080" : window.location.origin; +const ignoredFileNames = [".", ".."]; +class ApiClient { + /** + * @param {string} path + * @param {(string | string[])?} [args] + * @param {(string | string[])?} [env] + * @param {string} [cwd] + * @returns {Promise?>} only returns true, throws error if not success code + */ + static async launchApp(path, args = null, env = null, cwd = null) { + let params = new URLSearchParams({ + "pipe": "1", + "path": path + }); + + if (typeof args === "string") { + // @ts-ignore + params.append("args", args.replaceAll(" ", "\\ ")); + } else if (Array.isArray(args)) { + // @ts-ignore + params.append("args", args.map(arg => arg.replaceAll(" ", "\\ ")).join(" ")); + } + + if (env != null) { + // @ts-ignore + params.append("env", Object.entries(env).map(([key, val]) => + `${key}=${val}`.replaceAll(" ", "\\ ") + ).join(" ")); + } + + if (cwd != null) { + params.append("cwd", cwd); + } + + let uri = baseURL + "/hbldr?" + params.toString(); + + let response = await fetch(uri); + if (response.status !== 200) { + throw new Error("Failed to launch app, status code: " + response.status); + } + + return response.body; + } + + /** + * @param {string} path + * @returns {Promise} + */ + static async fsListDir(path) { + if (!path.endsWith("/")) { + path += "/"; + } + + let response = await fetch(baseURL + "/fs" + path + "?fmt=json"); + if (!response.ok) { + return null; + } + let data = await response.json(); + data.sort((x, y) => x.mode == y.mode ? + x.name.localeCompare(y.name) : + y.mode.localeCompare(x.mode)); + + return data.filter(entry => !ignoredFileNames.includes(entry.name)).map(entry => + new DirectoryListing(entry.name, entry.mode)); + } + + /** + * + * @param {string} path + * @returns {Promise?>} + */ + static async fsGetFileStream(path) { + if (path.endsWith("/") || !path.startsWith("/")) { + return null; + } + + let response = await fetch(baseURL + "/fs" + path); + if (!response.ok) { + return null; + } + return response.body; + } + + /** + * + * @param {string} path + * @returns {Promise} + */ + static async fsGetFileText(path) { + if (path.endsWith("/") || !path.startsWith("/")) { + return null; + } + + let response = await fetch(baseURL + "/fs" + path); + if (!response.ok) { + return null; + } + return await response.text(); + } + + static async getVersion() { + try { + const response = await fetch(baseURL + '/version'); + if (response.ok) { + return await response.json(); + } + } catch (error) { + } + return { + api: 0, + tag: '', + date: '', + time: '' + }; + } +} diff --git a/assets/carouselView.js b/assets/carouselView.js index 338313f..d080219 100644 --- a/assets/carouselView.js +++ b/assets/carouselView.js @@ -1,399 +1,399 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -// @ts-check - -/** - * @typedef {Object} CarouselItemSecondaryActions - * @property {string} text - * @property {function():(boolean|void|Promise|Promise)} onclick - return true to display you can exit now - */ - -/** - * @typedef {Object} CarouselItem - * @property {string} mainText - * @property {(function():(boolean|void|Promise|Promise))?} onclick - return true to display you can exit now - * @property {(function():Promise)} [asyncInfo] - * @property {string} [secondaryText] - * @property {string} [imgPath] - * @property {CarouselItemSecondaryActions[]} [options] - */ - - -/** - * Shouldnt be interacted with directly, use router methods - * @param {CarouselItem[]} items - */ -async function renderMainContentCarousel(items, fadeout = true) { - // reset content - const content = /** @type {HTMLElement?} */ (document.getElementById("content")); - if (!content) { - // window.location.href = "/"; - debugger; - return; - } - - if (content.children.length > 0) { - if (fadeout) { - let elementsWithLoading = content.children[0].querySelectorAll(".loading"); - for (let loading of elementsWithLoading) { - // specifically not removing the overlay to make it look less jarring - loading.classList.remove("loading"); - } - - for (let i = 0; i < content.children.length; i++) { - content.children[i].classList.add("fadeout"); - } - await sleep(250); - } - - content.innerHTML = ""; - } - - // create carousel div - const carousel = document.createElement("div"); - carousel.classList.add("carousel"); - carousel.classList.add("fadein"); - content.appendChild(carousel); - - let asyncInfoPromisesList = []; - - // add entries - for (let i = 0; i < items.length; i++) { - const item = items[i]; - let ii = i; - - const entryElement = document.createElement("div"); - entryElement.classList.add("entry-wrapper"); - - if (i == 0) { - entryElement.classList.add("selected"); - } - - const entryElementMainButton = document.createElement("div"); - entryElementMainButton.classList.add("entry"); - entryElementMainButton.classList.add("entry-main"); - - const entryElementImg = document.createElement("img"); - entryElementImg.classList.add("entry-img"); - entryElementImg.style.display = "none"; - entryElementImg.onerror = () => { - entryElementImg.style.display = "none"; - } - entryElementMainButton.appendChild(entryElementImg); - - - const entryElementMainText = document.createElement("p"); - entryElementMainText.classList.add("entry-name"); - entryElementMainText.innerText = item.asyncInfo ? "" : item.mainText; - entryElementMainButton.appendChild(entryElementMainText); - entryElement.appendChild(entryElementMainButton); - - if (item.asyncInfo) { - entryElementMainButton.classList.add("loading-overlay"); - entryElementMainButton.classList.add("loading"); - } - - carousel.appendChild(entryElement); - - asyncInfoPromisesList.push(async () => { - let asyncInfo = null; - if (item.asyncInfo) { - asyncInfo = await item.asyncInfo(); - entryElementMainButton.classList.remove("loading-overlay"); - entryElementMainButton.classList.remove("loading"); - } - - if (asyncInfo && asyncInfo.result.mainText) { - entryElementMainText.innerText = asyncInfo.result.mainText; - } - - if ((asyncInfo && asyncInfo.result.secondaryText) || item.secondaryText) { - const entryElementSecondaryText = document.createElement("p"); - entryElementSecondaryText.classList.add("entry-secondary"); - entryElementSecondaryText.classList.add("text-secondary"); - entryElementSecondaryText.innerText = ((asyncInfo && asyncInfo.result.secondaryText) ? asyncInfo.result.secondaryText : item.secondaryText) || ""; - entryElementMainButton.appendChild(entryElementSecondaryText); - } - - if (asyncInfo && !asyncInfo.uuid) { - // TODO: this is temporary maybe, idk if its better to show an error on the entry or remove the entry altogether - - // return if uuid is null which only happens on a timeout - // so image and onclicks arent set - return; - } - - if ((asyncInfo && asyncInfo.result.imgPath) || item.imgPath) { - entryElementImg.style.display = "block"; - let imgPath = ((asyncInfo && asyncInfo.result.imgPath) ? asyncInfo.result.imgPath : item.imgPath) || ""; - // workaround for svgs since the server doesnt return mime - if (imgPath.trimEnd().toLowerCase().endsWith(".svg")) { - try { - let imgBytes = await (await fetch(imgPath)).arrayBuffer(); - let imgBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(imgBytes))); - imgPath = "data:image/svg+xml;base64," + imgBase64; - } - catch (e) { - // nothing - } - } - - entryElementImg.src = imgPath; - } - - if ((asyncInfo && asyncInfo.result.options) || item.options) { - const entryElementMoreOptionsButton = document.createElement("div"); - entryElementMoreOptionsButton.classList.add("entry"); - entryElementMoreOptionsButton.classList.add("entry-more-button"); - entryElementMoreOptionsButton.innerHTML = "•••"; - - entryElementMoreOptionsButton.onclick = async () => { - if (!entryElement.classList.contains("selected")) { - smoothScrollToElementIndex(ii, true); - return; - } - let options = null; - if (asyncInfo && asyncInfo.result.options) { - options = asyncInfo.result.options; - } - else if (item.options) { - options = item.options; - } - // @ts-ignore - await Globals.Router.handleModal(options); - } - - entryElement.appendChild(entryElementMoreOptionsButton); - } - - - entryElementMainButton.onclick = async () => { - if (!entryElement.classList.contains("selected")) { - smoothScrollToElementIndex(ii, true); - return; - } - - entryElementMainButton.classList.add("loading-overlay"); - entryElementMainButton.classList.add("loading"); - await sleep(0); // TODO: Didnt test if its needed here, but i think if the asyncInfo.result.onclick isnt async we might need this to trigger a rerender, or ideally something better - let onclickResult = null; - if (asyncInfo && asyncInfo.result.onclick) { - onclickResult = await asyncInfo.result.onclick(); - } - else if (item.onclick) { - onclickResult = await item.onclick(); - } - else { - // TODO: invalid item, remove - } - - let res = onclickResult; - let logStream = null; - - if (res && res.path) { - logStream = await ApiClient.launchApp(res.path, res.args, res.env, res.cwd); - res = logStream != null; - } - - if (res == true) { - entryElement.style.transform = "scale(2)"; - setTimeout(() => { - entryElement.style.removeProperty("transform"); - }, 300); - Globals.Router.handleLaunchedAppView(logStream); - } - - entryElementMainButton.classList.remove("loading-overlay"); - entryElementMainButton.classList.remove("loading"); - } - - }); - - } - - smoothScrollToElementIndex(0, false); - - // await all and reset cursor snap overlays every time a task completes - await Promise.all(asyncInfoPromisesList.map(asyncInfoPromise => asyncInfoPromise().then(() => { - if (document.getElementById("modal-content")) { - return; - } - generateCursorSnapOverlays(); - }))); -} - - -function removeAllCursorSnapOverlays() { - const content = document.getElementById("content"); - if (!content) { - return; - } - const overlays = content.getElementsByClassName("home-cursor-snap-overlay"); - while (overlays.length > 0) { - overlays[0].remove(); - } -} - -function generateCursorSnapOverlays(entryWrapperIndex = -1) { - // create overlay for cursor snap - // remove existing overlays - removeAllCursorSnapOverlays(); - let content = document.getElementById("content"); - if (!content) { - return; - } - if (entryWrapperIndex === -1) { - // set to index of entry-wrapper with selected class - let wrappers = content.getElementsByClassName("entry-wrapper"); - for (let i = 0; i < wrappers.length; i++) { - if (wrappers[i].classList.contains("selected")) { - entryWrapperIndex = i; - break; - } - } - } - - const entry = content.getElementsByClassName("entry-wrapper")[entryWrapperIndex]; - - if (!entry) { - return; - } - - // for each entry in entry-wrapper make a cursor snap overlay - for (let i = 0; i < entry.children.length; i++) { - const entryChild = entry.children[i]; - const overlay = document.createElement("a"); - overlay.classList.add("home-cursor-snap-overlay"); - overlay.style.position = "fixed"; - - // calculate the offset from the top of the viewport - - const entryChildRect = entryChild.getBoundingClientRect(); - let entryChildBottom = entryChildRect.top + entryChildRect.height; - let entryChildHeight = entryChildRect.height; - let topOffset = 0; - - // this method has not added the selected class yet so if theres a selected class on the wrapper - // thats because the element was initialized with it, in that case the element is already 1.2x scaled - // but otherwise the bounding box is the 1.0x size here bc the animation has not run yet - // even if we add the selected class the bounding box scales with the animation so its useless until its finished - if (!entry.classList.contains("selected")) { - // we gotta calculate the transforms - const transformScale = 1.2; - const parentRect = entry.getBoundingClientRect(); - const parentCenter = parentRect.top + parentRect.height / 2; - const entryTopRelativeToParentCenter = entryChildRect.top - parentCenter; - const entryBottomRelativeToParentCenter = entryChildRect.bottom - parentCenter; - const entryTransformedTop = parentCenter + (entryTopRelativeToParentCenter * transformScale); - const entryTransformedBottom = parentCenter + (entryBottomRelativeToParentCenter * transformScale); - const entryTransformedHeight = entryTransformedBottom - entryTransformedTop; - entryChildHeight = entryTransformedHeight; - entryChildBottom = entryTransformedBottom; - } - - if (entryChild.classList.contains("entry")) { - // main big button - // snap cursor to the lower 3/8 of the button - topOffset = entryChildBottom - entryChildHeight * (2 / 5); - } else { - // snap to center - topOffset = entryChildBottom - (entryChildHeight / 2); - } - - overlay.style.top = topOffset + "px"; - overlay.style.left = "0"; - overlay.style.right = "0"; - overlay.style.height = "1px"; - // allow clicks through - overlay.style.pointerEvents = "none"; - // snap cursor - overlay.tabIndex = 0; - content.appendChild(overlay); - } -} - - -/** - * Only for home page carousel, also sets selected class and creates the cursor snap points - */ -function smoothScrollToElementIndex(index, smooth = true) { - const content = document.getElementById("content"); - if (!content) { - return; - } - const carousel = content.getElementsByClassName("carousel")[0]; - if (!carousel){ - return; - } - const entries = carousel.getElementsByClassName("entry-wrapper"); - - if (index < 0 || index >= entries.length) { - return; - } - - const entry = entries[index]; - - generateCursorSnapOverlays(index); - - for (let t_entry of entries) { - t_entry.classList.remove("selected"); - } - - entry.classList.add("selected"); - - // webkit doesnt do smooth scrolling and smoothscroll.js cant center the item so we have to do it manually - const itemRect = entry.getBoundingClientRect(); - const containerRect = content.getBoundingClientRect(); - const targetScrollLeft = itemRect.left - containerRect.left + content.scrollLeft - (containerRect.width - itemRect.width) / 2; - - if (targetScrollLeft < 1 && targetScrollLeft > -1) { - return; - } - - if (smooth) { - carousel.scrollBy({ left: targetScrollLeft, behavior: "smooth" }); - carousel.classList.add("scrolling"); - if (Globals.removeScrollingClassFromCarouselTimeoutId) { - clearTimeout(Globals.removeScrollingClassFromCarouselTimeoutId); - } - Globals.removeScrollingClassFromCarouselTimeoutId = setTimeout(() => { - carousel.classList.remove("scrolling"); - }, 468); // 468 the constant length of the scrolling with smoothscroll.js - } else { - carousel.scrollLeft = targetScrollLeft; - } -} - -/** - * Returns null if the current view isnt a carouselView - * @returns { {selectedIndex: number?, totalEntries: number}? } - */ -function getCurrentCarouselSelectionInfo() { - const content = /** @type {HTMLElement} */ (document.getElementById("content")); - const carousel = content.getElementsByClassName("carousel")[0]; - if (!carousel) { - return null; - } - - const entries = carousel.getElementsByClassName("entry-wrapper"); - let currentElementIndex = Array.from(entries).findIndex(entry => entry.classList.contains("selected")); - return { - selectedIndex: currentElementIndex, - totalEntries: entries.length - }; -} +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +// @ts-check + +/** + * @typedef {Object} CarouselItemSecondaryActions + * @property {string} text + * @property {function():(boolean|void|Promise|Promise)} onclick - return true to display you can exit now + */ + +/** + * @typedef {Object} CarouselItem + * @property {string} mainText + * @property {(function():(boolean|void|Promise|Promise))?} onclick - return true to display you can exit now + * @property {(function():Promise)} [asyncInfo] + * @property {string} [secondaryText] + * @property {string} [imgPath] + * @property {CarouselItemSecondaryActions[]} [options] + */ + + +/** + * Shouldnt be interacted with directly, use router methods + * @param {CarouselItem[]} items + */ +async function renderMainContentCarousel(items, fadeout = true) { + // reset content + const content = /** @type {HTMLElement?} */ (document.getElementById("content")); + if (!content) { + // window.location.href = "/"; + debugger; + return; + } + + if (content.children.length > 0) { + if (fadeout) { + let elementsWithLoading = content.children[0].querySelectorAll(".loading"); + for (let loading of elementsWithLoading) { + // specifically not removing the overlay to make it look less jarring + loading.classList.remove("loading"); + } + + for (let i = 0; i < content.children.length; i++) { + content.children[i].classList.add("fadeout"); + } + await sleep(250); + } + + content.innerHTML = ""; + } + + // create carousel div + const carousel = document.createElement("div"); + carousel.classList.add("carousel"); + carousel.classList.add("fadein"); + content.appendChild(carousel); + + let asyncInfoPromisesList = []; + + // add entries + for (let i = 0; i < items.length; i++) { + const item = items[i]; + let ii = i; + + const entryElement = document.createElement("div"); + entryElement.classList.add("entry-wrapper"); + + if (i == 0) { + entryElement.classList.add("selected"); + } + + const entryElementMainButton = document.createElement("div"); + entryElementMainButton.classList.add("entry"); + entryElementMainButton.classList.add("entry-main"); + + const entryElementImg = document.createElement("img"); + entryElementImg.classList.add("entry-img"); + entryElementImg.style.display = "none"; + entryElementImg.onerror = () => { + entryElementImg.style.display = "none"; + } + entryElementMainButton.appendChild(entryElementImg); + + + const entryElementMainText = document.createElement("p"); + entryElementMainText.classList.add("entry-name"); + entryElementMainText.innerText = item.asyncInfo ? "" : item.mainText; + entryElementMainButton.appendChild(entryElementMainText); + entryElement.appendChild(entryElementMainButton); + + if (item.asyncInfo) { + entryElementMainButton.classList.add("loading-overlay"); + entryElementMainButton.classList.add("loading"); + } + + carousel.appendChild(entryElement); + + asyncInfoPromisesList.push(async () => { + let asyncInfo = null; + if (item.asyncInfo) { + asyncInfo = await item.asyncInfo(); + entryElementMainButton.classList.remove("loading-overlay"); + entryElementMainButton.classList.remove("loading"); + } + + if (asyncInfo && asyncInfo.result.mainText) { + entryElementMainText.innerText = asyncInfo.result.mainText; + } + + if ((asyncInfo && asyncInfo.result.secondaryText) || item.secondaryText) { + const entryElementSecondaryText = document.createElement("p"); + entryElementSecondaryText.classList.add("entry-secondary"); + entryElementSecondaryText.classList.add("text-secondary"); + entryElementSecondaryText.innerText = ((asyncInfo && asyncInfo.result.secondaryText) ? asyncInfo.result.secondaryText : item.secondaryText) || ""; + entryElementMainButton.appendChild(entryElementSecondaryText); + } + + if (asyncInfo && !asyncInfo.uuid) { + // TODO: this is temporary maybe, idk if its better to show an error on the entry or remove the entry altogether + + // return if uuid is null which only happens on a timeout + // so image and onclicks arent set + return; + } + + if ((asyncInfo && asyncInfo.result.imgPath) || item.imgPath) { + entryElementImg.style.display = "block"; + let imgPath = ((asyncInfo && asyncInfo.result.imgPath) ? asyncInfo.result.imgPath : item.imgPath) || ""; + // workaround for svgs since the server doesnt return mime + if (imgPath.trimEnd().toLowerCase().endsWith(".svg")) { + try { + let imgBytes = await (await fetch(imgPath)).arrayBuffer(); + let imgBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(imgBytes))); + imgPath = "data:image/svg+xml;base64," + imgBase64; + } + catch (e) { + // nothing + } + } + + entryElementImg.src = imgPath; + } + + if ((asyncInfo && asyncInfo.result.options) || item.options) { + const entryElementMoreOptionsButton = document.createElement("div"); + entryElementMoreOptionsButton.classList.add("entry"); + entryElementMoreOptionsButton.classList.add("entry-more-button"); + entryElementMoreOptionsButton.innerHTML = "•••"; + + entryElementMoreOptionsButton.onclick = async () => { + if (!entryElement.classList.contains("selected")) { + smoothScrollToElementIndex(ii, true); + return; + } + let options = null; + if (asyncInfo && asyncInfo.result.options) { + options = asyncInfo.result.options; + } + else if (item.options) { + options = item.options; + } + // @ts-ignore + await Globals.Router.handleModal(options); + } + + entryElement.appendChild(entryElementMoreOptionsButton); + } + + + entryElementMainButton.onclick = async () => { + if (!entryElement.classList.contains("selected")) { + smoothScrollToElementIndex(ii, true); + return; + } + + entryElementMainButton.classList.add("loading-overlay"); + entryElementMainButton.classList.add("loading"); + await sleep(0); // TODO: Didnt test if its needed here, but i think if the asyncInfo.result.onclick isnt async we might need this to trigger a rerender, or ideally something better + let onclickResult = null; + if (asyncInfo && asyncInfo.result.onclick) { + onclickResult = await asyncInfo.result.onclick(); + } + else if (item.onclick) { + onclickResult = await item.onclick(); + } + else { + // TODO: invalid item, remove + } + + let res = onclickResult; + let logStream = null; + + if (res && res.path) { + logStream = await ApiClient.launchApp(res.path, res.args, res.env, res.cwd); + res = logStream != null; + } + + if (res == true) { + entryElement.style.transform = "scale(2)"; + setTimeout(() => { + entryElement.style.removeProperty("transform"); + }, 300); + Globals.Router.handleLaunchedAppView(logStream); + } + + entryElementMainButton.classList.remove("loading-overlay"); + entryElementMainButton.classList.remove("loading"); + } + + }); + + } + + smoothScrollToElementIndex(0, false); + + // await all and reset cursor snap overlays every time a task completes + await Promise.all(asyncInfoPromisesList.map(asyncInfoPromise => asyncInfoPromise().then(() => { + if (document.getElementById("modal-content")) { + return; + } + generateCursorSnapOverlays(); + }))); +} + + +function removeAllCursorSnapOverlays() { + const content = document.getElementById("content"); + if (!content) { + return; + } + const overlays = content.getElementsByClassName("home-cursor-snap-overlay"); + while (overlays.length > 0) { + overlays[0].remove(); + } +} + +function generateCursorSnapOverlays(entryWrapperIndex = -1) { + // create overlay for cursor snap + // remove existing overlays + removeAllCursorSnapOverlays(); + let content = document.getElementById("content"); + if (!content) { + return; + } + if (entryWrapperIndex === -1) { + // set to index of entry-wrapper with selected class + let wrappers = content.getElementsByClassName("entry-wrapper"); + for (let i = 0; i < wrappers.length; i++) { + if (wrappers[i].classList.contains("selected")) { + entryWrapperIndex = i; + break; + } + } + } + + const entry = content.getElementsByClassName("entry-wrapper")[entryWrapperIndex]; + + if (!entry) { + return; + } + + // for each entry in entry-wrapper make a cursor snap overlay + for (let i = 0; i < entry.children.length; i++) { + const entryChild = entry.children[i]; + const overlay = document.createElement("a"); + overlay.classList.add("home-cursor-snap-overlay"); + overlay.style.position = "fixed"; + + // calculate the offset from the top of the viewport + + const entryChildRect = entryChild.getBoundingClientRect(); + let entryChildBottom = entryChildRect.top + entryChildRect.height; + let entryChildHeight = entryChildRect.height; + let topOffset = 0; + + // this method has not added the selected class yet so if theres a selected class on the wrapper + // thats because the element was initialized with it, in that case the element is already 1.2x scaled + // but otherwise the bounding box is the 1.0x size here bc the animation has not run yet + // even if we add the selected class the bounding box scales with the animation so its useless until its finished + if (!entry.classList.contains("selected")) { + // we gotta calculate the transforms + const transformScale = 1.2; + const parentRect = entry.getBoundingClientRect(); + const parentCenter = parentRect.top + parentRect.height / 2; + const entryTopRelativeToParentCenter = entryChildRect.top - parentCenter; + const entryBottomRelativeToParentCenter = entryChildRect.bottom - parentCenter; + const entryTransformedTop = parentCenter + (entryTopRelativeToParentCenter * transformScale); + const entryTransformedBottom = parentCenter + (entryBottomRelativeToParentCenter * transformScale); + const entryTransformedHeight = entryTransformedBottom - entryTransformedTop; + entryChildHeight = entryTransformedHeight; + entryChildBottom = entryTransformedBottom; + } + + if (entryChild.classList.contains("entry")) { + // main big button + // snap cursor to the lower 3/8 of the button + topOffset = entryChildBottom - entryChildHeight * (2 / 5); + } else { + // snap to center + topOffset = entryChildBottom - (entryChildHeight / 2); + } + + overlay.style.top = topOffset + "px"; + overlay.style.left = "0"; + overlay.style.right = "0"; + overlay.style.height = "1px"; + // allow clicks through + overlay.style.pointerEvents = "none"; + // snap cursor + overlay.tabIndex = 0; + content.appendChild(overlay); + } +} + + +/** + * Only for home page carousel, also sets selected class and creates the cursor snap points + */ +function smoothScrollToElementIndex(index, smooth = true) { + const content = document.getElementById("content"); + if (!content) { + return; + } + const carousel = content.getElementsByClassName("carousel")[0]; + if (!carousel){ + return; + } + const entries = carousel.getElementsByClassName("entry-wrapper"); + + if (index < 0 || index >= entries.length) { + return; + } + + const entry = entries[index]; + + generateCursorSnapOverlays(index); + + for (let t_entry of entries) { + t_entry.classList.remove("selected"); + } + + entry.classList.add("selected"); + + // webkit doesnt do smooth scrolling and smoothscroll.js cant center the item so we have to do it manually + const itemRect = entry.getBoundingClientRect(); + const containerRect = content.getBoundingClientRect(); + const targetScrollLeft = itemRect.left - containerRect.left + content.scrollLeft - (containerRect.width - itemRect.width) / 2; + + if (targetScrollLeft < 1 && targetScrollLeft > -1) { + return; + } + + if (smooth) { + carousel.scrollBy({ left: targetScrollLeft, behavior: "smooth" }); + carousel.classList.add("scrolling"); + if (Globals.removeScrollingClassFromCarouselTimeoutId) { + clearTimeout(Globals.removeScrollingClassFromCarouselTimeoutId); + } + Globals.removeScrollingClassFromCarouselTimeoutId = setTimeout(() => { + carousel.classList.remove("scrolling"); + }, 468); // 468 the constant length of the scrolling with smoothscroll.js + } else { + carousel.scrollLeft = targetScrollLeft; + } +} + +/** + * Returns null if the current view isnt a carouselView + * @returns { {selectedIndex: number?, totalEntries: number}? } + */ +function getCurrentCarouselSelectionInfo() { + const content = /** @type {HTMLElement} */ (document.getElementById("content")); + const carousel = content.getElementsByClassName("carousel")[0]; + if (!carousel) { + return null; + } + + const entries = carousel.getElementsByClassName("entry-wrapper"); + let currentElementIndex = Array.from(entries).findIndex(entry => entry.classList.contains("selected")); + return { + selectedIndex: currentElementIndex, + totalEntries: entries.length + }; +} diff --git a/assets/extensionsHandler.js b/assets/extensionsHandler.js index ae3ec3a..f4b4077 100644 --- a/assets/extensionsHandler.js +++ b/assets/extensionsHandler.js @@ -1,247 +1,247 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -// @ts-check - -const EXTENSION_SANDBOX_EXECUTE_ACTION = "EXTENSION_SANDBOX_EXECUTE_ACTION"; - -const EXTENSION_SANDBOX_EXECUTE_RESPONSE = "EXTENSION_SANDBOX_EXECUTE_RESPONSE"; -// @ts-ignore (demo hb.js is in a diff context) -const EXTENSION_API_PARENT_SHOW_CAROUSEL = "EXTENSION_API_PARENT_SHOW_CAROUSEL"; -// @ts-ignore (demo hb.js is in a diff context) -const EXTENSION_API_PARENT_FILE_BROWSER = "EXTENSION_API_PARENT_FILE_BROWSER"; - -const EXTENSION_MAIN_TIMEOUT_MS = 5000; - -/** - * @typedef {Object} ExtensionInfo - * @property {string} uuid - reference the sandbox iframe by this id - * @property {CarouselItem} result - */ - -/** - * @typedef {Object} ExtensionSandboxExecResult - * @property {string} uuid - * @property {boolean?} result - */ - - -/** - * @param {string} extensionPath - * @returns {Promise} extensionId and result of main(), null if main() timed out, if null sandbox is deleted - */ -async function loadJsExtension(extensionPath) { - // load js as raw text - let hbJs = await ApiClient.fsGetFileText(extensionPath); - if (!hbJs) { - // throw new Error("Failed to load extension: " + extensionPath); - removeFromHomebrewList(extensionPath); - await Globals.Router.handleHome(); - return null; - } - - if (!Globals.hbApiJs) { - let request2 = await fetch("homebrewApi.js"); - if (!request2.ok) { - throw new Error("Failed to load extension api js"); - } - Globals.hbApiJs = await request2.text(); - } - - if (!Globals.ApiClientJs) { - let request3 = await fetch("apiClient.js"); - if (!request3.ok) { - throw new Error("Failed to load api client js for extension"); - } - Globals.ApiClientJs = await request3.text(); - } - - // create element to hold iframes if there isnt one already - let sandbox = document.getElementById("js-extension-sandbox"); - - if (!sandbox) { - sandbox = document.createElement("div"); - sandbox.style.display = "none"; - sandbox.id = "js-extension-sandbox"; - document.body.appendChild(sandbox); - } - - let extensionId = uuidv4(); - - const iframeContent = ` - - - - - - - - - - - - - `; - - const blob = new Blob([iframeContent], { type: "text/html" }); - const url = URL.createObjectURL(blob); - - const iframe = document.createElement("iframe"); - iframe.id = extensionId; - // @ts-ignore - iframe.sandbox = "allow-scripts allow-modals"; - iframe.style.display = "none"; - - let result = new Promise((resolve, reject) => { - let timeout = null; - let handler = function(event) { - if (event.data.extensionId === extensionId) { - clearTimeout(timeout); - window.removeEventListener("message", handler); - resolve({ uuid: extensionId, result: remapFunctionIdsToSandboxedProxyCalls(JSON.parse(decodeURIComponent(event.data.result)),extensionId) }); - } - } - - timeout = setTimeout(() => { - window.removeEventListener("message", handler); - sandbox.removeChild(iframe); - resolve(null); - }, EXTENSION_MAIN_TIMEOUT_MS); - - window.addEventListener("message", handler); - }); - - iframe.src = url; - - sandbox.appendChild(iframe); - - return result; -} - -/** - * @param {string} extensionId - * @param {string} funcId - * @returns {Promise?} - */ -function executeInExtensionSandbox(extensionId, funcId) { - const iframe = /** @type {HTMLIFrameElement} */ (document.getElementById(extensionId)); - if (!iframe || !iframe.contentWindow) { - throw new Error("extension sandbox iframe not found"); - } - - let resultId = uuidv4(); - - iframe.contentWindow.postMessage({ action: EXTENSION_SANDBOX_EXECUTE_ACTION, funcId: funcId, resultId: resultId }, "*"); - - return new Promise((resolve, reject) => { - window.addEventListener("message", function handler(event) { - if (event.data.extensionId === extensionId && event.data.action === EXTENSION_SANDBOX_EXECUTE_RESPONSE && event.data.resultId === resultId) { - window.removeEventListener("message", handler); - let decodedResult = decodeURIComponent(event.data.result); - if (decodedResult == "null" || decodedResult == "undefined") { - resolve({ uuid: extensionId, result: null }); - return; - } - resolve({ uuid: extensionId, result: JSON.parse(decodedResult) }); - } - }); - }); -} - -/** Reverse what remapFunctionsToFunctionIds of the hb api does */ -function remapFunctionIdsToSandboxedProxyCalls(obj, extensionId) { - if (typeof obj !== "object" || obj === null) { - return obj; - } - - // if array, iterate over its elements recursively - if (Array.isArray(obj)) { - return obj.map(item => remapFunctionIdsToSandboxedProxyCalls(item, extensionId)); - } - - return Object.keys(obj).reduce((acc, key) => { - if (key === "onclick") { - acc[key] = async () => { - let res = await executeInExtensionSandbox(extensionId, obj[key]); - if (res) { - return res.result; - } - return null; - } - } else if (typeof obj[key] === "object" && obj[key] !== null) { - // Recursively serialize nested objects - acc[key] = remapFunctionIdsToSandboxedProxyCalls(obj[key], extensionId); - } else { - acc[key] = obj[key]; - } - return acc; - }, {}); -} - - -/** - * Only call once at load - */ -function registerExtensionMessagesListener() { - window.addEventListener("message", async function handler(event) { - // check if message has an extension uuid and extension is loaded - if (!event.data.extensionId || !this.document.getElementById(event.data.extensionId)) { - return; - } - - if (event.data.action === EXTENSION_API_PARENT_FILE_BROWSER) { - let sandbox = /** @type {HTMLIFrameElement?} */ (document.getElementById(event.data.extensionId)); - if (!sandbox || !sandbox.contentWindow) { - return; - } - let result = await Globals.Router.pickFile(decodeURIComponent(event.data.initialPath), decodeURIComponent(event.data.title)); - - sandbox.contentWindow.postMessage({ extensionId: event.data.extensionId, callback: event.data.callback, result: result }, "*"); - } else if (event.data.action === EXTENSION_API_PARENT_SHOW_CAROUSEL) { - let temp_items = JSON.parse(decodeURIComponent(event.data.items)); - // these items should already be almost the same as what the carousel expects - // the only difference is the the onclicks where replaced by function ids - /** - * @type {CarouselItem[]} - */ - let items = remapFunctionIdsToSandboxedProxyCalls(temp_items, event.data.extensionId); - await Globals.Router.handleCarousel(items); - } - }); -} +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +// @ts-check + +const EXTENSION_SANDBOX_EXECUTE_ACTION = "EXTENSION_SANDBOX_EXECUTE_ACTION"; + +const EXTENSION_SANDBOX_EXECUTE_RESPONSE = "EXTENSION_SANDBOX_EXECUTE_RESPONSE"; +// @ts-ignore (demo hb.js is in a diff context) +const EXTENSION_API_PARENT_SHOW_CAROUSEL = "EXTENSION_API_PARENT_SHOW_CAROUSEL"; +// @ts-ignore (demo hb.js is in a diff context) +const EXTENSION_API_PARENT_FILE_BROWSER = "EXTENSION_API_PARENT_FILE_BROWSER"; + +const EXTENSION_MAIN_TIMEOUT_MS = 5000; + +/** + * @typedef {Object} ExtensionInfo + * @property {string} uuid - reference the sandbox iframe by this id + * @property {CarouselItem} result + */ + +/** + * @typedef {Object} ExtensionSandboxExecResult + * @property {string} uuid + * @property {boolean?} result + */ + + +/** + * @param {string} extensionPath + * @returns {Promise} extensionId and result of main(), null if main() timed out, if null sandbox is deleted + */ +async function loadJsExtension(extensionPath) { + // load js as raw text + let hbJs = await ApiClient.fsGetFileText(extensionPath); + if (!hbJs) { + // throw new Error("Failed to load extension: " + extensionPath); + removeFromHomebrewList(extensionPath); + await Globals.Router.handleHome(); + return null; + } + + if (!Globals.hbApiJs) { + let request2 = await fetch("homebrewApi.js"); + if (!request2.ok) { + throw new Error("Failed to load extension api js"); + } + Globals.hbApiJs = await request2.text(); + } + + if (!Globals.ApiClientJs) { + let request3 = await fetch("apiClient.js"); + if (!request3.ok) { + throw new Error("Failed to load api client js for extension"); + } + Globals.ApiClientJs = await request3.text(); + } + + // create element to hold iframes if there isnt one already + let sandbox = document.getElementById("js-extension-sandbox"); + + if (!sandbox) { + sandbox = document.createElement("div"); + sandbox.style.display = "none"; + sandbox.id = "js-extension-sandbox"; + document.body.appendChild(sandbox); + } + + let extensionId = uuidv4(); + + const iframeContent = ` + + + + + + + + + + + + + `; + + const blob = new Blob([iframeContent], { type: "text/html" }); + const url = URL.createObjectURL(blob); + + const iframe = document.createElement("iframe"); + iframe.id = extensionId; + // @ts-ignore + iframe.sandbox = "allow-scripts allow-modals"; + iframe.style.display = "none"; + + let result = new Promise((resolve, reject) => { + let timeout = null; + let handler = function(event) { + if (event.data.extensionId === extensionId) { + clearTimeout(timeout); + window.removeEventListener("message", handler); + resolve({ uuid: extensionId, result: remapFunctionIdsToSandboxedProxyCalls(JSON.parse(decodeURIComponent(event.data.result)),extensionId) }); + } + } + + timeout = setTimeout(() => { + window.removeEventListener("message", handler); + sandbox.removeChild(iframe); + resolve(null); + }, EXTENSION_MAIN_TIMEOUT_MS); + + window.addEventListener("message", handler); + }); + + iframe.src = url; + + sandbox.appendChild(iframe); + + return result; +} + +/** + * @param {string} extensionId + * @param {string} funcId + * @returns {Promise?} + */ +function executeInExtensionSandbox(extensionId, funcId) { + const iframe = /** @type {HTMLIFrameElement} */ (document.getElementById(extensionId)); + if (!iframe || !iframe.contentWindow) { + throw new Error("extension sandbox iframe not found"); + } + + let resultId = uuidv4(); + + iframe.contentWindow.postMessage({ action: EXTENSION_SANDBOX_EXECUTE_ACTION, funcId: funcId, resultId: resultId }, "*"); + + return new Promise((resolve, reject) => { + window.addEventListener("message", function handler(event) { + if (event.data.extensionId === extensionId && event.data.action === EXTENSION_SANDBOX_EXECUTE_RESPONSE && event.data.resultId === resultId) { + window.removeEventListener("message", handler); + let decodedResult = decodeURIComponent(event.data.result); + if (decodedResult == "null" || decodedResult == "undefined") { + resolve({ uuid: extensionId, result: null }); + return; + } + resolve({ uuid: extensionId, result: JSON.parse(decodedResult) }); + } + }); + }); +} + +/** Reverse what remapFunctionsToFunctionIds of the hb api does */ +function remapFunctionIdsToSandboxedProxyCalls(obj, extensionId) { + if (typeof obj !== "object" || obj === null) { + return obj; + } + + // if array, iterate over its elements recursively + if (Array.isArray(obj)) { + return obj.map(item => remapFunctionIdsToSandboxedProxyCalls(item, extensionId)); + } + + return Object.keys(obj).reduce((acc, key) => { + if (key === "onclick") { + acc[key] = async () => { + let res = await executeInExtensionSandbox(extensionId, obj[key]); + if (res) { + return res.result; + } + return null; + } + } else if (typeof obj[key] === "object" && obj[key] !== null) { + // Recursively serialize nested objects + acc[key] = remapFunctionIdsToSandboxedProxyCalls(obj[key], extensionId); + } else { + acc[key] = obj[key]; + } + return acc; + }, {}); +} + + +/** + * Only call once at load + */ +function registerExtensionMessagesListener() { + window.addEventListener("message", async function handler(event) { + // check if message has an extension uuid and extension is loaded + if (!event.data.extensionId || !this.document.getElementById(event.data.extensionId)) { + return; + } + + if (event.data.action === EXTENSION_API_PARENT_FILE_BROWSER) { + let sandbox = /** @type {HTMLIFrameElement?} */ (document.getElementById(event.data.extensionId)); + if (!sandbox || !sandbox.contentWindow) { + return; + } + let result = await Globals.Router.pickFile(decodeURIComponent(event.data.initialPath), decodeURIComponent(event.data.title)); + + sandbox.contentWindow.postMessage({ extensionId: event.data.extensionId, callback: event.data.callback, result: result }, "*"); + } else if (event.data.action === EXTENSION_API_PARENT_SHOW_CAROUSEL) { + let temp_items = JSON.parse(decodeURIComponent(event.data.items)); + // these items should already be almost the same as what the carousel expects + // the only difference is the the onclicks where replaced by function ids + /** + * @type {CarouselItem[]} + */ + let items = remapFunctionIdsToSandboxedProxyCalls(temp_items, event.data.extensionId); + await Globals.Router.handleCarousel(items); + } + }); +} diff --git a/assets/filePickerView.js b/assets/filePickerView.js index 2afa7c2..2fce738 100644 --- a/assets/filePickerView.js +++ b/assets/filePickerView.js @@ -1,134 +1,134 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -// @ts-check - -/** - * @param {string} path - * @returns {Promise<{path: string, finished: boolean}>} - */ -async function renderBrowsePage(path, fadein = false, title = 'Select file...') { - /** @type {Promise<{path: string, finished: boolean}>[]} */ - let result = []; - - let data = null; - - if (!path.endsWith("/")) { - path += "/"; - } - - data = await ApiClient.fsListDir(path); - - if (!data) { - throw new Error("Failed to load directory: " + path); - } - - // reset content - const content = /** @type {HTMLElement} */ (document.getElementById("content")); - content.innerHTML = ""; - - // create list div - const list = document.createElement("div"); - list.classList.add("list"); - - let titleElement = document.createElement("div"); - titleElement.classList.add("list-title"); - titleElement.innerText = title; - list.appendChild(titleElement); - - // add back button if needed - if (path !== "/") { - let entryElement = document.createElement("a"); - entryElement.classList.add("list-entry"); - - entryElement.tabIndex = 0; - - result.push(new Promise((resolve, reject) => { - entryElement.onclick = () => { - let backPath = path.substring(0, path.lastIndexOf("/")); - backPath = backPath.substring(0, backPath.lastIndexOf("/") + 1); - resolve({ path: backPath, finished: false }); - } - })); - - - const backIcon = document.createElement("div"); - backIcon.classList.add("list-entry-icon"); - backIcon.classList.add("back-icon"); - - entryElement.appendChild(backIcon); - - const nameElement = document.createElement("p"); - nameElement.classList.add("list-entry-name"); - nameElement.innerText = ".."; - entryElement.appendChild(nameElement); - - list.appendChild(entryElement); - } - - - // add entries - data.forEach(entry => { - let entryElement = document.createElement("a"); - entryElement.classList.add("list-entry"); - if (entry.isDir()) { - entryElement.classList.add("list-entry-directory"); - // add dir icon - const dirIcon = document.createElement("div"); - dirIcon.classList.add("list-entry-icon"); - dirIcon.classList.add("dir-icon"); - - entryElement.appendChild(dirIcon); - } else { - entryElement.classList.add("list-entry-file"); - // add file icon - const fileIcon = document.createElement("div"); - fileIcon.classList.add("list-entry-icon"); - fileIcon.classList.add("file-icon"); - - entryElement.appendChild(fileIcon); - } - - // make the cursor snap - entryElement.tabIndex = 0; - - const nameElement = document.createElement("p"); - nameElement.classList.add("list-entry-name"); - nameElement.innerText = entry.name; - entryElement.appendChild(nameElement); - - result.push(new Promise((resolve, reject) => { - entryElement.onclick = () => { - if (entry.isDir()) { - resolve({ path: path + entry.name + "/", finished: false }); - } else { - resolve({ path: path + entry.name, finished: true }); - } - } - })); - - list.appendChild(entryElement); - }); - - if (fadein) { - content.classList.add("fadein"); - } - - content.appendChild(list); - - return Promise.race(result); +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +// @ts-check + +/** + * @param {string} path + * @returns {Promise<{path: string, finished: boolean}>} + */ +async function renderBrowsePage(path, fadein = false, title = 'Select file...') { + /** @type {Promise<{path: string, finished: boolean}>[]} */ + let result = []; + + let data = null; + + if (!path.endsWith("/")) { + path += "/"; + } + + data = await ApiClient.fsListDir(path); + + if (!data) { + throw new Error("Failed to load directory: " + path); + } + + // reset content + const content = /** @type {HTMLElement} */ (document.getElementById("content")); + content.innerHTML = ""; + + // create list div + const list = document.createElement("div"); + list.classList.add("list"); + + let titleElement = document.createElement("div"); + titleElement.classList.add("list-title"); + titleElement.innerText = title; + list.appendChild(titleElement); + + // add back button if needed + if (path !== "/") { + let entryElement = document.createElement("a"); + entryElement.classList.add("list-entry"); + + entryElement.tabIndex = 0; + + result.push(new Promise((resolve, reject) => { + entryElement.onclick = () => { + let backPath = path.substring(0, path.lastIndexOf("/")); + backPath = backPath.substring(0, backPath.lastIndexOf("/") + 1); + resolve({ path: backPath, finished: false }); + } + })); + + + const backIcon = document.createElement("div"); + backIcon.classList.add("list-entry-icon"); + backIcon.classList.add("back-icon"); + + entryElement.appendChild(backIcon); + + const nameElement = document.createElement("p"); + nameElement.classList.add("list-entry-name"); + nameElement.innerText = ".."; + entryElement.appendChild(nameElement); + + list.appendChild(entryElement); + } + + + // add entries + data.forEach(entry => { + let entryElement = document.createElement("a"); + entryElement.classList.add("list-entry"); + if (entry.isDir()) { + entryElement.classList.add("list-entry-directory"); + // add dir icon + const dirIcon = document.createElement("div"); + dirIcon.classList.add("list-entry-icon"); + dirIcon.classList.add("dir-icon"); + + entryElement.appendChild(dirIcon); + } else { + entryElement.classList.add("list-entry-file"); + // add file icon + const fileIcon = document.createElement("div"); + fileIcon.classList.add("list-entry-icon"); + fileIcon.classList.add("file-icon"); + + entryElement.appendChild(fileIcon); + } + + // make the cursor snap + entryElement.tabIndex = 0; + + const nameElement = document.createElement("p"); + nameElement.classList.add("list-entry-name"); + nameElement.innerText = entry.name; + entryElement.appendChild(nameElement); + + result.push(new Promise((resolve, reject) => { + entryElement.onclick = () => { + if (entry.isDir()) { + resolve({ path: path + entry.name + "/", finished: false }); + } else { + resolve({ path: path + entry.name, finished: true }); + } + } + })); + + list.appendChild(entryElement); + }); + + if (fadein) { + content.classList.add("fadein"); + } + + content.appendChild(list); + + return Promise.race(result); } \ No newline at end of file diff --git a/assets/global.js b/assets/global.js index 5911887..ccc14bf 100644 --- a/assets/global.js +++ b/assets/global.js @@ -1,37 +1,37 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -// @ts-check - -/** - * @typedef {Object} Globals - * @property {Router} Router - * @property {string?} hbApiJs - * @property {string?} ApiClientJs - * @property {number?} removeScrollingClassFromCarouselTimeoutId - * @property {number} lastCarouselLeftRightKeyDownTimestamp - */ - -/** @type {Globals} */ -const Globals = { - // @ts-ignore - Router: null, - hbApiJs: null, - ApiClientJs: null, - removeScrollingClassFromCarouselTimeoutId: null, - lastCarouselLeftRightKeyDownTimestamp: 0, -}; +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +// @ts-check + +/** + * @typedef {Object} Globals + * @property {Router} Router + * @property {string?} hbApiJs + * @property {string?} ApiClientJs + * @property {number?} removeScrollingClassFromCarouselTimeoutId + * @property {number} lastCarouselLeftRightKeyDownTimestamp + */ + +/** @type {Globals} */ +const Globals = { + // @ts-ignore + Router: null, + hbApiJs: null, + ApiClientJs: null, + removeScrollingClassFromCarouselTimeoutId: null, + lastCarouselLeftRightKeyDownTimestamp: 0, +}; diff --git a/assets/homebrewApi.js b/assets/homebrewApi.js index 2103c85..fad8a49 100644 --- a/assets/homebrewApi.js +++ b/assets/homebrewApi.js @@ -1,81 +1,81 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -// the code here is what gets injected before the extension.js -// these are things the extension can use to interact with the frontend - -const EXTENSION_API_PARENT_SHOW_CAROUSEL = "EXTENSION_API_PARENT_SHOW_CAROUSEL"; -const EXTENSION_API_PARENT_FILE_BROWSER = "EXTENSION_API_PARENT_FILE_BROWSER"; - -// https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid -function uuidv4() { - return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => - (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16) - ); -} - -function remapFunctionsToFunctionIds(obj) { - if (typeof obj !== "object" || obj === null) { - return obj; - } - - // if array, iterate over its elements recursively - if (Array.isArray(obj)) { - return obj.map(item => remapFunctionsToFunctionIds(item)); - } - - return Object.keys(obj).reduce((acc, key) => { - // return a uuid instead of the function and save the the function here in the sandbox so its state is saved and we can easily call it - if (typeof obj[key] === "function") { - let uuid = uuidv4(); - if (!window.Funcs) { - window.Funcs = {}; - } - window.Funcs[uuid] = obj[key]; - acc[key] = uuid; - } else if (typeof obj[key] === "object" && obj[key] !== null) { - // Recursively serialize nested objects - acc[key] = remapFunctionsToFunctionIds(obj[key]); - } else { - acc[key] = obj[key]; - } - return acc; - }, {}); -} - -// public functions below, apiClient.js also will be injected - -function showCarousel(items) { - window.parent.postMessage({ extensionId: window.extensionId, action: EXTENSION_API_PARENT_SHOW_CAROUSEL, items: encodeURIComponent(JSON.stringify(remapFunctionsToFunctionIds(items))) }, "*"); -} - -function pickFile(initialPath, title = 'Select file...') { - const uuid = uuidv4(); - window.parent.postMessage({ extensionId: window.extensionId, action: EXTENSION_API_PARENT_FILE_BROWSER, callback: uuid, initialPath: encodeURIComponent(initialPath), title: encodeURIComponent(title)}, "*"); - - // wait for response - return new Promise((resolve, reject) => { - window.addEventListener("message", function handler(event) { - if (event.data.callback !== uuid) { - return; - } - - window.removeEventListener("message", handler); - resolve(event.data.result); - }); - }); -} +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +// the code here is what gets injected before the extension.js +// these are things the extension can use to interact with the frontend + +const EXTENSION_API_PARENT_SHOW_CAROUSEL = "EXTENSION_API_PARENT_SHOW_CAROUSEL"; +const EXTENSION_API_PARENT_FILE_BROWSER = "EXTENSION_API_PARENT_FILE_BROWSER"; + +// https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => + (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16) + ); +} + +function remapFunctionsToFunctionIds(obj) { + if (typeof obj !== "object" || obj === null) { + return obj; + } + + // if array, iterate over its elements recursively + if (Array.isArray(obj)) { + return obj.map(item => remapFunctionsToFunctionIds(item)); + } + + return Object.keys(obj).reduce((acc, key) => { + // return a uuid instead of the function and save the the function here in the sandbox so its state is saved and we can easily call it + if (typeof obj[key] === "function") { + let uuid = uuidv4(); + if (!window.Funcs) { + window.Funcs = {}; + } + window.Funcs[uuid] = obj[key]; + acc[key] = uuid; + } else if (typeof obj[key] === "object" && obj[key] !== null) { + // Recursively serialize nested objects + acc[key] = remapFunctionsToFunctionIds(obj[key]); + } else { + acc[key] = obj[key]; + } + return acc; + }, {}); +} + +// public functions below, apiClient.js also will be injected + +function showCarousel(items) { + window.parent.postMessage({ extensionId: window.extensionId, action: EXTENSION_API_PARENT_SHOW_CAROUSEL, items: encodeURIComponent(JSON.stringify(remapFunctionsToFunctionIds(items))) }, "*"); +} + +function pickFile(initialPath, title = 'Select file...') { + const uuid = uuidv4(); + window.parent.postMessage({ extensionId: window.extensionId, action: EXTENSION_API_PARENT_FILE_BROWSER, callback: uuid, initialPath: encodeURIComponent(initialPath), title: encodeURIComponent(title)}, "*"); + + // wait for response + return new Promise((resolve, reject) => { + window.addEventListener("message", function handler(event) { + if (event.data.callback !== uuid) { + return; + } + + window.removeEventListener("message", handler); + resolve(event.data.result); + }); + }); +} diff --git a/assets/homebrewStore.js b/assets/homebrewStore.js index 285c1bf..57c6a42 100644 --- a/assets/homebrewStore.js +++ b/assets/homebrewStore.js @@ -1,160 +1,160 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -// @ts-check -const HOMEBREW_AUTO_SCAN_PATHS = ["/data/homebrew/"]; -// order of this matters, if both homebrew.js and eboot.elf exist, homebrew.js will be used -const HOMEBREW_AUTO_SCAN_ALLOWED_EXEC_NAMES = [ "homebrew.js", "eboot.elf" ]; - -const LOCALSTORE_HOMEBREW_LIST_KEY = "LOCALSTORE_HOMEBREW_LIST"; - -/** - * @typedef {Object} HomebrewEntry - * @property {string} path - * @property {string} dir - * @property {string} filename - */ - -/** - * If item already exists it puts the item at the start - * @param {string} path - Full absolute path with filename - */ -function addToHomebrewStore(path) { - let hbList = []; - let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY); - if (temp_hblistStr) { - hbList = JSON.parse(temp_hblistStr); - // if theres already an entry with the same path, remove it - hbList = hbList.filter(entry => entry !== path); - } - - // Add to start - hbList.unshift( - path - ); - - localStorage.setItem(LOCALSTORE_HOMEBREW_LIST_KEY, JSON.stringify(hbList)); -} - -/** - * @param {string} path - Full absolute path with filename - */ -function removeFromHomebrewList(path) { - let hbList = []; - - let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY); - if (!temp_hblistStr) { - return; - } - - hbList = JSON.parse(temp_hblistStr); - - // if theres already an entry with the same path, remove it - hbList = hbList.filter(entry => entry !== path); - - localStorage.setItem(LOCALSTORE_HOMEBREW_LIST_KEY, JSON.stringify(hbList)); -} - -/** - * @returns {HomebrewEntry[]} - */ -function getHomebrewList() { - let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY); - if (!temp_hblistStr) { - return []; - } - - return JSON.parse(temp_hblistStr).reduce((acc, entry) => { - acc.push({ - path: entry, - dir: entry.substring(0, entry.lastIndexOf("/")), - filename: entry.substring(entry.lastIndexOf("/") + 1) - }); - return acc; - }, []).sort((x, y) => { - return x.dir.localeCompare(y.dir); - }); -} - -// TODO: this is currently unused -async function removeNonexistentHomebrews() { - let hbs = getHomebrewList(); - - // group by path to avoid making a request for every entry in case theres multiple in the same folder - let pathGroups = {}; - - hbs.forEach(entry => { - if (!pathGroups[entry.dir]) { - pathGroups[entry.dir] = []; - } - pathGroups[entry.dir].push(entry); - }); - - // loop though groups - Object.keys(pathGroups).forEach(async (key) => { - let json = await ApiClient.fsListDir(key); - - pathGroups[key].forEach(entry => { - if (json === null) { - removeFromHomebrewList(entry.path); - return; - } - - json.forEach(jsonDirent => { - if (jsonDirent.name === entry.filename) { - return; - } - }); - - removeFromHomebrewList(entry.path); - }); - }); -} - -async function scanHomebrews() { - for (let path of HOMEBREW_AUTO_SCAN_PATHS) { - let autoScanParentDirEntryList = await ApiClient.fsListDir(path); - if (autoScanParentDirEntryList === null) { - continue; - } - - for (let entry of autoScanParentDirEntryList) { - let hbDirEntries = await ApiClient.fsListDir(path + entry.name); - if (hbDirEntries === null) { - continue; - } - - let foundExecutableName = null; - for (let hbDirEntry of hbDirEntries) { - for (let allowedName of HOMEBREW_AUTO_SCAN_ALLOWED_EXEC_NAMES) { - if (hbDirEntry.name.toLowerCase() === allowedName.toLowerCase()) { - foundExecutableName = allowedName; - break; - } - } - - if (foundExecutableName) { - break; - } - } - - if (foundExecutableName) { - addToHomebrewStore(path + entry.name + "/" + foundExecutableName); - } - } - } -} +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +// @ts-check +const HOMEBREW_AUTO_SCAN_PATHS = ["/data/homebrew/"]; +// order of this matters, if both homebrew.js and eboot.elf exist, homebrew.js will be used +const HOMEBREW_AUTO_SCAN_ALLOWED_EXEC_NAMES = [ "homebrew.js", "eboot.elf" ]; + +const LOCALSTORE_HOMEBREW_LIST_KEY = "LOCALSTORE_HOMEBREW_LIST"; + +/** + * @typedef {Object} HomebrewEntry + * @property {string} path + * @property {string} dir + * @property {string} filename + */ + +/** + * If item already exists it puts the item at the start + * @param {string} path - Full absolute path with filename + */ +function addToHomebrewStore(path) { + let hbList = []; + let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY); + if (temp_hblistStr) { + hbList = JSON.parse(temp_hblistStr); + // if theres already an entry with the same path, remove it + hbList = hbList.filter(entry => entry !== path); + } + + // Add to start + hbList.unshift( + path + ); + + localStorage.setItem(LOCALSTORE_HOMEBREW_LIST_KEY, JSON.stringify(hbList)); +} + +/** + * @param {string} path - Full absolute path with filename + */ +function removeFromHomebrewList(path) { + let hbList = []; + + let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY); + if (!temp_hblistStr) { + return; + } + + hbList = JSON.parse(temp_hblistStr); + + // if theres already an entry with the same path, remove it + hbList = hbList.filter(entry => entry !== path); + + localStorage.setItem(LOCALSTORE_HOMEBREW_LIST_KEY, JSON.stringify(hbList)); +} + +/** + * @returns {HomebrewEntry[]} + */ +function getHomebrewList() { + let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY); + if (!temp_hblistStr) { + return []; + } + + return JSON.parse(temp_hblistStr).reduce((acc, entry) => { + acc.push({ + path: entry, + dir: entry.substring(0, entry.lastIndexOf("/")), + filename: entry.substring(entry.lastIndexOf("/") + 1) + }); + return acc; + }, []).sort((x, y) => { + return x.dir.localeCompare(y.dir); + }); +} + +// TODO: this is currently unused +async function removeNonexistentHomebrews() { + let hbs = getHomebrewList(); + + // group by path to avoid making a request for every entry in case theres multiple in the same folder + let pathGroups = {}; + + hbs.forEach(entry => { + if (!pathGroups[entry.dir]) { + pathGroups[entry.dir] = []; + } + pathGroups[entry.dir].push(entry); + }); + + // loop though groups + Object.keys(pathGroups).forEach(async (key) => { + let json = await ApiClient.fsListDir(key); + + pathGroups[key].forEach(entry => { + if (json === null) { + removeFromHomebrewList(entry.path); + return; + } + + json.forEach(jsonDirent => { + if (jsonDirent.name === entry.filename) { + return; + } + }); + + removeFromHomebrewList(entry.path); + }); + }); +} + +async function scanHomebrews() { + for (let path of HOMEBREW_AUTO_SCAN_PATHS) { + let autoScanParentDirEntryList = await ApiClient.fsListDir(path); + if (autoScanParentDirEntryList === null) { + continue; + } + + for (let entry of autoScanParentDirEntryList) { + let hbDirEntries = await ApiClient.fsListDir(path + entry.name); + if (hbDirEntries === null) { + continue; + } + + let foundExecutableName = null; + for (let hbDirEntry of hbDirEntries) { + for (let allowedName of HOMEBREW_AUTO_SCAN_ALLOWED_EXEC_NAMES) { + if (hbDirEntry.name.toLowerCase() === allowedName.toLowerCase()) { + foundExecutableName = allowedName; + break; + } + } + + if (foundExecutableName) { + break; + } + } + + if (foundExecutableName) { + addToHomebrewStore(path + entry.name + "/" + foundExecutableName); + } + } + } +} diff --git a/assets/index.html b/assets/index.html index 3df2f28..96e6343 100644 --- a/assets/index.html +++ b/assets/index.html @@ -1,52 +1,52 @@ - - - - - - - - - - Homebrew Launcher - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + Homebrew Launcher + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/main.css b/assets/main.css index b44e9cb..758a04a 100644 --- a/assets/main.css +++ b/assets/main.css @@ -1,452 +1,452 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - -@keyframes fadein { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -@keyframes fadeout { - 0% { - opacity: 1; - } - - 100% { - opacity: 0; - } -} - -@keyframes zoomin { - 0% { - transform: scale(0.8); - } - - 100% { - transform: scale(1); - } -} - -@keyframes spinner { - to { - transform: rotate(360deg); - } -} - - -:root { - font-family: "Arial", sans-serif; - --text-color: #fff; - --secondary-text-color: #bbb; - color: var(--text-color); - --background-color: #0b0d0f; - --surface-color: #1f2225; - --surface-color-hover: #3b4047; - --modal-background-color: #181a1c; - --file-picker-back-icon-color: var(--text-color); - --file-picker-file-icon-color: var(--text-color); - --file-picker-directory-icon-color: #f9e87c; -} - -*:focus { - outline: none; -} - -body { - background-color: var(--background-color); - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - font-size: 20px; - margin: 0; -} - -.header { - text-align: center; - margin-top: 2rem; -} - -.header * { - margin: 0; - padding: 0; -} - -div.separator { - margin-top: 2rem; - border-bottom: 1px solid var(--text-color); -} - -div.carousel { - padding: 40px; - height: 100vh; - display: flex; - flex-direction: row; - width: 100%; - box-sizing: border-box; - overflow-x: hidden; - overflow-y: hidden; - align-items: center; -} - -/* add some padding to be able scroll the first/last item in the carousel to the center */ -div.carousel::after, -div.carousel::before { - position: relative; - content: ""; - flex: none; - width: 50vw; - height: 1px; -} - -div.carousel.scrolling div.entry-wrapper.selected div.entry-main { - background-color: var(--surface-color-hover); -} - -div.entry-wrapper.selected>div:hover { - background-color: var(--surface-color-hover); -} - -div.entry-wrapper.selected:not(:hover) div.entry-main { - background-color: var(--surface-color-hover); -} - -div.entry-wrapper:not(.selected) div.entry { - background-color: var(--surface-color) !important; -} - - -div.entry-wrapper { - flex: none; - height: 55vh; - width: 50vh; - margin-right: 8vh; - transition: transform 0.3s; - box-sizing: border-box; - position: relative; -} - -div.entry-wrapper.selected { - /* some js is tied to this for the cursor snapping to work so if you change this also change that */ - transform: scale(1.2); -} - -div.entry { - box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); - background-color: var(--surface-color); - border-radius: 1vh; - transition: background-color 0.3s; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - box-sizing: border-box; - height: calc(100% - 50px); - cursor: pointer; - padding: 1rem; - text-wrap: pretty; - overflow-wrap: break-word; - text-align: center; - overflow: hidden; - position: relative; -} - -div.entry p { - padding: 0; - margin: 5px; - max-width: 100%; -} - -.entry img { - display: block; - max-width: 100%; - max-height: 30vh; - object-fit: contain; -} - -div.entry-more-button { - display: flex; - justify-content: center; - align-items: center; - height: 7vh; - text-align: center; - margin-top: 0.5em; - background-color: var(--surface-color); - cursor: pointer; - transition: background-color 0.3s; -} - -.text-secondary { - font-size: 0.85em; - color: var(--secondary-text-color); -} - -.entry-name { - font-weight: 700; -} - -div.list { - margin: 0 10vh 0 10vh; - display: flex; - flex-direction: column; - align-items: stretch; -} - -div.list-title { - padding: 0.5em; - margin-top: 1em; - font-weight: bold; - font-size: larger; - text-align: center; -} - -a.list-entry { - display: flex; - flex-direction: row; - align-items: center; - padding: 0.5em; - background-color: var(--surface-color); - margin-top: 1em; - border-radius: 6px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); - cursor: pointer; - transition: background-color 0.3s; -} - -a.list-entry:hover { - background-color: var(--surface-color-hover); -} - -div.list-entry-icon { - min-height: 2rem; - min-width: 2rem; - margin-left: 1rem; - margin-right: 1rem; - mask-size: contain; - -webkit-mask-size: contain; -} - -.entry-main{ - position: relative; -} - -.entry-img{ - margin-bottom: 10vh; -} - -.entry-main img[style*="display: block"]~p:nth-child(2) { - position: absolute; - bottom: 7vh; -} - -.entry-main img[style*="display: block"]~p:nth-child(3) { - position: absolute; - bottom: 3vh; -} - -/* div.dir-icon { - background-color: var(--file-picker-directory-icon-color); - mask: url("googlefonts_folder.svg") no-repeat center; - -webkit-mask: url("googlefonts_folder.svg") no-repeat center; - mask-size:contain; - -webkit-mask-size:contain; -} - -div.file-icon { - background-color: var(--file-picker-file-icon-color); - mask: url("googlefonts_file.svg") no-repeat center; - -webkit-mask: url("googlefonts_file.svg") no-repeat center; - mask-size:contain; - -webkit-mask-size:contain; -} - -div.back-icon { - background-color: var(--file-picker-back-icon-color); - mask: url("googlefonts_arrow_back.svg") no-repeat center; - -webkit-mask: url("googlefonts_arrow_back.svg") no-repeat center; - mask-size:contain; - -webkit-mask-size:contain; -} */ - -div.dir-icon { - /* from google fonts */ - background-color: var(--file-picker-directory-icon-color); - mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4IiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJNMTYwLTE2MHEtMzMgMC01Ni41LTIzLjVUODAtMjQwdi00ODBxMC0zMyAyMy41LTU2LjVUMTYwLTgwMGgyMDdxMTYgMCAzMC41IDZ0MjUuNSAxN2w1NyA1N2gzMjBxMzMgMCA1Ni41IDIzLjVUODgwLTY0MHY0MDBxMCAzMy0yMy41IDU2LjVUODAwLTE2MEgxNjBabTAtODBoNjQwdi00MDBINDQ3bC04MC04MEgxNjB2NDgwWm0wIDB2LTQ4MCA0ODBaIi8+PC9zdmc+') no-repeat center; - -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4IiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJNMTYwLTE2MHEtMzMgMC01Ni41LTIzLjVUODAtMjQwdi00ODBxMC0zMyAyMy41LTU2LjVUMTYwLTgwMGgyMDdxMTYgMCAzMC41IDZ0MjUuNSAxN2w1NyA1N2gzMjBxMzMgMCA1Ni41IDIzLjVUODgwLTY0MHY0MDBxMCAzMy0yMy41IDU2LjVUODAwLTE2MEgxNjBabTAtODBoNjQwdi00MDBINDQ3bC04MC04MEgxNjB2NDgwWm0wIDB2LTQ4MCA0ODBaIi8+PC9zdmc+') no-repeat center; - mask-size: contain; - -webkit-mask-size: contain; -} - -div.file-icon { - /* from google fonts */ - background-color: var(--file-picker-file-icon-color); - mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4Ij48cGF0aCBkPSJNMjQwLTgwcS0zMyAwLTU2LjUtMjMuNVQxNjAtMTYwdi02NDBxMC0zMyAyMy41LTU2LjVUMjQwLTg4MGgyODdxMTYgMCAzMC41IDZ0MjUuNSAxN2wxOTQgMTk0cTExIDExIDE3IDI1LjV0NiAzMC41djQ0N3EwIDMzLTIzLjUgNTYuNVQ3MjAtODBIMjQwWm0yODAtNTYwdi0xNjBIMjQwdjY0MGg0ODB2LTQ0MEg1NjBxLTE3IDAtMjguNS0xMS41VDUyMC02NDBaTTI0MC04MDB2MjAwLTIwMCA2NDAtNjQwWiIvPjwvc3ZnPg==') no-repeat center; - -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4Ij48cGF0aCBkPSJNMjQwLTgwcS0zMyAwLTU2LjUtMjMuNVQxNjAtMTYwdi02NDBxMC0zMyAyMy41LTU2LjVUMjQwLTg4MGgyODdxMTYgMCAzMC41IDZ0MjUuNSAxN2wxOTQgMTk0cTExIDExIDE3IDI1LjV0NiAzMC41djQ0N3EwIDMzLTIzLjUgNTYuNVQ3MjAtODBIMjQwWm0yODAtNTYwdi0xNjBIMjQwdjY0MGg0ODB2LTQ0MEg1NjBxLTE3IDAtMjguNS0xMS41VDUyMC02NDBaTTI0MC04MDB2MjAwLTIwMCA2NDAtNjQwWiIvPjwvc3ZnPg==') no-repeat center; - mask-size: contain; - -webkit-mask-size: contain; -} - -div.back-icon { - /* from google fonts */ - background-color: var(--file-picker-back-icon-color); - mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJtMzEzLTQ0MCAxOTYgMTk2cTEyIDEyIDExLjUgMjhUNTA4LTE4OHEtMTIgMTEtMjggMTEuNVQ0NTItMTg4TDE4OC00NTJxLTYtNi04LjUtMTN0LTIuNS0xNXEwLTggMi41LTE1dDguNS0xM2wyNjQtMjY0cTExLTExIDI3LjUtMTF0MjguNSAxMXExMiAxMiAxMiAyOC41VDUwOC03MTVMMzEzLTUyMGg0NDdxMTcgMCAyOC41IDExLjVUODAwLTQ4MHEwIDE3LTExLjUgMjguNVQ3NjAtNDQwSDMxM1oiLz48L3N2Zz4=') no-repeat center; - -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJtMzEzLTQ0MCAxOTYgMTk2cTEyIDEyIDExLjUgMjhUNTA4LTE4OHEtMTIgMTEtMjggMTEuNVQ0NTItMTg4TDE4OC00NTJxLTYtNi04LjUtMTN0LTIuNS0xNXEwLTggMi41LTE1dDguNS0xM2wyNjQtMjY0cTExLTExIDI3LjUtMTF0MjguNSAxMXExMiAxMiAxMiAyOC41VDUwOC03MTVMMzEzLTUyMGg0NDdxMTcgMCAyOC41IDExLjVUODAwLTQ4MHEwIDE3LTExLjUgMjguNVQ3NjAtNDQwSDMxM1oiLz48L3N2Zz4=') no-repeat center; - mask-size: contain; - -webkit-mask-size: contain; -} - -span.ps-triangle-icon { - /* https://commons.wikimedia.org/wiki/File:PlayStation_Portable_T_button.svg */ - background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwMiAyMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjAuMDAyIDIwIiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGc+Cgk8Zz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwLjAwMSIgeTE9IjAiIHgyPSIxMC4wMDEiIHkyPSIxNy45MDA4Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAzMjYiIHN0eWxlPSJzdG9wLWNvbG9yOiM1NzU4NUMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NTYiIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMyMzQiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43OTMyIiBzdHlsZT0ic3RvcC1jb2xvcjojMUMxQjFDIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxjaXJjbGUgZmlsbD0idXJsKCNTVkdJRF8xXykiIGN4PSIxMC4wMDEiIGN5PSIxMCIgcj0iMTAuMDAxIi8+CgkJPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8yXyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHgxPSIxMC4wMDEiIHkxPSIwIiB4Mj0iMTAuMDAxIiB5Mj0iMjAuMDAwNSI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QTVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wMTk3IiBzdHlsZT0ic3RvcC1jb2xvcjojNTY1NzVBIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMTUyNCIgc3R5bGU9InN0b3AtY29sb3I6IzNFM0U0MCIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjI5ODkiIHN0eWxlPSJzdG9wLWNvbG9yOiMyQjJBMkMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NjQ3IiBzdHlsZT0ic3RvcC1jb2xvcjojMUUxQzFFIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuNjY1NyIgc3R5bGU9InN0b3AtY29sb3I6IzE2MTQxNiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxMjEzIi8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8cGF0aCBmaWxsPSJ1cmwoI1NWR0lEXzJfKSIgZD0iTTEwLjAwMiwxLjZjNC42MzEsMCw4LjQsMy43NjgsOC40LDguNGMwLDQuNjMyLTMuNzcsOC40LTguNCw4LjRDNS4zNjksMTguNCwxLjYsMTQuNjMyLDEuNiwxMAoJCQlDMS42LDUuMzY4LDUuMzY5LDEuNiwxMC4wMDIsMS42IE0xMC4wMDIsMEM0LjQ3OCwwLDAsNC40NzcsMCwxMHM0LjQ3OCwxMCwxMC4wMDIsMTBjNS41MjEsMCwxMC00LjQ3OCwxMC0xMFMxNS41MjMsMCwxMC4wMDIsMAoJCQlMMTAuMDAyLDB6Ii8+Cgk8L2c+Cgk8cGF0aCBmaWxsPSIjRURFQ0VCIiBkPSJNMTAuMDAxLDcuNTg3bDEuMjY5LDIuMTk3bDEuMjcsMi4xOThoLTIuNTM4SDcuNDY0bDEuMjY5LTIuMTk4TDEwLjAwMSw3LjU4NyBNMTAuMDAxLDUuMjQ4TDcuNzIsOS4yCgkJbC0yLjI4MiwzLjk1M2g0LjU2M2g0LjU2M0wxMi4yODIsOS4yTDEwLjAwMSw1LjI0OEwxMC4wMDEsNS4yNDh6Ii8+CjwvZz4KPC9zdmc+Cg=='); - display: inline-block; - height: 2rem; - width: 2rem; - margin: 0.5rem; -} - -span.ps-circle-icon { - /* https://commons.wikimedia.org/wiki/File:PlayStation_Portable_C_button.svg */ - background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwMSAyMC4wMDEiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDIwLjAwMSAyMC4wMDEiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxnPgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMV8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMTAiIHkxPSIwIiB4Mj0iMTAiIHkyPSIxNy45MDE2Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAzMjYiIHN0eWxlPSJzdG9wLWNvbG9yOiM1NzU4NUMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NTYiIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMyMzQiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43OTMyIiBzdHlsZT0ic3RvcC1jb2xvcjojMUMxQjFDIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxjaXJjbGUgZmlsbD0idXJsKCNTVkdJRF8xXykiIGN4PSIxMCIgY3k9IjEwIiByPSIxMCIvPgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMl8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMTAiIHkxPSIwIiB4Mj0iMTAiIHkyPSIyMC4wMDE1Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAxOTciIHN0eWxlPSJzdG9wLWNvbG9yOiM1NjU3NUEiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4xNTI0IiBzdHlsZT0ic3RvcC1jb2xvcjojM0UzRTQwIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMjk4OSIgc3R5bGU9InN0b3AtY29sb3I6IzJCMkEyQyIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjQ2NDciIHN0eWxlPSJzdG9wLWNvbG9yOiMxRTFDMUUiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC42NjU3IiBzdHlsZT0ic3RvcC1jb2xvcjojMTYxNDE2Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMl8pIiBkPSJNMTAsMS42YzQuNjMzLDAsOC40LDMuNzY5LDguNCw4LjQwMWMwLDQuNjMxLTMuNzY4LDguNC04LjQsOC40Yy00LjYzMSwwLTguNC0zLjc3LTguNC04LjQKCQkJQzEuNiw1LjM2OCw1LjM2OSwxLjYsMTAsMS42IE0xMCwwQzQuNDc3LDAsMCw0LjQ3OCwwLDEwYzAsNS41MjMsNC40NzcsMTAuMDAxLDEwLDEwLjAwMVMyMCwxNS41MjQsMjAsMTBDMjAsNC40NzgsMTUuNTIzLDAsMTAsMAoJCQlMMTAsMHoiLz4KCTwvZz4KCTxwYXRoIGZpbGw9IiNFREVDRUIiIGQ9Ik0xMCw2LjgyMWMxLjc1NCwwLDMuMTc4LDEuNDI2LDMuMTc4LDMuMTc5YzAsMS43NTItMS40MjQsMy4xNzgtMy4xNzgsMy4xNzgKCQljLTEuNzUyLDAtMy4xNzgtMS40MjYtMy4xNzgtMy4xNzhDNi44MjIsOC4yNDcsOC4yNDgsNi44MjEsMTAsNi44MjEgTTEwLDUuNTc5Yy0yLjQ0MSwwLTQuNDIsMS45NzktNC40Miw0LjQyMQoJCWMwLDIuNDQxLDEuOTc5LDQuNDIsNC40Miw0LjQyczQuNDIyLTEuOTc5LDQuNDIyLTQuNDJDMTQuNDIyLDcuNTU5LDEyLjQ0MSw1LjU3OSwxMCw1LjU3OUwxMCw1LjU3OXoiLz4KPC9nPgo8L3N2Zz4K'); - display: inline-block; - height: 2rem; - width: 2rem; - margin: 0.5rem; -} - -span.ps-icon { - /* https://commons.wikimedia.org/wiki/File:PlayStation_button_Home.svg */ - background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwNyAyMC4wMDYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDIwLjAwNyAyMC4wMDYiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCQoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMV8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iOTYuMDAwNSIgeTE9IjM1NS45OTU2IiB4Mj0iMTE2LjAwNjgiIHkyPSIzNTUuOTk1NiIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgwIDEgLTEgMCAzNjUuOTk5NSAtOTYuMDAwNSkiPgoJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxNDE0Ii8+Cgk8L2xpbmVhckdyYWRpZW50PgoJPGNpcmNsZSBmaWxsPSJ1cmwoI1NWR0lEXzFfKSIgY3g9IjEwLjAwMyIgY3k9IjEwLjAwMyIgcj0iMTAuMDAzIi8+Cgk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzJfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwLjAwMzQiIHkxPSIwIiB4Mj0iMTAuMDAzNCIgeTI9IjIwLjAwNjMiPgoJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjAyMzEiIHN0eWxlPSJzdG9wLWNvbG9yOiM1OTU5NUQiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjQ1MDciIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMzMzUiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjc5MTIiIHN0eWxlPSJzdG9wLWNvbG9yOiMxQzFDMUQiLz4KCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxNDE0Ii8+Cgk8L2xpbmVhckdyYWRpZW50PgoJPHBhdGggZmlsbD0idXJsKCNTVkdJRF8yXykiIGQ9Ik0xMC4wMDMsMS4yOTFjNC44MDQsMCw4LjcxMywzLjkwOSw4LjcxMyw4LjcxM3MtMy45MDksOC43MTItOC43MTMsOC43MTIKCQljLTQuODAzLDAtOC43MTItMy45MDktOC43MTItOC43MTJTNS4yLDEuMjkxLDEwLjAwMywxLjI5MSBNMTAuMDAzLDBDNC40NzksMCwwLDQuNDc5LDAsMTAuMDAzYzAsNS41MjQsNC40NzksMTAuMDAzLDEwLjAwMywxMC4wMDMKCQljNS41MjUsMCwxMC4wMDQtNC40NzksMTAuMDA0LTEwLjAwM0MyMC4wMDcsNC40NzksMTUuNTI4LDAsMTAuMDAzLDBMMTAuMDAzLDB6Ii8+CjwvZz4KPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTEyLjUyNSwxMC43NjNjMC40MiwwLDAuNzcyLTAuMTcsMS4wMTgtMC40OTFjMC4yNjYtMC4zNDcsMC40MDYtMC44NjcsMC40MDYtMS41MDQKCWMwLTEuODQzLTAuNjI1LTIuNjQ4LTIuNTczLTMuMzE2Yy0wLjYxNy0wLjIwOS0yLjAwNS0wLjY0LTMuMDQ4LTAuODUxdjEwLjEyNmwyLjA1OCwwLjY1M1Y2Ljg0OGMwLTAuNTI4LDAuMjk2LTAuODU1LDAuNjczLTAuNzMKCWMwLjU2NCwwLjE1NiwwLjU2NCwwLjc5NSwwLjU2NCwxLjAzNnYzLjM5NEMxMS45NDMsMTAuNjksMTIuMjQ2LDEwLjc2MywxMi41MjUsMTAuNzYzeiIvPgo8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTYuOTkzLDEyLjg3M2MwLjA4OS0wLjExMiwwLjExNy0wLjIyOCwwLjA4Mi0wLjM0M2MtMC4wNDMtMC4xNDEtMC4yMjctMC40MTMtMC45NjUtMC42NDcKCWMtMC45MTgtMC4zNDgtMi4xNzctMC40OTQtMy4yMDgtMC4zODNjLTAuOTk1LDAuMTEtMS43MTYsMC4zNjItMS43MjQsMC4zNjVsLTAuMDQ1LDAuMDE1djEuMDk2bDIuMzI1LTAuODE5CgljMC40NDYtMC4xNiwxLjEzNi0wLjE5NiwxLjUwOS0wLjA3NGMwLjI2OCwwLjA4OCwwLjMyNiwwLjIyOSwwLjMyNywwLjMzMmMwLjAwMSwwLjEyMy0wLjA3OSwwLjI5OC0wLjQ3LDAuNDM4bC0zLjY5MSwxLjMxNnYxLjA0NQoJbDQuOTUtMS43NzdDMTYuMDg5LDEzLjQzNSwxNi43NCwxMy4xOTIsMTYuOTkzLDEyLjg3M3oiLz4KPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTcuNTcxLDEwLjkzbC0zLjE4LDEuMTNjLTAuMDExLDAuMDA0LTAuODAzLDAuMjY3LTEuMjYsMC42MDVjLTAuMTYyLDAuMTItMC4yMzUsMC4yNjgtMC4yMDgsMC40MTcKCWMwLjA1MSwwLjI3OCwwLjQxNSwwLjUzNywwLjk3MiwwLjY5NGMxLjE2NSwwLjM4NSwyLjM4NywwLjQ4MywzLjU2OCwwLjI4OWwwLjEwNy0wLjAxOHYtMC44ODRsLTAuOTY1LDAuMzUKCWMtMC40NDYsMC4xNi0xLjEzNywwLjE5Ni0xLjUxLDAuMDc0Yy0wLjI2OC0wLjA4OC0wLjMyNi0wLjIyOS0wLjMyNy0wLjMzMmMtMC4wMDItMC4xMjMsMC4wNzktMC4yOTgsMC40NzEtMC40MzlsMi4zMzItMC44MzVWMTAuOTMKCXoiLz4KPC9zdmc+Cg=='); - display: inline-block; - height: 2rem; - width: 2rem; - margin: 0.5rem; -} - -span.ps-r2-icon { - /* https://commons.wikimedia.org/wiki/File:PlayStation_button_R2.svg */ - background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwIDE0Ljk0OSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjAgMTQuOTQ5IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGc+Cgk8Zz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwIiB5MT0iMCIgeDI9IjEwIiB5Mj0iMTMuMzc5NCI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wNjI2IiBzdHlsZT0ic3RvcC1jb2xvcjojNTM1MzU2Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMzAxOCIgc3R5bGU9InN0b3AtY29sb3I6IzM3MzczOSIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjUzOTciIHN0eWxlPSJzdG9wLWNvbG9yOiMyNDI0MjUiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43NzM5IiBzdHlsZT0ic3RvcC1jb2xvcjojMTgxODE4Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDE0MTQiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMV8pIiBkPSJNMjAsMTAuNzQ2YzAsNS42MDQtMjAsNS42MDQtMjAsMFYyLjU2M0MwLDEuMTQ3LDEuMTQ2LDAsMi41NjIsMGgxNC44NzYKCQkJQzE4Ljg1NCwwLDIwLDEuMTQ3LDIwLDIuNTYzVjEwLjc0NnoiLz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzJfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwIiB5MT0iMCIgeDI9IjEwIiB5Mj0iMTQuOTQ5MiI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wMTU1IiBzdHlsZT0ic3RvcC1jb2xvcjojNTg1ODVCIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMTQ4OCIgc3R5bGU9InN0b3AtY29sb3I6IzNGM0Y0MSIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjI5NiIgc3R5bGU9InN0b3AtY29sb3I6IzJDMkMyRCIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjQ2MjQiIHN0eWxlPSJzdG9wLWNvbG9yOiMxRTFFMUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC42NjQzIiBzdHlsZT0ic3RvcC1jb2xvcjojMTYxNjE3Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDE0MTQiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMl8pIiBkPSJNMTcuNDM4LDEuNTEyYzAuNTgsMCwxLjA1MSwwLjQ3MSwxLjA1MSwxLjA1MXY4LjE4NGMwLDAuNTg2LTAuODAxLDEuMjM1LTIuMTQzLDEuNzM2CgkJCWMtMS42MjUsMC42MDctMy45MzgsMC45NTUtNi4zNDYsMC45NTVzLTQuNzIyLTAuMzQ4LTYuMzQ2LTAuOTU1Yy0xLjM0Mi0wLjUwMS0yLjE0My0xLjE1LTIuMTQzLTEuNzM2VjIuNTYzCgkJCWMwLTAuNTgsMC40NzEtMS4wNTEsMS4wNS0xLjA1MUgxNy40MzggTTE3LjQzOCwwSDIuNTYyQzEuMTQ2LDAsMCwxLjE0NywwLDIuNTYzdjguMTg0YzAsMi44MDIsNSw0LjIwMywxMCw0LjIwMwoJCQlzMTAtMS40MDEsMTAtNC4yMDNWMi41NjNDMjAsMS4xNDcsMTguODU0LDAsMTcuNDM4LDBMMTcuNDM4LDB6Ii8+Cgk8L2c+Cgk8Zz4KCQk8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTAuMDgyLDYuMDhjMC4yMDEtMC4zMjYsMC4zMDMtMC43MDUsMC4zMDMtMS4xMjZjMC0wLjMxNy0wLjA1Ny0wLjYxOC0wLjE3LTAuODk1CgkJCWMtMC4xMTMtMC4yNzgtMC4yNzktMC41MjEtMC40OTEtMC43MjJDOS41MTMsMy4xMzksOS4yNTksMi45OCw4Ljk2OCwyLjg2OEM4LjY3OCwyLjc1Nyw4LjM1NCwyLjcsOC4wMDIsMi43SDQuMTg4djcuNTE5aDEuODI5CgkJCVY3LjQyM2gxLjQwM2MwLjQ3MiwwLDAuNjcsMC4wOTMsMC43NTMsMC4xN2MwLjExNywwLjEwOSwwLjE3NywwLjMxMSwwLjE3NywwLjU5OXYyLjAyN2gxLjg0MVY4LjAwOAoJCQljMC0wLjQ1Ni0wLjA3NS0wLjc5Ni0wLjIyOS0xLjAzN0M5Ljg2OSw2LjgyNSw5Ljc0LDYuNzAyLDkuNTc1LDYuNjAzQzkuNzgxLDYuNDY5LDkuOTUxLDYuMjk0LDEwLjA4Miw2LjA4eiBNNi4wMTgsNC4zMTRoMS43MzYKCQkJYzAuMzE2LDAsMC41MzgsMC4wNjksMC42NjIsMC4yMDZDOC41NDUsNC42NjUsOC42MDksNC44NSw4LjYwOSw1LjA4M2MwLDAuMjI2LTAuMDY0LDAuNDA1LTAuMTk3LDAuNTUKCQkJQzguMjg0LDUuNzcxLDguMDQ1LDUuODQxLDcuNzAxLDUuODQxSDYuMDE4VjQuMzE0eiIvPgoJCTxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xMi43OTEsOC42MjZjMC4wNDMtMC4wOTksMC4xMTEtMC4yMDYsMC4yMDUtMC4zMjJjMC4xNzQtMC4yMiwwLjM5NS0wLjQyNywwLjY1LTAuNjE1CgkJCWMwLjM4MS0wLjI3MywwLjcxMS0wLjUyNiwwLjk3OS0wLjc1MWMwLjI3NS0wLjIzLDAuNTAxLTAuNDYyLDAuNjctMC42ODhjMC4xNzQtMC4yMywwLjMwMS0wLjQ3LDAuMzc5LTAuNzExCgkJCWMwLjA3Ny0wLjI0LDAuMTE2LTAuNTEsMC4xMTYtMC44MDFjMC0wLjM3Ni0wLjA2NS0wLjcxMi0wLjE5NC0wLjk5N2MtMC4xMy0wLjI4Ny0wLjMxNC0wLjUyOS0wLjU0OC0wLjcyCgkJCWMtMC4yMjktMC4xODgtMC41MDktMC4zMjktMC44My0wLjQyMmMtMC4zMTMtMC4wOTEtMC42NjItMC4xMzctMS4wMzYtMC4xMzdjLTAuNzksMC0xLjQxNCwwLjIzOS0xLjg1NCwwLjcxMQoJCQljLTAuNDM4LDAuNDY4LTAuNjU5LDEuMTIxLTAuNjU5LDEuOTQxdjAuMTg4aDEuNzIzVjUuMTE1YzAtMC40MiwwLjA3Ny0wLjcxNywwLjIyMy0wLjg2YzAuMTUxLTAuMTQ4LDAuMzU3LTAuMjIsMC42MzItMC4yMgoJCQljMC4xMiwwLDAuMjM2LDAuMDEzLDAuMzQ0LDAuMDM4YzAuMDk1LDAuMDIxLDAuMTc0LDAuMDYsMC4yNDIsMC4xMTNjMC4wNjQsMC4wNTMsMC4xMTUsMC4xMjMsMC4xNTMsMC4yMTUKCQkJYzAuMDQsMC4xLDAuMDYyLDAuMjI4LDAuMDYyLDAuMzhjMCwwLjEzLTAuMDIsMC4yNTEtMC4wNTUsMC4zNmMtMC4wMzcsMC4xMDctMC4wOTksMC4yMTktMC4xODYsMC4zMzEKCQkJYy0wLjA5MiwwLjExOC0wLjIxOSwwLjI0Ni0wLjM3NywwLjM4MWMtMC4xNjMsMC4xMzktMC4zNzQsMC4yOTgtMC42MjcsMC40NzVjLTAuNDY3LDAuMzM0LTAuODQ4LDAuNjUyLTEuMTM0LDAuOTQ2CgkJCWMtMC4yOSwwLjI5OC0wLjUxOSwwLjU5Ny0wLjY4MSwwLjg5Yy0wLjE2MywwLjI5Ni0wLjI3NCwwLjYwMS0wLjMzMiwwLjkwNmMtMC4wNTYsMC4yOTctMC4wODQsMC42Mi0wLjA4NCwwLjk2djAuMTg4aDUuMjM5VjguNjI2CgkJCUgxMi43OTF6Ii8+Cgk8L2c+CjwvZz4KPC9zdmc+Cg=='); - background-repeat: no-repeat; - display: inline-block; - height: 1.5rem; - width: 2rem; - margin: 0.5rem; -} - - -.launched-app-view { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -.launched-app-view div{ - display: flex; - align-items: center; -} - -.launched-app-view div.terminal { - display: block; - border: 0px solid var(--secondary-text-color); - border-radius: 2px; -} - -.launched-app-view div.terminal div { - display: block; -} - -.launched-app-view p { - text-align: center; -} - -.modal-overlay { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - z-index: 100000; - display: flex; - justify-content: center; - align-items: center; - animation: fadein 0.3s ease-in-out; - background-color: rgba(0, 0, 0, 0.7); - -webkit-backdrop-filter: blur(5px); - backdrop-filter: blur(5px); -} - -.modal-content { - background-color: var(--modal-background-color); - padding: 20px; - border-radius: 10px; - animation: zoomin 0.3s ease; - width: 70vh; - height: 70vh; -} - -.text-center { - text-align: center; - width: 100%; -} - -.fadeout { - animation: fadeout 0.25s ease !important; -} - -.fadein { - animation: fadein 0.25s ease; -} - -.loading-overlay::before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.5); - border-radius: inherit; - pointer-events: none; - animation: fadein 0.3s ease; -} - -/* https://stephanwagner.me/only-css-loading-spinner */ -.loading::after { - content: ""; - position: absolute; - top: 50%; - left: 50%; - width: 5vh; - height: 5vh; - margin-top: -2.5vh; - margin-left: -2.5vh; - border-radius: 50%; - border-top: 2px solid var(--text-color); - border-right: 2px solid transparent; - animation: spinner .6s linear infinite, fadein 0.3s ease; - pointer-events: none; +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + +@keyframes fadein { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes fadeout { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +@keyframes zoomin { + 0% { + transform: scale(0.8); + } + + 100% { + transform: scale(1); + } +} + +@keyframes spinner { + to { + transform: rotate(360deg); + } +} + + +:root { + font-family: "Arial", sans-serif; + --text-color: #fff; + --secondary-text-color: #bbb; + color: var(--text-color); + --background-color: #0b0d0f; + --surface-color: #1f2225; + --surface-color-hover: #3b4047; + --modal-background-color: #181a1c; + --file-picker-back-icon-color: var(--text-color); + --file-picker-file-icon-color: var(--text-color); + --file-picker-directory-icon-color: #f9e87c; +} + +*:focus { + outline: none; +} + +body { + background-color: var(--background-color); + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + font-size: 20px; + margin: 0; +} + +.header { + text-align: center; + margin-top: 2rem; +} + +.header * { + margin: 0; + padding: 0; +} + +div.separator { + margin-top: 2rem; + border-bottom: 1px solid var(--text-color); +} + +div.carousel { + padding: 40px; + height: 100vh; + display: flex; + flex-direction: row; + width: 100%; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: hidden; + align-items: center; +} + +/* add some padding to be able scroll the first/last item in the carousel to the center */ +div.carousel::after, +div.carousel::before { + position: relative; + content: ""; + flex: none; + width: 50vw; + height: 1px; +} + +div.carousel.scrolling div.entry-wrapper.selected div.entry-main { + background-color: var(--surface-color-hover); +} + +div.entry-wrapper.selected>div:hover { + background-color: var(--surface-color-hover); +} + +div.entry-wrapper.selected:not(:hover) div.entry-main { + background-color: var(--surface-color-hover); +} + +div.entry-wrapper:not(.selected) div.entry { + background-color: var(--surface-color) !important; +} + + +div.entry-wrapper { + flex: none; + height: 55vh; + width: 50vh; + margin-right: 8vh; + transition: transform 0.3s; + box-sizing: border-box; + position: relative; +} + +div.entry-wrapper.selected { + /* some js is tied to this for the cursor snapping to work so if you change this also change that */ + transform: scale(1.2); +} + +div.entry { + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + background-color: var(--surface-color); + border-radius: 1vh; + transition: background-color 0.3s; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: calc(100% - 50px); + cursor: pointer; + padding: 1rem; + text-wrap: pretty; + overflow-wrap: break-word; + text-align: center; + overflow: hidden; + position: relative; +} + +div.entry p { + padding: 0; + margin: 5px; + max-width: 100%; +} + +.entry img { + display: block; + max-width: 100%; + max-height: 30vh; + object-fit: contain; +} + +div.entry-more-button { + display: flex; + justify-content: center; + align-items: center; + height: 7vh; + text-align: center; + margin-top: 0.5em; + background-color: var(--surface-color); + cursor: pointer; + transition: background-color 0.3s; +} + +.text-secondary { + font-size: 0.85em; + color: var(--secondary-text-color); +} + +.entry-name { + font-weight: 700; +} + +div.list { + margin: 0 10vh 0 10vh; + display: flex; + flex-direction: column; + align-items: stretch; +} + +div.list-title { + padding: 0.5em; + margin-top: 1em; + font-weight: bold; + font-size: larger; + text-align: center; +} + +a.list-entry { + display: flex; + flex-direction: row; + align-items: center; + padding: 0.5em; + background-color: var(--surface-color); + margin-top: 1em; + border-radius: 6px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + cursor: pointer; + transition: background-color 0.3s; +} + +a.list-entry:hover { + background-color: var(--surface-color-hover); +} + +div.list-entry-icon { + min-height: 2rem; + min-width: 2rem; + margin-left: 1rem; + margin-right: 1rem; + mask-size: contain; + -webkit-mask-size: contain; +} + +.entry-main{ + position: relative; +} + +.entry-img{ + margin-bottom: 10vh; +} + +.entry-main img[style*="display: block"]~p:nth-child(2) { + position: absolute; + bottom: 7vh; +} + +.entry-main img[style*="display: block"]~p:nth-child(3) { + position: absolute; + bottom: 3vh; +} + +/* div.dir-icon { + background-color: var(--file-picker-directory-icon-color); + mask: url("googlefonts_folder.svg") no-repeat center; + -webkit-mask: url("googlefonts_folder.svg") no-repeat center; + mask-size:contain; + -webkit-mask-size:contain; +} + +div.file-icon { + background-color: var(--file-picker-file-icon-color); + mask: url("googlefonts_file.svg") no-repeat center; + -webkit-mask: url("googlefonts_file.svg") no-repeat center; + mask-size:contain; + -webkit-mask-size:contain; +} + +div.back-icon { + background-color: var(--file-picker-back-icon-color); + mask: url("googlefonts_arrow_back.svg") no-repeat center; + -webkit-mask: url("googlefonts_arrow_back.svg") no-repeat center; + mask-size:contain; + -webkit-mask-size:contain; +} */ + +div.dir-icon { + /* from google fonts */ + background-color: var(--file-picker-directory-icon-color); + mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4IiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJNMTYwLTE2MHEtMzMgMC01Ni41LTIzLjVUODAtMjQwdi00ODBxMC0zMyAyMy41LTU2LjVUMTYwLTgwMGgyMDdxMTYgMCAzMC41IDZ0MjUuNSAxN2w1NyA1N2gzMjBxMzMgMCA1Ni41IDIzLjVUODgwLTY0MHY0MDBxMCAzMy0yMy41IDU2LjVUODAwLTE2MEgxNjBabTAtODBoNjQwdi00MDBINDQ3bC04MC04MEgxNjB2NDgwWm0wIDB2LTQ4MCA0ODBaIi8+PC9zdmc+') no-repeat center; + -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4IiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJNMTYwLTE2MHEtMzMgMC01Ni41LTIzLjVUODAtMjQwdi00ODBxMC0zMyAyMy41LTU2LjVUMTYwLTgwMGgyMDdxMTYgMCAzMC41IDZ0MjUuNSAxN2w1NyA1N2gzMjBxMzMgMCA1Ni41IDIzLjVUODgwLTY0MHY0MDBxMCAzMy0yMy41IDU2LjVUODAwLTE2MEgxNjBabTAtODBoNjQwdi00MDBINDQ3bC04MC04MEgxNjB2NDgwWm0wIDB2LTQ4MCA0ODBaIi8+PC9zdmc+') no-repeat center; + mask-size: contain; + -webkit-mask-size: contain; +} + +div.file-icon { + /* from google fonts */ + background-color: var(--file-picker-file-icon-color); + mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4Ij48cGF0aCBkPSJNMjQwLTgwcS0zMyAwLTU2LjUtMjMuNVQxNjAtMTYwdi02NDBxMC0zMyAyMy41LTU2LjVUMjQwLTg4MGgyODdxMTYgMCAzMC41IDZ0MjUuNSAxN2wxOTQgMTk0cTExIDExIDE3IDI1LjV0NiAzMC41djQ0N3EwIDMzLTIzLjUgNTYuNVQ3MjAtODBIMjQwWm0yODAtNTYwdi0xNjBIMjQwdjY0MGg0ODB2LTQ0MEg1NjBxLTE3IDAtMjguNS0xMS41VDUyMC02NDBaTTI0MC04MDB2MjAwLTIwMCA2NDAtNjQwWiIvPjwvc3ZnPg==') no-repeat center; + -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4Ij48cGF0aCBkPSJNMjQwLTgwcS0zMyAwLTU2LjUtMjMuNVQxNjAtMTYwdi02NDBxMC0zMyAyMy41LTU2LjVUMjQwLTg4MGgyODdxMTYgMCAzMC41IDZ0MjUuNSAxN2wxOTQgMTk0cTExIDExIDE3IDI1LjV0NiAzMC41djQ0N3EwIDMzLTIzLjUgNTYuNVQ3MjAtODBIMjQwWm0yODAtNTYwdi0xNjBIMjQwdjY0MGg0ODB2LTQ0MEg1NjBxLTE3IDAtMjguNS0xMS41VDUyMC02NDBaTTI0MC04MDB2MjAwLTIwMCA2NDAtNjQwWiIvPjwvc3ZnPg==') no-repeat center; + mask-size: contain; + -webkit-mask-size: contain; +} + +div.back-icon { + /* from google fonts */ + background-color: var(--file-picker-back-icon-color); + mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJtMzEzLTQ0MCAxOTYgMTk2cTEyIDEyIDExLjUgMjhUNTA4LTE4OHEtMTIgMTEtMjggMTEuNVQ0NTItMTg4TDE4OC00NTJxLTYtNi04LjUtMTN0LTIuNS0xNXEwLTggMi41LTE1dDguNS0xM2wyNjQtMjY0cTExLTExIDI3LjUtMTF0MjguNSAxMXExMiAxMiAxMiAyOC41VDUwOC03MTVMMzEzLTUyMGg0NDdxMTcgMCAyOC41IDExLjVUODAwLTQ4MHEwIDE3LTExLjUgMjguNVQ3NjAtNDQwSDMxM1oiLz48L3N2Zz4=') no-repeat center; + -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJtMzEzLTQ0MCAxOTYgMTk2cTEyIDEyIDExLjUgMjhUNTA4LTE4OHEtMTIgMTEtMjggMTEuNVQ0NTItMTg4TDE4OC00NTJxLTYtNi04LjUtMTN0LTIuNS0xNXEwLTggMi41LTE1dDguNS0xM2wyNjQtMjY0cTExLTExIDI3LjUtMTF0MjguNSAxMXExMiAxMiAxMiAyOC41VDUwOC03MTVMMzEzLTUyMGg0NDdxMTcgMCAyOC41IDExLjVUODAwLTQ4MHEwIDE3LTExLjUgMjguNVQ3NjAtNDQwSDMxM1oiLz48L3N2Zz4=') no-repeat center; + mask-size: contain; + -webkit-mask-size: contain; +} + +span.ps-triangle-icon { + /* https://commons.wikimedia.org/wiki/File:PlayStation_Portable_T_button.svg */ + background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwMiAyMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjAuMDAyIDIwIiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGc+Cgk8Zz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwLjAwMSIgeTE9IjAiIHgyPSIxMC4wMDEiIHkyPSIxNy45MDA4Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAzMjYiIHN0eWxlPSJzdG9wLWNvbG9yOiM1NzU4NUMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NTYiIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMyMzQiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43OTMyIiBzdHlsZT0ic3RvcC1jb2xvcjojMUMxQjFDIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxjaXJjbGUgZmlsbD0idXJsKCNTVkdJRF8xXykiIGN4PSIxMC4wMDEiIGN5PSIxMCIgcj0iMTAuMDAxIi8+CgkJPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8yXyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHgxPSIxMC4wMDEiIHkxPSIwIiB4Mj0iMTAuMDAxIiB5Mj0iMjAuMDAwNSI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QTVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wMTk3IiBzdHlsZT0ic3RvcC1jb2xvcjojNTY1NzVBIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMTUyNCIgc3R5bGU9InN0b3AtY29sb3I6IzNFM0U0MCIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjI5ODkiIHN0eWxlPSJzdG9wLWNvbG9yOiMyQjJBMkMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NjQ3IiBzdHlsZT0ic3RvcC1jb2xvcjojMUUxQzFFIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuNjY1NyIgc3R5bGU9InN0b3AtY29sb3I6IzE2MTQxNiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxMjEzIi8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8cGF0aCBmaWxsPSJ1cmwoI1NWR0lEXzJfKSIgZD0iTTEwLjAwMiwxLjZjNC42MzEsMCw4LjQsMy43NjgsOC40LDguNGMwLDQuNjMyLTMuNzcsOC40LTguNCw4LjRDNS4zNjksMTguNCwxLjYsMTQuNjMyLDEuNiwxMAoJCQlDMS42LDUuMzY4LDUuMzY5LDEuNiwxMC4wMDIsMS42IE0xMC4wMDIsMEM0LjQ3OCwwLDAsNC40NzcsMCwxMHM0LjQ3OCwxMCwxMC4wMDIsMTBjNS41MjEsMCwxMC00LjQ3OCwxMC0xMFMxNS41MjMsMCwxMC4wMDIsMAoJCQlMMTAuMDAyLDB6Ii8+Cgk8L2c+Cgk8cGF0aCBmaWxsPSIjRURFQ0VCIiBkPSJNMTAuMDAxLDcuNTg3bDEuMjY5LDIuMTk3bDEuMjcsMi4xOThoLTIuNTM4SDcuNDY0bDEuMjY5LTIuMTk4TDEwLjAwMSw3LjU4NyBNMTAuMDAxLDUuMjQ4TDcuNzIsOS4yCgkJbC0yLjI4MiwzLjk1M2g0LjU2M2g0LjU2M0wxMi4yODIsOS4yTDEwLjAwMSw1LjI0OEwxMC4wMDEsNS4yNDh6Ii8+CjwvZz4KPC9zdmc+Cg=='); + display: inline-block; + height: 2rem; + width: 2rem; + margin: 0.5rem; +} + +span.ps-circle-icon { + /* https://commons.wikimedia.org/wiki/File:PlayStation_Portable_C_button.svg */ + background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwMSAyMC4wMDEiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDIwLjAwMSAyMC4wMDEiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxnPgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMV8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMTAiIHkxPSIwIiB4Mj0iMTAiIHkyPSIxNy45MDE2Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAzMjYiIHN0eWxlPSJzdG9wLWNvbG9yOiM1NzU4NUMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NTYiIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMyMzQiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43OTMyIiBzdHlsZT0ic3RvcC1jb2xvcjojMUMxQjFDIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxjaXJjbGUgZmlsbD0idXJsKCNTVkdJRF8xXykiIGN4PSIxMCIgY3k9IjEwIiByPSIxMCIvPgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMl8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMTAiIHkxPSIwIiB4Mj0iMTAiIHkyPSIyMC4wMDE1Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAxOTciIHN0eWxlPSJzdG9wLWNvbG9yOiM1NjU3NUEiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4xNTI0IiBzdHlsZT0ic3RvcC1jb2xvcjojM0UzRTQwIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMjk4OSIgc3R5bGU9InN0b3AtY29sb3I6IzJCMkEyQyIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjQ2NDciIHN0eWxlPSJzdG9wLWNvbG9yOiMxRTFDMUUiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC42NjU3IiBzdHlsZT0ic3RvcC1jb2xvcjojMTYxNDE2Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMl8pIiBkPSJNMTAsMS42YzQuNjMzLDAsOC40LDMuNzY5LDguNCw4LjQwMWMwLDQuNjMxLTMuNzY4LDguNC04LjQsOC40Yy00LjYzMSwwLTguNC0zLjc3LTguNC04LjQKCQkJQzEuNiw1LjM2OCw1LjM2OSwxLjYsMTAsMS42IE0xMCwwQzQuNDc3LDAsMCw0LjQ3OCwwLDEwYzAsNS41MjMsNC40NzcsMTAuMDAxLDEwLDEwLjAwMVMyMCwxNS41MjQsMjAsMTBDMjAsNC40NzgsMTUuNTIzLDAsMTAsMAoJCQlMMTAsMHoiLz4KCTwvZz4KCTxwYXRoIGZpbGw9IiNFREVDRUIiIGQ9Ik0xMCw2LjgyMWMxLjc1NCwwLDMuMTc4LDEuNDI2LDMuMTc4LDMuMTc5YzAsMS43NTItMS40MjQsMy4xNzgtMy4xNzgsMy4xNzgKCQljLTEuNzUyLDAtMy4xNzgtMS40MjYtMy4xNzgtMy4xNzhDNi44MjIsOC4yNDcsOC4yNDgsNi44MjEsMTAsNi44MjEgTTEwLDUuNTc5Yy0yLjQ0MSwwLTQuNDIsMS45NzktNC40Miw0LjQyMQoJCWMwLDIuNDQxLDEuOTc5LDQuNDIsNC40Miw0LjQyczQuNDIyLTEuOTc5LDQuNDIyLTQuNDJDMTQuNDIyLDcuNTU5LDEyLjQ0MSw1LjU3OSwxMCw1LjU3OUwxMCw1LjU3OXoiLz4KPC9nPgo8L3N2Zz4K'); + display: inline-block; + height: 2rem; + width: 2rem; + margin: 0.5rem; +} + +span.ps-icon { + /* https://commons.wikimedia.org/wiki/File:PlayStation_button_Home.svg */ + background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwNyAyMC4wMDYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDIwLjAwNyAyMC4wMDYiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCQoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMV8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iOTYuMDAwNSIgeTE9IjM1NS45OTU2IiB4Mj0iMTE2LjAwNjgiIHkyPSIzNTUuOTk1NiIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgwIDEgLTEgMCAzNjUuOTk5NSAtOTYuMDAwNSkiPgoJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxNDE0Ii8+Cgk8L2xpbmVhckdyYWRpZW50PgoJPGNpcmNsZSBmaWxsPSJ1cmwoI1NWR0lEXzFfKSIgY3g9IjEwLjAwMyIgY3k9IjEwLjAwMyIgcj0iMTAuMDAzIi8+Cgk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzJfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwLjAwMzQiIHkxPSIwIiB4Mj0iMTAuMDAzNCIgeTI9IjIwLjAwNjMiPgoJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjAyMzEiIHN0eWxlPSJzdG9wLWNvbG9yOiM1OTU5NUQiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjQ1MDciIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMzMzUiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjc5MTIiIHN0eWxlPSJzdG9wLWNvbG9yOiMxQzFDMUQiLz4KCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxNDE0Ii8+Cgk8L2xpbmVhckdyYWRpZW50PgoJPHBhdGggZmlsbD0idXJsKCNTVkdJRF8yXykiIGQ9Ik0xMC4wMDMsMS4yOTFjNC44MDQsMCw4LjcxMywzLjkwOSw4LjcxMyw4LjcxM3MtMy45MDksOC43MTItOC43MTMsOC43MTIKCQljLTQuODAzLDAtOC43MTItMy45MDktOC43MTItOC43MTJTNS4yLDEuMjkxLDEwLjAwMywxLjI5MSBNMTAuMDAzLDBDNC40NzksMCwwLDQuNDc5LDAsMTAuMDAzYzAsNS41MjQsNC40NzksMTAuMDAzLDEwLjAwMywxMC4wMDMKCQljNS41MjUsMCwxMC4wMDQtNC40NzksMTAuMDA0LTEwLjAwM0MyMC4wMDcsNC40NzksMTUuNTI4LDAsMTAuMDAzLDBMMTAuMDAzLDB6Ii8+CjwvZz4KPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTEyLjUyNSwxMC43NjNjMC40MiwwLDAuNzcyLTAuMTcsMS4wMTgtMC40OTFjMC4yNjYtMC4zNDcsMC40MDYtMC44NjcsMC40MDYtMS41MDQKCWMwLTEuODQzLTAuNjI1LTIuNjQ4LTIuNTczLTMuMzE2Yy0wLjYxNy0wLjIwOS0yLjAwNS0wLjY0LTMuMDQ4LTAuODUxdjEwLjEyNmwyLjA1OCwwLjY1M1Y2Ljg0OGMwLTAuNTI4LDAuMjk2LTAuODU1LDAuNjczLTAuNzMKCWMwLjU2NCwwLjE1NiwwLjU2NCwwLjc5NSwwLjU2NCwxLjAzNnYzLjM5NEMxMS45NDMsMTAuNjksMTIuMjQ2LDEwLjc2MywxMi41MjUsMTAuNzYzeiIvPgo8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTYuOTkzLDEyLjg3M2MwLjA4OS0wLjExMiwwLjExNy0wLjIyOCwwLjA4Mi0wLjM0M2MtMC4wNDMtMC4xNDEtMC4yMjctMC40MTMtMC45NjUtMC42NDcKCWMtMC45MTgtMC4zNDgtMi4xNzctMC40OTQtMy4yMDgtMC4zODNjLTAuOTk1LDAuMTEtMS43MTYsMC4zNjItMS43MjQsMC4zNjVsLTAuMDQ1LDAuMDE1djEuMDk2bDIuMzI1LTAuODE5CgljMC40NDYtMC4xNiwxLjEzNi0wLjE5NiwxLjUwOS0wLjA3NGMwLjI2OCwwLjA4OCwwLjMyNiwwLjIyOSwwLjMyNywwLjMzMmMwLjAwMSwwLjEyMy0wLjA3OSwwLjI5OC0wLjQ3LDAuNDM4bC0zLjY5MSwxLjMxNnYxLjA0NQoJbDQuOTUtMS43NzdDMTYuMDg5LDEzLjQzNSwxNi43NCwxMy4xOTIsMTYuOTkzLDEyLjg3M3oiLz4KPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTcuNTcxLDEwLjkzbC0zLjE4LDEuMTNjLTAuMDExLDAuMDA0LTAuODAzLDAuMjY3LTEuMjYsMC42MDVjLTAuMTYyLDAuMTItMC4yMzUsMC4yNjgtMC4yMDgsMC40MTcKCWMwLjA1MSwwLjI3OCwwLjQxNSwwLjUzNywwLjk3MiwwLjY5NGMxLjE2NSwwLjM4NSwyLjM4NywwLjQ4MywzLjU2OCwwLjI4OWwwLjEwNy0wLjAxOHYtMC44ODRsLTAuOTY1LDAuMzUKCWMtMC40NDYsMC4xNi0xLjEzNywwLjE5Ni0xLjUxLDAuMDc0Yy0wLjI2OC0wLjA4OC0wLjMyNi0wLjIyOS0wLjMyNy0wLjMzMmMtMC4wMDItMC4xMjMsMC4wNzktMC4yOTgsMC40NzEtMC40MzlsMi4zMzItMC44MzVWMTAuOTMKCXoiLz4KPC9zdmc+Cg=='); + display: inline-block; + height: 2rem; + width: 2rem; + margin: 0.5rem; +} + +span.ps-r2-icon { + /* https://commons.wikimedia.org/wiki/File:PlayStation_button_R2.svg */ + background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwIDE0Ljk0OSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjAgMTQuOTQ5IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGc+Cgk8Zz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwIiB5MT0iMCIgeDI9IjEwIiB5Mj0iMTMuMzc5NCI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wNjI2IiBzdHlsZT0ic3RvcC1jb2xvcjojNTM1MzU2Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMzAxOCIgc3R5bGU9InN0b3AtY29sb3I6IzM3MzczOSIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjUzOTciIHN0eWxlPSJzdG9wLWNvbG9yOiMyNDI0MjUiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43NzM5IiBzdHlsZT0ic3RvcC1jb2xvcjojMTgxODE4Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDE0MTQiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMV8pIiBkPSJNMjAsMTAuNzQ2YzAsNS42MDQtMjAsNS42MDQtMjAsMFYyLjU2M0MwLDEuMTQ3LDEuMTQ2LDAsMi41NjIsMGgxNC44NzYKCQkJQzE4Ljg1NCwwLDIwLDEuMTQ3LDIwLDIuNTYzVjEwLjc0NnoiLz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzJfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwIiB5MT0iMCIgeDI9IjEwIiB5Mj0iMTQuOTQ5MiI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wMTU1IiBzdHlsZT0ic3RvcC1jb2xvcjojNTg1ODVCIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMTQ4OCIgc3R5bGU9InN0b3AtY29sb3I6IzNGM0Y0MSIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjI5NiIgc3R5bGU9InN0b3AtY29sb3I6IzJDMkMyRCIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjQ2MjQiIHN0eWxlPSJzdG9wLWNvbG9yOiMxRTFFMUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC42NjQzIiBzdHlsZT0ic3RvcC1jb2xvcjojMTYxNjE3Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDE0MTQiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMl8pIiBkPSJNMTcuNDM4LDEuNTEyYzAuNTgsMCwxLjA1MSwwLjQ3MSwxLjA1MSwxLjA1MXY4LjE4NGMwLDAuNTg2LTAuODAxLDEuMjM1LTIuMTQzLDEuNzM2CgkJCWMtMS42MjUsMC42MDctMy45MzgsMC45NTUtNi4zNDYsMC45NTVzLTQuNzIyLTAuMzQ4LTYuMzQ2LTAuOTU1Yy0xLjM0Mi0wLjUwMS0yLjE0My0xLjE1LTIuMTQzLTEuNzM2VjIuNTYzCgkJCWMwLTAuNTgsMC40NzEtMS4wNTEsMS4wNS0xLjA1MUgxNy40MzggTTE3LjQzOCwwSDIuNTYyQzEuMTQ2LDAsMCwxLjE0NywwLDIuNTYzdjguMTg0YzAsMi44MDIsNSw0LjIwMywxMCw0LjIwMwoJCQlzMTAtMS40MDEsMTAtNC4yMDNWMi41NjNDMjAsMS4xNDcsMTguODU0LDAsMTcuNDM4LDBMMTcuNDM4LDB6Ii8+Cgk8L2c+Cgk8Zz4KCQk8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTAuMDgyLDYuMDhjMC4yMDEtMC4zMjYsMC4zMDMtMC43MDUsMC4zMDMtMS4xMjZjMC0wLjMxNy0wLjA1Ny0wLjYxOC0wLjE3LTAuODk1CgkJCWMtMC4xMTMtMC4yNzgtMC4yNzktMC41MjEtMC40OTEtMC43MjJDOS41MTMsMy4xMzksOS4yNTksMi45OCw4Ljk2OCwyLjg2OEM4LjY3OCwyLjc1Nyw4LjM1NCwyLjcsOC4wMDIsMi43SDQuMTg4djcuNTE5aDEuODI5CgkJCVY3LjQyM2gxLjQwM2MwLjQ3MiwwLDAuNjcsMC4wOTMsMC43NTMsMC4xN2MwLjExNywwLjEwOSwwLjE3NywwLjMxMSwwLjE3NywwLjU5OXYyLjAyN2gxLjg0MVY4LjAwOAoJCQljMC0wLjQ1Ni0wLjA3NS0wLjc5Ni0wLjIyOS0xLjAzN0M5Ljg2OSw2LjgyNSw5Ljc0LDYuNzAyLDkuNTc1LDYuNjAzQzkuNzgxLDYuNDY5LDkuOTUxLDYuMjk0LDEwLjA4Miw2LjA4eiBNNi4wMTgsNC4zMTRoMS43MzYKCQkJYzAuMzE2LDAsMC41MzgsMC4wNjksMC42NjIsMC4yMDZDOC41NDUsNC42NjUsOC42MDksNC44NSw4LjYwOSw1LjA4M2MwLDAuMjI2LTAuMDY0LDAuNDA1LTAuMTk3LDAuNTUKCQkJQzguMjg0LDUuNzcxLDguMDQ1LDUuODQxLDcuNzAxLDUuODQxSDYuMDE4VjQuMzE0eiIvPgoJCTxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xMi43OTEsOC42MjZjMC4wNDMtMC4wOTksMC4xMTEtMC4yMDYsMC4yMDUtMC4zMjJjMC4xNzQtMC4yMiwwLjM5NS0wLjQyNywwLjY1LTAuNjE1CgkJCWMwLjM4MS0wLjI3MywwLjcxMS0wLjUyNiwwLjk3OS0wLjc1MWMwLjI3NS0wLjIzLDAuNTAxLTAuNDYyLDAuNjctMC42ODhjMC4xNzQtMC4yMywwLjMwMS0wLjQ3LDAuMzc5LTAuNzExCgkJCWMwLjA3Ny0wLjI0LDAuMTE2LTAuNTEsMC4xMTYtMC44MDFjMC0wLjM3Ni0wLjA2NS0wLjcxMi0wLjE5NC0wLjk5N2MtMC4xMy0wLjI4Ny0wLjMxNC0wLjUyOS0wLjU0OC0wLjcyCgkJCWMtMC4yMjktMC4xODgtMC41MDktMC4zMjktMC44My0wLjQyMmMtMC4zMTMtMC4wOTEtMC42NjItMC4xMzctMS4wMzYtMC4xMzdjLTAuNzksMC0xLjQxNCwwLjIzOS0xLjg1NCwwLjcxMQoJCQljLTAuNDM4LDAuNDY4LTAuNjU5LDEuMTIxLTAuNjU5LDEuOTQxdjAuMTg4aDEuNzIzVjUuMTE1YzAtMC40MiwwLjA3Ny0wLjcxNywwLjIyMy0wLjg2YzAuMTUxLTAuMTQ4LDAuMzU3LTAuMjIsMC42MzItMC4yMgoJCQljMC4xMiwwLDAuMjM2LDAuMDEzLDAuMzQ0LDAuMDM4YzAuMDk1LDAuMDIxLDAuMTc0LDAuMDYsMC4yNDIsMC4xMTNjMC4wNjQsMC4wNTMsMC4xMTUsMC4xMjMsMC4xNTMsMC4yMTUKCQkJYzAuMDQsMC4xLDAuMDYyLDAuMjI4LDAuMDYyLDAuMzhjMCwwLjEzLTAuMDIsMC4yNTEtMC4wNTUsMC4zNmMtMC4wMzcsMC4xMDctMC4wOTksMC4yMTktMC4xODYsMC4zMzEKCQkJYy0wLjA5MiwwLjExOC0wLjIxOSwwLjI0Ni0wLjM3NywwLjM4MWMtMC4xNjMsMC4xMzktMC4zNzQsMC4yOTgtMC42MjcsMC40NzVjLTAuNDY3LDAuMzM0LTAuODQ4LDAuNjUyLTEuMTM0LDAuOTQ2CgkJCWMtMC4yOSwwLjI5OC0wLjUxOSwwLjU5Ny0wLjY4MSwwLjg5Yy0wLjE2MywwLjI5Ni0wLjI3NCwwLjYwMS0wLjMzMiwwLjkwNmMtMC4wNTYsMC4yOTctMC4wODQsMC42Mi0wLjA4NCwwLjk2djAuMTg4aDUuMjM5VjguNjI2CgkJCUgxMi43OTF6Ii8+Cgk8L2c+CjwvZz4KPC9zdmc+Cg=='); + background-repeat: no-repeat; + display: inline-block; + height: 1.5rem; + width: 2rem; + margin: 0.5rem; +} + + +.launched-app-view { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.launched-app-view div{ + display: flex; + align-items: center; +} + +.launched-app-view div.terminal { + display: block; + border: 0px solid var(--secondary-text-color); + border-radius: 2px; +} + +.launched-app-view div.terminal div { + display: block; +} + +.launched-app-view p { + text-align: center; +} + +.modal-overlay { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 100000; + display: flex; + justify-content: center; + align-items: center; + animation: fadein 0.3s ease-in-out; + background-color: rgba(0, 0, 0, 0.7); + -webkit-backdrop-filter: blur(5px); + backdrop-filter: blur(5px); +} + +.modal-content { + background-color: var(--modal-background-color); + padding: 20px; + border-radius: 10px; + animation: zoomin 0.3s ease; + width: 70vh; + height: 70vh; +} + +.text-center { + text-align: center; + width: 100%; +} + +.fadeout { + animation: fadeout 0.25s ease !important; +} + +.fadein { + animation: fadein 0.25s ease; +} + +.loading-overlay::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + border-radius: inherit; + pointer-events: none; + animation: fadein 0.3s ease; +} + +/* https://stephanwagner.me/only-css-loading-spinner */ +.loading::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 5vh; + height: 5vh; + margin-top: -2.5vh; + margin-left: -2.5vh; + border-radius: 50%; + border-top: 2px solid var(--text-color); + border-right: 2px solid transparent; + animation: spinner .6s linear infinite, fadein 0.3s ease; + pointer-events: none; } \ No newline at end of file diff --git a/assets/main.js b/assets/main.js index 1c0866d..f6e6f95 100644 --- a/assets/main.js +++ b/assets/main.js @@ -1,296 +1,296 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -// @ts-check - -async function renderHomePage() { - await scanHomebrews(); - let items = getHomebrewList(); - - // remove previous extension sandboxes - let oldsandbox = document.getElementById("js-extension-sandbox"); - if (oldsandbox) { - oldsandbox.remove(); - } - - let carouselItems = items.reduce( - /** - * @param {Array} acc - * @param {*} item - * @returns {CarouselItem[]} - */ - (acc, item) => { - let newItem = { - mainText: item.filename, - secondaryText: item.dir, - imgPath: baseURL + "/fs" + item.dir + "/sce_sys/icon0.png" - } - if (item.filename.endsWith(".js")) { - newItem.asyncInfo = async () => { - let temp = await loadJsExtension(item.path); - if (!temp) { - // TODO: Remove entry - return { result: { mainText: "Error", secondaryText: "Extension main() timed out." } }; - } - - // let removeFromListOption = { - // text: "Remove from list", - // onclick: async () => { - // removeFromHomebrewList(item.path); - // await Globals.Router.handleHome(); - // } - // }; - - // if (temp.result.options) { - // temp.result.options.push(removeFromListOption); - // } else { - // temp.result.options = [removeFromListOption]; - // } - - return temp; - } - } else { - newItem.onclick = () => { - return { path: item.path }; - } - - newItem.options = [ - { - text: "Remove from list", - onclick: async () => { - removeFromHomebrewList(item.path); - await Globals.Router.handleHome(); - } - }, - { - text: "Launch with args", - onclick: () => { - let args = window.prompt("Enter args"); - if (args) { - return { path: item.path, args: args }; - } - return null; - } - }, - { - text: "Launch with file", - onclick: async () => { - let result = await Globals.Router.pickFile(item.path.substring(0, item.path.lastIndexOf("/"))); - - if (result) { - return { path: item.path, args: result }; - } - } - } - ]; - } - - acc.push(newItem); - return acc; - }, []); - - // add "Add more..." button - carouselItems.push({ - mainText: "+", - secondaryText: "Add more...", - onclick: async () => { - let result = await Globals.Router.pickFile(); - - if (result) { - addToHomebrewStore(result); - await Globals.Router.handleHome(); - } - } - }) - - await renderMainContentCarousel(carouselItems); -} - -/** - * @param {ReadableStream?} [hbldrLogStream] - */ -async function renderLaunchedAppView(hbldrLogStream = null) { - const content = document.getElementById("content"); - if (!content) { - return; - } - const wrapper = document.createElement("div"); - wrapper.classList.add("launched-app-view"); - content.appendChild(wrapper); - - const msgWrapper = document.createElement("div"); - wrapper.appendChild(msgWrapper); - - const msgIngress = document.createElement("span"); - msgIngress.innerText = "App is running in the background, press "; - msgWrapper.appendChild(msgIngress); - - const btnCircle = document.createElement("span"); - btnCircle.classList.add("ps-circle-icon"); - msgWrapper.appendChild(btnCircle); - - const textCircle = document.createElement("span"); - textCircle.innerText = " to close this dialog, or "; - msgWrapper.appendChild(textCircle); - - const btnTriangle = document.createElement("span"); - btnTriangle.classList.add("ps-triangle-icon"); - msgWrapper.appendChild(btnTriangle); - - const textTriangle = document.createElement("span"); - textTriangle.innerText = " to go back to the launcher."; - msgWrapper.appendChild(textTriangle); - - if (hbldrLogStream) { - const terminal = document.createElement("div"); - terminal.classList.add("terminal"); - - terminal.style.display = "block"; - terminal.style.alignItems = "unset"; - terminal.style.justifyContent = "unset"; - terminal.style.borderWidth = "2px"; - terminal.style.padding = "5px"; - wrapper.appendChild(terminal); - - await (async () => { - const term = new Terminal({ - convertEol: true, - altClickMovesCursor: false, - disableStdin: true, - fontSize: 18, - cols: 132, - rows: 26 - }); - - try { - const reader = hbldrLogStream.getReader(); - const decoder = new TextDecoder(); - - term.open(terminal); - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - let decodedValue = decoder.decode(value); - // alert(decodedValue); - term.write(decodedValue); - } - } catch (error) { - - } - })(); - } -} - -window.onload = async function () { - window.addEventListener("error", (event) => { - alert(event.error); - // window.location.href = "/"; - }) - window.addEventListener("unhandledrejection", (event) => { - alert(event.reason); - // window.location.href = "/"; - }); - - const ver = await ApiClient.getVersion(); - if (ver.api > 0) { - const verstr = ` ${ver.tag} (compiled at ${ver.date} ${ver.time})`; - document.title += verstr; - } else { - document.title += ' (unknown version)'; - } - - registerExtensionMessagesListener(); - - // for home page carousel - document.addEventListener("keydown", (event) => { - // L2 - if (event.keyCode === 118) { - // reset hb list - if (!confirm("Reset homebrew list?")) { - return; - } - localStorage.removeItem(LOCALSTORE_HOMEBREW_LIST_KEY); - window.location.href = "/"; - } - - // L1 - go to first element in carousel - if (event.keyCode === 116) { - smoothScrollToElementIndex(0, true); // doesnt do anything if the current view isnt a carouselView - event.preventDefault(); - } - - // R1 - go to last element in carousel - if (event.keyCode === 117) { - let carouselInfo = getCurrentCarouselSelectionInfo(); - if (!carouselInfo) { - // current view not a carousel - return; - } - - smoothScrollToElementIndex(carouselInfo.totalEntries - 1, true); - event.preventDefault(); - } - - // carousel left-right dpad - if (event.keyCode === 39 || event.keyCode === 37) { - // return if modal is open - if (document.getElementById("modal-overlay")) { - return; - } - - let carouselInfo = getCurrentCarouselSelectionInfo(); - - if (!carouselInfo) { - // current view not a carousel - return; - } - - let currentElementIndex = carouselInfo.selectedIndex; - if (!currentElementIndex) { - currentElementIndex = 0; - } - - if (event.keyCode === 39) { - // right - // if the current element is the last element, go to the first element - if (currentElementIndex === carouselInfo.totalEntries - 1 && - (Date.now() - Globals.lastCarouselLeftRightKeyDownTimestamp) > 200) { // 200ms so holding down the key doesnt keep scrolling - currentElementIndex = 0; - } else { - currentElementIndex++; - } - } else if (event.keyCode === 37) { - // left - // if the current element is the first element, go to the last element - if (currentElementIndex === 0 && - (Date.now() - Globals.lastCarouselLeftRightKeyDownTimestamp) > 200) { // 200ms so holding down the key doesnt keep scrolling - currentElementIndex = carouselInfo.totalEntries - 1; - } else { - currentElementIndex--; - } - } - - Globals.lastCarouselLeftRightKeyDownTimestamp = Date.now(); - smoothScrollToElementIndex(currentElementIndex, true); // this checks if new index is out of bounds - } - }); - // this ctor renders the main page - Globals.Router = new Router(); -}; - +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +// @ts-check + +async function renderHomePage() { + await scanHomebrews(); + let items = getHomebrewList(); + + // remove previous extension sandboxes + let oldsandbox = document.getElementById("js-extension-sandbox"); + if (oldsandbox) { + oldsandbox.remove(); + } + + let carouselItems = items.reduce( + /** + * @param {Array} acc + * @param {*} item + * @returns {CarouselItem[]} + */ + (acc, item) => { + let newItem = { + mainText: item.filename, + secondaryText: item.dir, + imgPath: baseURL + "/fs" + item.dir + "/sce_sys/icon0.png" + } + if (item.filename.endsWith(".js")) { + newItem.asyncInfo = async () => { + let temp = await loadJsExtension(item.path); + if (!temp) { + // TODO: Remove entry + return { result: { mainText: "Error", secondaryText: "Extension main() timed out." } }; + } + + // let removeFromListOption = { + // text: "Remove from list", + // onclick: async () => { + // removeFromHomebrewList(item.path); + // await Globals.Router.handleHome(); + // } + // }; + + // if (temp.result.options) { + // temp.result.options.push(removeFromListOption); + // } else { + // temp.result.options = [removeFromListOption]; + // } + + return temp; + } + } else { + newItem.onclick = () => { + return { path: item.path }; + } + + newItem.options = [ + { + text: "Remove from list", + onclick: async () => { + removeFromHomebrewList(item.path); + await Globals.Router.handleHome(); + } + }, + { + text: "Launch with args", + onclick: () => { + let args = window.prompt("Enter args"); + if (args) { + return { path: item.path, args: args }; + } + return null; + } + }, + { + text: "Launch with file", + onclick: async () => { + let result = await Globals.Router.pickFile(item.path.substring(0, item.path.lastIndexOf("/"))); + + if (result) { + return { path: item.path, args: result }; + } + } + } + ]; + } + + acc.push(newItem); + return acc; + }, []); + + // add "Add more..." button + carouselItems.push({ + mainText: "+", + secondaryText: "Add more...", + onclick: async () => { + let result = await Globals.Router.pickFile(); + + if (result) { + addToHomebrewStore(result); + await Globals.Router.handleHome(); + } + } + }) + + await renderMainContentCarousel(carouselItems); +} + +/** + * @param {ReadableStream?} [hbldrLogStream] + */ +async function renderLaunchedAppView(hbldrLogStream = null) { + const content = document.getElementById("content"); + if (!content) { + return; + } + const wrapper = document.createElement("div"); + wrapper.classList.add("launched-app-view"); + content.appendChild(wrapper); + + const msgWrapper = document.createElement("div"); + wrapper.appendChild(msgWrapper); + + const msgIngress = document.createElement("span"); + msgIngress.innerText = "App is running in the background, press "; + msgWrapper.appendChild(msgIngress); + + const btnCircle = document.createElement("span"); + btnCircle.classList.add("ps-circle-icon"); + msgWrapper.appendChild(btnCircle); + + const textCircle = document.createElement("span"); + textCircle.innerText = " to close this dialog, or "; + msgWrapper.appendChild(textCircle); + + const btnTriangle = document.createElement("span"); + btnTriangle.classList.add("ps-triangle-icon"); + msgWrapper.appendChild(btnTriangle); + + const textTriangle = document.createElement("span"); + textTriangle.innerText = " to go back to the launcher."; + msgWrapper.appendChild(textTriangle); + + if (hbldrLogStream) { + const terminal = document.createElement("div"); + terminal.classList.add("terminal"); + + terminal.style.display = "block"; + terminal.style.alignItems = "unset"; + terminal.style.justifyContent = "unset"; + terminal.style.borderWidth = "2px"; + terminal.style.padding = "5px"; + wrapper.appendChild(terminal); + + await (async () => { + const term = new Terminal({ + convertEol: true, + altClickMovesCursor: false, + disableStdin: true, + fontSize: 18, + cols: 132, + rows: 26 + }); + + try { + const reader = hbldrLogStream.getReader(); + const decoder = new TextDecoder(); + + term.open(terminal); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + let decodedValue = decoder.decode(value); + // alert(decodedValue); + term.write(decodedValue); + } + } catch (error) { + + } + })(); + } +} + +window.onload = async function () { + window.addEventListener("error", (event) => { + alert(event.error); + // window.location.href = "/"; + }) + window.addEventListener("unhandledrejection", (event) => { + alert(event.reason); + // window.location.href = "/"; + }); + + const ver = await ApiClient.getVersion(); + if (ver.api > 0) { + const verstr = ` ${ver.tag} (compiled at ${ver.date} ${ver.time})`; + document.title += verstr; + } else { + document.title += ' (unknown version)'; + } + + registerExtensionMessagesListener(); + + // for home page carousel + document.addEventListener("keydown", (event) => { + // L2 + if (event.keyCode === 118) { + // reset hb list + if (!confirm("Reset homebrew list?")) { + return; + } + localStorage.removeItem(LOCALSTORE_HOMEBREW_LIST_KEY); + window.location.href = "/"; + } + + // L1 - go to first element in carousel + if (event.keyCode === 116) { + smoothScrollToElementIndex(0, true); // doesnt do anything if the current view isnt a carouselView + event.preventDefault(); + } + + // R1 - go to last element in carousel + if (event.keyCode === 117) { + let carouselInfo = getCurrentCarouselSelectionInfo(); + if (!carouselInfo) { + // current view not a carousel + return; + } + + smoothScrollToElementIndex(carouselInfo.totalEntries - 1, true); + event.preventDefault(); + } + + // carousel left-right dpad + if (event.keyCode === 39 || event.keyCode === 37) { + // return if modal is open + if (document.getElementById("modal-overlay")) { + return; + } + + let carouselInfo = getCurrentCarouselSelectionInfo(); + + if (!carouselInfo) { + // current view not a carousel + return; + } + + let currentElementIndex = carouselInfo.selectedIndex; + if (!currentElementIndex) { + currentElementIndex = 0; + } + + if (event.keyCode === 39) { + // right + // if the current element is the last element, go to the first element + if (currentElementIndex === carouselInfo.totalEntries - 1 && + (Date.now() - Globals.lastCarouselLeftRightKeyDownTimestamp) > 200) { // 200ms so holding down the key doesnt keep scrolling + currentElementIndex = 0; + } else { + currentElementIndex++; + } + } else if (event.keyCode === 37) { + // left + // if the current element is the first element, go to the last element + if (currentElementIndex === 0 && + (Date.now() - Globals.lastCarouselLeftRightKeyDownTimestamp) > 200) { // 200ms so holding down the key doesnt keep scrolling + currentElementIndex = carouselInfo.totalEntries - 1; + } else { + currentElementIndex--; + } + } + + Globals.lastCarouselLeftRightKeyDownTimestamp = Date.now(); + smoothScrollToElementIndex(currentElementIndex, true); // this checks if new index is out of bounds + } + }); + // this ctor renders the main page + Globals.Router = new Router(); +}; + diff --git a/assets/modalView.js b/assets/modalView.js index c26637e..dd56385 100644 --- a/assets/modalView.js +++ b/assets/modalView.js @@ -1,105 +1,105 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -// @ts-check - -/** - * @typedef {Object} ModalItem - * @property {string} text - * @property {function():any} onclick - */ - -/** - * Shouldnt be interacted with directly, use router methods - * @param {ModalItem[]} items - */ -function renderModalOverlay(items) { - const content = document.getElementById("content"); - if (!content) { - throw new Error("content not found"); - } - const oldOverlays = content.querySelectorAll("[data-modal-overlay]"); - for (let overlay of oldOverlays) { - overlay.remove(); - } - - removeAllCursorSnapOverlays(); - - const overlay = document.createElement("div"); - overlay.setAttribute("data-modal-overlay", ""); - overlay.classList.add("modal-overlay"); - - const modalContent = document.createElement("div"); - modalContent.classList.add("modal-content"); - - for (let item of items) { - const entry = document.createElement("a"); - entry.classList.add("list-entry"); - entry.style.transition = "transform 0.3s ease"; - entry.style.position = "relative"; - const textElement = document.createElement("p"); - textElement.classList.add("text-center"); - textElement.innerText = item.text; - entry.appendChild(textElement); - - // entry.onclick = item.onclick; - entry.onclick = async () => { - - entry.classList.add("loading-overlay"); - entry.classList.add("loading"); - await sleep(0); - - let onclickResult = await item.onclick(); - - let res = onclickResult; - let logStream = null; - - if (res && res.path) { - logStream = await ApiClient.launchApp(res.path, res.args, res.env, res.cwd); - res = logStream != null; - } - - if (res == true) { - entry.style.transform = "scale(2)"; - setTimeout(() => { - entry.style.removeProperty("transform"); - }, 300); - Globals.Router.handleLaunchedAppView(logStream); - } - - entry.classList.remove("loading-overlay"); - entry.classList.remove("loading"); - } - entry.tabIndex = 1; - modalContent.appendChild(entry); - } - - overlay.appendChild(modalContent); - content.appendChild(overlay); -} - -function closeModal() { - const content = document.getElementById("content"); - if (!content) { - throw new Error("content not found"); - } - const oldOverlays = content.querySelectorAll("[data-modal-overlay]"); - for (let overlay of oldOverlays) { - overlay.remove(); - } - generateCursorSnapOverlays(); -} +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +// @ts-check + +/** + * @typedef {Object} ModalItem + * @property {string} text + * @property {function():any} onclick + */ + +/** + * Shouldnt be interacted with directly, use router methods + * @param {ModalItem[]} items + */ +function renderModalOverlay(items) { + const content = document.getElementById("content"); + if (!content) { + throw new Error("content not found"); + } + const oldOverlays = content.querySelectorAll("[data-modal-overlay]"); + for (let overlay of oldOverlays) { + overlay.remove(); + } + + removeAllCursorSnapOverlays(); + + const overlay = document.createElement("div"); + overlay.setAttribute("data-modal-overlay", ""); + overlay.classList.add("modal-overlay"); + + const modalContent = document.createElement("div"); + modalContent.classList.add("modal-content"); + + for (let item of items) { + const entry = document.createElement("a"); + entry.classList.add("list-entry"); + entry.style.transition = "transform 0.3s ease"; + entry.style.position = "relative"; + const textElement = document.createElement("p"); + textElement.classList.add("text-center"); + textElement.innerText = item.text; + entry.appendChild(textElement); + + // entry.onclick = item.onclick; + entry.onclick = async () => { + + entry.classList.add("loading-overlay"); + entry.classList.add("loading"); + await sleep(0); + + let onclickResult = await item.onclick(); + + let res = onclickResult; + let logStream = null; + + if (res && res.path) { + logStream = await ApiClient.launchApp(res.path, res.args, res.env, res.cwd); + res = logStream != null; + } + + if (res == true) { + entry.style.transform = "scale(2)"; + setTimeout(() => { + entry.style.removeProperty("transform"); + }, 300); + Globals.Router.handleLaunchedAppView(logStream); + } + + entry.classList.remove("loading-overlay"); + entry.classList.remove("loading"); + } + entry.tabIndex = 1; + modalContent.appendChild(entry); + } + + overlay.appendChild(modalContent); + content.appendChild(overlay); +} + +function closeModal() { + const content = document.getElementById("content"); + if (!content) { + throw new Error("content not found"); + } + const oldOverlays = content.querySelectorAll("[data-modal-overlay]"); + for (let overlay of oldOverlays) { + overlay.remove(); + } + generateCursorSnapOverlays(); +} diff --git a/assets/router.js b/assets/router.js index 96a0fda..c9a5d24 100644 --- a/assets/router.js +++ b/assets/router.js @@ -1,383 +1,383 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -// @ts-check - -/** - * these paths are only used for internally, navigating to these manually wont resolve them, - * since most of these require non serializable parameters (function execution context) - * @private -*/ -const RouterRoutes = [ - { - path: "/", - keepInHistory: true, - replaceSamePath: true, - forceReplaceCurrentState: true - }, - { - path: "/modal", - keepInHistory: false - }, - { - path: "/filePicker", - keepInHistory: false - }, - { - path: "/carousel", - keepInHistory: true - }, - { - path: "/launchedAppView", - keepInHistory: true, - replaceSamePath: true, - forceReplaceCurrentState: true - }, -]; - -class Router { - /** - * @typedef {Object} State - * @property {string} path - * @property {string} dataRouterId - */ - - /** - * @typedef {Object} Route - * @property {string} path - * @property {function():any} handler - * @property {boolean} keepInHistory - */ - - /** @type {Semaphore} */ - navigationLock = new Semaphore(1); - - /** @type {number} */ - initialHistoryLength; - - /** @type {boolean} */ - disablePopHandler = false; - - constructor() { - // save history length on first load (which in first load can only contain backward history) - if (!sessionStorage.getItem("initialHistoryLength")) { - sessionStorage.setItem("initialHistoryLength", window.history.length.toString()); - } - this.initialHistoryLength = parseInt(/** @type {string} */(sessionStorage.getItem("initialHistoryLength"))); - - // if the user reloads with history from old session, delete it - // for it to be reliable first we need to get rid of the forwards history - window.history.pushState({}, "", `#${uuidv4()}`); - - // the popstate after back fires twice if we do history.go(-) even though im removing the handler before it? - // i guess removing the event listener is async? - let handledInitialBack = false; - - // sadly history.back doesnt update instantly on webkit... - window.addEventListener("popstate", function ctorPart2() { - if (handledInitialBack) { - window.removeEventListener("popstate", ctorPart2); - return; - } - handledInitialBack = true; - - window.removeEventListener("popstate", ctorPart2); - - // we know there must be exactly one forward history, so subtract that - if (this.initialHistoryLength != (window.history.length - 1)) { - let goBackCount = -((window.history.length - 1) - this.initialHistoryLength); - window.history.go(goBackCount); - } - - window.addEventListener("popstate", this.handlePopState.bind(this)); - this.handleHome(); - }.bind(this)); - - window.history.back(); - } - - async pushOrReplaceState(newPath) { - await this.navigationLock.acquire(); - - try { - const oldPathItem = RouterRoutes.find((r) => r.path === this.getPath()); - const newPathItem = RouterRoutes.find((r) => r.path === newPath); - - let shouldReplace = oldPathItem ? !oldPathItem.keepInHistory : false; - let shouldReplaceSamePath = oldPathItem ? oldPathItem.replaceSamePath : false; - if (shouldReplaceSamePath && newPath === this.getPath()) { - shouldReplace = true; - } - - if (newPathItem && newPathItem.forceReplaceCurrentState) { - shouldReplace = true; - } - - /** @type {State} */ - const state = { - path: newPath, - dataRouterId: uuidv4() - }; - - // add hash if not root - if (newPath !== "/") { - newPath = `#${newPath}`; - } - - const content = document.getElementById("content"); - if (content) { - content.classList.add("fadeout"); - await sleep(250); - } - - if (content && !shouldReplace) { - // hide current content - content.classList.remove("fadein"); - content.removeAttribute("id"); - content.style.display = "none"; - content.classList.remove("fadeout"); - } else if (content && shouldReplace) { - // delete current content - content.remove(); - } - - // create new content with data-router-id - const newContent = document.createElement("div"); - newContent.id = "content" - newContent.setAttribute("data-router-id", state.dataRouterId); - newContent.setAttribute("data-router-path", encodeURIComponent(state.path)); - newContent.style.display = "block"; - document.body.appendChild(newContent); - - - if (shouldReplace) { - history.replaceState(state, "", newPath); - } else { - history.pushState(state, "", newPath); - } - - closeModal(); - } finally { - await this.navigationLock.release(); - } - - } - - async handlePopState(event) { - if (this.disablePopHandler) { - return; - } - - await this.navigationLock.acquire(); - - try { - window.dispatchEvent(new CustomEvent("popstateHandlerEntered")); - /** @type {State} */ - const state = event.state; - - if (!state) { - await this.handleFallbackHome(); - return; - } - - // delete current content div since we dont care about forwards history - const currentContent = document.getElementById("content"); - if (currentContent) { - currentContent.classList.add("fadeout"); - await sleep(250); - // keep a copy of index - if (currentContent.getAttribute("data-router-path") === encodeURIComponent("/")) { - currentContent.removeAttribute("id"); - currentContent.style.display = "none"; - currentContent.classList.remove("fadeout"); - } - else { - currentContent.remove(); - } - } - - // restore saved content div - /** @type {HTMLElement?} */ - const restoredContent = document.querySelector(`[data-router-id="${state.dataRouterId}"]`); - if (!restoredContent) { - await this.handleFallbackHome(); - return; - } - - restoredContent.id = "content"; - restoredContent.classList.add("fadein"); - setTimeout(() => { - restoredContent.classList.remove("fadein"); - }, 250); - restoredContent.style.display = "block"; - - closeModal(); - } finally { - await this.navigationLock.release(); - } - } - - getNavigateAwayPromise() { - return new Promise((resolve) => { - function handler(event) { - window.removeEventListener("popstate", handler); - window.removeEventListener("hashchange", handler); - resolve(null); - } - - window.addEventListener("popstate", handler); - window.addEventListener("hashchange", handler); - }); - } - - async handleHome() { - await this.pushOrReplaceState("/"); - await renderHomePage(); - } - - async handleFallbackHome() { - let homeContent = /** @type {HTMLElement?} */ (document.querySelector(`[data-router-path="${encodeURIComponent("/")}"]`)); - // delete current content - const currentContent = document.getElementById("content"); - if (currentContent && (!homeContent || homeContent.id !== "content")) { - // currentContent.classList.add("fadeout"); - // await sleep(250); - currentContent.remove(); - } - - let contentUuid = uuidv4(); - - if (!homeContent) { - homeContent = document.createElement("div"); - homeContent.id = "content"; - homeContent.setAttribute("data-router-path", encodeURIComponent("/")); - homeContent.setAttribute("data-router-id", contentUuid); - homeContent.style.display = "block"; - document.body.appendChild(homeContent); - - renderHomePage(); // fire and forget - } - else { - homeContent.id = "content"; - homeContent.style.display = "block"; - contentUuid = /** @type {string} */ (homeContent.getAttribute("data-router-id")); - } - - history.replaceState({ path: "/", dataRouterId: contentUuid }, "", `/`); - } - - /** - * @param {CarouselItem[]} items - */ - async handleCarousel(items) { - await this.pushOrReplaceState("/carousel"); - await renderMainContentCarousel(items, true); - } - - /** - * @param {ModalItem[]} items - */ - async handleModal(items) { - await this.pushOrReplaceState("/modal"); - renderModalOverlay(items); - } - - /** - * Replaces the initial page so you can exit with the back button - * so you cant exit this, reload page to close this view - * @param {ReadableStream?} [logStream] - */ - async handleLaunchedAppView(logStream = null) { - // TODO: theres a workaround for the back button to close the window even if there was history before this site - // you can listen to keycode 27, and navigate back as far as possible there - - this.disablePopHandler = true; - // remove forward history - window.history.pushState({}, "", `#${uuidv4()}`); - - await new Promise(async (resolve) => { - function handler(event) { - window.removeEventListener("popstate", handler); - resolve(null); - } - window.addEventListener("popstate", handler); - - window.history.back(); - }); - - if (this.initialHistoryLength != (window.history.length - 1)) { - let goBackCount = -((window.history.length - 1) - this.initialHistoryLength); - window.history.go(goBackCount); - } - - await this.pushOrReplaceState("/launchedAppView"); - await renderLaunchedAppView(logStream); - - // this.disablePopHandler = false; - } - - /** - * @param {string} initialPath - * @returns {Promise} - */ - async pickFile(initialPath = "/", title = "Select file...") { - await this.pushOrReplaceState("/filePicker"); - let navigateAwayPromise = this.getNavigateAwayPromise(); - - /** @type {{path: string, finished: boolean}?} */ - let result = await Promise.race([renderBrowsePage(initialPath, true, title), navigateAwayPromise]); - while (result && !result.finished) { - result = await Promise.race([renderBrowsePage(result.path, false, title), navigateAwayPromise]); - } - - // result is only null if the user already navigated back - if (result) { - await this.back(); - } - - return result ? result.path : null; - } - - getPath() { - const hash = window.location.hash; - if (!hash) { - return "/"; - } - - return hash.slice(1); - } - - async back() { - // history.back doesnt update the history.length and window.location.href instantly - // plus we await the popstate handler for the animations to finish - - await new Promise(async (resolve) => { - function handler(event) { - window.removeEventListener("popstateHandlerEntered", handler); - // window.removeEventListener("hashchange", handler); - resolve(null); - } - window.addEventListener("popstateHandlerEntered", handler); - // window.addEventListener("hashchange", handler); - - window.history.back(); - }); - - await this.navigationLock.awaitCurrentQueue(); - } - -} +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +// @ts-check + +/** + * these paths are only used for internally, navigating to these manually wont resolve them, + * since most of these require non serializable parameters (function execution context) + * @private +*/ +const RouterRoutes = [ + { + path: "/", + keepInHistory: true, + replaceSamePath: true, + forceReplaceCurrentState: true + }, + { + path: "/modal", + keepInHistory: false + }, + { + path: "/filePicker", + keepInHistory: false + }, + { + path: "/carousel", + keepInHistory: true + }, + { + path: "/launchedAppView", + keepInHistory: true, + replaceSamePath: true, + forceReplaceCurrentState: true + }, +]; + +class Router { + /** + * @typedef {Object} State + * @property {string} path + * @property {string} dataRouterId + */ + + /** + * @typedef {Object} Route + * @property {string} path + * @property {function():any} handler + * @property {boolean} keepInHistory + */ + + /** @type {Semaphore} */ + navigationLock = new Semaphore(1); + + /** @type {number} */ + initialHistoryLength; + + /** @type {boolean} */ + disablePopHandler = false; + + constructor() { + // save history length on first load (which in first load can only contain backward history) + if (!sessionStorage.getItem("initialHistoryLength")) { + sessionStorage.setItem("initialHistoryLength", window.history.length.toString()); + } + this.initialHistoryLength = parseInt(/** @type {string} */(sessionStorage.getItem("initialHistoryLength"))); + + // if the user reloads with history from old session, delete it + // for it to be reliable first we need to get rid of the forwards history + window.history.pushState({}, "", `#${uuidv4()}`); + + // the popstate after back fires twice if we do history.go(-) even though im removing the handler before it? + // i guess removing the event listener is async? + let handledInitialBack = false; + + // sadly history.back doesnt update instantly on webkit... + window.addEventListener("popstate", function ctorPart2() { + if (handledInitialBack) { + window.removeEventListener("popstate", ctorPart2); + return; + } + handledInitialBack = true; + + window.removeEventListener("popstate", ctorPart2); + + // we know there must be exactly one forward history, so subtract that + if (this.initialHistoryLength != (window.history.length - 1)) { + let goBackCount = -((window.history.length - 1) - this.initialHistoryLength); + window.history.go(goBackCount); + } + + window.addEventListener("popstate", this.handlePopState.bind(this)); + this.handleHome(); + }.bind(this)); + + window.history.back(); + } + + async pushOrReplaceState(newPath) { + await this.navigationLock.acquire(); + + try { + const oldPathItem = RouterRoutes.find((r) => r.path === this.getPath()); + const newPathItem = RouterRoutes.find((r) => r.path === newPath); + + let shouldReplace = oldPathItem ? !oldPathItem.keepInHistory : false; + let shouldReplaceSamePath = oldPathItem ? oldPathItem.replaceSamePath : false; + if (shouldReplaceSamePath && newPath === this.getPath()) { + shouldReplace = true; + } + + if (newPathItem && newPathItem.forceReplaceCurrentState) { + shouldReplace = true; + } + + /** @type {State} */ + const state = { + path: newPath, + dataRouterId: uuidv4() + }; + + // add hash if not root + if (newPath !== "/") { + newPath = `#${newPath}`; + } + + const content = document.getElementById("content"); + if (content) { + content.classList.add("fadeout"); + await sleep(250); + } + + if (content && !shouldReplace) { + // hide current content + content.classList.remove("fadein"); + content.removeAttribute("id"); + content.style.display = "none"; + content.classList.remove("fadeout"); + } else if (content && shouldReplace) { + // delete current content + content.remove(); + } + + // create new content with data-router-id + const newContent = document.createElement("div"); + newContent.id = "content" + newContent.setAttribute("data-router-id", state.dataRouterId); + newContent.setAttribute("data-router-path", encodeURIComponent(state.path)); + newContent.style.display = "block"; + document.body.appendChild(newContent); + + + if (shouldReplace) { + history.replaceState(state, "", newPath); + } else { + history.pushState(state, "", newPath); + } + + closeModal(); + } finally { + await this.navigationLock.release(); + } + + } + + async handlePopState(event) { + if (this.disablePopHandler) { + return; + } + + await this.navigationLock.acquire(); + + try { + window.dispatchEvent(new CustomEvent("popstateHandlerEntered")); + /** @type {State} */ + const state = event.state; + + if (!state) { + await this.handleFallbackHome(); + return; + } + + // delete current content div since we dont care about forwards history + const currentContent = document.getElementById("content"); + if (currentContent) { + currentContent.classList.add("fadeout"); + await sleep(250); + // keep a copy of index + if (currentContent.getAttribute("data-router-path") === encodeURIComponent("/")) { + currentContent.removeAttribute("id"); + currentContent.style.display = "none"; + currentContent.classList.remove("fadeout"); + } + else { + currentContent.remove(); + } + } + + // restore saved content div + /** @type {HTMLElement?} */ + const restoredContent = document.querySelector(`[data-router-id="${state.dataRouterId}"]`); + if (!restoredContent) { + await this.handleFallbackHome(); + return; + } + + restoredContent.id = "content"; + restoredContent.classList.add("fadein"); + setTimeout(() => { + restoredContent.classList.remove("fadein"); + }, 250); + restoredContent.style.display = "block"; + + closeModal(); + } finally { + await this.navigationLock.release(); + } + } + + getNavigateAwayPromise() { + return new Promise((resolve) => { + function handler(event) { + window.removeEventListener("popstate", handler); + window.removeEventListener("hashchange", handler); + resolve(null); + } + + window.addEventListener("popstate", handler); + window.addEventListener("hashchange", handler); + }); + } + + async handleHome() { + await this.pushOrReplaceState("/"); + await renderHomePage(); + } + + async handleFallbackHome() { + let homeContent = /** @type {HTMLElement?} */ (document.querySelector(`[data-router-path="${encodeURIComponent("/")}"]`)); + // delete current content + const currentContent = document.getElementById("content"); + if (currentContent && (!homeContent || homeContent.id !== "content")) { + // currentContent.classList.add("fadeout"); + // await sleep(250); + currentContent.remove(); + } + + let contentUuid = uuidv4(); + + if (!homeContent) { + homeContent = document.createElement("div"); + homeContent.id = "content"; + homeContent.setAttribute("data-router-path", encodeURIComponent("/")); + homeContent.setAttribute("data-router-id", contentUuid); + homeContent.style.display = "block"; + document.body.appendChild(homeContent); + + renderHomePage(); // fire and forget + } + else { + homeContent.id = "content"; + homeContent.style.display = "block"; + contentUuid = /** @type {string} */ (homeContent.getAttribute("data-router-id")); + } + + history.replaceState({ path: "/", dataRouterId: contentUuid }, "", `/`); + } + + /** + * @param {CarouselItem[]} items + */ + async handleCarousel(items) { + await this.pushOrReplaceState("/carousel"); + await renderMainContentCarousel(items, true); + } + + /** + * @param {ModalItem[]} items + */ + async handleModal(items) { + await this.pushOrReplaceState("/modal"); + renderModalOverlay(items); + } + + /** + * Replaces the initial page so you can exit with the back button + * so you cant exit this, reload page to close this view + * @param {ReadableStream?} [logStream] + */ + async handleLaunchedAppView(logStream = null) { + // TODO: theres a workaround for the back button to close the window even if there was history before this site + // you can listen to keycode 27, and navigate back as far as possible there + + this.disablePopHandler = true; + // remove forward history + window.history.pushState({}, "", `#${uuidv4()}`); + + await new Promise(async (resolve) => { + function handler(event) { + window.removeEventListener("popstate", handler); + resolve(null); + } + window.addEventListener("popstate", handler); + + window.history.back(); + }); + + if (this.initialHistoryLength != (window.history.length - 1)) { + let goBackCount = -((window.history.length - 1) - this.initialHistoryLength); + window.history.go(goBackCount); + } + + await this.pushOrReplaceState("/launchedAppView"); + await renderLaunchedAppView(logStream); + + // this.disablePopHandler = false; + } + + /** + * @param {string} initialPath + * @returns {Promise} + */ + async pickFile(initialPath = "/", title = "Select file...") { + await this.pushOrReplaceState("/filePicker"); + let navigateAwayPromise = this.getNavigateAwayPromise(); + + /** @type {{path: string, finished: boolean}?} */ + let result = await Promise.race([renderBrowsePage(initialPath, true, title), navigateAwayPromise]); + while (result && !result.finished) { + result = await Promise.race([renderBrowsePage(result.path, false, title), navigateAwayPromise]); + } + + // result is only null if the user already navigated back + if (result) { + await this.back(); + } + + return result ? result.path : null; + } + + getPath() { + const hash = window.location.hash; + if (!hash) { + return "/"; + } + + return hash.slice(1); + } + + async back() { + // history.back doesnt update the history.length and window.location.href instantly + // plus we await the popstate handler for the animations to finish + + await new Promise(async (resolve) => { + function handler(event) { + window.removeEventListener("popstateHandlerEntered", handler); + // window.removeEventListener("hashchange", handler); + resolve(null); + } + window.addEventListener("popstateHandlerEntered", handler); + // window.addEventListener("hashchange", handler); + + window.history.back(); + }); + + await this.navigationLock.awaitCurrentQueue(); + } + +} diff --git a/assets/utils.js b/assets/utils.js index 2f9490b..52d4748 100644 --- a/assets/utils.js +++ b/assets/utils.js @@ -1,81 +1,81 @@ -/* Copyright (C) 2024 idlesauce - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -/** - * throws exception if the server is down - * @param {string} path - * @returns {object?} null if not success - */ -async function fetchJson(path) { - let response = await fetch(baseURL + path); - if (!response.ok) { - return null; - } - let data = await response.json(); - return data; -} - -// https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid -function uuidv4() { - return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => - (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16) - ); -} - -async function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -class Semaphore { - /** - * @param {number} [maxConcurrency] - */ - constructor(maxConcurrency = 1) { - /** @type {{resolve: () => void, promise: Promise}[]} */ - this.queue = []; - this.maxConcurrency = maxConcurrency; - } - - acquire() { - let resolver; - const promise = new Promise((resolve) => { - resolver = resolve; - }); - - this.queue.push({ resolve: resolver, promise: promise }); - - if (this.queue.length <= this.maxConcurrency) { - return Promise.resolve(); - } - - return promise; - } - - release() { - if (this.queue.length === 0) { - return; - } - - this.queue.shift().resolve(); - } - - - awaitCurrentQueue() { - const promises = this.queue.map(item => item.promise); - return Promise.all(promises); - } +/* Copyright (C) 2024 idlesauce + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +/** + * throws exception if the server is down + * @param {string} path + * @returns {object?} null if not success + */ +async function fetchJson(path) { + let response = await fetch(baseURL + path); + if (!response.ok) { + return null; + } + let data = await response.json(); + return data; +} + +// https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => + (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16) + ); +} + +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +class Semaphore { + /** + * @param {number} [maxConcurrency] + */ + constructor(maxConcurrency = 1) { + /** @type {{resolve: () => void, promise: Promise}[]} */ + this.queue = []; + this.maxConcurrency = maxConcurrency; + } + + acquire() { + let resolver; + const promise = new Promise((resolve) => { + resolver = resolve; + }); + + this.queue.push({ resolve: resolver, promise: promise }); + + if (this.queue.length <= this.maxConcurrency) { + return Promise.resolve(); + } + + return promise; + } + + release() { + if (this.queue.length === 0) { + return; + } + + this.queue.shift().resolve(); + } + + + awaitCurrentQueue() { + const promises = this.queue.map(item => item.promise); + return Promise.all(promises); + } } \ No newline at end of file diff --git a/homebrew/DevilutionX/homebrew.js b/homebrew/DevilutionX/homebrew.js index ec43ad4..f5d696e 100755 --- a/homebrew/DevilutionX/homebrew.js +++ b/homebrew/DevilutionX/homebrew.js @@ -1,60 +1,60 @@ -/* Copyright (C) 2024 John Törnblom - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -async function main() { - const PAYLOAD = window.workingDir + '/devilutionx.elf'; - - return { - mainText: "DevilutionX", - secondaryText: 'Diablo build for modern OSes', - onclick: async () => { - return { - path: PAYLOAD, - args: '' - }; - }, - options: [ - { - text: "Force Shareware mode", - onclick: async () => { - return { - path: PAYLOAD, - args: '--spawn' - }; - } - }, - { - text: "Force Diablo mode", - onclick: async () => { - return { - path: PAYLOAD, - args: '--diablo' - }; - } - }, - { - text: "Force Hellfire mode", - onclick: async () => { - return { - path: PAYLOAD, - args: '--hellfire' - }; - } - } - ] - }; -} +/* Copyright (C) 2024 John Törnblom + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +async function main() { + const PAYLOAD = window.workingDir + '/devilutionx.elf'; + + return { + mainText: "DevilutionX", + secondaryText: 'Diablo build for modern OSes', + onclick: async () => { + return { + path: PAYLOAD, + args: '' + }; + }, + options: [ + { + text: "Force Shareware mode", + onclick: async () => { + return { + path: PAYLOAD, + args: '--spawn' + }; + } + }, + { + text: "Force Diablo mode", + onclick: async () => { + return { + path: PAYLOAD, + args: '--diablo' + }; + } + }, + { + text: "Force Hellfire mode", + onclick: async () => { + return { + path: PAYLOAD, + args: '--hellfire' + }; + } + } + ] + }; +} diff --git a/homebrew/FBNeo/homebrew.js b/homebrew/FBNeo/homebrew.js index ce08f9d..29d2704 100755 --- a/homebrew/FBNeo/homebrew.js +++ b/homebrew/FBNeo/homebrew.js @@ -1,34 +1,34 @@ -/* Copyright (C) 2024 John Törnblom - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -async function main() { - const PAYLOAD = window.workingDir + '/fbneo.elf'; - const ARGS = ['-menu', '-integerscale', '-fullscreen', '-joy'] - const ENVVARS = {FBNEO_CONFIG_PATH: window.workingDir + '/config'}; - - return { - mainText: "FinalBurn Neo", - secondaryText: 'An emulator for arcade games', - onclick: async () => { - return { - path: PAYLOAD, - args: ARGS, - env: ENVVARS - }; - } - }; -} +/* Copyright (C) 2024 John Törnblom + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +async function main() { + const PAYLOAD = window.workingDir + '/fbneo.elf'; + const ARGS = ['-menu', '-integerscale', '-fullscreen', '-joy'] + const ENVVARS = {FBNEO_CONFIG_PATH: window.workingDir + '/config'}; + + return { + mainText: "FinalBurn Neo", + secondaryText: 'An emulator for arcade games', + onclick: async () => { + return { + path: PAYLOAD, + args: ARGS, + env: ENVVARS + }; + } + }; +} diff --git a/homebrew/FFplay/homebrew.js b/homebrew/FFplay/homebrew.js index db93da6..e81d715 100755 --- a/homebrew/FFplay/homebrew.js +++ b/homebrew/FFplay/homebrew.js @@ -1,36 +1,36 @@ -/* Copyright (C) 2024 John Törnblom - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -async function main() { - const PAYLOAD = window.workingDir + '/ffplay.elf'; - const MEDIADIR = window.workingDir + '/media/'; - - return { - mainText: "FFplay", - secondaryText: 'FFmpeg Media Player', - onclick: async () => { - const file = await pickFile(MEDIADIR); - if(!file) { - return; - } - return { - path: PAYLOAD, - args: ['-fs', file] - }; - } - }; -} +/* Copyright (C) 2024 John Törnblom + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +async function main() { + const PAYLOAD = window.workingDir + '/ffplay.elf'; + const MEDIADIR = window.workingDir + '/media/'; + + return { + mainText: "FFplay", + secondaryText: 'FFmpeg Media Player', + onclick: async () => { + const file = await pickFile(MEDIADIR); + if(!file) { + return; + } + return { + path: PAYLOAD, + args: ['-fs', file] + }; + } + }; +} diff --git a/homebrew/LakeSnes/homebrew.js b/homebrew/LakeSnes/homebrew.js index d49e06f..498107c 100644 --- a/homebrew/LakeSnes/homebrew.js +++ b/homebrew/LakeSnes/homebrew.js @@ -1,48 +1,48 @@ -/* Copyright (C) 2024 John Törnblom - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -async function main() { - const PAYLOAD = window.workingDir + '/lakesnes.elf'; - const ROMDIR = window.workingDir + '/roms/'; - const ROMTYPES = ['smc', 'sfc'] - - async function getRomList() { - let listing = await ApiClient.fsListDir(ROMDIR); - return listing.filter(entry => - ROMTYPES.includes(entry.name.slice(-3))).map(entry => { - const name = entry.name.slice(0, -4); - return { - mainText: name, - imgPath: '/fs/' + ROMDIR + name + '.jpg', - onclick: async() => { - return { - path: PAYLOAD, - args: ROMDIR + entry.name - } - } - }; - }); - } - return { - mainText: "LakeSnes", - secondaryText: 'Super Nintendo Emulator', - onclick: async () => { - let items = await getRomList(); - showCarousel(items); - } - }; -} +/* Copyright (C) 2024 John Törnblom + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +async function main() { + const PAYLOAD = window.workingDir + '/lakesnes.elf'; + const ROMDIR = window.workingDir + '/roms/'; + const ROMTYPES = ['smc', 'sfc'] + + async function getRomList() { + let listing = await ApiClient.fsListDir(ROMDIR); + return listing.filter(entry => + ROMTYPES.includes(entry.name.slice(-3))).map(entry => { + const name = entry.name.slice(0, -4); + return { + mainText: name, + imgPath: '/fs/' + ROMDIR + name + '.jpg', + onclick: async() => { + return { + path: PAYLOAD, + args: ROMDIR + entry.name + } + } + }; + }); + } + return { + mainText: "LakeSnes", + secondaryText: 'Super Nintendo Emulator', + onclick: async () => { + let items = await getRomList(); + showCarousel(items); + } + }; +} diff --git a/homebrew/Mednafen/homebrew.js b/homebrew/Mednafen/homebrew.js index c320042..af6c18b 100644 --- a/homebrew/Mednafen/homebrew.js +++ b/homebrew/Mednafen/homebrew.js @@ -1,134 +1,134 @@ -/* Copyright (C) 2024 John Törnblom - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -async function main() { - const PAYLOAD = window.workingDir + '/mednafen.elf'; - const ENVVARS = {MEDNAFEN_HOME: window.workingDir}; - - const ROMDIR = window.workingDir + '/roms/'; - const ROMTYPES = ['cue', 'dsk', 'gb', 'gba', 'gbc', 'gen', 'gg', 'lnx', - 'm3u', 'nes', 'ngp', 'pce', 'psx', 'sfc', 'smc', 'sms', - 'vb', 'ws', 'wsc', 'zip']; - - function getRomType(filename) { - const dotIndex = filename.lastIndexOf('.'); - if (dotIndex === -1) { - return ''; - } - return filename.slice(dotIndex + 1); - } - - function getRomName(filename) { - const dotIndex = filename.lastIndexOf('.'); - if (dotIndex === -1) { - return filename; - } - - return filename.slice(0, dotIndex); - } - - function getPlatformName(romType) { - switch(romType) { - case 'dsk': return 'Apple ]['; - case 'lnx': return 'Atari Lynx'; - case 'gb': return 'Game Boy'; - case 'gba': return 'Game Boy Advance'; - case 'gbc': return 'Game Boy Color'; - case 'gen': return 'Sega MegaDrive'; - case 'gg': return 'Sega Game Gear'; - case 'nes': return 'Nintendo Entertainment System'; - case 'ngp': return 'Neo Geo Pocket'; - case 'pce': return 'PC Engine'; - case 'psx': return 'Sony Playstation'; - case 'sfc': - case 'smc': return 'Super Nintendo'; - case 'sms': return 'Sega Master System'; - case 'vb': return 'Virtual Boy'; - case 'ws': return 'WonderSwan'; - case 'wsc': return 'WonderSwan Color'; - default: return ''; - } - } - - async function checkApiVersion() { - try { - const ver = await ApiClient.getVersion(); - console.log(ver); - if (ver.api > 0) { - return true; - } - } catch(error) { - console.error(error); - } - - alert('Incompatible web server'); - return false; - } - - async function getRomList() { - let listing = await ApiClient.fsListDir(ROMDIR); - return listing.filter(entry => - ROMTYPES.includes(getRomType(entry.name))).map(entry => { - const romType = getRomType(entry.name); - const romName = getRomName(entry.name); - const platformName = getPlatformName(romType); - - return { - mainText: romName, - secondaryText: platformName, - imgPath: '/fs/' + ROMDIR + romName + '.jpg', - onclick: async() => { - return { - path: PAYLOAD, - args: ROMDIR + entry.name, - env: ENVVARS - } - } - }; - }); - } - - return { - mainText: "Mednafen", - secondaryText: 'Multi-system Emulator', - onclick: async () => { - if(await checkApiVersion()) { - let items = await getRomList(); - showCarousel(items); - } - }, - options: [ - { - text: "Browse ROM...", - onclick: async () => { - if(await checkApiVersion()) { - const file = await pickFile(window.workingDir, "Select ROM..."); - if(!file) { - return; - } - return { - path: PAYLOAD, - args: file, - env: ENVVARS - }; - } - } - } - ] - }; -} - +/* Copyright (C) 2024 John Törnblom + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +async function main() { + const PAYLOAD = window.workingDir + '/mednafen.elf'; + const ENVVARS = {MEDNAFEN_HOME: window.workingDir}; + + const ROMDIR = window.workingDir + '/roms/'; + const ROMTYPES = ['cue', 'dsk', 'gb', 'gba', 'gbc', 'gen', 'gg', 'lnx', + 'm3u', 'nes', 'ngp', 'pce', 'psx', 'sfc', 'smc', 'sms', + 'vb', 'ws', 'wsc', 'zip']; + + function getRomType(filename) { + const dotIndex = filename.lastIndexOf('.'); + if (dotIndex === -1) { + return ''; + } + return filename.slice(dotIndex + 1); + } + + function getRomName(filename) { + const dotIndex = filename.lastIndexOf('.'); + if (dotIndex === -1) { + return filename; + } + + return filename.slice(0, dotIndex); + } + + function getPlatformName(romType) { + switch(romType) { + case 'dsk': return 'Apple ]['; + case 'lnx': return 'Atari Lynx'; + case 'gb': return 'Game Boy'; + case 'gba': return 'Game Boy Advance'; + case 'gbc': return 'Game Boy Color'; + case 'gen': return 'Sega MegaDrive'; + case 'gg': return 'Sega Game Gear'; + case 'nes': return 'Nintendo Entertainment System'; + case 'ngp': return 'Neo Geo Pocket'; + case 'pce': return 'PC Engine'; + case 'psx': return 'Sony Playstation'; + case 'sfc': + case 'smc': return 'Super Nintendo'; + case 'sms': return 'Sega Master System'; + case 'vb': return 'Virtual Boy'; + case 'ws': return 'WonderSwan'; + case 'wsc': return 'WonderSwan Color'; + default: return ''; + } + } + + async function checkApiVersion() { + try { + const ver = await ApiClient.getVersion(); + console.log(ver); + if (ver.api > 0) { + return true; + } + } catch(error) { + console.error(error); + } + + alert('Incompatible web server'); + return false; + } + + async function getRomList() { + let listing = await ApiClient.fsListDir(ROMDIR); + return listing.filter(entry => + ROMTYPES.includes(getRomType(entry.name))).map(entry => { + const romType = getRomType(entry.name); + const romName = getRomName(entry.name); + const platformName = getPlatformName(romType); + + return { + mainText: romName, + secondaryText: platformName, + imgPath: '/fs/' + ROMDIR + romName + '.jpg', + onclick: async() => { + return { + path: PAYLOAD, + args: ROMDIR + entry.name, + env: ENVVARS + } + } + }; + }); + } + + return { + mainText: "Mednafen", + secondaryText: 'Multi-system Emulator', + onclick: async () => { + if(await checkApiVersion()) { + let items = await getRomList(); + showCarousel(items); + } + }, + options: [ + { + text: "Browse ROM...", + onclick: async () => { + if(await checkApiVersion()) { + const file = await pickFile(window.workingDir, "Select ROM..."); + if(!file) { + return; + } + return { + path: PAYLOAD, + args: file, + env: ENVVARS + }; + } + } + } + ] + }; +} + diff --git a/homebrew/OffAct/homebrew.js b/homebrew/OffAct/homebrew.js index d84aca3..33f4302 100644 --- a/homebrew/OffAct/homebrew.js +++ b/homebrew/OffAct/homebrew.js @@ -1,30 +1,30 @@ -/* Copyright (C) 2024 John Törnblom - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -async function main() { - const PAYLOAD = window.workingDir + '/OffAct.elf'; - - return { - mainText: "OffAct", - secondaryText: 'Offline account activation', - onclick: async () => { - return { - path: PAYLOAD - }; - } - }; -} +/* Copyright (C) 2024 John Törnblom + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +async function main() { + const PAYLOAD = window.workingDir + '/OffAct.elf'; + + return { + mainText: "OffAct", + secondaryText: 'Offline account activation', + onclick: async () => { + return { + path: PAYLOAD + }; + } + }; +} diff --git a/homebrew/SVTplay/homebrew.js b/homebrew/SVTplay/homebrew.js index 5f318ca..3f7e3f8 100644 --- a/homebrew/SVTplay/homebrew.js +++ b/homebrew/SVTplay/homebrew.js @@ -1,78 +1,78 @@ -/* Copyright (C) 2024 John Törnblom - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -async function main() { - const BASE_API_URL = "https://api.svt.se/"; - const BASE_IMG_URL = 'https://www.svtstatic.se/image/custom/400/'; - const PAYLOAD_PATH = window.workingDir + '/ffplay.elf'; - - async function resolveStreamURL(channelId) { - let response = await fetch(BASE_API_URL + 'video/' + channelId); - if (!response.ok) { - return ''; - } - let data = await response.json(); - for (const vidref of data.videoReferences) { - if(vidref.format == 'hls') { - return ['-fs', vidref.redirect]; - } - } - - return ''; - } - - async function getChannelList() { - const params = new URLSearchParams({ - "ua": "svtplaywebb-play-render-produnction-client", - "operationName": "ChannelsQuery", - "variables": "{}", - "extensions": JSON.stringify({ - "persistedQuery":{ - "sha256Hash":"21da6ea41fa3a9300a7b51071f9dc91317955a7dcddc800ddce58fc708e7c634", - "version" : 1 - } - }) - }); - - let response = await fetch(BASE_API_URL + "/contento/graphql?" + params.toString()); - if (!response.ok) { - return []; - } - - let data = await response.json(); - return data.data.channels.channels.map((ch) => ({ - mainText: ch.name, - secondaryText: ch.running.name, - imgPath:BASE_IMG_URL + ch.running.image.id + '/' + ch.running.image.changed, - onclick: async() => { - return { - path: PAYLOAD_PATH, - args: await resolveStreamURL(ch.id) - }; - } - })); - } - - return { - mainText: "SVT Play", - secondaryText: 'Live TV from Swedish public service', - onclick: async () => { - let items = await getChannelList(); - showCarousel(items); - } - }; -} +/* Copyright (C) 2024 John Törnblom + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +async function main() { + const BASE_API_URL = "https://api.svt.se/"; + const BASE_IMG_URL = 'https://www.svtstatic.se/image/custom/400/'; + const PAYLOAD_PATH = window.workingDir + '/ffplay.elf'; + + async function resolveStreamURL(channelId) { + let response = await fetch(BASE_API_URL + 'video/' + channelId); + if (!response.ok) { + return ''; + } + let data = await response.json(); + for (const vidref of data.videoReferences) { + if(vidref.format == 'hls') { + return ['-fs', vidref.redirect]; + } + } + + return ''; + } + + async function getChannelList() { + const params = new URLSearchParams({ + "ua": "svtplaywebb-play-render-produnction-client", + "operationName": "ChannelsQuery", + "variables": "{}", + "extensions": JSON.stringify({ + "persistedQuery":{ + "sha256Hash":"21da6ea41fa3a9300a7b51071f9dc91317955a7dcddc800ddce58fc708e7c634", + "version" : 1 + } + }) + }); + + let response = await fetch(BASE_API_URL + "/contento/graphql?" + params.toString()); + if (!response.ok) { + return []; + } + + let data = await response.json(); + return data.data.channels.channels.map((ch) => ({ + mainText: ch.name, + secondaryText: ch.running.name, + imgPath:BASE_IMG_URL + ch.running.image.id + '/' + ch.running.image.changed, + onclick: async() => { + return { + path: PAYLOAD_PATH, + args: await resolveStreamURL(ch.id) + }; + } + })); + } + + return { + mainText: "SVT Play", + secondaryText: 'Live TV from Swedish public service', + onclick: async () => { + let items = await getChannelList(); + showCarousel(items); + } + }; +} diff --git a/homebrew/SverigesRadio/homebrew.js b/homebrew/SverigesRadio/homebrew.js index 5fab6f0..2347290 100644 --- a/homebrew/SverigesRadio/homebrew.js +++ b/homebrew/SverigesRadio/homebrew.js @@ -1,85 +1,85 @@ -/* Copyright (C) 2024 John Törnblom - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU General Public License as published by the -Free Software Foundation; either version 3, or (at your option) any -later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; see the file COPYING. If not, see -. */ - - -async function main() { - const BASE_API_URL = "http://api.sr.se/api/v2/"; - const PAYLOAD_PATH = window.workingDir + '/ffplay.elf'; - - async function getChannelProgramme(chid) { - const params = new URLSearchParams({ - "channelid": chid, - "format": "json" - }); - - try { - let response = await fetch(BASE_API_URL + "/scheduledepisodes/rightnow?" + params.toString()); - if (response.ok) { - let data = await response.json(); - return data.channel.currentscheduledepisode; - } - } catch (error) { - } - } - - async function getChannelList() { - const params = new URLSearchParams({ - "format": "json", - "pagination": "false", - "audioquality": "hi" - }); - let response = await fetch(BASE_API_URL + "/channels?" + params.toString()); - if (!response.ok) { - return []; - } - - let data = await response.json(); - - return await Promise.all(data.channels.map(async (ch) => { - let img = ch.image; - let title = ''; - let proginfo = await getChannelProgramme(ch.id); - - if(proginfo != undefined) { - title = proginfo.title; - if(proginfo.socialimage) { - img = proginfo.socialimage; - } - } - - return { - mainText: ch.name, - secondaryText: title, - imgPath: img, - onclick: async () => { - return { - path: PAYLOAD_PATH, - args: ch.liveaudio.url - }; - } - }; - })); - } - - return { - mainText: "Sveriges Radio", - secondaryText: 'Live Radio from Swedish public service', - onclick: async () => { - let items = await getChannelList(); - showCarousel(items); - } - }; -} +/* Copyright (C) 2024 John Törnblom + +This program is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the +Free Software Foundation; either version 3, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; see the file COPYING. If not, see +. */ + + +async function main() { + const BASE_API_URL = "http://api.sr.se/api/v2/"; + const PAYLOAD_PATH = window.workingDir + '/ffplay.elf'; + + async function getChannelProgramme(chid) { + const params = new URLSearchParams({ + "channelid": chid, + "format": "json" + }); + + try { + let response = await fetch(BASE_API_URL + "/scheduledepisodes/rightnow?" + params.toString()); + if (response.ok) { + let data = await response.json(); + return data.channel.currentscheduledepisode; + } + } catch (error) { + } + } + + async function getChannelList() { + const params = new URLSearchParams({ + "format": "json", + "pagination": "false", + "audioquality": "hi" + }); + let response = await fetch(BASE_API_URL + "/channels?" + params.toString()); + if (!response.ok) { + return []; + } + + let data = await response.json(); + + return await Promise.all(data.channels.map(async (ch) => { + let img = ch.image; + let title = ''; + let proginfo = await getChannelProgramme(ch.id); + + if(proginfo != undefined) { + title = proginfo.title; + if(proginfo.socialimage) { + img = proginfo.socialimage; + } + } + + return { + mainText: ch.name, + secondaryText: title, + imgPath: img, + onclick: async () => { + return { + path: PAYLOAD_PATH, + args: ch.liveaudio.url + }; + } + }; + })); + } + + return { + mainText: "Sveriges Radio", + secondaryText: 'Live Radio from Swedish public service', + onclick: async () => { + let items = await getChannelList(); + showCarousel(items); + } + }; +} diff --git a/homebrew/demo/homebrew.js b/homebrew/demo/homebrew.js index 5e8c2a8..e919081 100644 --- a/homebrew/demo/homebrew.js +++ b/homebrew/demo/homebrew.js @@ -1,60 +1,60 @@ -// must be called main -// main must return in 5 seconds, other stuff like onclicks dont have such restrictions -async function main() { - let stateTest = "State is preserved"; - // must return an object like this - return { - // every field of this is optional - mainText: "demo", - secondaryText: window.workingDir, // injected, contains the path to the folder containing this js file - imgPath: baseURL + "/fs/data/homebrew/demo/proxy-image.png", // the image in sce_sys/icon0.png wont show bc this overrides it - onclick: async () => { - // get rom infos here or you can create a function for it outside - - // one second delay to test the spinner - await new Promise(resolve => setTimeout(resolve, 1000)); - - // this is the format you need to pass to showCarousel - let items = [ - { - mainText: "The Final Insect (Taurus 1993)", - imgPath: baseURL + "/fs/data/homebrew/demo/roms/1/img.jpg", - onclick: async () => { - await ApiClient.launchApp("/fs/data/homebrew/demo/demo.elf"); - return true; // return true to indicate stuff has launched and show you can exit now text - }, - options: [ - { - text: "Enter args", - onclick: () => { alert(prompt("Enter args")); } - } - ] - }, - { - mainText: stateTest, - secondaryText: "bottom text", - onclick: () => { - return false; - } - }, - { - mainText: "Pick file...", - onclick: async () => { alert("This alert is coming from the extension, path:" + await pickFile(window.workingDir)); } // pickfile arg = initial path, optional - } - ]; - - showCarousel(items); - }, - options: [ - { - text: "Error test", - onclick: () => { throw new Error("Testing"); } - }, - { - text: "Option 2", - onclick: () => { alert("Option 2"); } - } - ] - - }; -} +// must be called main +// main must return in 5 seconds, other stuff like onclicks dont have such restrictions +async function main() { + let stateTest = "State is preserved"; + // must return an object like this + return { + // every field of this is optional + mainText: "demo", + secondaryText: window.workingDir, // injected, contains the path to the folder containing this js file + imgPath: baseURL + "/fs/data/homebrew/demo/proxy-image.png", // the image in sce_sys/icon0.png wont show bc this overrides it + onclick: async () => { + // get rom infos here or you can create a function for it outside + + // one second delay to test the spinner + await new Promise(resolve => setTimeout(resolve, 1000)); + + // this is the format you need to pass to showCarousel + let items = [ + { + mainText: "The Final Insect (Taurus 1993)", + imgPath: baseURL + "/fs/data/homebrew/demo/roms/1/img.jpg", + onclick: async () => { + await ApiClient.launchApp("/fs/data/homebrew/demo/demo.elf"); + return true; // return true to indicate stuff has launched and show you can exit now text + }, + options: [ + { + text: "Enter args", + onclick: () => { alert(prompt("Enter args")); } + } + ] + }, + { + mainText: stateTest, + secondaryText: "bottom text", + onclick: () => { + return false; + } + }, + { + mainText: "Pick file...", + onclick: async () => { alert("This alert is coming from the extension, path:" + await pickFile(window.workingDir)); } // pickfile arg = initial path, optional + } + ]; + + showCarousel(items); + }, + options: [ + { + text: "Error test", + onclick: () => { throw new Error("Testing"); } + }, + { + text: "Option 2", + onclick: () => { alert("Option 2"); } + } + ] + + }; +}