Initial Release

This commit is contained in:
Mars-Marcelyn 2026-06-12 21:20:41 +02:00
parent 56fb120b7a
commit e16e564f33
8 changed files with 1453 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
data.json

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mars Marcelyn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

41
README.md Normal file
View file

@ -0,0 +1,41 @@
# Shuffle Lab
Timed, randomized fullscreen image slideshows for drawing practice, presentations, and more.
## Features
- Fullscreen slideshow with randomized images from any vault folder
- Configurable duration per image with presets (30s, 60s, 2min, 5min, custom)
- Loop toggle — choose whether to repeat images or stop when they run out
- Session timer with real-time display
- Intro prompt slide (optional markdown file per folder)
- Session logging to prioritize unseen images next session
- Language support (desktop only)
## Usage
1. Open **Settings → Shuffle Lab** and set your **Main directory** (the root folder containing your reference image subfolders).
2. Click the shuffle icon in the ribbon or run the **New session** command.
3. Select a folder, adjust settings, and click **Start Session**.
4. Press **Space** to pause/resume, **◀/▶** to navigate, **ESC** to exit.
## Installation
### From Community Plugins (desktop)
Search for "Shuffle Lab" in Community Plugins and install.
### Manual / mobile
Copy `main.js`, `manifest.json`, and `styles.css` to `.obsidian/plugins/shuffle-lab/` in your vault.
## Localization (desktop only)
1. Open **Settings → Shuffle Lab** and click **Open folder** next to "Language files".
2. Copy `en.json` from the `lang/` folder, rename it to your language code (e.g. `de.json`).
3. Translate all values (the text after the colons). Keep keys, `{0}` placeholders, and `\n` unchanged.
4. Reload Obsidian, then select your language in **Settings → Shuffle Lab**.
The language dropdown only shows `.json` files present in the `lang/` folder. On mobile, the plugin uses English only.
## License
MIT

71
lang/en.json Normal file
View file

@ -0,0 +1,71 @@
{
"_instructions": "To create a translation: copy this file, rename it to your language code (e.g. 'de.json'), then translate every value (the text after the colon). Keep keys, {0} placeholders, and \\n newlines unchanged. The filename (without .json) will appear as the language name in Settings.",
"pluginName": "Shuffle Lab",
"commandName": "New session",
"noticeNoDirectory": "Please set your main directory in Shuffle Lab settings",
"noticeSelectTopic": "Please select a folder",
"noticeTopicNotFound": "Folder not found",
"noticeNoImages": "No image files found in the selected folder",
"noticeLogCleared": "Session log cleared",
"noticeNoLog": "No log file found",
"noticePromptDeleted": "Prompt file deleted",
"settingsHeading": "Shuffle Lab",
"mainDirectoryName": "Main directory",
"mainDirectoryDesc": "Root folder containing subfolders with reference images",
"folderPlaceholder": "— Select a folder —",
"sessionHeading": "New session",
"noFoldersFound": "No folders found. Add subfolders inside your main directory.",
"folderName": "Folder",
"folderDesc": "Choose a folder for this session",
"numImagesName": "Number of images",
"numImagesDesc": "Images to include in the session pool",
"noImagesWarning": "This folder has no images",
"loopName": "Loop images",
"loopDesc": "Repeat shuffled images indefinitely until session ends or stops",
"durationName": "Duration per image",
"durationDesc": "How long each image is displayed",
"customDurationName": "Custom duration",
"customDurationDesc": "Enter duration in seconds",
"totalTimeName": "Total session time",
"totalTimeDesc": "Calculated from images × duration",
"loggingName": "Session logging",
"loggingDesc": "Log shown images to prioritize unseen ones next session",
"clearLogName": "Clear session log",
"clearLogDesc": "Delete the log file for the selected topic",
"clearLogBtn": "Clear",
"promptFileName": "Prompt file",
"promptFileDesc": "Intro slide shown before the session starts",
"editBtn": "Edit",
"deleteBtn": "Delete",
"createBtn": "Create",
"startBtn": "Start Session",
"durationPresets": {
"30s": "30s",
"60s": "60s",
"2min": "2 min",
"5min": "5 min",
"custom": "Custom"
},
"introStartBtn": "Start",
"endOfDeckTitle": "No more images left",
"endOfDeckHint": "Press ▶ to finish or ◀ to review",
"sessionCompleteTitle": "Session Complete",
"sessionCompleteHint": "Press ▶ or ESC to exit",
"failedToLoad": "(failed to load)",
"failedToLoadName": "Failed to load: {0}",
"sessionLogTitle": "# Session Log",
"sessionLogHeader": "Shown in last session:",
"promptTemplate": "# Insert your prompt title here\n\nWrite your prompt instructions here.\n\n---\n# Notes\n\nAnything you write below the divider won't show up in the intro slide\n",
"languageName": "Language",
"languageDesc": "Choose interface language",
"noticeLanguageChanged": "Language changed. Reload Obsidian or the plugin for it to take effect.",
"languageFilesName": "Language files",
"languageFilesDesc": "Add or edit translation files",
"openFolderBtn": "Open folder"
}

71
lang/es.json Normal file
View file

@ -0,0 +1,71 @@
{
"_instructions": "Para crear una traducción: copia este archivo, renómbralo con tu código de idioma (ej. 'fr.json'), luego traduce cada valor (el texto después de los dos puntos). Mantén las claves, los marcadores {0} y los \\n sin cambios. El nombre del archivo (sin .json) aparecerá como nombre del idioma en Ajustes.",
"pluginName": "Shuffle Lab",
"commandName": "Nueva sesión",
"noticeNoDirectory": "Establece tu directorio principal en los ajustes de Shuffle Lab",
"noticeSelectTopic": "Selecciona una carpeta",
"noticeTopicNotFound": "Carpeta no encontrada",
"noticeNoImages": "No se encontraron imágenes en la carpeta seleccionada",
"noticeLogCleared": "Registro de sesión borrado",
"noticeNoLog": "No se encontró ningún registro",
"noticePromptDeleted": "Archivo de instrucciones eliminado",
"settingsHeading": "Shuffle Lab",
"mainDirectoryName": "Directorio principal",
"mainDirectoryDesc": "Carpeta raíz que contiene subcarpetas con imágenes de referencia",
"folderPlaceholder": "— Selecciona una carpeta —",
"sessionHeading": "Nueva sesión",
"noFoldersFound": "No se encontraron carpetas. Añade subcarpetas dentro de tu directorio principal.",
"folderName": "Carpeta",
"folderDesc": "Elige una carpeta para esta sesión",
"numImagesName": "Número de imágenes",
"numImagesDesc": "Imágenes para incluir en la sesión",
"noImagesWarning": "Esta carpeta no tiene imágenes",
"loopName": "Repetir imágenes",
"loopDesc": "Repite las imágenes mezcladas indefinidamente hasta que la sesión termine o se detenga",
"durationName": "Duración por imagen",
"durationDesc": "Cuánto tiempo se muestra cada imagen",
"customDurationName": "Duración personalizada",
"customDurationDesc": "Introduce la duración en segundos",
"totalTimeName": "Tiempo total de sesión",
"totalTimeDesc": "Calculado de imágenes × duración",
"loggingName": "Registro de sesión",
"loggingDesc": "Registra las imágenes mostradas para priorizar las no vistas en la próxima sesión",
"clearLogName": "Borrar registro de sesión",
"clearLogDesc": "Elimina el archivo de registro de la carpeta seleccionada",
"clearLogBtn": "Borrar",
"promptFileName": "Archivo de instrucciones",
"promptFileDesc": "Diapositiva de introducción mostrada antes de que comience la sesión",
"editBtn": "Editar",
"deleteBtn": "Eliminar",
"createBtn": "Crear",
"startBtn": "Empezar sesión",
"durationPresets": {
"30s": "30s",
"60s": "60s",
"2min": "2 min",
"5min": "5 min",
"custom": "Personalizado"
},
"introStartBtn": "Comenzar",
"endOfDeckTitle": "No quedan más imágenes",
"endOfDeckHint": "Presiona ▶ para finalizar o ◀ para revisar",
"sessionCompleteTitle": "Sesión completada",
"sessionCompleteHint": "Presiona ▶ o ESC para salir",
"failedToLoad": "(error al cargar)",
"failedToLoadName": "Error al cargar: {0}",
"sessionLogTitle": "# Registro de sesión",
"sessionLogHeader": "Mostradas en la última sesión:",
"promptTemplate": "# Escribe tu título aquí\n\nEscribe tus instrucciones aquí.\n\n---\n# Notas\n\nTodo lo que escribas debajo del divisor no aparecerá en la diapositiva de introducción\n",
"languageName": "Idioma",
"languageDesc": "Elige el idioma de la interfaz",
"noticeLanguageChanged": "Idioma cambiado. Recarga Obsidian o el plugin para que surta efecto.",
"languageFilesName": "Archivos de idioma",
"languageFilesDesc": "Añadir o editar archivos de traducción",
"openFolderBtn": "Abrir carpeta"
}

978
main.js Normal file
View file

@ -0,0 +1,978 @@
const { Plugin, PluginSettingTab, Setting, Modal, TFolder, TFile, Notice, MarkdownRenderer, Platform } = require('obsidian');
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp'];
const FALLBACK_LOCALE = {
"pluginName": "Shuffle Lab",
"commandName": "New session",
"noticeNoDirectory": "Please set your main directory in Shuffle Lab settings",
"noticeSelectTopic": "Please select a folder",
"noticeTopicNotFound": "Folder not found",
"noticeNoImages": "No image files found in the selected folder",
"noticeLogCleared": "Session log cleared",
"noticeNoLog": "No log file found",
"noticePromptDeleted": "Prompt file deleted",
"settingsHeading": "Shuffle Lab",
"mainDirectoryName": "Main directory",
"mainDirectoryDesc": "Root folder containing subfolders with reference images",
"folderPlaceholder": "\u2014 Select a folder \u2014",
"sessionHeading": "New session",
"noFoldersFound": "No folders found. Add subfolders inside your main directory.",
"folderName": "Folder",
"folderDesc": "Choose a folder for this session",
"numImagesName": "Number of images",
"numImagesDesc": "Images to include in the session pool",
"noImagesWarning": "This folder has no images",
"loopName": "Loop images",
"loopDesc": "Repeat shuffled images indefinitely until session ends or stops",
"durationName": "Duration per image",
"durationDesc": "How long each image is displayed",
"customDurationName": "Custom duration",
"customDurationDesc": "Enter duration in seconds",
"totalTimeName": "Total session time",
"totalTimeDesc": "Calculated from images \u00d7 duration",
"loggingName": "Session logging",
"loggingDesc": "Log shown images to prioritize unseen ones next session",
"clearLogName": "Clear session log",
"clearLogDesc": "Delete the log file for the selected topic",
"clearLogBtn": "Clear",
"promptFileName": "Prompt file",
"promptFileDesc": "Intro slide shown before the session starts",
"editBtn": "Edit",
"deleteBtn": "Delete",
"createBtn": "Create",
"startBtn": "Start Session",
"durationPresets": {
"30s": "30s",
"60s": "60s",
"2min": "2 min",
"5min": "5 min",
"custom": "Custom"
},
"introStartBtn": "Start",
"endOfDeckTitle": "No more images left",
"endOfDeckHint": "Press \u25b6 to finish or \u25c0 to review",
"sessionCompleteTitle": "Session Complete",
"sessionCompleteHint": "Press \u25b6 or ESC to exit",
"failedToLoad": "(failed to load)",
"failedToLoadName": "Failed to load: {0}",
"sessionLogTitle": "# Session Log",
"sessionLogHeader": "Shown in last session:",
"promptTemplate": "# Insert your prompt title here\n\nWrite your prompt instructions here.\n\n---\n# Notes\n\nAnything you write below the divider won't show up in the intro slide\n",
"languageName": "Language",
"languageDesc": "Choose interface language",
"languageFilesName": "Language files",
"languageFilesDesc": "Add or edit translation files",
"openFolderBtn": "Open folder",
"noticeLanguageChanged": "Language changed. Reload Obsidian or the plugin for it to take effect."
};
const DEFAULT_SETTINGS = {
mainDirectory: '',
folder: '',
numPictures: 5,
durationPerImage: 30,
loop: true,
language: 'en',
};
module.exports = class VisualConceptLab extends Plugin {
async onload() {
await this.loadSettings();
await this.loadLocale();
await this.cacheAvailableLanguages();
this.addSettingTab(new DrawingSessionSettingTab(this.app, this));
this.addRibbonIcon('shuffle', this.t('pluginName'), () => {
this.openSetupModal();
});
this.addCommand({
id: 'open-session-setup',
name: this.t('commandName'),
callback: () => this.openSetupModal(),
});
this.app.vault.on('rename', (file, oldPath) => {
if (!(file instanceof TFolder)) return;
if (this.settings.folder === oldPath) {
this.settings.folder = file.path;
this.saveSettings();
}
});
}
openSetupModal() {
if (!this.settings.mainDirectory) {
new Notice(this.t('noticeNoDirectory'));
return;
}
new SessionSetupModal(this.app, this).open();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
async loadLocale() {
const lang = this.settings.language || 'en';
try {
const path = require('path');
const fs = require('fs');
const basePath = this.app.vault.adapter.getBasePath();
const filePath = path.join(basePath, this.app.vault.configDir, 'plugins', 'shuffle-lab', 'lang', lang + '.json');
const content = fs.readFileSync(filePath, 'utf-8');
this.locale = JSON.parse(content);
return;
} catch (e) {}
this.locale = Object.assign({}, FALLBACK_LOCALE);
}
async cacheAvailableLanguages() {
try {
const adapter = this.app.vault.adapter;
const langDir = this.app.vault.configDir + '/plugins/shuffle-lab/lang';
const result = await adapter.list(langDir);
const found = result.files.filter(f => f.endsWith('.json')).map(f => f.replace('.json', '').split('/').pop());
if (found.length > 0) {
this._cachedLanguages = found.map(code => [code, code]);
this.settings.availableLanguages = found;
await this.saveSettings();
return;
}
} catch (e) {}
if (this.settings.availableLanguages) {
this._cachedLanguages = this.settings.availableLanguages.map(code => [code, code]);
} else {
this._cachedLanguages = [['en', 'English']];
}
}
getAvailableLanguages() {
return this._cachedLanguages || [['en', 'English']];
}
openLanguageFolder() {
if (!Platform.isDesktop) {
new Notice('Cannot open folder on this device');
return;
}
try {
const path = require('path');
const { exec } = require('child_process');
const basePath = this.app.vault.adapter.getBasePath();
const langDir = path.join(basePath, this.app.vault.configDir, 'plugins', 'shuffle-lab', 'lang');
exec(`explorer.exe "${langDir}"`);
} catch (e) {
new Notice('Cannot open folder on this device');
}
}
t(key, ...args) {
const parts = key.split('.');
let value = this.locale;
for (const part of parts) {
if (value && typeof value === 'object' && part in value) {
value = value[part];
} else {
return key;
}
}
if (typeof value === 'string') {
if (args.length > 0) {
return value.replace(/{(\d+)}/g, (match, num) => args[num] !== undefined ? args[num] : match);
}
return value;
}
return key;
}
};
class DrawingSessionSettingTab extends PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName(this.plugin.t('mainDirectoryName'))
.setDesc(this.plugin.t('mainDirectoryDesc'))
.addDropdown(dropdown => {
const folders = this.getFolders();
folders.forEach(([value, label]) => dropdown.addOption(value, label));
dropdown.setValue(this.plugin.settings.mainDirectory);
dropdown.onChange(async value => {
this.plugin.settings.mainDirectory = value;
await this.plugin.saveSettings();
});
});
if (Platform.isDesktop) {
const langSetting = new Setting(containerEl)
.setName(this.plugin.t('languageName'))
.setDesc(this.plugin.t('languageDesc'))
.addDropdown(dropdown => {
const langs = this.plugin.getAvailableLanguages();
langs.forEach(([value, label]) => dropdown.addOption(value, label));
dropdown.setValue(this.plugin.settings.language || 'en');
dropdown.onChange(async value => {
const changed = value !== this.plugin.settings.language;
this.plugin.settings.language = value;
await this.plugin.saveSettings();
langWarningEl.classList.toggle('shuffle-lab-is-hidden', !changed);
});
});
const langWarningEl = langSetting.descEl.createEl('div', {
text: this.plugin.t('noticeLanguageChanged'),
cls: 'shuffle-lab-lang-warning shuffle-lab-is-hidden'
});
new Setting(containerEl)
.setName(this.plugin.t('languageFilesName'))
.setDesc(this.plugin.t('languageFilesDesc'))
.addButton(button => button
.setButtonText(this.plugin.t('openFolderBtn'))
.onClick(() => this.plugin.openLanguageFolder()));
}
}
getFolders() {
const folders = [['', this.plugin.t('folderPlaceholder')]];
this.app.vault.getAllLoadedFiles().forEach(file => {
if (file instanceof TFolder && file.path !== '/') {
folders.push([file.path, file.path]);
}
});
return folders.sort((a, b) => a[0].localeCompare(b[0]));
}
}
class SessionSetupModal extends Modal {
constructor(app, plugin) {
super(app);
this.plugin = plugin;
this.selectedFolder = plugin.settings.folder || null;
this.numPictures = plugin.settings.numPictures;
this.durationPerImage = plugin.settings.durationPerImage;
this.loop = plugin.settings.loop;
this.enableLogging = true;
this.sliderComponent = null;
}
onOpen() {
this.render();
}
render() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('session-setup-modal');
contentEl.createEl('h2', { text: this.plugin.t('sessionHeading') });
const folders = this.getFolders();
if (folders.length === 0) {
contentEl.createEl('p', {
text: this.plugin.t('noFoldersFound'),
cls: 'setting-item-description',
});
return;
}
new Setting(contentEl)
.setName(this.plugin.t('folderName'))
.setDesc(this.plugin.t('folderDesc'))
.addDropdown(dropdown => {
folders.forEach(t => dropdown.addOption(t.path, t.name));
const current = this.selectedFolder && folders.some(t => t.path === this.selectedFolder)
? this.selectedFolder : folders[0].path;
this.selectedFolder = current;
dropdown.setValue(current);
dropdown.onChange(value => {
this.selectedFolder = value;
this.updateSliderMax();
this.updatePromptBtn();
});
});
new Setting(contentEl)
.setName(this.plugin.t('numImagesName'))
.setDesc(this.plugin.t('numImagesDesc'))
.addSlider(slider => {
this.sliderComponent = slider;
const max = Math.max(1, this.countImagesInTopic(this.selectedFolder));
slider.setLimits(1, max, 1);
if (this.numPictures > max) this.numPictures = max;
slider.setValue(this.numPictures);
slider.setDynamicTooltip();
slider.onChange(value => {
this.numPictures = value;
this.updateTotalTime();
});
});
this.warningEl = contentEl.createEl('div', {
cls: 'setting-item-description',
text: this.plugin.t('noImagesWarning')
});
this.warningEl.classList.add('shuffle-lab-warning');
this.warningEl.classList.toggle('shuffle-lab-is-hidden', this.countImagesInTopic(this.selectedFolder) !== 0);
new Setting(contentEl)
.setName(this.plugin.t('loopName'))
.setDesc(this.plugin.t('loopDesc'))
.addToggle(toggle => {
toggle.setValue(this.loop);
toggle.onChange(value => {
this.loop = value;
});
});
const DURATION_PRESETS = [
{ label: '30s', value: 30, localeKey: '30s' },
{ label: '60s', value: 60, localeKey: '60s' },
{ label: '2 min', value: 120, localeKey: '2min' },
{ label: '5 min', value: 300, localeKey: '5min' },
{ label: 'Custom', value: -1, localeKey: 'custom' },
];
const matchedPreset = DURATION_PRESETS.find(p => p.value === this.durationPerImage);
new Setting(contentEl)
.setName(this.plugin.t('durationName'))
.setDesc(this.plugin.t('durationDesc'))
.addDropdown(dropdown => {
DURATION_PRESETS.forEach(p => dropdown.addOption(String(p.value), this.plugin.t('durationPresets.' + p.localeKey)));
dropdown.setValue(matchedPreset ? String(matchedPreset.value) : '-1');
dropdown.onChange(value => {
const num = parseInt(value);
if (num === -1) {
customDurationSetting.classList.remove('shuffle-lab-is-hidden');
} else {
this.durationPerImage = num;
customDurationSetting.classList.add('shuffle-lab-is-hidden');
}
});
});
const customDurationSetting = new Setting(contentEl)
.setName(this.plugin.t('customDurationName'))
.setDesc(this.plugin.t('customDurationDesc'))
.addText(text => {
text.setValue(String(this.durationPerImage));
text.onChange(value => {
const num = parseInt(value);
if (!isNaN(num) && num > 0) {
this.durationPerImage = num;
this.updateTotalTime();
}
});
}).settingEl;
customDurationSetting.classList.toggle('shuffle-lab-is-hidden', !!matchedPreset);
this.totalTimeSetting = new Setting(contentEl)
.setName(this.plugin.t('totalTimeName'))
.setDesc(this.plugin.t('totalTimeDesc'));
this.totalTimeEl = this.totalTimeSetting.controlEl.createEl('span', { cls: 'drawing-session-total-time' });
this.updateTotalTime();
new Setting(contentEl)
.setName(this.plugin.t('loggingName'))
.setDesc(this.plugin.t('loggingDesc'))
.addToggle(toggle => {
toggle.setValue(this.enableLogging);
toggle.onChange(value => { this.enableLogging = value; });
});
new Setting(contentEl)
.setName(this.plugin.t('clearLogName'))
.setDesc(this.plugin.t('clearLogDesc'))
.addButton(button => button
.setButtonText(this.plugin.t('clearLogBtn'))
.onClick(() => this.clearLog()));
const promptSetting = new Setting(contentEl)
.setName(this.plugin.t('promptFileName'))
.setDesc(this.plugin.t('promptFileDesc'))
.addButton(button => {
this.promptBtn = button;
button.onClick(() => this.promptFileAction());
})
.addButton(button => {
this.deletePromptBtn = button;
button.setButtonText(this.plugin.t('deleteBtn'));
button.onClick(() => this.deletePromptFile());
});
this.updatePromptBtn();
new Setting(contentEl)
.addButton(button => button
.setButtonText(this.plugin.t('startBtn'))
.setCta()
.onClick(() => this.startSession()));
}
getFolders() {
const mainDir = this.app.vault.getAbstractFileByPath(this.plugin.settings.mainDirectory);
if (!(mainDir instanceof TFolder)) return [];
return mainDir.children.filter(child => child instanceof TFolder);
}
countImagesInTopic(folderPath) {
const folder = this.app.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) return 0;
let count = 0;
for (const child of folder.children) {
if (child instanceof TFolder) {
count += this.countImagesRecursive(child);
} else if (child instanceof TFile && IMAGE_EXTENSIONS.includes(child.extension.toLowerCase())) {
count++;
}
}
return count;
}
countImagesRecursive(folder) {
let count = 0;
for (const child of folder.children) {
if (child instanceof TFolder) {
count += this.countImagesRecursive(child);
} else if (child instanceof TFile && IMAGE_EXTENSIONS.includes(child.extension.toLowerCase())) {
count++;
}
}
return count;
}
updateSliderMax() {
if (!this.sliderComponent) return;
const count = this.countImagesInTopic(this.selectedFolder);
const max = Math.max(1, count);
this.sliderComponent.setLimits(1, max, 1);
if (this.numPictures > max) {
this.numPictures = max;
this.sliderComponent.setValue(max);
}
this.warningEl.classList.toggle('shuffle-lab-is-hidden', count !== 0);
this.updateTotalTime();
}
updateTotalTime() {
if (!this.totalTimeEl) return;
const totalSeconds = this.numPictures * this.durationPerImage;
this.totalTimeEl.textContent = formatTimeLong(totalSeconds);
}
startSession() {
if (!this.selectedFolder) {
new Notice(this.plugin.t('noticeSelectTopic'));
return;
}
this.plugin.settings.folder = this.selectedFolder;
this.plugin.settings.numPictures = this.numPictures;
this.plugin.settings.durationPerImage = this.durationPerImage;
this.plugin.settings.loop = this.loop;
this.plugin.saveSettings();
this.close();
new SlideshowModal(this.app, this.plugin, this.selectedFolder, this.numPictures, this.durationPerImage, this.loop, this.enableLogging).open();
}
async clearLog() {
if (!this.selectedFolder) return;
const filePath = this.selectedFolder + '/_session_log.md';
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
await this.app.vault.delete(file);
new Notice(this.plugin.t('noticeLogCleared'));
} else {
new Notice(this.plugin.t('noticeNoLog'));
}
}
updatePromptBtn() {
if (!this.promptBtn) return;
if (!this.selectedFolder) {
this.promptBtn.setButtonText('—');
this.promptBtn.setDisabled(true);
if (this.deletePromptBtn) this.deletePromptBtn.buttonEl.style.display = 'none';
return;
}
const filePath = this.selectedFolder + '/_prompt.md';
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
this.promptBtn.setButtonText(this.plugin.t('editBtn'));
this.promptBtn.setDisabled(false);
if (this.deletePromptBtn) {
this.deletePromptBtn.setDisabled(false);
this.deletePromptBtn.setButtonText(this.plugin.t('deleteBtn'));
this.deletePromptBtn.buttonEl.style.display = '';
}
} else {
this.promptBtn.setButtonText(this.plugin.t('createBtn'));
this.promptBtn.setDisabled(false);
if (this.deletePromptBtn) this.deletePromptBtn.buttonEl.style.display = 'none';
}
}
async deletePromptFile() {
if (!this.selectedFolder) return;
const filePath = this.selectedFolder + '/_prompt.md';
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
await this.app.vault.delete(file);
new Notice(this.plugin.t('noticePromptDeleted'));
this.updatePromptBtn();
}
}
async promptFileAction() {
if (!this.selectedFolder) return;
const filePath = this.selectedFolder + '/_prompt.md';
let file = this.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) {
const template = this.plugin.t('promptTemplate');
file = await this.app.vault.create(filePath, template);
}
const leaf = this.app.workspace.getLeaf();
await leaf.openFile(file);
this.close();
}
}
class SlideshowModal extends Modal {
constructor(app, plugin, sessionFolder, numPictures, durationPerImage, loop, enableLogging) {
super(app);
this.plugin = plugin;
this.sessionFolder = sessionFolder;
this.numPictures = numPictures;
this.durationPerImage = durationPerImage;
this.loop = loop;
this.enableLogging = enableLogging;
this.imageFiles = [];
this.currentIndex = 0;
this.sessionTimeRemaining = numPictures * durationPerImage;
this.imageTimeRemaining = durationPerImage;
this.isPaused = false;
this.sessionEnded = false;
this.showingEndOfDeck = false;
this.shownImageNames = [];
this.sessionInterval = null;
this.imageInterval = null;
this.scope.register([], 'Escape', (e) => { this.stopSession(); return false; });
}
async onOpen() {
const folder = this.app.vault.getAbstractFileByPath(this.sessionFolder);
if (!(folder instanceof TFolder)) {
new Notice(this.plugin.t('noticeTopicNotFound'));
this.close();
return;
}
const allImages = [];
this.collectImages(folder, allImages);
if (allImages.length === 0) {
new Notice(this.plugin.t('noticeNoImages'));
this.close();
return;
}
if (this.enableLogging) {
const log = await this.readSessionLog();
const newImages = shuffleArray(allImages.filter(f => !log.includes(f.name)));
const oldImages = shuffleArray(allImages.filter(f => log.includes(f.name)));
this.imageFiles = [...newImages.slice(0, this.numPictures)];
if (this.imageFiles.length < this.numPictures) {
this.imageFiles.push(...oldImages.slice(0, this.numPictures - this.imageFiles.length));
}
} else {
this.imageFiles = shuffleArray(pickRandom(allImages, this.numPictures));
}
this.renderUI();
try {
if (this.modalEl.requestFullscreen) {
await this.modalEl.requestFullscreen();
}
} catch (e) {
// Fullscreen not available; modal fills viewport via CSS
}
await this.showIntroSlide();
await this.showCountdown();
this.scope.register([], 'ArrowLeft', () => { this.prevImage(); return false; });
this.scope.register([], 'ArrowRight', () => { this.nextImage(); return false; });
await this.showImage(0);
this.startTimers();
this.updateTimeDisplay();
}
onClose() {
this.stopTimers();
if (this.enableLogging) {
this.writeSessionLog().catch(e => console.error('Failed to write session log:', e));
}
if (this.imageEl && this.imageEl.dataset.blobUrl) {
URL.revokeObjectURL(this.imageEl.dataset.blobUrl);
}
}
async showIntroSlide() {
const folder = this.app.vault.getAbstractFileByPath(this.sessionFolder);
if (!(folder instanceof TFolder)) return false;
const targetName = '_prompt';
const mdFile = folder.children.find(
child => child instanceof TFile && child.name === targetName + '.md'
);
if (!mdFile) return false;
const content = await this.app.vault.read(mdFile);
const slideContent = content.split('\n---\n')[0];
const introEl = this.contentEl.createDiv({ cls: 'drawing-session-intro' });
const introContent = introEl.createDiv({ cls: 'drawing-session-intro-content' });
await MarkdownRenderer.render(this.app, slideContent, introContent, this.sessionFolder, this);
const len = introContent.textContent.length;
let fontSize = '18px';
if (len < 50) fontSize = '36px';
else if (len < 150) fontSize = '28px';
else if (len < 400) fontSize = '22px';
introContent.style.fontSize = fontSize;
if (this.controlsEl) this.controlsEl.style.display = 'none';
if (this.timeDisplayEl) this.timeDisplayEl.style.display = 'none';
if (this.imageEl) this.imageEl.style.display = 'none';
const bottomBar = introEl.createDiv({ cls: 'drawing-session-intro-bottom' });
const startBtn = bottomBar.createEl('button', { cls: 'mod-cta', text: this.plugin.t('introStartBtn') });
return new Promise(resolve => {
const dismiss = () => {
if (this.controlsEl) this.controlsEl.style.display = '';
if (this.timeDisplayEl) this.timeDisplayEl.style.display = '';
if (this.imageEl) this.imageEl.style.display = '';
introEl.remove();
resolve(true);
};
startBtn.addEventListener('click', dismiss);
this.scope.register([], ' ', () => { dismiss(); return false; });
this.scope.register([], 'Enter', () => { dismiss(); return false; });
});
}
showCountdown() {
if (this.imageEl) this.imageEl.style.display = 'none';
const countdownEl = this.contentEl.createDiv({ cls: 'drawing-session-countdown' });
return new Promise(resolve => {
let count = 5;
countdownEl.textContent = String(count);
const interval = window.setInterval(() => {
count--;
if (count <= 0) {
clearInterval(interval);
countdownEl.style.opacity = '0';
setTimeout(() => {
countdownEl.remove();
resolve();
}, 300);
} else {
countdownEl.textContent = String(count);
}
}, 1000);
});
}
collectImages(folder, result) {
for (const child of folder.children) {
if (child instanceof TFolder) {
this.collectImages(child, result);
} else if (child instanceof TFile && IMAGE_EXTENSIONS.includes(child.extension.toLowerCase())) {
result.push(child);
}
}
}
async readSessionLog() {
const filePath = this.sessionFolder + '/_session_log.md';
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
try {
const content = await this.app.vault.read(file);
const names = [];
for (const line of content.split('\n')) {
const m = line.match(/^- (.+)$/);
if (m) names.push(m[1]);
}
return names;
} catch (e) {}
}
return [];
}
async writeSessionLog() {
const lines = [this.plugin.t('sessionLogTitle'), '', this.plugin.t('sessionLogHeader')];
for (const name of this.shownImageNames) {
lines.push('- ' + name);
}
const content = lines.join('\n') + '\n';
const filePath = this.sessionFolder + '/_session_log.md';
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
await this.app.vault.modify(file, content);
} else {
await this.app.vault.create(filePath, content);
}
}
renderUI() {
const { contentEl, modalEl } = this;
modalEl.addClass('shuffle-lab-modal');
contentEl.empty();
contentEl.classList.add('drawing-session-modal');
const topBar = contentEl.createDiv({ cls: 'drawing-session-topbar' });
this.timeDisplayEl = topBar.createSpan({ cls: 'drawing-session-time' });
this.counterDisplayEl = topBar.createSpan({ cls: 'drawing-session-counter' });
this.imageContainerEl = contentEl.createDiv({ cls: 'drawing-session-image-container' });
this.imageEl = this.imageContainerEl.createEl('img', { cls: 'drawing-session-image' });
this.imageEl.addEventListener('error', () => {
this.imageEl.alt = this.plugin.t('failedToLoad');
});
this.controlsEl = contentEl.createDiv({ cls: 'drawing-session-controls' });
const controls = this.controlsEl;
this.prevBtn = controls.createEl('button', { cls: 'drawing-session-btn', text: '\u25C0' });
this.prevBtn.addEventListener('click', () => this.prevImage());
this.pauseBtn = controls.createEl('button', { cls: 'drawing-session-btn', text: '\u23F8' });
this.pauseBtn.addEventListener('click', () => this.togglePause());
this.stopBtn = controls.createEl('button', { cls: 'drawing-session-btn', text: '\u23F9' });
this.stopBtn.addEventListener('click', () => this.stopSession());
this.nextBtn = controls.createEl('button', { cls: 'drawing-session-btn', text: '\u25B6' });
this.nextBtn.addEventListener('click', () => this.nextImage());
this.endOfDeckEl = contentEl.createDiv({ cls: 'drawing-session-endofdeck' });
this.endOfDeckEl.createEl('div', { cls: 'drawing-session-endofdeck-icon', text: '\u2713' });
this.endOfDeckEl.createEl('h2', { cls: 'drawing-session-endofdeck-title', text: this.plugin.t('endOfDeckTitle') });
this.endOfDeckEl.createEl('p', { cls: 'drawing-session-endofdeck-hint', text: this.plugin.t('endOfDeckHint') });
this.endOfDeckEl.style.display = 'none';
this.scope.register([], ' ', (e) => { e.preventDefault(); this.togglePause(); return false; });
}
async showImage(index) {
if (this.imageFiles.length === 0) return;
this.showingEndOfDeck = false;
this.endOfDeckEl.style.display = 'none';
this.imageContainerEl.style.display = '';
if (this.imageEl) this.imageEl.style.display = '';
this.currentIndex = index;
const file = this.imageFiles[this.currentIndex];
if (!this.shownImageNames.includes(file.name)) {
this.shownImageNames.push(file.name);
}
try {
const data = await this.app.vault.readBinary(file);
const mime = file.extension === 'svg' ? 'image/svg+xml' : 'image/' + file.extension;
const blob = new Blob([data], { type: mime });
const url = URL.createObjectURL(blob);
if (this.imageEl.dataset.blobUrl) {
URL.revokeObjectURL(this.imageEl.dataset.blobUrl);
}
this.imageEl.src = url;
this.imageEl.dataset.blobUrl = url;
this.imageEl.alt = file.name;
} catch (e) {
console.error('Failed to load image:', file.path, e);
this.imageEl.src = '';
this.imageEl.alt = this.plugin.t('failedToLoadName', file.name);
}
this.imageTimeRemaining = this.durationPerImage;
this.updateNavState();
this.updateTimeDisplay();
}
startTimers() {
this.sessionInterval = window.setInterval(() => {
if (this.isPaused) return;
this.sessionTimeRemaining--;
this.updateTimeDisplay();
if (this.sessionTimeRemaining <= 0) {
this.sessionComplete();
}
}, 1000);
this.imageInterval = window.setInterval(() => {
if (this.isPaused) return;
this.imageTimeRemaining--;
if (this.imageTimeRemaining <= 0) {
this.nextImage();
}
}, 1000);
}
updateNavState() {
if (this.sessionEnded) {
this.prevBtn.style.display = 'none';
this.nextBtn.style.display = '';
return;
}
if (this.showingEndOfDeck) {
this.prevBtn.style.display = '';
this.nextBtn.style.display = '';
return;
}
this.prevBtn.style.display = '';
this.nextBtn.style.display = '';
}
stopTimers() {
if (this.sessionInterval !== null) {
clearInterval(this.sessionInterval);
this.sessionInterval = null;
}
if (this.imageInterval !== null) {
clearInterval(this.imageInterval);
this.imageInterval = null;
}
}
showEndOfDeck() {
this.showingEndOfDeck = true;
this.endOfDeckEl.style.display = '';
this.imageTimeRemaining = this.durationPerImage;
this.updateNavState();
}
nextImage() {
if (this.sessionEnded) {
this.stopSession();
return;
}
if (this.showingEndOfDeck) {
this.sessionComplete();
return;
}
const next = this.currentIndex + 1;
if (next >= this.imageFiles.length) {
if (this.loop) {
this.imageFiles = shuffleArray(this.imageFiles);
this.showImage(0);
} else {
this.showEndOfDeck();
}
} else {
this.showImage(next);
}
}
prevImage() {
if (this.sessionEnded) return;
if (this.showingEndOfDeck) {
this.showImage(this.imageFiles.length - 1);
return;
}
const prev = this.currentIndex - 1;
if (prev >= 0) {
this.showImage(prev);
}
}
togglePause() {
this.isPaused = !this.isPaused;
this.pauseBtn.textContent = this.isPaused ? '\u25B6' : '\u23F8';
}
stopSession() {
this.stopTimers();
this.close();
}
sessionComplete() {
this.stopTimers();
this.isPaused = false;
this.sessionEnded = true;
this.showingEndOfDeck = false;
if (this.endOfDeckEl) this.endOfDeckEl.style.display = 'none';
if (this.pauseBtn) this.pauseBtn.style.display = 'none';
if (this.stopBtn) this.stopBtn.style.display = 'none';
this.updateNavState();
this.sessionTimeRemaining = 0;
this.updateTimeDisplay();
const endEl = this.contentEl.createDiv({ cls: 'drawing-session-end' });
endEl.createEl('div', { cls: 'drawing-session-end-icon', text: '\u2713' });
endEl.createEl('h2', { cls: 'drawing-session-end-title', text: this.plugin.t('sessionCompleteTitle') });
endEl.createEl('p', { cls: 'drawing-session-end-hint', text: this.plugin.t('sessionCompleteHint') });
}
updateTimeDisplay() {
if (this.timeDisplayEl) {
this.timeDisplayEl.textContent = '\u23F1 ' + formatTimeShort(this.sessionTimeRemaining);
}
if (this.counterDisplayEl) {
this.counterDisplayEl.textContent = (this.currentIndex + 1) + ' / ' + this.imageFiles.length;
}
}
}
function shuffleArray(arr) {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
function pickRandom(arr, n) {
const shuffled = shuffleArray(arr);
return shuffled.slice(0, Math.min(n, shuffled.length));
}
function formatTimeShort(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
}
function formatTimeLong(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts = [];
if (h > 0) parts.push(h + 'h');
if (m > 0 || h > 0) parts.push(m + 'm');
parts.push(s + 's');
return parts.join(' ');
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "shuffle-lab",
"name": "Shuffle Lab",
"version": "1.0.0",
"minAppVersion": "1.0.0",
"description": "Timed, randomized fullscreen image slideshows for presentations, games, drawing sessions, and more. By Mars Marcelyn.",
"author": "Mars Marcelyn",
"authorUrl": "",
"isDesktopOnly": true
}

260
styles.css Normal file
View file

@ -0,0 +1,260 @@
.session-setup-modal {
padding: 0 8px;
}
.drawing-session-modal {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: #000;
position: relative;
}
.drawing-session-topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 18px;
font-variant-numeric: tabular-nums;
z-index: 10;
user-select: none;
}
.drawing-session-image-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #000;
}
.drawing-session-image {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.drawing-session-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
padding: 20px;
background: rgba(0, 0, 0, 0.6);
}
.drawing-session-btn {
width: 64px;
height: 64px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 26px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, transform 0.1s ease;
padding: 0;
line-height: 1;
}
.drawing-session-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
.drawing-session-btn:active {
transform: scale(0.95);
}
.drawing-session-total-time {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.drawing-session-intro {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.92);
color: #fff;
z-index: 20;
}
.drawing-session-intro-content {
flex: 1;
max-width: 800px;
width: 100%;
margin: 0 auto;
font-size: 18px;
line-height: 1.6;
overflow-y: auto;
padding: 48px 40px 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
}
.drawing-session-intro-content h1,
.drawing-session-intro-content h2,
.drawing-session-intro-content h3 {
color: #fff;
margin-top: 0;
width: 100%;
}
.drawing-session-intro-content p {
margin: 16px 0;
width: 100%;
}
.drawing-session-intro-content ul,
.drawing-session-intro-content ol {
padding-left: 24px;
width: 100%;
}
.drawing-session-intro-content li {
margin: 8px 0;
}
.drawing-session-intro-content a {
color: #6eb0ff;
}
.drawing-session-intro-bottom {
flex-shrink: 0;
display: flex;
justify-content: center;
padding: 16px 40px 32px;
}
.drawing-session-intro-bottom button {
font-size: 24px;
padding: 16px 64px;
border-radius: 12px;
}
.drawing-session-countdown {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 160px;
font-weight: 700;
color: #fff;
z-index: 30;
background: rgba(0, 0, 0, 0.85);
transition: opacity 0.3s ease;
user-select: none;
pointer-events: none;
}
.drawing-session-end {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
color: #fff;
z-index: 30;
gap: 16px;
padding: 40px;
}
.drawing-session-end-icon {
font-size: 80px;
color: #4caf50;
line-height: 1;
}
.drawing-session-end-title {
font-size: 36px;
font-weight: 700;
margin: 0;
color: #fff;
}
.drawing-session-end-hint {
font-size: 18px;
color: rgba(255, 255, 255, 0.6);
margin: 8px 0 0;
}
.drawing-session-endofdeck {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
color: #fff;
z-index: 25;
gap: 16px;
padding: 40px;
}
.drawing-session-endofdeck-icon {
font-size: 60px;
color: #888;
line-height: 1;
}
.drawing-session-endofdeck-title {
font-size: 30px;
font-weight: 600;
margin: 0;
color: #fff;
}
.drawing-session-endofdeck-hint {
font-size: 16px;
color: rgba(255, 255, 255, 0.5);
margin: 8px 0 0;
}
.shuffle-lab-modal {
width: 100vw !important;
height: 100vh !important;
max-width: 100% !important;
max-height: 100% !important;
margin: 0 !important;
border-radius: 0 !important;
background-color: #000 !important;
}
.shuffle-lab-warning {
color: var(--text-warning);
margin-bottom: 16px;
margin-top: -8px;
}
.shuffle-lab-lang-warning {
color: var(--text-warning);
margin-top: 4px;
}
.shuffle-lab-is-hidden {
display: none;
}