diff --git a/module/webui/scripts/applist.js b/module/webui/scripts/applist.js
index b5af484..ba93e5c 100644
--- a/module/webui/scripts/applist.js
+++ b/module/webui/scripts/applist.js
@@ -7,6 +7,17 @@ export const appListContainer = document.getElementById('apps-list');
export const updateCard = document.getElementById('update-card');
let targetList = [];
+let wrapInputStream;
+
+if (typeof $packageManager !== 'undefined') {
+ import("https://mui.kernelsu.org/internal/assets/ext/wrapInputStream.mjs")
+ .then(module => {
+ wrapInputStream = module.wrapInputStream;
+ })
+ .catch(err => {
+ console.error("Failed to load wrapInputStream:", err);
+ });
+}
// Fetch and render applist
export async function fetchAppList() {
@@ -44,6 +55,13 @@ export async function fetchAppList() {
packageName
};
}
+ if (typeof $packageManager !== 'undefined') {
+ const info = $packageManager.getApplicationInfo(packageName, 0, 0);
+ return {
+ appName: info.getLabel(),
+ packageName
+ };
+ }
return new Promise((resolve) => {
const output = spawn('sh', [`${basePath}/common/get_extra.sh`, '--appname', packageName],
{ env: { PATH: `$PATH:${basePath}/common:/data/data/com.termux/files/usr/bin` } });
@@ -75,24 +93,43 @@ function renderAppList(data) {
return (a.appName || "").localeCompare(b.appName || "");
});
- // Render
+ // Clear container
appListContainer.innerHTML = "";
- sortedApps.forEach(({ appName, packageName }) => {
+ loadingIndicator.style.display = "none";
+ hideFloatingBtn(false);
+ if (updateCard) appListContainer.appendChild(updateCard);
+
+ // Append app
+ const appendApps = (index) => {
+ if (index >= sortedApps.length) {
+ document.querySelector('.uninstall-container').classList.remove('hidden-uninstall');
+ toggleableCheckbox();
+ setupRadioButtonListeners();
+ setupModeMenu();
+ updateCheckboxColor();
+ applyRippleEffect();
+ if (typeof $packageManager !== 'undefined') {
+ setupIconIntersectionObserver();
+ }
+ return;
+ }
+
+ const { appName, packageName } = sortedApps[index];
const appElement = document.importNode(appTemplate, true);
const contentElement = appElement.querySelector(".content");
contentElement.setAttribute("data-package", packageName);
-
+
// Set unique names for radio button groups
const radioButtons = appElement.querySelectorAll('input[type="radio"]');
radioButtons.forEach((radio) => {
radio.name = `mode-radio-${packageName}`;
});
-
+
// Preselect the radio button based on the package name
const generateRadio = appElement.querySelector('#generate-mode');
const hackRadio = appElement.querySelector('#hack-mode');
const normalRadio = appElement.querySelector('#normal-mode');
-
+
if (appsWithExclamation.includes(packageName)) {
generateRadio.checked = true;
} else if (appsWithQuestion.includes(packageName)) {
@@ -100,9 +137,13 @@ function renderAppList(data) {
} else {
normalRadio.checked = true;
}
-
+
const nameElement = appElement.querySelector(".name");
nameElement.innerHTML = `
+
+
+
![]()
+
${appName}
${packageName}
@@ -111,19 +152,76 @@ function renderAppList(data) {
const checkbox = appElement.querySelector(".checkbox");
checkbox.checked = targetList.includes(packageName);
appListContainer.appendChild(appElement);
+ appendApps(index + 1);
+ };
+
+ appendApps(0);
+}
+
+/**
+ * Sets up an IntersectionObserver to load app icons when they enter the viewport
+ */
+function setupIconIntersectionObserver() {
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ const container = entry.target;
+ const packageName = container.querySelector('.app-icon').getAttribute('data-package');
+ if (packageName) {
+ loadIcons(packageName);
+ observer.unobserve(container);
+ }
+ }
+ });
+ }, {
+ rootMargin: '100px',
+ threshold: 0.1
});
- loadingIndicator.style.display = "none";
- hideFloatingBtn(false);
- document.querySelector('.uninstall-container').classList.remove('hidden-uninstall');
- toggleableCheckbox();
- if (appListContainer.firstChild !== updateCard) {
- appListContainer.insertBefore(updateCard, appListContainer.firstChild);
+ const iconContainers = document.querySelectorAll('.app-icon-container');
+ iconContainers.forEach(container => {
+ observer.observe(container);
+ });
+}
+
+const iconCache = new Map();
+
+/**
+ * Load all app icons asynchronously after UI is rendered
+ * @param {Array
} packageName - package names to load icons for
+ */
+function loadIcons(packageName) {
+ const imgElement = document.querySelector(`.app-icon[data-package="${packageName}"]`);
+ const loader = document.querySelector(`.loader[data-package="${packageName}"]`);
+
+ if (iconCache.has(packageName)) {
+ imgElement.src = iconCache.get(packageName);
+ loader.style.display = 'none';
+ imgElement.style.opacity = '1';
+ } else {
+ const stream = $packageManager.getApplicationIcon(packageName, 0, 0);
+ wrapInputStream(stream)
+ .then(r => r.arrayBuffer())
+ .then(buffer => {
+ const base64 = 'data:image/png;base64,' + arrayBufferToBase64(buffer);
+ iconCache.set(packageName, base64);
+ imgElement.src = base64;
+ loader.style.display = 'none';
+ imgElement.style.opacity = '1';
+ })
}
- setupRadioButtonListeners();
- setupModeMenu();
- updateCheckboxColor();
- applyRippleEffect();
+}
+
+/**
+ * convert array buffer to base 64
+ * @param {string} buffer
+ * @returns {string}
+ */
+function arrayBufferToBase64(buffer) {
+ const uint8Array = new Uint8Array(buffer);
+ let binary = '';
+ uint8Array.forEach(byte => binary += String.fromCharCode(byte));
+ return btoa(binary);
}
// Function to save app with ! and ? then process target list
@@ -262,4 +360,4 @@ function updateCheckboxColor() {
checkbox.classList.remove("checkbox-checked-generate", "checkbox-checked-hack");
}
});
-}
\ No newline at end of file
+}
diff --git a/module/webui/styles/applist.css b/module/webui/styles/applist.css
index 4986a33..d333279 100644
--- a/module/webui/styles/applist.css
+++ b/module/webui/styles/applist.css
@@ -218,13 +218,54 @@
}
.name {
- display: inline-block;
+ display: flex;
+ align-items: center;
margin: 0;
font-size: 15.5px;
max-width: calc(100% - 30px);
+ user-select: none;
+}
+
+.app-info {
+ display: inline-block;
overflow-wrap: break-word;
word-break: break-word;
- user-select: none;
+}
+
+.app-icon-container {
+ flex-shrink: 0;
+ height: 3em;
+ width: 3em;
+ margin-right: 10px;
+ position: relative;
+}
+
+.loader {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, var(--surfaceContainer), var(--surfaceContainerHigh), var(--surfaceContainer));
+ background-size: 200% 100%;
+ animation: shimmer 1.2s infinite linear;
+ border-radius: 6px;
+}
+
+.app-icon {
+ height: 100%;
+ opacity: 0;
+ transition: opacity 1s ease;
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: -200% 0;
+ }
+
+ 100% {
+ background-position: 200% 0;
+ }
}
.checkbox-wrapper {