Skip to content

Commit 81b408f

Browse files
committed
refactor: split monolithic app.js into modular frontend scripts
Break the ~1975-line app.js into 13 feature-focused classic <script> modules under static/js/, keeping app.js as a small bootstrap entry. Modules load in dependency order (core first, bootstrap last) sharing global scope, which avoids ES module/CORS issues under pywebview's file:// loading and requires no build step. index.html now loads the modules in order; the GUI static test was updated to concatenate all modules for content assertions.
1 parent 665a502 commit 81b408f

16 files changed

Lines changed: 2428 additions & 2102 deletions

plexmuxy_gui/static/app.js

Lines changed: 0 additions & 2087 deletions
Large diffs are not rendered by default.

plexmuxy_gui/static/index.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,19 @@ <h2 id="diagnostics-dialog-title" data-i18n="jobs.diagnostics.title">Job diagnos
749749
</dialog>
750750

751751
<script src="./i18n.js"></script>
752+
<script src="./js/core.js"></script>
753+
<script src="./js/dom-utils.js"></script>
754+
<script src="./js/custom-select.js"></script>
755+
<script src="./js/theme.js"></script>
756+
<script src="./js/locale.js"></script>
757+
<script src="./js/navigation.js"></script>
758+
<script src="./js/window.js"></script>
759+
<script src="./js/settings.js"></script>
760+
<script src="./js/plan.js"></script>
761+
<script src="./js/results.js"></script>
762+
<script src="./js/jobs.js"></script>
763+
<script src="./js/diagnostics.js"></script>
764+
<script src="./js/bindings.js"></script>
752765
<script src="./app.js"></script>
753766
</body>
754767
</html>

plexmuxy_gui/static/js/bindings.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
function bindEvents() {
2+
const bindings = [
3+
["window-minimize-btn", "click", minimizeWindow], ["window-maximize-btn", "click", toggleMaximizeWindow],
4+
["window-close-btn", "click", closeWindow], ["window-drag-region", "dblclick", toggleMaximizeWindow],
5+
["sidebar-toggle", "click", toggleSidebar], ["sidebar-scrim", "click", closeSidebar],
6+
["skip-link", "click", skipToMainContent],
7+
["alert-close-btn", "click", clearError],
8+
["choose-dir-btn", "click", chooseDirectory], ["open-config-btn", "click", openConfigLocation],
9+
["diagnostics-btn", "click", exportDiagnostics], ["save-settings-btn", "click", saveSettings],
10+
["save-environment-btn", "click", saveEnvironmentSettings], ["test-notification-btn", "click", testNotification],
11+
["plan-btn", "click", generatePlan], ["run-btn", "click", runMux], ["cancel-btn", "click", cancelJob],
12+
["plan-save-fab", "click", savePlanEdits],
13+
["refresh-jobs-btn", "click", loadJobs],
14+
["queue-toggle-btn", "click", toggleQueue], ["clear-font-cache-btn", "click", clearFontCache],
15+
["delete-all-jobs-btn", "click", deleteAllJobs],
16+
["diagnostics-export-btn", "click", exportDiagnosticsFromDialog],
17+
["diagnostics-copy-btn", "click", copyDiagnosticsFromDialog],
18+
["open-font-cache-btn", "click", () => callApi("open_font_cache_location")],
19+
["check-updates-btn", "click", checkUpdates],
20+
["input-dir", "input", (event) => { state.inputDir = event.target.value; clearReports(); }],
21+
["cleanup", "change", handleOverrideChange], ["extra-dir", "input", handleOverrideChange],
22+
["output-suffix", "input", handleOverrideChange], ["output-dir", "input", handleOverrideChange],
23+
["name-strategy", "change", handleOverrideChange], ["name-template", "input", handleOverrideChange],
24+
["overwrite", "change", handleOverrideChange], ["font-subset", "change", handleFontSubsetChange],
25+
["audio-filter-enabled", "change", handleOverrideChange],
26+
["audio-exclude-patterns", "input", handleOverrideChange], ["audio-keep-languages", "input", handleOverrideChange],
27+
["keep-default-audio", "change", handleOverrideChange], ["keep-unknown-audio", "change", handleOverrideChange],
28+
["allow-no-audio", "change", handleOverrideChange],
29+
["notifications-enabled", "change", handleEnvironmentChange],
30+
["font-cache-enabled", "change", handleEnvironmentChange], ["font-cache-max-size", "input", handleEnvironmentChange],
31+
["font-cache-max-age", "input", handleEnvironmentChange], ["updates-enabled", "change", handleEnvironmentChange],
32+
["plex-enabled", "change", handleEnvironmentChange], ["plex-server-url", "input", handleEnvironmentChange],
33+
["plex-section-id", "input", handleEnvironmentChange], ["plex-token-env", "input", handleEnvironmentChange],
34+
["plex-path-mappings", "input", handleEnvironmentChange],
35+
];
36+
bindings.forEach(([id, eventName, handler]) => {
37+
const element = $(id); const key = `bound${eventName}`;
38+
if (element && !element.dataset[key]) { element.addEventListener(eventName, handler); element.dataset[key] = "true"; }
39+
});
40+
document.querySelectorAll(".theme-option").forEach((button) => {
41+
if (button.classList.contains("language-option")) return;
42+
if (!button.dataset.boundClick) { button.addEventListener("click", chooseTheme); button.dataset.boundClick = "true"; }
43+
});
44+
document.querySelectorAll(".language-option").forEach((button) => {
45+
if (!button.dataset.boundClick) { button.addEventListener("click", chooseLocale); button.dataset.boundClick = "true"; }
46+
});
47+
document.querySelectorAll(".theme-menu").forEach((menu) => {
48+
if (!menu.dataset.boundToggle) { menu.addEventListener("toggle", closeSiblingMenus); menu.dataset.boundToggle = "true"; }
49+
});
50+
document.querySelectorAll(".step[href^='#']").forEach((link) => {
51+
if (!link.dataset.boundClick) { link.addEventListener("click", handleNavigationClick); link.dataset.boundClick = "true"; }
52+
});
53+
document.querySelectorAll(".dependency-browse").forEach((button) => {
54+
if (!button.dataset.boundClick) { button.addEventListener("click", chooseDependency); button.dataset.boundClick = "true"; }
55+
});
56+
document.querySelectorAll(".dependency-auto-detect").forEach((button) => {
57+
if (!button.dataset.boundClick) { button.addEventListener("click", autoDetectDependency); button.dataset.boundClick = "true"; }
58+
});
59+
document.querySelectorAll("[data-project-link]").forEach((button) => {
60+
if (!button.dataset.boundClick) { button.addEventListener("click", openProjectLink); button.dataset.boundClick = "true"; }
61+
});
62+
const installUnrarButton = $("install-unrar-btn");
63+
if (installUnrarButton && !installUnrarButton.dataset.boundClick) { installUnrarButton.addEventListener("click", installUnrarFromRarlab); installUnrarButton.dataset.boundClick = "true"; }
64+
if (document.body && !document.body.dataset.boundResponsiveUi) {
65+
window.addEventListener("resize", syncSidebarAccessibility);
66+
window.addEventListener("plexmuxy:localechange", handleLocaleChange);
67+
document.addEventListener("keydown", handleGlobalKeyDown);
68+
document.addEventListener("pointerdown", handleOutsideSelectPointer);
69+
window.addEventListener("hashchange", () => handleRoute(false));
70+
document.body.dataset.boundResponsiveUi = "true";
71+
}
72+
}
73+
74+

plexmuxy_gui/static/js/core.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const state = {
2+
appInfo: null, config: null, inputDir: "", planReport: null,
3+
runReport: null, loading: false, activeJobId: null, windowMaximized: false,
4+
settingsDirty: false, environmentDirty: false, themeMode: "system", systemThemeQuery: null, navigationObserver: null,
5+
navigationTarget: null, navigationScrollCleanup: null, localeMode: "system",
6+
loadingMessageKey: "loading.initializing", lastJobStatus: null, currentView: "workspace",
7+
routed: false, lastHandledRoute: "",
8+
lastNonSubsetFontMode: "all",
9+
planEdits: new Map(), jobs: [], queuePaused: false, activePreviewId: null,
10+
planSaving: false,
11+
planOrder: null, planData: null, collapsedCards: new Set(),
12+
dependencyDrafts: { mkvmerge: null, ffmpeg: null, unrar: null }, dependencyBusy: {},
13+
currentDiagnosticsJobId: null,
14+
};
15+
16+
const THEME_STORAGE_KEY = "plexmuxy-theme";
17+
const selectSearch = new WeakMap();
18+
19+
const $ = (id) => document.getElementById(id);
20+
const t = (key, variables = {}) => window.PlexMuxyI18n?.t(key, variables) ?? key;
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
function closeSiblingMenus(event) {
2+
if (!event.currentTarget.open) return;
3+
document.querySelectorAll(".theme-menu[open]").forEach((menu) => {
4+
if (menu !== event.currentTarget) menu.open = false;
5+
});
6+
}
7+
8+
9+
10+
function initializeCustomSelects() {
11+
document.querySelectorAll("[data-custom-select]").forEach((root) => {
12+
if (root.dataset.boundSelect) return;
13+
const trigger = root.querySelector('[role="combobox"]');
14+
const options = Array.from(root.querySelectorAll('[role="option"]'));
15+
trigger.addEventListener("click", () => toggleCustomSelect(root, true));
16+
trigger.addEventListener("keydown", (event) => handleSelectTriggerKey(event, root));
17+
options.forEach((option) => {
18+
option.addEventListener("click", () => selectCustomOption(root, option, true));
19+
option.addEventListener("keydown", (event) => handleSelectOptionKey(event, root, option));
20+
});
21+
root.dataset.boundSelect = "true";
22+
setCustomSelectValue(trigger.id, trigger.dataset.value || options[0]?.dataset.value || "", false);
23+
});
24+
}
25+
26+
27+
28+
function handleSelectTriggerKey(event, root) {
29+
const options = customSelectOptions(root);
30+
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
31+
event.preventDefault();
32+
openCustomSelect(root, false);
33+
const selected = Math.max(0, options.findIndex((option) => option.getAttribute("aria-selected") === "true"));
34+
const index = event.key === "End" ? options.length - 1 : event.key === "Home" ? 0 : event.key === "ArrowUp" ? Math.max(0, selected - 1) : Math.min(options.length - 1, selected + 1);
35+
options[index]?.focus();
36+
return;
37+
}
38+
if (event.key === "Enter" || event.key === " ") {
39+
event.preventDefault();
40+
toggleCustomSelect(root, true);
41+
return;
42+
}
43+
if (event.key === "Escape") { closeCustomSelect(root, true); return; }
44+
if (isTypeaheadKey(event)) {
45+
const match = findTypeaheadMatch(root, event.key);
46+
if (match) { event.preventDefault(); selectCustomOption(root, match, true, false); }
47+
}
48+
}
49+
50+
51+
52+
function handleSelectOptionKey(event, root, option) {
53+
const options = customSelectOptions(root); const index = options.indexOf(option);
54+
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
55+
event.preventDefault();
56+
const next = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : event.key === "ArrowDown" ? (index + 1) % options.length : (index - 1 + options.length) % options.length;
57+
options[next]?.focus();
58+
return;
59+
}
60+
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectCustomOption(root, option, true); return; }
61+
if (event.key === "Escape") { event.preventDefault(); closeCustomSelect(root, true); return; }
62+
if (event.key === "Tab") { closeCustomSelect(root, false); return; }
63+
if (isTypeaheadKey(event)) {
64+
const match = findTypeaheadMatch(root, event.key);
65+
if (match) { event.preventDefault(); match.focus(); }
66+
}
67+
}
68+
69+
70+
71+
function isTypeaheadKey(event) {
72+
return event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey;
73+
}
74+
75+
76+
77+
function findTypeaheadMatch(root, character) {
78+
const now = Date.now(); const previous = selectSearch.get(root);
79+
const query = previous && now - previous.time < 700 ? previous.query + character : character;
80+
selectSearch.set(root, { query, time: now });
81+
const normalized = query.toLocaleLowerCase();
82+
return customSelectOptions(root).find((option) => option.textContent.trim().toLocaleLowerCase().startsWith(normalized));
83+
}
84+
85+
86+
87+
function customSelectOptions(root) { return Array.from(root.querySelectorAll('[role="option"]')); }
88+
89+
90+
91+
function toggleCustomSelect(root, focusOption) {
92+
const trigger = root.querySelector('[role="combobox"]');
93+
if (trigger.disabled) return;
94+
if (trigger.getAttribute("aria-expanded") === "true") closeCustomSelect(root, true);
95+
else openCustomSelect(root, focusOption);
96+
}
97+
98+
99+
100+
function openCustomSelect(root, focusOption = true) {
101+
closeAllCustomSelects(root);
102+
const trigger = root.querySelector('[role="combobox"]'); const listbox = root.querySelector('[role="listbox"]');
103+
listbox.hidden = false;
104+
trigger.setAttribute("aria-expanded", "true");
105+
root.classList.add("is-open");
106+
root.classList.remove("opens-up");
107+
const triggerRect = trigger.getBoundingClientRect(); const listRect = listbox.getBoundingClientRect();
108+
const below = window.innerHeight - triggerRect.bottom; const above = triggerRect.top;
109+
if (below < listRect.height + 12 && above > below) root.classList.add("opens-up");
110+
if (focusOption) {
111+
const selected = customSelectOptions(root).find((option) => option.getAttribute("aria-selected") === "true");
112+
(selected || customSelectOptions(root)[0])?.focus();
113+
}
114+
}
115+
116+
117+
118+
function closeCustomSelect(root, restoreFocus = false) {
119+
const trigger = root.querySelector('[role="combobox"]'); const listbox = root.querySelector('[role="listbox"]');
120+
if (!trigger || !listbox) return;
121+
trigger.setAttribute("aria-expanded", "false");
122+
listbox.hidden = true;
123+
root.classList.remove("is-open", "opens-up");
124+
if (restoreFocus) trigger.focus();
125+
}
126+
127+
128+
129+
function closeAllCustomSelects(except = null) {
130+
document.querySelectorAll("[data-custom-select].is-open").forEach((root) => { if (root !== except) closeCustomSelect(root, false); });
131+
}
132+
133+
134+
135+
function handleOutsideSelectPointer(event) {
136+
document.querySelectorAll("[data-custom-select].is-open").forEach((root) => { if (!root.contains(event.target)) closeCustomSelect(root, false); });
137+
}
138+
139+
140+
141+
function selectCustomOption(root, option, emitChange, restoreFocus = true) {
142+
if (isCustomOptionDisabled(option)) { closeCustomSelect(root, restoreFocus); return; }
143+
const trigger = root.querySelector('[role="combobox"]');
144+
setCustomSelectValue(trigger.id, option.dataset.value, emitChange);
145+
closeCustomSelect(root, restoreFocus);
146+
}
147+
148+
149+
150+
function isCustomOptionDisabled(option) {
151+
return Boolean(option?.disabled) || option?.getAttribute("aria-disabled") === "true";
152+
}
153+
154+
155+
156+
function setCustomSelectValue(id, value, emitChange = false) {
157+
const trigger = $(id); if (!trigger) return;
158+
const root = trigger.closest("[data-custom-select]");
159+
const options = customSelectOptions(root);
160+
const selected = options.find((option) => option.dataset.value === String(value)) || options[0];
161+
if (!selected) return;
162+
trigger.dataset.value = selected.dataset.value;
163+
options.forEach((option) => option.setAttribute("aria-selected", String(option === selected)));
164+
const label = trigger.querySelector("[data-select-label]");
165+
if (label) label.textContent = selected.textContent.trim();
166+
if (emitChange) trigger.dispatchEvent(new Event("change", { bubbles: true }));
167+
}
168+
169+
170+
171+
function getCustomSelectValue(id) { return $(id)?.dataset.value || ""; }
172+
173+
174+
175+
function syncCustomSelectLabels() {
176+
document.querySelectorAll("[data-custom-select] [role=combobox]").forEach((trigger) => setCustomSelectValue(trigger.id, trigger.dataset.value, false));
177+
}
178+
179+
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
async function exportDiagnostics() {
2+
clearError();
3+
try {
4+
const result = await callApi("export_diagnostics");
5+
showToast(t("toast.diagnostics.body", { path: result.path }), "success", t("toast.diagnostics.title"), {
6+
label: t("toast.diagnostics.open"),
7+
callback: async () => { try { await callApi("open_diagnostics_location"); } catch (error) { showToast(error.message, "error", t("toast.environmentError.title")); } },
8+
}, 12000);
9+
} catch (error) { showError(error.message); }
10+
}
11+
12+
13+
14+
async function previewJobDiagnostics(job) {
15+
state.currentDiagnosticsJobId = job.id;
16+
const dialog = $("diagnostics-dialog");
17+
const content = $("diagnostics-content");
18+
content.textContent = t("jobs.diagnostics.loading");
19+
dialog.returnValue = "";
20+
dialog.showModal();
21+
try {
22+
const result = await callApi("get_job_diagnostics", job.id);
23+
content.textContent = result.text;
24+
} catch (error) {
25+
content.textContent = error.message;
26+
}
27+
}
28+
29+
30+
31+
async function exportDiagnosticsFromDialog() {
32+
const jobId = state.currentDiagnosticsJobId;
33+
const dialog = $("diagnostics-dialog");
34+
if (!jobId) return;
35+
dialog.querySelector("#diagnostics-export-btn").disabled = true;
36+
try {
37+
const result = await callApi("export_job_diagnostics", jobId);
38+
showToast(t("toast.diagnostics.body", { path: result.path }), "success", t("toast.diagnostics.title"), {
39+
label: t("toast.diagnostics.open"),
40+
callback: async () => { try { await callApi("open_diagnostics_location"); } catch (error) { showToast(error.message, "error", t("toast.environmentError.title")); } },
41+
}, 12000);
42+
} catch (error) { showError(error.message); }
43+
finally { dialog.querySelector("#diagnostics-export-btn").disabled = false; }
44+
}
45+
46+
47+
48+
async function copyDiagnosticsFromDialog() {
49+
const text = $("diagnostics-content").textContent;
50+
try {
51+
await navigator.clipboard.writeText(text);
52+
showToast(t("jobs.diagnostics.copied"), "success", t("toast.diagnostics.title"));
53+
} catch (error) {
54+
showToast(error.message, "error", t("toast.environmentError.title"));
55+
}
56+
}
57+
58+
59+
60+
async function callApi(method, payload) {
61+
if (!window.pywebview?.api?.[method]) throw new Error(t("error.bridgeUnavailable"));
62+
const response = payload === undefined ? await window.pywebview.api[method]() : await window.pywebview.api[method](payload);
63+
if (!response || response.ok !== true) throw new Error(response?.error || t("error.unknownApi"));
64+
return response.data;
65+
}
66+
67+
68+
69+
async function openProjectLink(event) {
70+
const button = event.currentTarget;
71+
button.disabled = true;
72+
try { await callApi("open_project_link", button.dataset.projectLink); }
73+
catch (error) { showToast(error.message, "error", t("toast.linkError.title")); }
74+
finally { button.disabled = false; }
75+
}
76+
77+

0 commit comments

Comments
 (0)