mirror of
https://github.com/danieladov/jellyfin-plugin-skin-manager.git
synced 2026-01-18 16:37:31 +01:00
V.3
This commit is contained in:
152
Jellyfin.Plugin.SkinManager/Configuration/ConfigController.js
Normal file
152
Jellyfin.Plugin.SkinManager/Configuration/ConfigController.js
Normal file
@@ -0,0 +1,152 @@
|
||||
class ConfigController {
|
||||
|
||||
constructor() {
|
||||
console.log("ConfigController initialized");
|
||||
this.pluginId = "e9ca8b8e-ca6d-40e7-85dc-58e536df8eb3";
|
||||
this.MANAGED_CSS_MARKER = "/* Skin Manager CSS */";
|
||||
}
|
||||
|
||||
async saveSkin(skin) {
|
||||
skin.name = new Date().toLocaleString() + " - " + skin.name;
|
||||
console.log("Saving skin:", skin);
|
||||
const config = await this.getPluginConfiguration();
|
||||
const serialized = this.serializeSkin(skin);
|
||||
if (serialized) {
|
||||
config.skinHistory.push(serialized);
|
||||
}
|
||||
const result = await ApiClient.updatePluginConfiguration(this.pluginId, config);
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
}
|
||||
|
||||
async loadHistorySkins() {
|
||||
const config = await this.getPluginConfiguration();
|
||||
return [...config.skinHistory]
|
||||
.reverse()
|
||||
.map(s => this.deserializeSkin(s))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
serializeSkin(skin) {
|
||||
try {
|
||||
// Convert the skin object into a JSON string
|
||||
return JSON.stringify(skin);
|
||||
} catch (error) {
|
||||
console.error("Error serializing the skin:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
deserializeSkin(serializedSkin) {
|
||||
try {
|
||||
// Parse the JSON string back into a JavaScript object
|
||||
const skinData = JSON.parse(serializedSkin);
|
||||
|
||||
return new Skin(skinData);
|
||||
} catch (error) {
|
||||
console.error("Error parsing the skin data:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveUserCss(css) {
|
||||
if (!css || !css.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await this.getPluginConfiguration();
|
||||
|
||||
const existingHistory = config.userCssHistory
|
||||
.map(entry => this.deserializeUserCssEntry(entry))
|
||||
.filter(Boolean);
|
||||
|
||||
const lastEntry = existingHistory[existingHistory.length - 1];
|
||||
if (lastEntry && lastEntry.css === css) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssEntry = this.createUserCssEntry(css);
|
||||
const serialized = this.serializeUserCssEntry(cssEntry);
|
||||
if (serialized) {
|
||||
config.userCssHistory.push(serialized);
|
||||
}
|
||||
await ApiClient.updatePluginConfiguration(this.pluginId, config);
|
||||
}
|
||||
|
||||
async loadUserCssHistory() {
|
||||
const config = await this.getPluginConfiguration();
|
||||
return config.userCssHistory
|
||||
.map(entry => this.deserializeUserCssEntry(entry))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
serializeUserCssEntry(entry) {
|
||||
try {
|
||||
return JSON.stringify(entry);
|
||||
} catch (error) {
|
||||
console.error("Error serializing user CSS entry:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
deserializeUserCssEntry(serializedEntry) {
|
||||
if (!serializedEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(serializedEntry);
|
||||
} catch (error) {
|
||||
console.error("Error parsing user CSS entry:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
createUserCssEntry(css) {
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateId();
|
||||
|
||||
return {
|
||||
id,
|
||||
css,
|
||||
savedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
generateId() {
|
||||
if (typeof window !== "undefined" && window.crypto && window.crypto.randomUUID) {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `user-css-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async getPluginConfiguration() {
|
||||
const config = await ApiClient.getPluginConfiguration(this.pluginId);
|
||||
if (!Array.isArray(config.skinHistory)) {
|
||||
config.skinHistory = [];
|
||||
}
|
||||
if (!Array.isArray(config.userCssHistory)) {
|
||||
config.userCssHistory = [];
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async setSelectedSkin(skinName) {
|
||||
const config = await this.getPluginConfiguration();
|
||||
config.selectedSkin = skinName || "";
|
||||
|
||||
const result = await ApiClient.updatePluginConfiguration(this.pluginId, config);
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
}
|
||||
|
||||
isManagedCss(css) {
|
||||
if (!css) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = css.trimStart();
|
||||
return trimmed.startsWith(this.MANAGED_CSS_MARKER)
|
||||
|| trimmed.startsWith("#Skin Manager CSS");
|
||||
}
|
||||
}
|
||||
325
Jellyfin.Plugin.SkinManager/Configuration/HistoryController.js
Normal file
325
Jellyfin.Plugin.SkinManager/Configuration/HistoryController.js
Normal file
@@ -0,0 +1,325 @@
|
||||
class HistoryController {
|
||||
constructor() {
|
||||
// Initialize ConfigController to handle skin serialization
|
||||
this.configController = new ConfigController();
|
||||
|
||||
// Array to keep track of the history of saved skins
|
||||
this.history = [];
|
||||
this.currentSkin = null;
|
||||
this.userCssHistory = [];
|
||||
this.currentCss = null;
|
||||
|
||||
this.selectElement = document.getElementById("cssOptions-history");
|
||||
this.optionsElement = document.getElementById("options-history");
|
||||
this.setSkinButton = document.getElementById("setSkin-history");
|
||||
|
||||
this.cssContainerElement = document.getElementById("cssHistoryContainer");
|
||||
this.cssSelectElement = document.getElementById("cssHistorySelect");
|
||||
this.cssMetaElement = document.getElementById("cssHistoryMeta");
|
||||
this.cssCodeElement = document.getElementById("cssHistoryCode");
|
||||
this.cssEmptyElement = document.getElementById("cssHistoryEmpty");
|
||||
this.restoreCssButton = document.getElementById("restoreCssButton");
|
||||
}
|
||||
|
||||
// Initialize history controller logic
|
||||
async init() {
|
||||
console.log("HistoryController initialized");
|
||||
this.history = await this.configController.loadHistorySkins();
|
||||
this.populateSelect();
|
||||
this.initEventListeners();
|
||||
|
||||
await this.loadUserCssHistory();
|
||||
this.renderCssHistory();
|
||||
this.initCssEventListeners();
|
||||
}
|
||||
|
||||
populateSelect() {
|
||||
this.selectElement.innerHTML = "";
|
||||
this.history.forEach((skin, index) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = index;
|
||||
option.textContent = skin.name;
|
||||
this.selectElement.appendChild(option);
|
||||
});
|
||||
|
||||
if (this.history.length > 0) {
|
||||
this.selectElement.value = 0;
|
||||
this.currentSkin = this.history[0];
|
||||
this.showSkin();
|
||||
}
|
||||
}
|
||||
|
||||
showSkin() {
|
||||
this.optionsElement.innerHTML = this.currentSkin.generateHTML({ includePreview: false });
|
||||
this.currentSkin.attachEventListeners();
|
||||
}
|
||||
|
||||
changeSkin() {
|
||||
const selectedIndex = this.selectElement.value;
|
||||
this.currentSkin = this.history[selectedIndex];
|
||||
this.showSkin();
|
||||
console.log(`Skin changed to: ${this.currentSkin.name}`);
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
this.setSkinButton.addEventListener('click', () => {
|
||||
if (this.currentSkin) {
|
||||
this.applyCurrentSkin();
|
||||
}
|
||||
});
|
||||
|
||||
this.selectElement.addEventListener('change', () => {
|
||||
this.changeSkin();
|
||||
});
|
||||
}
|
||||
|
||||
async loadUserCssHistory() {
|
||||
try {
|
||||
const history = await this.configController.loadUserCssHistory();
|
||||
this.userCssHistory = (history || [])
|
||||
.filter(entry => entry && typeof entry.css === "string")
|
||||
.map(entry => ({
|
||||
...entry,
|
||||
id: entry.id ? String(entry.id) : this.generateEntryId(entry)
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.savedAt || 0).getTime();
|
||||
const dateB = new Date(b.savedAt || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading user CSS history:", error);
|
||||
this.userCssHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
renderCssHistory() {
|
||||
if (!this.cssSelectElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cssSelectElement.innerHTML = "";
|
||||
|
||||
if (!this.userCssHistory.length) {
|
||||
this.cssSelectElement.disabled = true;
|
||||
this.updateCssContainerState(true);
|
||||
if (this.cssEmptyElement) {
|
||||
this.cssEmptyElement.textContent = "No saved CSS yet.";
|
||||
}
|
||||
this.clearCssDetail();
|
||||
return;
|
||||
}
|
||||
|
||||
this.cssSelectElement.disabled = false;
|
||||
this.userCssHistory.forEach(entry => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(entry.id);
|
||||
option.textContent = this.buildCssListLabel(entry);
|
||||
this.cssSelectElement.appendChild(option);
|
||||
});
|
||||
|
||||
const firstEntryId = String(this.userCssHistory[0].id);
|
||||
this.cssSelectElement.value = firstEntryId;
|
||||
this.selectCssEntry(firstEntryId);
|
||||
}
|
||||
|
||||
generateEntryId(entry) {
|
||||
if (entry.savedAt) {
|
||||
return `user-css-${entry.savedAt}`;
|
||||
}
|
||||
return `user-css-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
buildCssListLabel(entry) {
|
||||
const formattedDate = this.formatDate(entry.savedAt);
|
||||
return formattedDate ? `Saved on ${formattedDate}` : "Saved CSS";
|
||||
}
|
||||
|
||||
buildCssDetailMeta(entry) {
|
||||
if (!entry.savedAt) {
|
||||
return "";
|
||||
}
|
||||
return `Saved on ${this.formatDate(entry.savedAt)}`;
|
||||
}
|
||||
|
||||
formatDate(isoString) {
|
||||
if (!isoString) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return isoString;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
} catch (error) {
|
||||
console.warn("Unable to format date", error);
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
selectCssEntry(id) {
|
||||
const normalizedId = String(id);
|
||||
const entry = this.userCssHistory.find(item => String(item.id) === normalizedId);
|
||||
if (!entry) {
|
||||
this.clearCssDetail();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentCss = entry;
|
||||
this.updateCssContainerState(false);
|
||||
|
||||
if (this.cssSelectElement && this.cssSelectElement.value !== normalizedId) {
|
||||
this.cssSelectElement.value = normalizedId;
|
||||
}
|
||||
|
||||
const metaText = this.buildCssDetailMeta(entry);
|
||||
if (this.cssMetaElement) {
|
||||
this.cssMetaElement.textContent = metaText;
|
||||
this.cssMetaElement.style.display = metaText ? "block" : "none";
|
||||
}
|
||||
|
||||
if (this.cssCodeElement) {
|
||||
this.cssCodeElement.textContent = entry.css || "";
|
||||
}
|
||||
|
||||
if (this.cssEmptyElement) {
|
||||
this.cssEmptyElement.textContent = "Select a revision to inspect its content.";
|
||||
}
|
||||
|
||||
this.toggleRestoreButton(true);
|
||||
}
|
||||
|
||||
clearCssDetail() {
|
||||
this.currentCss = null;
|
||||
const hasEntries = this.userCssHistory.length > 0;
|
||||
this.updateCssContainerState(!hasEntries);
|
||||
|
||||
if (this.cssMetaElement) {
|
||||
this.cssMetaElement.textContent = "";
|
||||
this.cssMetaElement.style.display = "none";
|
||||
}
|
||||
|
||||
if (this.cssCodeElement) {
|
||||
this.cssCodeElement.textContent = "";
|
||||
}
|
||||
|
||||
if (this.cssSelectElement) {
|
||||
if (hasEntries) {
|
||||
this.cssSelectElement.disabled = false;
|
||||
} else {
|
||||
this.cssSelectElement.disabled = true;
|
||||
this.cssSelectElement.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cssEmptyElement) {
|
||||
this.cssEmptyElement.textContent = hasEntries
|
||||
? "Select a revision to inspect its content."
|
||||
: "No saved CSS yet.";
|
||||
}
|
||||
|
||||
this.toggleRestoreButton(false);
|
||||
}
|
||||
|
||||
updateCssContainerState(isEmpty) {
|
||||
if (this.cssContainerElement) {
|
||||
this.cssContainerElement.dataset.empty = isEmpty ? "true" : "false";
|
||||
}
|
||||
}
|
||||
|
||||
toggleRestoreButton(enabled) {
|
||||
if (!this.restoreCssButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.restoreCssButton.dataset.restoreState === "working") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.restoreCssButton.disabled = !enabled;
|
||||
}
|
||||
|
||||
initCssEventListeners() {
|
||||
if (this.cssSelectElement) {
|
||||
this.cssSelectElement.addEventListener("change", event => {
|
||||
const selectedId = event.target.value;
|
||||
this.selectCssEntry(selectedId);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.restoreCssButton) {
|
||||
this.restoreCssButton.addEventListener("click", () => {
|
||||
this.restoreCurrentCss();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async restoreCurrentCss() {
|
||||
if (!this.currentCss) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showRestoreProgress();
|
||||
|
||||
try {
|
||||
await this.applyCssToServer(this.currentCss.css);
|
||||
} catch (error) {
|
||||
console.error("Unable to restore CSS", error);
|
||||
this.showRestoreIdle();
|
||||
}
|
||||
}
|
||||
|
||||
async applyCssToServer(css) {
|
||||
const serverConfig = await ApiClient.getServerConfiguration();
|
||||
await ApiClient.updateServerConfiguration(serverConfig);
|
||||
|
||||
const brandingConfig = await ApiClient.getNamedConfiguration("branding");
|
||||
brandingConfig.CustomCss = css;
|
||||
await ApiClient.updateNamedConfiguration("branding", brandingConfig);
|
||||
|
||||
Dashboard.processServerConfigurationUpdateResult();
|
||||
window.location.reload(true);
|
||||
}
|
||||
|
||||
showRestoreProgress() {
|
||||
if (!this.restoreCssButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = this.restoreCssButton.querySelector("span");
|
||||
const originalText = label ? label.textContent : this.restoreCssButton.textContent;
|
||||
|
||||
if (label) {
|
||||
label.dataset.originalText = originalText;
|
||||
label.textContent = "Restoring...";
|
||||
} else {
|
||||
this.restoreCssButton.dataset.originalText = originalText;
|
||||
this.restoreCssButton.textContent = "Restoring...";
|
||||
}
|
||||
|
||||
this.restoreCssButton.dataset.restoreState = "working";
|
||||
this.restoreCssButton.disabled = true;
|
||||
}
|
||||
|
||||
showRestoreIdle() {
|
||||
if (!this.restoreCssButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = this.restoreCssButton.querySelector("span");
|
||||
const originalText = label ? label.dataset.originalText : this.restoreCssButton.dataset.originalText;
|
||||
|
||||
if (label && originalText) {
|
||||
label.textContent = originalText;
|
||||
delete label.dataset.originalText;
|
||||
} else if (originalText) {
|
||||
this.restoreCssButton.textContent = originalText;
|
||||
delete this.restoreCssButton.dataset.originalText;
|
||||
}
|
||||
|
||||
delete this.restoreCssButton.dataset.restoreState;
|
||||
this.restoreCssButton.disabled = !this.currentCss;
|
||||
}
|
||||
}
|
||||
388
Jellyfin.Plugin.SkinManager/Configuration/MainController.js
Normal file
388
Jellyfin.Plugin.SkinManager/Configuration/MainController.js
Normal file
@@ -0,0 +1,388 @@
|
||||
class MainController {
|
||||
constructor(jsonUrl) {
|
||||
this.jsonUrl = jsonUrl;
|
||||
this.skins = [];
|
||||
this.currentSkin = null;
|
||||
this.selectElement = document.getElementById("cssOptions");
|
||||
this.descriptionElement = document.getElementById("description");
|
||||
this.optionsElement = document.getElementById("options");
|
||||
this.setSkinButton = document.getElementById("setSkin");
|
||||
this.configController = new ConfigController();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const [json, currentSkin] = await Promise.all([
|
||||
this.fetchJson(),
|
||||
this.loadCurrentSkinFromHistory()
|
||||
]);
|
||||
this.loadSkins(json);
|
||||
this.injectCurrentSkin(currentSkin);
|
||||
this.populateSelect();
|
||||
this.initEventListeners();
|
||||
} catch (error) {
|
||||
console.error("Error cargando las skins:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchJson() {
|
||||
const response = await fetch(this.jsonUrl);
|
||||
if (!response.ok) throw new Error(`Error HTTP: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async loadCurrentSkinFromHistory() {
|
||||
try {
|
||||
const history = await this.configController.loadHistorySkins();
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const latest = history[0];
|
||||
return this.cloneAsCurrentSkin(latest);
|
||||
} catch (error) {
|
||||
console.warn("No se pudo cargar la skin actual:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
loadSkins(json) {
|
||||
this.skins = json.skins.map(s => new Skin(s));
|
||||
}
|
||||
|
||||
injectCurrentSkin(currentSkin) {
|
||||
if (!currentSkin) {
|
||||
return;
|
||||
}
|
||||
this.skins.unshift(currentSkin);
|
||||
}
|
||||
|
||||
cloneAsCurrentSkin(skin) {
|
||||
if (!skin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const plainSkin = JSON.parse(JSON.stringify(skin));
|
||||
const baseName = this.extractSkinBaseName(plainSkin.name);
|
||||
plainSkin.name = `Current Skin - ${baseName}`;
|
||||
plainSkin.description = plainSkin.description || "Skin currently applied with your saved settings.";
|
||||
|
||||
if (Array.isArray(plainSkin.categories)) {
|
||||
plainSkin.categories = plainSkin.categories.filter(cat => cat && cat.name !== "Custom CSS");
|
||||
}
|
||||
|
||||
return new Skin(plainSkin);
|
||||
}
|
||||
|
||||
extractSkinBaseName(name) {
|
||||
if (!name) {
|
||||
return "unknown";
|
||||
}
|
||||
const match = String(name).match(/-\s*(.+)$/);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
return String(name).trim();
|
||||
}
|
||||
|
||||
populateSelect() {
|
||||
this.selectElement.innerHTML = ""; // Limpiar por si acaso
|
||||
this.skins.forEach((skin, index) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = index;
|
||||
option.textContent = skin.name;
|
||||
this.selectElement.appendChild(option);
|
||||
});
|
||||
|
||||
// Mostrar la primera skin por defecto si existe
|
||||
if (this.skins.length > 0) {
|
||||
this.selectElement.value = 0;
|
||||
this.currentSkin = this.skins[0];
|
||||
this.showSkin();
|
||||
}
|
||||
}
|
||||
|
||||
showSkin() {
|
||||
this.optionsElement.innerHTML = this.currentSkin.generateHTML();
|
||||
this.currentSkin.attachEventListeners();
|
||||
}
|
||||
|
||||
updateSkinInfo(index) {
|
||||
const skin = this.skins[index];
|
||||
if (!skin) return;
|
||||
|
||||
// Actualizar descripción
|
||||
this.descriptionElement.textContent = `Skin: ${skin.name}`;
|
||||
|
||||
// Actualizar categorías
|
||||
this.optionsElement.innerHTML = "";
|
||||
skin.categories.forEach(cat => {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = cat;
|
||||
this.optionsElement.appendChild(div);
|
||||
});
|
||||
|
||||
// Aplicar el CSS
|
||||
this.applySkin(skin.css);
|
||||
}
|
||||
|
||||
|
||||
|
||||
changeSkin() {
|
||||
const selectedIndex = parseInt(this.selectElement.value, 10);
|
||||
if (Number.isNaN(selectedIndex) || !this.skins[selectedIndex]) {
|
||||
return;
|
||||
}
|
||||
this.currentSkin = this.skins[selectedIndex];
|
||||
this.showSkin();
|
||||
console.log(`Skin changed to: ${this.currentSkin.name}`);
|
||||
}
|
||||
|
||||
async applyCurrentSkin() {
|
||||
if (!this.currentSkin) return;
|
||||
|
||||
const css = this.currentSkin.generateCSS();
|
||||
const appliedSkinName = this.currentSkin.name;
|
||||
|
||||
try {
|
||||
const serverConfig = await ApiClient.getServerConfiguration();
|
||||
await ApiClient.updateServerConfiguration(serverConfig);
|
||||
|
||||
const brandingConfig = await ApiClient.getNamedConfiguration("branding");
|
||||
const existingCss = brandingConfig && typeof brandingConfig.CustomCss === "string"
|
||||
? brandingConfig.CustomCss
|
||||
: "";
|
||||
|
||||
if (existingCss && !this.configController.isManagedCss(existingCss)) {
|
||||
await this.configController.saveUserCss(existingCss);
|
||||
}
|
||||
|
||||
brandingConfig.CustomCss = css;
|
||||
await ApiClient.updateNamedConfiguration("branding", brandingConfig);
|
||||
Dashboard.processServerConfigurationUpdateResult();
|
||||
|
||||
// Save the skin to history after a successful update
|
||||
await this.configController.saveSkin(this.currentSkin);
|
||||
await this.configController.setSelectedSkin(appliedSkinName);
|
||||
|
||||
window.location.reload(true);
|
||||
} catch (error) {
|
||||
console.error("Error applying skin:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
initEventListeners() {
|
||||
this.setSkinButton.addEventListener('click', () => {
|
||||
if (this.currentSkin) {
|
||||
this.applyCurrentSkin();
|
||||
}
|
||||
});
|
||||
|
||||
this.selectElement.addEventListener('change', () => {
|
||||
this.changeSkin();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async initDebug() {
|
||||
try {
|
||||
const json = `{
|
||||
"skins": [
|
||||
{
|
||||
"name": "Finimalism",
|
||||
"description": "A modern, customizable skin for Jellyfin.",
|
||||
"css":"",
|
||||
"categories": [
|
||||
{
|
||||
"name": "Default",
|
||||
"controls": [
|
||||
{
|
||||
"type": "select",
|
||||
"label": "Mode",
|
||||
"description": "Select light or dark mode",
|
||||
"id": "mode",
|
||||
"default": "@import url('https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@latest/finimalism10.11.css');",
|
||||
"css": "%value%",
|
||||
"options": [
|
||||
{
|
||||
"label": "Colour",
|
||||
"value": "@import url('https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@latest/finimalism10.11.css');"
|
||||
},
|
||||
{
|
||||
"label": "Black",
|
||||
"value": "@import url('https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@latest/finimalism10.11-black.css');"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
},
|
||||
{
|
||||
"name": "JellySkin",
|
||||
"description": "A modern, customizable skin for Jellyfin.",
|
||||
"css": "@import url('https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/main.css');",
|
||||
"previews": [
|
||||
{
|
||||
"name": "Login Page",
|
||||
"url": "https://raw.githubusercontent.com/danieladov/jellyfin-plugin-skin-manager/master/src/img/Default/1.png"
|
||||
},
|
||||
{
|
||||
"name": "Home/Index Page",
|
||||
"url": "https://raw.githubusercontent.com/danieladov/jellyfin-plugin-skin-manager/master/src/img/Default/2.png"
|
||||
},
|
||||
{
|
||||
"name": "Library Page",
|
||||
"url": "https://raw.githubusercontent.com/danieladov/jellyfin-plugin-skin-manager/master/src/img/Default/3.png"
|
||||
},
|
||||
{
|
||||
"name": "Title page",
|
||||
"url": "https://raw.githubusercontent.com/danieladov/jellyfin-plugin-skin-manager/master/src/img/Default/4.png"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
{
|
||||
"name": "Default",
|
||||
"controls": [
|
||||
{
|
||||
"type": "fontPicker",
|
||||
"label": "Base Font",
|
||||
"description": "Select the base font for the skin",
|
||||
"id": "baseFont",
|
||||
"default": "Arial, sans-serif",
|
||||
"css": ":root { --base-font: %value%; }"
|
||||
},
|
||||
{
|
||||
"type": "color",
|
||||
"label": "Background Color",
|
||||
"description": "Set the background color of the skin",
|
||||
"id": "bgColor",
|
||||
"default": "#ffffff",
|
||||
"css": ":root { --bg-color: %value%; }"
|
||||
},
|
||||
{
|
||||
"type": "color",
|
||||
"label": "Text Color",
|
||||
"id": "textColor",
|
||||
"default": "#000000",
|
||||
"css": ":root { --text-color: %value%; }"
|
||||
},
|
||||
{
|
||||
"type": "slider",
|
||||
"label": "Font Size",
|
||||
"id": "fontSize",
|
||||
"min": 10,
|
||||
"max": 30,
|
||||
"default": 16,
|
||||
"css": ":root { --font-size: %value%px; }"
|
||||
},
|
||||
{
|
||||
"type": "checkbox",
|
||||
"label": "Dark Mode",
|
||||
"description": "Enable dark mode",
|
||||
"id": "checkbox",
|
||||
"default": true,
|
||||
"css": ":root { --dark-mode: %value%; }"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"label": "Border Radius",
|
||||
"description": "Set the border radius",
|
||||
"id": "borderRadius",
|
||||
"min": 0,
|
||||
"max": 50,
|
||||
"default": 0,
|
||||
"css": ":root { --border-radius: %value%px; }"
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"label": "Font Family",
|
||||
"description": "Select the font family",
|
||||
"id": "fontFamily",
|
||||
"default": "Arial",
|
||||
"css": ":root { --font-family: %value%; }",
|
||||
"options": [
|
||||
{
|
||||
"label": "Arial",
|
||||
"value": "Arial"
|
||||
},
|
||||
{
|
||||
"label": "Verdana",
|
||||
"value": "Verdana"
|
||||
},
|
||||
{
|
||||
"label": "Georgia",
|
||||
"value": "Georgia"
|
||||
},
|
||||
{
|
||||
"label": "Times New Roman",
|
||||
"value": "Times New Roman"
|
||||
},
|
||||
{
|
||||
"label": "Trebuchet MS",
|
||||
"value": "Trebuchet MS"
|
||||
},
|
||||
{
|
||||
"label": "Arial Black",
|
||||
"value": "Arial Black"
|
||||
},
|
||||
{
|
||||
"label": "Impact",
|
||||
"value": "Impact"
|
||||
},
|
||||
{
|
||||
"label": "Comic Sans MS",
|
||||
"value": "Comic Sans MS"
|
||||
},
|
||||
{
|
||||
"label": "Courier New",
|
||||
"value": "Courier New"
|
||||
},
|
||||
{
|
||||
"label": "Lucida Console",
|
||||
"value": "Lucida Console"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "name": "DarkSkin",
|
||||
"description": "A sleek dark theme for Jellyfin.",
|
||||
"css": "@import url('https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/dark.css');",
|
||||
"previews": [],
|
||||
"categories": [
|
||||
{
|
||||
"name": "Dark Mode",
|
||||
"controls": [
|
||||
{
|
||||
"type": "color",
|
||||
"label": "Background Color",
|
||||
"description": "Set the background color of the skin",
|
||||
"id": "bgColor",
|
||||
"default": "#121212",
|
||||
"css": ":root { --bg-color: %value%; }"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`;
|
||||
const jsonObj = JSON.parse(json);
|
||||
this.loadSkins(jsonObj);
|
||||
const currentSkin = await this.loadCurrentSkinFromHistory();
|
||||
this.injectCurrentSkin(currentSkin);
|
||||
this.populateSelect();
|
||||
this.initEventListeners();
|
||||
} catch (error) {
|
||||
console.error("Error cargando las skins:", error);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,14 @@ namespace Jellyfin.Plugin.SkinManager.Configuration
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
public string selectedSkin { get; set; }
|
||||
public string[] options { get; set; }
|
||||
public string[] skinHistory { get; set; }
|
||||
public string[] userCssHistory { get; set; }
|
||||
|
||||
public PluginConfiguration()
|
||||
{
|
||||
selectedSkin = "";
|
||||
options = Array.Empty<String>();
|
||||
skinHistory = Array.Empty<String>();
|
||||
userCssHistory = Array.Empty<String>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2341
Jellyfin.Plugin.SkinManager/Configuration/colorpicker.js
Normal file
2341
Jellyfin.Plugin.SkinManager/Configuration/colorpicker.js
Normal file
File diff suppressed because it is too large
Load Diff
16
Jellyfin.Plugin.SkinManager/Configuration/common.js
Normal file
16
Jellyfin.Plugin.SkinManager/Configuration/common.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const getConfigurationPageUrl = (name) => {
|
||||
return 'configurationpage?name=' + encodeURIComponent(name);
|
||||
}
|
||||
|
||||
function getTabs() {
|
||||
var tabs = [
|
||||
{
|
||||
href: getConfigurationPageUrl('SkinManager'),
|
||||
name: 'Skin Manager'
|
||||
},
|
||||
{
|
||||
href: getConfigurationPageUrl('history'),
|
||||
name: 'History'
|
||||
}];
|
||||
return tabs;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
class Category {
|
||||
|
||||
constructor(name, controls) {
|
||||
console.log('Initializing Category with controls:', controls);
|
||||
this.name = name;
|
||||
this.controls = controls.map(controlConfig => {
|
||||
switch (controlConfig.type) {
|
||||
case 'color':
|
||||
return new ColorControl(controlConfig);
|
||||
case 'slider':
|
||||
return new SliderControl(controlConfig);
|
||||
case 'checkbox':
|
||||
return new CheckBoxControl(controlConfig);
|
||||
case 'number':
|
||||
return new NumberControl(controlConfig);
|
||||
case 'select':
|
||||
return new SelectControl(controlConfig);
|
||||
case 'fontPicker':
|
||||
return new FontPickerControl(controlConfig);
|
||||
case 'textarea':
|
||||
return new TextAreaControl(controlConfig);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
generateHeader() {
|
||||
return `<fieldset class="verticalSection verticalSection-extrabottompadding"><legend>${this.name}</legend>`;
|
||||
}
|
||||
|
||||
generateFooter() {
|
||||
return "</fieldset>";
|
||||
}
|
||||
|
||||
generateHTML() {
|
||||
return this.generateHeader() + this.controls.map(control => control.generateHTML()).join('</br>') + this.generateFooter();
|
||||
}
|
||||
|
||||
generateCSS() {
|
||||
return this.controls.map(control => control.generateCSS()).join('\n');
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
this.controls.forEach(control => control.attachEventListeners());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
window.Category = Category;
|
||||
@@ -0,0 +1,28 @@
|
||||
class CheckBoxControl extends Control {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.type = 'checkbox';
|
||||
}
|
||||
|
||||
generateHTML() {
|
||||
var checkValue = this.value ? "checked" : "";
|
||||
return `<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input class = "checkbox" type="checkbox" is="emby-checkbox" id= ${this.id} ${checkValue}/>
|
||||
<span>${this.label}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${this.label}</div>
|
||||
</div>`;
|
||||
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
const checkbox = document.getElementById(this.id);
|
||||
checkbox.addEventListener('change', (event) => {
|
||||
this.value = event.target.checked;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
window.CheckBoxControl = CheckBoxControl;
|
||||
@@ -0,0 +1,84 @@
|
||||
class ColorControl extends Control {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.type = 'color';
|
||||
}
|
||||
|
||||
generateHTML() {
|
||||
return `
|
||||
<div class="inputContainer">
|
||||
<label for="${this.id}">${this.label}</label>
|
||||
<input
|
||||
class="color"
|
||||
type="text"
|
||||
id="${this.id}"
|
||||
name="${this.id}"
|
||||
data-css="${this.css}"
|
||||
/>
|
||||
<div class="fieldDescription">${this.description}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
this.initColorpicker();
|
||||
}
|
||||
|
||||
initColorpicker() {
|
||||
const $el = $("#" + this.id);
|
||||
|
||||
$el.spectrum({
|
||||
color: this.value,
|
||||
showInput: true,
|
||||
className: "full-spectrum",
|
||||
showInitial: true,
|
||||
showPalette: true,
|
||||
showAlpha: true,
|
||||
showSelectionPalette: true,
|
||||
maxSelectionSize: 10,
|
||||
preferredFormat: "hex",
|
||||
localStorageKey: "spectrum.demo",
|
||||
move: (color) => {
|
||||
this.value = this._formatColor(color);
|
||||
},
|
||||
show: function () {
|
||||
|
||||
},
|
||||
beforeShow: function () {
|
||||
|
||||
},
|
||||
hide: function () {
|
||||
|
||||
},
|
||||
change: (color) => {
|
||||
this.value = this._formatColor(color);
|
||||
$el.val(this.value).trigger("input");
|
||||
},
|
||||
palette: [
|
||||
["rgb(0, 0, 0)", "rgb(67, 67, 67)", "rgb(102, 102, 102)",
|
||||
"rgb(204, 204, 204)", "rgb(217, 217, 217)", "rgb(255, 255, 255)"
|
||||
],
|
||||
["rgb(152, 0, 0)", "rgb(255, 0, 0)", "rgb(255, 153, 0)", "rgb(255, 255, 0)", "rgb(0, 255, 0)",
|
||||
"rgb(0, 255, 255)", "rgb(74, 134, 232)", "rgb(0, 0, 255)", "rgb(153, 0, 255)", "rgb(255, 0, 255)"
|
||||
],
|
||||
["rgb(230, 184, 175)", "rgb(244, 204, 204)", "rgb(252, 229, 205)", "rgb(255, 242, 204)", "rgb(217, 234, 211)",
|
||||
"rgb(208, 224, 227)", "rgb(201, 218, 248)", "rgb(207, 226, 243)", "rgb(217, 210, 233)", "rgb(234, 209, 220)",
|
||||
"rgb(221, 126, 107)", "rgb(234, 153, 153)", "rgb(249, 203, 156)", "rgb(255, 229, 153)", "rgb(182, 215, 168)",
|
||||
"rgb(162, 196, 201)", "rgb(164, 194, 244)", "rgb(159, 197, 232)", "rgb(180, 167, 214)", "rgb(213, 166, 189)",
|
||||
"rgb(204, 65, 37)", "rgb(224, 102, 102)", "rgb(246, 178, 107)", "rgb(255, 217, 102)", "rgb(147, 196, 125)",
|
||||
"rgb(118, 165, 175)", "rgb(109, 158, 235)", "rgb(111, 168, 220)", "rgb(142, 124, 195)", "rgb(194, 123, 160)",
|
||||
"rgb(166, 28, 0)", "rgb(204, 0, 0)", "rgb(230, 145, 56)", "rgb(241, 194, 50)", "rgb(106, 168, 79)",
|
||||
"rgb(69, 129, 142)", "rgb(60, 120, 216)", "rgb(61, 133, 198)", "rgb(103, 78, 167)", "rgb(166, 77, 121)",
|
||||
"rgb(91, 15, 0)", "rgb(102, 0, 0)", "rgb(120, 63, 4)", "rgb(127, 96, 0)", "rgb(39, 78, 19)",
|
||||
"rgb(12, 52, 61)", "rgb(28, 69, 135)", "rgb(7, 55, 99)", "rgb(32, 18, 77)", "rgb(76, 17, 48)"
|
||||
]
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
_formatColor(color) {
|
||||
return color.getAlpha() < 1 ? color.toHex8String() : color.toHexString();
|
||||
}
|
||||
}
|
||||
|
||||
window.ColorControl = ColorControl;
|
||||
@@ -0,0 +1,19 @@
|
||||
class Control {
|
||||
constructor({ label, description, value, default: defaultValue, css }) {
|
||||
this.id = this.constructor.name + Math.random().toString(36).substring(7);
|
||||
this.label = label;
|
||||
this.value = value ?? defaultValue;
|
||||
this.css = css;
|
||||
this.description = description || '';
|
||||
}
|
||||
|
||||
generateHTML() {
|
||||
return "";
|
||||
}
|
||||
|
||||
generateCSS() {
|
||||
return this.css.replace("%value%", this.value);
|
||||
}
|
||||
}
|
||||
|
||||
window.Control = Control;
|
||||
@@ -0,0 +1,117 @@
|
||||
class FontPickerControl extends Control {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.type = 'fontPicker';
|
||||
this.previewText = config.previewText || "Hello, This is your selected font-family.";
|
||||
}
|
||||
|
||||
generateHTML() {
|
||||
const fontValue = this.value ?? "";
|
||||
const safeValue = fontValue.replace(/"/g, """);
|
||||
const parsed = this._parseFontSpec(fontValue);
|
||||
const previewStyle = parsed.family
|
||||
? ` style="${this._composePreviewStyle(parsed)}"`
|
||||
: "";
|
||||
|
||||
return `<div class="inputContainer">
|
||||
<label for="${this.id}">${this.label}</label>
|
||||
<input
|
||||
is="emby-input"
|
||||
type="text"
|
||||
class="fontPickerInput"
|
||||
id="${this.id}"
|
||||
name="${this.id}"
|
||||
value="${safeValue}"
|
||||
/>
|
||||
<div class="fontCont fontPreviewContainer">
|
||||
<p class="fontPreviewText" id="${this.id}-preview"${previewStyle}>${this.previewText}</p>
|
||||
</div>
|
||||
<div class="fieldDescription">${this.description}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
const input = document.getElementById(this.id);
|
||||
const preview = document.getElementById(`${this.id}-preview`);
|
||||
|
||||
const updatePreview = (fontValue) => {
|
||||
const sanitizedValue = fontValue || "";
|
||||
this.value = sanitizedValue;
|
||||
const { family, weight, italic } = this._parseFontSpec(sanitizedValue);
|
||||
if (preview) {
|
||||
preview.style.fontFamily = family || sanitizedValue;
|
||||
preview.style.fontWeight = weight ? String(weight) : "";
|
||||
preview.style.fontStyle = italic ? "italic" : "";
|
||||
}
|
||||
};
|
||||
|
||||
if (input) {
|
||||
const $input = window.jQuery ? window.jQuery(input) : null;
|
||||
if ($input && $input.fontpicker) {
|
||||
$input.fontpicker({ variants: true });
|
||||
$input.on('change', (event) => updatePreview(event.target.value));
|
||||
}
|
||||
|
||||
input.addEventListener('input', (event) => updatePreview(event.target.value));
|
||||
input.addEventListener('change', (event) => updatePreview(event.target.value));
|
||||
}
|
||||
|
||||
updatePreview(this.value);
|
||||
}
|
||||
|
||||
_escapeFontFamily(fontFamily) {
|
||||
return (fontFamily || "").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
_parseFontSpec(fontSpec) {
|
||||
if (!fontSpec) {
|
||||
return { family: "", weight: null, italic: false };
|
||||
}
|
||||
|
||||
const [familyPart, variantRaw = ""] = fontSpec.split(":");
|
||||
const family = (familyPart || "").trim();
|
||||
const variant = (variantRaw || "").trim().toLowerCase();
|
||||
|
||||
let weight = null;
|
||||
let italic = false;
|
||||
|
||||
if (!variant) {
|
||||
return { family, weight, italic };
|
||||
}
|
||||
|
||||
const match = variant.match(/^(\d+)(i)?$/i);
|
||||
if (match) {
|
||||
weight = parseInt(match[1], 10);
|
||||
italic = !!match[2];
|
||||
return { family, weight, italic };
|
||||
}
|
||||
|
||||
if (variant === "italic") {
|
||||
italic = true;
|
||||
return { family, weight, italic };
|
||||
}
|
||||
|
||||
const numeric = parseInt(variant, 10);
|
||||
if (!isNaN(numeric)) {
|
||||
weight = numeric;
|
||||
}
|
||||
|
||||
return { family, weight, italic };
|
||||
}
|
||||
|
||||
_composePreviewStyle({ family, weight, italic }) {
|
||||
const parts = [];
|
||||
if (family) {
|
||||
parts.push(`font-family: ${this._escapeFontFamily(family)};`);
|
||||
}
|
||||
if (weight) {
|
||||
parts.push(`font-weight: ${weight};`);
|
||||
}
|
||||
if (italic) {
|
||||
parts.push("font-style: italic;");
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
}
|
||||
|
||||
window.FontPickerControl = FontPickerControl;
|
||||
@@ -0,0 +1,26 @@
|
||||
class NumberControl extends Control {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.min = config.min;
|
||||
this.max = config.max;
|
||||
this.type = 'number';
|
||||
}
|
||||
|
||||
generateHTML() {
|
||||
return `<div class="inputContainer">
|
||||
<input is="emby-input" type="number" class="number"
|
||||
value='${this.value}' id='${this.id}' min='${this.min}' max='${this.max}' label='${this.label}'>
|
||||
<div class="fieldDescription">${this.description}</div>
|
||||
</div>`;
|
||||
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
const numberInput = document.getElementById(this.id);
|
||||
numberInput.addEventListener('input', (event) => {
|
||||
this.value = event.target.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.NumberControl = NumberControl;
|
||||
@@ -0,0 +1,29 @@
|
||||
class SelectControl extends Control {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.options = config.options;
|
||||
this.type = 'select';
|
||||
}
|
||||
|
||||
generateHTML() {
|
||||
|
||||
|
||||
return `<div class="selectContainer">
|
||||
<label for= "${this.id}" > ${this.label}</label >
|
||||
<select is="emby-select" id="${this.id}">
|
||||
${this.options.map(option => `<option value="${option.value}"
|
||||
${option.value == this.value ? "selected" : ""} >${option.label}</option>`).join('')}
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
const select = document.getElementById(this.id);
|
||||
select.addEventListener('change', (event) => {
|
||||
console.log("selected ", event.target.value);
|
||||
this.value = event.target.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.SelectControl = SelectControl;
|
||||
@@ -0,0 +1,26 @@
|
||||
class SliderControl extends Control {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.min = config.min;
|
||||
this.max = config.max;
|
||||
this.type = 'slider';
|
||||
}
|
||||
|
||||
generateHTML() {
|
||||
return `<div class="inputContainer" >
|
||||
<input type="range" class="slider" value="${this.value}" data-css="${this.css}" id="${this.id}" min="${this.min}" max="${this.max}" label="${this.name}">
|
||||
<div class="fieldDescription">${this.label}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
const slider = document.getElementById(this.id);
|
||||
slider.addEventListener('input', (event) => {
|
||||
console.log(event.target.value);
|
||||
this.value = event.target.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.SliderControl = SliderControl;
|
||||
@@ -0,0 +1,24 @@
|
||||
class TextAreaControl extends Control {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.type = 'textarea';
|
||||
}
|
||||
|
||||
generateHTML() {
|
||||
return `<div class="inputContainer customCssContainer">
|
||||
<label class= "textareaLabel" for="${this.id}">${this.label}</label>
|
||||
<textarea is="emby-textarea" class= "textarea-mono emby-textarea" id="${this.id}" rows="1">${this.value}</textarea>
|
||||
<div class="fieldDescription">${this.description}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
const textArea = document.getElementById(this.id);
|
||||
textArea.addEventListener('input', (event) => {
|
||||
this.value = event.target.value;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
window.TextAreaControl = TextAreaControl;
|
||||
File diff suppressed because it is too large
Load Diff
102
Jellyfin.Plugin.SkinManager/Configuration/history.html
Normal file
102
Jellyfin.Plugin.SkinManager/Configuration/history.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Skin Manager</title>
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="historyPage" data-role="page"
|
||||
class="page type-interior pluginConfigurationPage tbsConfigurationPage withTabs"
|
||||
data-require="emby-input,emby-button" data-controller="__plugin/fontpicker.js">
|
||||
<script src="/web/configurationpage?name=controls.Control.js"></script>
|
||||
<script src="/web/configurationpage?name=controls.ColorControl.js"></script>
|
||||
<script src="/web/configurationpage?name=controls.SliderControl.js"></script>
|
||||
<script src="/web/configurationpage?name=controls.CheckBoxControl.js"></script>
|
||||
<script src="/web/configurationpage?name=controls.NumberControl.js"></script>
|
||||
<script src="/web/configurationpage?name=controls.SelectControl.js"></script>
|
||||
<script src="/web/configurationpage?name=controls.FontPickerControl.js"></script>
|
||||
<script src="/web/configurationpage?name=controls.TextAreaControl.js"></script>
|
||||
<script src="/web/configurationpage?name=controls.Category.js"></script>
|
||||
<script src="/web/configurationpage?name=skin.js"></script>
|
||||
<script src="/web/configurationpage?name=fontpicker.js"></script>
|
||||
<script src="/web/configurationpage?name=colorpicker.js"></script>
|
||||
<script src="/web/configurationpage?name=MainController.js"></script>
|
||||
<script src="/web/configurationpage?name=common.js"></script>
|
||||
<script src="/web/configurationpage?name=ConfigController.js"></script>
|
||||
<script src="/web/configurationpage?name=HistoryController.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="/web/configurationpage?name=style.css" />
|
||||
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<div class="tbsConfigurationPage">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">Skin Manager</h2>
|
||||
<a is="emby-linkbutton" class="raised button-alt headerHelpButton emby-button" target="_blank"
|
||||
href="https://prayag17.github.io/jellyfin-plugin-skin-manager/src/html/help.html">Help</a>
|
||||
</div>
|
||||
<div class="history-split">
|
||||
<section class="history-column history-column-skins">
|
||||
<header class="history-column-header">
|
||||
<h3>Skin History</h3>
|
||||
<p>Reapply any skin you used before.</p>
|
||||
</header>
|
||||
<div class="history-card">
|
||||
<label for="cssOptions-history" class="history-label">Select a skin</label>
|
||||
<select is="emby-select" id="cssOptions-history" class="history-select"></select>
|
||||
</div>
|
||||
<div class="history-panel" id="skinHistoryPanel">
|
||||
<div class="history-panel-inner checkboxList checkboxList-verticalwrap"
|
||||
id="options-history"></div>
|
||||
</div>
|
||||
<button is="emby-button" type="button" class="raised history-action"
|
||||
id="setSkin-history"><span>Apply skin</span></button>
|
||||
</section>
|
||||
<section class="history-column history-column-css">
|
||||
<header class="history-column-header">
|
||||
<h3>User CSS</h3>
|
||||
<p>Keep your personal tweaks safe when switching skins.</p>
|
||||
</header>
|
||||
<div class="css-history" id="cssHistoryContainer" data-empty="true">
|
||||
<div class="history-card">
|
||||
<label for="cssHistorySelect" class="history-label">Select a revision</label>
|
||||
<select is="emby-select" id="cssHistorySelect" class="history-select"></select>
|
||||
</div>
|
||||
<button is="emby-button" type="button" class="raised history-action-secondary"
|
||||
id="restoreCssButton">
|
||||
<span>Restore CSS</span>
|
||||
</button>
|
||||
<p class="css-history-meta" id="cssHistoryMeta"></p>
|
||||
<pre class="css-history-code" id="cssHistoryCode"></pre>
|
||||
<div class="css-history-empty" id="cssHistoryEmpty">
|
||||
No saved CSS yet.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
console.log("history page loaded");
|
||||
const setHistoryTabs = function () {
|
||||
LibraryMenu.setTabs('history', 1, getTabs);
|
||||
};
|
||||
if (!window.SkinManagerHistoryViewListener) {
|
||||
window.SkinManagerHistoryViewListener = function (e) {
|
||||
if (e.target && e.target.id === 'historyPage') {
|
||||
setHistoryTabs();
|
||||
}
|
||||
};
|
||||
document.addEventListener('viewshow', window.SkinManagerHistoryViewListener);
|
||||
}
|
||||
setHistoryTabs();
|
||||
let historyController = new HistoryController();
|
||||
historyController.init();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
88
Jellyfin.Plugin.SkinManager/Configuration/skin.js
Normal file
88
Jellyfin.Plugin.SkinManager/Configuration/skin.js
Normal file
@@ -0,0 +1,88 @@
|
||||
class Skin {
|
||||
constructor({ name, description, css, previews, categories }) {
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.css = css;
|
||||
this.previews = previews;
|
||||
this.categories = categories.map(category => new Category(category.name, category.controls));
|
||||
this.addCustomCssInput();
|
||||
|
||||
console.log(`Skin "${this.name}" initialized with ${this.categories.length} categories.`);
|
||||
}
|
||||
|
||||
generateHTML({ includePreview = true } = {}) {
|
||||
const categoriesHTML = this.categories
|
||||
.map(category => category.generateHTML())
|
||||
.join('');
|
||||
|
||||
const previewsHTML = includePreview ? this.generatePreviewHTML() : '';
|
||||
|
||||
return `
|
||||
<div data-role="controlgroup" class="optionsContainer">
|
||||
<div class="categoriesContainer">
|
||||
${categoriesHTML}
|
||||
</div>
|
||||
<br/>
|
||||
${previewsHTML}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
generateCSS() {
|
||||
const marker = "/* Skin Manager CSS */";
|
||||
const sections = [marker];
|
||||
|
||||
if (typeof this.css === "string" && this.css.trim()) {
|
||||
sections.push(this.css.trim());
|
||||
}
|
||||
|
||||
const categoryCss = this.categories
|
||||
.map(c => c.generateCSS())
|
||||
.filter(Boolean)
|
||||
.map(section => typeof section === "string" ? section.trim() : section)
|
||||
.filter(section => !!section && (typeof section !== "string" || section.length));
|
||||
|
||||
sections.push(...categoryCss);
|
||||
|
||||
return sections.join("\n\n") + "\n";
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
this.categories.forEach(category => category.attachEventListeners());
|
||||
}
|
||||
|
||||
generatePreviewHTML() {
|
||||
if (!this.previews || this.previews.length === 0) return '';
|
||||
|
||||
return `
|
||||
<div class="verticalSection verticalSection-extrabottompadding previewSection">
|
||||
<h2 class="sectionTitle">Previews</h2>
|
||||
${this.previews
|
||||
.map(p => `
|
||||
<fieldset class="verticalSection verticalSection-extrabottompadding">
|
||||
<img src="${p.url}" alt="${p.name || ''}">
|
||||
<legend>${p.name}</legend>
|
||||
</fieldset>`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
addCustomCssInput() {
|
||||
const CustomCssControl = new TextAreaControl({
|
||||
label: "Custom CSS",
|
||||
description: "Add your own CSS rules here. These will be applied last, so they can override other settings.",
|
||||
value: "",
|
||||
default: "",
|
||||
css: "%value%"
|
||||
});
|
||||
|
||||
const CustomCategory = new Category("Custom CSS", [CustomCssControl]);
|
||||
this.categories.push(CustomCategory);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
1250
Jellyfin.Plugin.SkinManager/Configuration/style.css
Normal file
1250
Jellyfin.Plugin.SkinManager/Configuration/style.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>NET8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<IsPackable>true</IsPackable>
|
||||
<AssemblyVersion>2.0.2</AssemblyVersion>
|
||||
<FileVersion>2.0.2</FileVersion>
|
||||
@@ -10,15 +10,18 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.0" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configurationpage.html" />
|
||||
<EmbeddedResource Include="Configuration\configurationpage.html" />
|
||||
|
||||
<None Remove="Configuration\fontpicker.js" />
|
||||
<EmbeddedResource Include="Configuration\fontpicker.js" />
|
||||
|
||||
<None Remove="Configuration\*" />
|
||||
<EmbeddedResource Include="Configuration\*" />
|
||||
|
||||
<None Remove="Configuration\controls\*" />
|
||||
<EmbeddedResource Include="Configuration\controls\*" />
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.SkinManager.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
@@ -20,32 +21,53 @@ namespace Jellyfin.Plugin.SkinManager
|
||||
|
||||
public static Plugin Instance { get; private set; }
|
||||
|
||||
public override string Description
|
||||
=> "Skin Manager";
|
||||
public override string Description => "Skin Manager";
|
||||
|
||||
private readonly Guid _id = new Guid("e9ca8b8e-ca6d-40e7-85dc-58e536df8eb3");
|
||||
public override Guid Id => _id;
|
||||
|
||||
public PluginConfiguration PluginConfiguration => Configuration;
|
||||
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return new[]
|
||||
var pageNames = new[]
|
||||
{
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = "SkinManager",
|
||||
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configurationpage.html"
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = "fontpicker.js",
|
||||
EmbeddedResourcePath = GetType().Namespace + ".Configuration.fontpicker.js"
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = "fontpicker.css",
|
||||
EmbeddedResourcePath = GetType().Namespace + ".Configuration.jquery.fontpicker.min.css"
|
||||
}
|
||||
"SkinManager",
|
||||
"fontpicker.js",
|
||||
"fontpicker.css",
|
||||
"skin.js",
|
||||
"controls.Control.js",
|
||||
"controls.ColorControl.js",
|
||||
"controls.SliderControl.js",
|
||||
"controls.CheckBoxControl.js",
|
||||
"controls.NumberControl.js",
|
||||
"controls.SelectControl.js",
|
||||
"controls.FontPickerControl.js",
|
||||
"controls.TextAreaControl.js",
|
||||
"controls.Category.js",
|
||||
"colorpicker.js",
|
||||
"MainController.js",
|
||||
"style.css",
|
||||
"common.js",
|
||||
"history",
|
||||
"ConfigController.js",
|
||||
"HistoryController.js",
|
||||
};
|
||||
|
||||
var prefix = GetType().Namespace + ".Configuration.";
|
||||
|
||||
return pageNames.Select(name => new PluginPageInfo
|
||||
{
|
||||
Name = name,
|
||||
EmbeddedResourcePath = name switch
|
||||
{
|
||||
"SkinManager" => prefix + "configurationpage.html",
|
||||
"history" => prefix + "history.html",
|
||||
"fontpicker.css" => prefix + "jquery.fontpicker.min.css",
|
||||
_ => prefix + name
|
||||
},
|
||||
EnableInMainMenu = name.Equals("SkinManager", StringComparison.OrdinalIgnoreCase)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
126
skins-4.0.json
Normal file
126
skins-4.0.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"skins": [
|
||||
{
|
||||
"name": "JellySkin",
|
||||
"description": "A modern, customizable skin for Jellyfin.",
|
||||
"css": "@import url('https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/main.css');",
|
||||
"previews": [
|
||||
{
|
||||
"name": "Login Page",
|
||||
"url": "https://raw.githubusercontent.com/danieladov/jellyfin-plugin-skin-manager/master/src/img/Default/1.png"
|
||||
},
|
||||
{
|
||||
"name": "Home/Index Page",
|
||||
"url": "https://raw.githubusercontent.com/danieladov/jellyfin-plugin-skin-manager/master/src/img/Default/2.png"
|
||||
},
|
||||
{
|
||||
"name": "Library Page",
|
||||
"url": "https://raw.githubusercontent.com/danieladov/jellyfin-plugin-skin-manager/master/src/img/Default/3.png"
|
||||
},
|
||||
{
|
||||
"name": "Title page",
|
||||
"url": "https://raw.githubusercontent.com/danieladov/jellyfin-plugin-skin-manager/master/src/img/Default/4.png"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
{
|
||||
"name": "Default",
|
||||
"controls": [
|
||||
{
|
||||
"type": "color",
|
||||
"label": "Background Color",
|
||||
"description": "Set the background color of the skin",
|
||||
"id": "bgColor",
|
||||
"default": "#ffffff",
|
||||
"css": ":root { --bg-color: %value%; }"
|
||||
},
|
||||
{
|
||||
"type": "color",
|
||||
"label": "Text Color",
|
||||
"id": "textColor",
|
||||
"default": "#000000",
|
||||
"css": ":root { --text-color: %value%; }"
|
||||
},
|
||||
{
|
||||
"type": "slider",
|
||||
"label": "Font Size",
|
||||
"id": "fontSize",
|
||||
"min": 10,
|
||||
"max": 30,
|
||||
"default": 16,
|
||||
"css": ":root { --font-size: %value%px; }"
|
||||
},
|
||||
{
|
||||
"type": "checkbox",
|
||||
"label": "Dark Mode",
|
||||
"description": "Enable dark mode",
|
||||
"id": "checkbox",
|
||||
"default": true,
|
||||
"css": ":root { --dark-mode: %value%; }"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"label": "Border Radius",
|
||||
"description": "Set the border radius",
|
||||
"id": "borderRadius",
|
||||
"min": 0,
|
||||
"max": 50,
|
||||
"default": 0,
|
||||
"css": ":root { --"
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"label": "Font Family",
|
||||
"description": "Select the font family",
|
||||
"id": "fontFamily",
|
||||
"default": "Arial",
|
||||
"css": ":root { --font-family: %value%; }",
|
||||
"options": [
|
||||
{
|
||||
"label": "Arial",
|
||||
"value": "Arial"
|
||||
},
|
||||
{
|
||||
"label": "Verdana",
|
||||
"value": "Verdana"
|
||||
},
|
||||
{
|
||||
"label": "Georgia",
|
||||
"value": "Georgia"
|
||||
},
|
||||
{
|
||||
"label": "Times New Roman",
|
||||
"value": "Times New Roman"
|
||||
},
|
||||
{
|
||||
"label": "Trebuchet MS",
|
||||
"value": "Trebuchet MS"
|
||||
},
|
||||
{
|
||||
"label": "Arial Black",
|
||||
"value": "Arial Black"
|
||||
},
|
||||
{
|
||||
"label": "Impact",
|
||||
"value": "Impact"
|
||||
},
|
||||
{
|
||||
"label": "Comic Sans MS",
|
||||
"value": "Comic Sans MS"
|
||||
},
|
||||
{
|
||||
"label": "Courier New",
|
||||
"value": "Courier New"
|
||||
},
|
||||
{
|
||||
"label": "Lucida Console",
|
||||
"value": "Lucida Console"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user