mirror of
https://github.com/mars-marcelyn/Shuffle-Lab.git
synced 2026-07-22 07:44:45 +00:00
Initial Release
This commit is contained in:
parent
56fb120b7a
commit
e16e564f33
8 changed files with 1453 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
data.json
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
41
README.md
Normal 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
71
lang/en.json
Normal 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
71
lang/es.json
Normal 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
978
main.js
Normal 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
10
manifest.json
Normal 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
260
styles.css
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue