This commit is contained in:
Daniel
2025-12-22 00:07:16 +01:00
parent 0e68847fff
commit 7bdf39f0fa
24 changed files with 9813 additions and 4464 deletions

View 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");
}
}

View 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;
}
}

View 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);
}
}
}

View File

@@ -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>();
}
}
}

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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, "&quot;");
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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View 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>

View 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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
View 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"
}
]
}
]
}
]
}
]
}