orca-gallery-panel 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,11 +17,14 @@ can change without leaving Orca.
17
17
 
18
18
  - **Slideshow**: shuffles your images and changes them on an interval you choose. Every image
19
19
  shows once before any repeats, and the same one never appears twice in a row.
20
- - **Controls** on hover: previous, pause/resume, next, a position counter, interval, display
21
- settings and fit/fill.
20
+ - **Several at once**: `--layout stack` stacks 2 to 4 images top to bottom, with no image on
21
+ screen twice. They change one at a time, taking turns, or all together with `--rotate all`.
22
+ - **Controls** on hover: previous, pause/resume, next, a position counter (slideshow only),
23
+ interval, display settings and fit/fill.
22
24
  - **Settings in the panel**: click the clock to type the seconds between images (`0` stops the
23
- automatic change), or the sliders to turn on a blurred background, a slow Ken Burns zoom or
24
- the image's name. Changes last until Orca reloads the panel, because Orca gives panels no
25
+ automatic change), or the sliders to turn on a blurred background, a slow Ken Burns zoom,
26
+ the image's name or the stacked layout, and to pick how many images it stacks and whether they
27
+ change one at a time. Changes last until Orca reloads the panel, because Orca gives panels no
25
28
  storage; the defaults come from your build settings.
26
29
  - **Blurred background**: an image that doesn't fill the panel sits on a blurred copy of itself
27
30
  instead of empty bars.
@@ -115,27 +118,43 @@ git, so your paths stay on your machine; `gallery.config.example.json` is a star
115
118
 
116
119
  ## Options
117
120
 
118
- | Option | Config key | Default | Description |
119
- | ----------------------- | ----------- | --------- | ------------------------------------------------- |
120
- | `[folder]` | `folder` | `images` | Folder with your images. |
121
- | `-o, --out <dir>` | `out` | `dist` | Output folder for the plugin. |
122
- | `-t, --title <text>` | `title` | `Gallery` | Panel title shown in Orca. |
123
- | `-i, --interval <sec>` | `interval` | `180` | Seconds between images. `0` disables rotation. |
124
- | `-s, --max-size <px>` | `maxSize` | `800` | Longest side of each image, in pixels. |
125
- | `-q, --quality <1-100>` | `quality` | `80` | WebP quality. |
126
- | `--fit <mode>` | `fit` | `contain` | Initial mode: `contain` (whole image) or `cover`. |
127
- | `--effect <name>` | `effect` | `none` | `none`, or `kenburns` for a slow zoom. |
128
- | `--no-blur` | `blur` | `true` | Blurred background behind images. |
129
- | `--caption` | `caption` | `false` | Show the image's file name. |
130
- | `-r, --recursive` | `recursive` | `false` | Include images in subfolders. |
131
- | | `galleries` | | Several galleries; see above. |
132
- | `-w, --watch` | | | Rebuild when images or the config file change. |
133
- | `-c, --config <file>` | | | Config file to read instead of the default. |
121
+ | Option | Config key | Default | Description |
122
+ | ----------------------- | ----------- | ----------- | -------------------------------------------------- |
123
+ | `[folder]` | `folder` | `images` | Folder with your images. |
124
+ | `-o, --out <dir>` | `out` | `dist` | Output folder for the plugin. |
125
+ | `-t, --title <text>` | `title` | `Gallery` | Panel title shown in Orca. |
126
+ | `-i, --interval <sec>` | `interval` | `180` | Seconds between images. `0` disables rotation. |
127
+ | `-s, --max-size <px>` | `maxSize` | `800` | Longest side of each image, in pixels. |
128
+ | `-q, --quality <1-100>` | `quality` | `80` | WebP quality. |
129
+ | `--fit <mode>` | `fit` | `contain` | Initial mode: `contain` (whole image) or `cover`. |
130
+ | `--effect <name>` | `effect` | `none` | `none`, or `kenburns` for a slow zoom. |
131
+ | `--no-blur` | `blur` | `true` | Blurred background behind images. |
132
+ | `--caption` | `caption` | `false` | Show the image's file name. |
133
+ | `-r, --recursive` | `recursive` | `false` | Include images in subfolders. |
134
+ | `--layout <mode>` | `layout` | `slideshow` | `slideshow`, or `stack` for several at once. |
135
+ | `--count <2-4>` | `count` | `3` | Images stacked at once with `stack`. |
136
+ | `--rotate <mode>` | `rotate` | `one` | With `stack`: change `one` per interval, or `all`. |
137
+ | | `galleries` | | Several galleries; see above. |
138
+ | `-w, --watch` | | | Rebuild when images or the config file change. |
139
+ | `-c, --config <file>` | | | Config file to read instead of the default. |
134
140
 
135
141
  Precedence: command line, then the gallery's own settings, then the top level of
136
142
  `gallery.config.json`, then the defaults. Paths in the config file are relative to the file, and
137
- `~` means your home folder. The `fit`, `interval`, `blur`, `effect` and `caption` settings are
138
- only where the panel starts: you can change them from the panel itself.
143
+ `~` means your home folder. The `fit`, `interval`, `blur`, `effect`, `caption`, `layout`,
144
+ `count` and `rotate` settings are only where the panel starts: you can change them from the panel
145
+ itself.
146
+
147
+ ### Several images at once
148
+
149
+ ```bash
150
+ npx orca-gallery-panel ~/Pictures/wallpapers --out ~/orca-gallery --layout stack --watch
151
+ ```
152
+
153
+ The panel splits into three images stacked top to bottom (`--count 2` to `4` changes that), never
154
+ the same image twice. Every interval one of them changes, top to bottom in turn, so each stays up
155
+ for three intervals; with `--rotate all` the whole set changes at once. Next and previous swap the
156
+ whole set, and clicking an image replaces just that one. With fewer images than places, it stacks
157
+ as many as there are.
139
158
 
140
159
  ### Several galleries
141
160
 
@@ -67,6 +67,23 @@
67
67
  "description": "Include images in subfolders.",
68
68
  "default": false
69
69
  },
70
+ "layout": {
71
+ "enum": ["slideshow", "stack"],
72
+ "description": "slideshow shows one image at a time; stack shows several stacked top to bottom.",
73
+ "default": "slideshow"
74
+ },
75
+ "count": {
76
+ "type": "integer",
77
+ "minimum": 2,
78
+ "maximum": 4,
79
+ "description": "Images shown at once with the stack layout.",
80
+ "default": 3
81
+ },
82
+ "rotate": {
83
+ "enum": ["one", "all"],
84
+ "description": "With the stack layout: change one image per interval, taking turns, or all of them at once.",
85
+ "default": "one"
86
+ },
70
87
  "out": {
71
88
  "type": "string",
72
89
  "minLength": 1,
@@ -120,6 +137,15 @@
120
137
  },
121
138
  "recursive": {
122
139
  "$ref": "#/properties/recursive"
140
+ },
141
+ "layout": {
142
+ "$ref": "#/properties/layout"
143
+ },
144
+ "count": {
145
+ "$ref": "#/properties/count"
146
+ },
147
+ "rotate": {
148
+ "$ref": "#/properties/rotate"
123
149
  }
124
150
  }
125
151
  }
package/lib/build.js CHANGED
@@ -87,6 +87,9 @@ async function buildGallery(gallery, logger, demo) {
87
87
  blur: gallery.blur,
88
88
  effect: gallery.effect,
89
89
  caption: gallery.caption,
90
+ layout: gallery.layout,
91
+ count: gallery.count,
92
+ rotate: gallery.rotate,
90
93
  demo,
91
94
  });
92
95
  const panelBytes = Buffer.byteLength(html);
package/lib/options.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { basename, resolve } from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
- import { EFFECTS, FIT_MODES, isEffect, isFitMode, MAX_INTERVAL_SECONDS, } from "./panel/config.js";
4
+ import { EFFECTS, FIT_MODES, isEffect, isFitMode, isLayout, isRotation, LAYOUTS, MAX_INTERVAL_SECONDS, MAX_STACK_COUNT, MIN_STACK_COUNT, ROTATIONS, } from "./panel/config.js";
5
5
  export class UsageError extends Error {
6
6
  name = 'UsageError';
7
7
  }
@@ -18,6 +18,9 @@ export const DEFAULTS = {
18
18
  effect: 'none',
19
19
  caption: false,
20
20
  recursive: false,
21
+ layout: 'slideshow',
22
+ count: 3,
23
+ rotate: 'one',
21
24
  };
22
25
  export const GALLERY_KEYS = [
23
26
  'folder',
@@ -30,6 +33,9 @@ export const GALLERY_KEYS = [
30
33
  'effect',
31
34
  'caption',
32
35
  'recursive',
36
+ 'layout',
37
+ 'count',
38
+ 'rotate',
33
39
  ];
34
40
  export const SETTING_KEYS = [
35
41
  ...GALLERY_KEYS,
@@ -40,6 +46,7 @@ const LIMITS = {
40
46
  interval: [0, MAX_INTERVAL_SECONDS],
41
47
  maxSize: [16, 8192],
42
48
  quality: [1, 100],
49
+ count: [MIN_STACK_COUNT, MAX_STACK_COUNT],
43
50
  };
44
51
  export const HELP = `Build an Orca side panel plugin from a folder of images.
45
52
 
@@ -60,6 +67,9 @@ Options:
60
67
  --effect <name> ${EFFECTS.join(' | ')} (default: ${DEFAULTS.effect})
61
68
  --no-blur No blurred background behind images that don't fill the panel
62
69
  --caption Show the image name
70
+ --layout <mode> ${LAYOUTS.join(' | ')}: one image, or several stacked (default: ${DEFAULTS.layout})
71
+ --count <2-4> Images stacked at once with --layout stack (default: ${String(DEFAULTS.count)})
72
+ --rotate <mode> With --layout stack, change ${ROTATIONS.join(' | ')} image(s) per interval (default: ${DEFAULTS.rotate})
63
73
  -r, --recursive Include images in subfolders
64
74
  -w, --watch Rebuild whenever the images or the config file change
65
75
  -c, --config <file> Read settings from this file (default: ./${CONFIG_FILE} if it exists)
@@ -95,7 +105,7 @@ export function validateGallerySettings(raw, name) {
95
105
  throw new UsageError(`${name('title')} must be at most 256 characters`);
96
106
  }
97
107
  }
98
- for (const key of ['interval', 'maxSize', 'quality']) {
108
+ for (const key of ['interval', 'maxSize', 'quality', 'count']) {
99
109
  const value = raw[key];
100
110
  if (value === undefined)
101
111
  continue;
@@ -117,6 +127,18 @@ export function validateGallerySettings(raw, name) {
117
127
  }
118
128
  settings.effect = raw['effect'];
119
129
  }
130
+ if (raw['layout'] !== undefined) {
131
+ if (!isLayout(raw['layout'])) {
132
+ throw new UsageError(`${name('layout')} must be one of ${LAYOUTS.join(', ')}, got ${JSON.stringify(raw['layout'])}`);
133
+ }
134
+ settings.layout = raw['layout'];
135
+ }
136
+ if (raw['rotate'] !== undefined) {
137
+ if (!isRotation(raw['rotate'])) {
138
+ throw new UsageError(`${name('rotate')} must be one of ${ROTATIONS.join(', ')}, got ${JSON.stringify(raw['rotate'])}`);
139
+ }
140
+ settings.rotate = raw['rotate'];
141
+ }
120
142
  for (const key of ['blur', 'caption', 'recursive']) {
121
143
  if (raw[key] !== undefined)
122
144
  settings[key] = validateBoolean(raw[key], name(key));
@@ -163,6 +185,9 @@ const FLAG_NAMES = {
163
185
  blur: '--no-blur',
164
186
  caption: '--caption',
165
187
  recursive: '--recursive',
188
+ layout: '--layout',
189
+ count: '--count',
190
+ rotate: '--rotate',
166
191
  };
167
192
  /** Numeric flags arrive as text; anything that is not a plain number is kept so validation reports it. */
168
193
  function numberFlag(value) {
@@ -188,6 +213,9 @@ export function parseCommand(argv) {
188
213
  'no-blur': { type: 'boolean' },
189
214
  caption: { type: 'boolean' },
190
215
  recursive: { type: 'boolean', short: 'r' },
216
+ layout: { type: 'string' },
217
+ count: { type: 'string' },
218
+ rotate: { type: 'string' },
191
219
  watch: { type: 'boolean', short: 'w', default: false },
192
220
  config: { type: 'string', short: 'c' },
193
221
  help: { type: 'boolean', short: 'h', default: false },
@@ -218,6 +246,9 @@ export function parseCommand(argv) {
218
246
  blur: values['no-blur'] === true ? false : undefined,
219
247
  caption: values.caption,
220
248
  recursive: values.recursive,
249
+ layout: values.layout,
250
+ count: numberFlag(values.count),
251
+ rotate: values.rotate,
221
252
  }, (key) => FLAG_NAMES[key] ?? key);
222
253
  return { kind: 'build', settings, configPath: values.config, watch: values.watch };
223
254
  }
@@ -253,6 +284,9 @@ function galleryOptions(layers, cwd, title) {
253
284
  effect: pick('effect').value,
254
285
  caption: pick('caption').value,
255
286
  recursive: pick('recursive').value,
287
+ layout: pick('layout').value,
288
+ count: pick('count').value,
289
+ rotate: pick('rotate').value,
256
290
  };
257
291
  }
258
292
  /**
@@ -1,5 +1,11 @@
1
1
  export const FIT_MODES = ['contain', 'cover'];
2
2
  export const EFFECTS = ['none', 'kenburns'];
3
+ /** slideshow: one image at a time. stack: several images stacked top to bottom. */
4
+ export const LAYOUTS = ['slideshow', 'stack'];
5
+ /** How a stack changes: one image per interval, taking turns, or all of them at once. */
6
+ export const ROTATIONS = ['one', 'all'];
7
+ export const MIN_STACK_COUNT = 2;
8
+ export const MAX_STACK_COUNT = 4;
3
9
  export const CONFIG_ELEMENT_ID = 'gallery-config';
4
10
  export const MAX_INTERVAL_SECONDS = 86_400;
5
11
  export function isFitMode(value) {
@@ -8,10 +14,22 @@ export function isFitMode(value) {
8
14
  export function isEffect(value) {
9
15
  return EFFECTS.includes(value);
10
16
  }
17
+ export function isLayout(value) {
18
+ return LAYOUTS.includes(value);
19
+ }
20
+ export function isRotation(value) {
21
+ return ROTATIONS.includes(value);
22
+ }
23
+ export function isStackCount(value) {
24
+ return (typeof value === 'number' &&
25
+ Number.isInteger(value) &&
26
+ value >= MIN_STACK_COUNT &&
27
+ value <= MAX_STACK_COUNT);
28
+ }
11
29
  export function parseConfig(raw) {
12
30
  if (typeof raw !== 'object' || raw === null)
13
31
  throw new TypeError('Config must be an object');
14
- const { images, intervalMs, fit, blur, effect, caption, demo } = raw;
32
+ const { images, intervalMs, fit, blur, effect, caption, layout, count, rotate, demo } = raw;
15
33
  if (!Array.isArray(images))
16
34
  throw new TypeError('Config.images must be an array');
17
35
  const parsed = images.map((image, i) => {
@@ -36,7 +54,27 @@ export function parseConfig(raw) {
36
54
  if (demo !== undefined && typeof demo !== 'boolean') {
37
55
  throw new TypeError('Config.demo must be a boolean');
38
56
  }
39
- return { images: parsed, intervalMs, fit, blur, effect, caption, demo: demo ?? false };
57
+ if (layout !== undefined && !isLayout(layout)) {
58
+ throw new TypeError(`Config.layout must be one of ${LAYOUTS.join(', ')}`);
59
+ }
60
+ if (count !== undefined && !isStackCount(count)) {
61
+ throw new TypeError(`Config.count must be an integer from ${String(MIN_STACK_COUNT)} to ${String(MAX_STACK_COUNT)}`);
62
+ }
63
+ if (rotate !== undefined && !isRotation(rotate)) {
64
+ throw new TypeError(`Config.rotate must be one of ${ROTATIONS.join(', ')}`);
65
+ }
66
+ return {
67
+ images: parsed,
68
+ intervalMs,
69
+ fit,
70
+ blur,
71
+ effect,
72
+ caption,
73
+ layout: layout ?? 'slideshow',
74
+ count: count ?? 3,
75
+ rotate: rotate ?? 'one',
76
+ demo: demo ?? false,
77
+ };
40
78
  }
41
79
  /** Parses what a user typed as the interval; undefined unless it is a whole number of seconds in range. */
42
80
  export function parseIntervalSeconds(text) {
package/lib/panel/i18n.js CHANGED
@@ -16,6 +16,9 @@ const en = {
16
16
  blur: 'Blurred background',
17
17
  kenburns: 'Slow zoom (Ken Burns)',
18
18
  caption: 'Show image name',
19
+ stack: 'Several images, stacked',
20
+ count: 'Images at once',
21
+ rotateOne: 'Change one at a time',
19
22
  settingsNote: 'Changes last until the panel reloads. Set defaults in gallery.config.json.',
20
23
  counter: (position, total) => `Image ${String(position)} of ${String(total)}`,
21
24
  empty: 'No images yet. Rebuild the panel with a folder that contains images.',
@@ -44,6 +47,9 @@ const es = {
44
47
  blur: 'Fondo difuminado',
45
48
  kenburns: 'Zoom lento (Ken Burns)',
46
49
  caption: 'Mostrar nombre',
50
+ stack: 'Varias imágenes apiladas',
51
+ count: 'Imágenes a la vez',
52
+ rotateOne: 'Cambiar de a una',
47
53
  settingsNote: 'Los cambios duran hasta que el panel se recargue. Los valores por defecto van en gallery.config.json.',
48
54
  counter: (position, total) => `Imagen ${String(position)} de ${String(total)}`,
49
55
  empty: 'Todavía no hay imágenes. Vuelve a generar el panel con una carpeta que tenga imágenes.',
package/lib/panel/main.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CONFIG_ELEMENT_ID, parseConfig, parseIntervalSeconds, } from "./config.js";
1
+ import { CONFIG_ELEMENT_ID, MAX_STACK_COUNT, parseConfig, parseIntervalSeconds, } from "./config.js";
2
2
  import { catalogs, pickLocale } from "./i18n.js";
3
3
  import { Playlist } from "./playlist.js";
4
4
  const KEN_BURNS_IDLE_MS = 30_000;
@@ -80,24 +80,87 @@ class Popover {
80
80
  this.button.focus();
81
81
  }
82
82
  }
83
+ const SWITCHES = ['blur', 'kenburns', 'caption', 'stack', 'rotateOne'];
84
+ /** Steps "previous" can walk back through in the stack layout. */
85
+ const MAX_SNAPSHOTS = 100;
86
+ /** One place for an image: two layers that cross-fade, a blurred backdrop each, and a caption. */
87
+ class Slot {
88
+ root;
89
+ #layers;
90
+ #caption;
91
+ #front = 0;
92
+ #shown = 0;
93
+ #request = 0;
94
+ /** The image shown or loading here; undefined while the slot is not in use. */
95
+ index;
96
+ constructor(root) {
97
+ this.root = root;
98
+ const layers = root.querySelectorAll('.layer');
99
+ const [a, b] = [layers[0], layers[1]];
100
+ if (!a || !b)
101
+ throw new Error('A slot needs two layers');
102
+ this.#layers = [a, b];
103
+ this.#caption = query('.caption', HTMLElement, root);
104
+ }
105
+ /** Loads into the hidden layer and cross-fades once it is decoded; stale loads are ignored. */
106
+ show(image, index, loaded, failed) {
107
+ this.index = index;
108
+ const request = ++this.#request;
109
+ const back = this.#layers[this.#front === 0 ? 1 : 0];
110
+ const front = this.#layers[this.#front];
111
+ const photo = query('.photo', HTMLImageElement, back);
112
+ const backdrop = query('.backdrop', HTMLImageElement, back);
113
+ photo.onload = () => {
114
+ if (request !== this.#request)
115
+ return;
116
+ backdrop.src = image.src;
117
+ back.classList.toggle('alternate', this.#shown++ % 2 === 1);
118
+ // Flushes the removal of "animate" so the zoom restarts even if no frame ran in between.
119
+ back.getBoundingClientRect();
120
+ back.classList.add('visible', 'animate');
121
+ front.classList.remove('visible');
122
+ this.#front = this.#front === 0 ? 1 : 0;
123
+ this.#caption.textContent = image.label;
124
+ loaded();
125
+ };
126
+ photo.onerror = () => {
127
+ if (request !== this.#request)
128
+ return;
129
+ failed();
130
+ };
131
+ // The outgoing layer keeps its zoom while it fades; this one restarts it once loaded.
132
+ back.classList.remove('animate');
133
+ photo.alt = image.label;
134
+ photo.src = image.src;
135
+ }
136
+ /** Takes the slot out of use, drops any load still in flight, and blanks it. */
137
+ clear() {
138
+ this.index = undefined;
139
+ this.#request++;
140
+ for (const layer of this.#layers)
141
+ layer.classList.remove('visible', 'animate');
142
+ this.#caption.textContent = '';
143
+ }
144
+ }
83
145
  class Gallery {
84
146
  #config;
85
147
  #t;
86
148
  #playlist;
87
- #layers;
149
+ #slots;
88
150
  #buttons;
89
151
  #counter;
90
- #caption;
91
152
  #interval;
92
153
  #settings;
93
154
  #switches;
155
+ #counts;
94
156
  #failed = new Set();
95
157
  #state;
96
- #front = 0;
97
- #shown = 0;
158
+ /** In the stack layout, which slot changes next when they take turns. */
159
+ #turn = 0;
160
+ /** In the stack layout, what was on screen before each change, for "previous". */
161
+ #snapshots = [];
98
162
  #paused = false;
99
163
  #timer;
100
- #request = 0;
101
164
  constructor(config, t) {
102
165
  this.#config = config;
103
166
  this.#t = t;
@@ -107,11 +170,19 @@ class Gallery {
107
170
  blur: config.blur,
108
171
  effect: config.effect,
109
172
  caption: config.caption,
173
+ layout: config.layout,
174
+ count: config.count,
175
+ rotate: config.rotate,
110
176
  };
111
177
  this.#playlist = new Playlist(config.images.length);
112
- this.#layers = [query('#layer-a', HTMLElement), query('#layer-b', HTMLElement)];
113
178
  this.#counter = query('#counter', HTMLElement);
114
- this.#caption = query('#caption', HTMLElement);
179
+ const container = query('#slots', HTMLElement);
180
+ const template = query('#slot-template', HTMLTemplateElement);
181
+ this.#slots = Array.from({ length: MAX_STACK_COUNT }, () => {
182
+ const root = query('.slot', HTMLElement, template.content.cloneNode(true));
183
+ container.append(root);
184
+ return new Slot(root);
185
+ });
115
186
  const button = (action) => query(`[data-action="${action}"]`, HTMLButtonElement);
116
187
  this.#buttons = {
117
188
  previous: button('previous'),
@@ -138,23 +209,40 @@ class Gallery {
138
209
  blur: toggle('blur'),
139
210
  kenburns: toggle('kenburns'),
140
211
  caption: toggle('caption'),
212
+ stack: toggle('stack'),
213
+ rotateOne: toggle('rotateOne'),
141
214
  };
215
+ this.#counts = Array.from(document.querySelectorAll('input[name="count"]'));
142
216
  }
143
217
  get #size() {
144
218
  return this.#config.images.length;
145
219
  }
220
+ /** How many slots are in use: one for the slideshow, up to `count` for the stack. */
221
+ get #active() {
222
+ return this.#state.layout === 'stack' ? Math.min(this.#state.count, this.#size) : 1;
223
+ }
224
+ get #activeSlots() {
225
+ return this.#slots.slice(0, this.#active);
226
+ }
146
227
  start() {
147
228
  this.#renderStaticText();
148
229
  this.#renderAll();
149
230
  this.#bindToolbar();
150
231
  this.#bindInterval();
151
232
  this.#bindSettings();
152
- for (const layer of this.#layers) {
153
- layer.addEventListener('click', () => {
154
- if (this.#size > 1)
233
+ this.#slots.forEach((slot) => {
234
+ slot.root.addEventListener('click', () => {
235
+ if (this.#size <= 1)
236
+ return;
237
+ if (this.#state.layout === 'stack') {
238
+ this.#remember();
239
+ this.#replace(slot);
240
+ }
241
+ else {
155
242
  this.#next();
243
+ }
156
244
  });
157
- }
245
+ });
158
246
  document.addEventListener('pointerdown', (event) => {
159
247
  const open = Popover.current;
160
248
  if (open && !(event.target instanceof Node && open.contains(event.target)))
@@ -166,7 +254,7 @@ class Gallery {
166
254
  document.addEventListener('visibilitychange', () => {
167
255
  this.#schedule();
168
256
  });
169
- this.#next();
257
+ this.#applyLayout();
170
258
  }
171
259
  #renderStaticText() {
172
260
  const text = (selector, value) => {
@@ -180,7 +268,8 @@ class Gallery {
180
268
  this.#interval.apply.textContent = this.#t.apply;
181
269
  text('#settings-title', this.#t.settings);
182
270
  text('#settings-note', this.#t.settingsNote);
183
- for (const name of ['blur', 'kenburns', 'caption']) {
271
+ text('#count-label', this.#t.count);
272
+ for (const name of SWITCHES) {
184
273
  query('span', HTMLElement, this.#switches[name].closest('label') ?? document).textContent =
185
274
  this.#t[name];
186
275
  }
@@ -224,22 +313,39 @@ class Gallery {
224
313
  this.#switches.caption.addEventListener('change', () => {
225
314
  this.#update({ caption: this.#switches.caption.checked });
226
315
  });
316
+ this.#switches.stack.addEventListener('change', () => {
317
+ this.#update({ layout: this.#switches.stack.checked ? 'stack' : 'slideshow' });
318
+ });
319
+ this.#switches.rotateOne.addEventListener('change', () => {
320
+ this.#update({ rotate: this.#switches.rotateOne.checked ? 'one' : 'all' });
321
+ });
322
+ for (const radio of this.#counts) {
323
+ radio.addEventListener('change', () => {
324
+ if (radio.checked)
325
+ this.#update({ count: Number(radio.value) });
326
+ });
327
+ }
227
328
  }
228
329
  #update(change) {
229
330
  Object.assign(this.#state, change);
230
331
  this.#renderAll();
231
- if (change.intervalMs !== undefined)
332
+ if (change.layout !== undefined || change.count !== undefined)
333
+ this.#applyLayout();
334
+ else if (change.intervalMs !== undefined || change.rotate !== undefined)
232
335
  this.#schedule();
233
336
  }
234
337
  #renderAll() {
235
- const { fit, blur, effect, caption, intervalMs } = this.#state;
338
+ const { fit, blur, effect, caption, intervalMs, layout, count, rotate } = this.#state;
236
339
  const body = document.body;
237
340
  body.dataset['fit'] = fit;
341
+ body.dataset['layout'] = layout;
238
342
  body.classList.toggle('blur', blur);
239
343
  body.classList.toggle('kenburns', effect === 'kenburns');
240
344
  body.classList.toggle('show-caption', caption);
241
345
  body.classList.toggle('paused', this.#paused);
242
- body.style.setProperty('--gallery-kenburns', `${String(intervalMs > 0 ? intervalMs + 600 : KEN_BURNS_IDLE_MS)}ms`);
346
+ // Taking turns, each image stays up for one interval per slot.
347
+ const visibleMs = layout === 'stack' && rotate === 'one' ? intervalMs * this.#active : intervalMs;
348
+ body.style.setProperty('--gallery-kenburns', `${String(visibleMs > 0 ? visibleMs + 600 : KEN_BURNS_IDLE_MS)}ms`);
243
349
  setLabel(this.#buttons.fit, fit === 'contain' ? this.#t.fill : this.#t.fit);
244
350
  setLabel(this.#interval.popover.button, this.#t.interval(intervalMs / 1000));
245
351
  this.#buttons.toggle.setAttribute('aria-pressed', String(this.#paused));
@@ -247,60 +353,147 @@ class Gallery {
247
353
  this.#switches.blur.checked = blur;
248
354
  this.#switches.kenburns.checked = effect === 'kenburns';
249
355
  this.#switches.caption.checked = caption;
356
+ this.#switches.stack.checked = layout === 'stack';
357
+ this.#switches.rotateOne.checked = rotate === 'one';
358
+ this.#switches.rotateOne.disabled = layout !== 'stack';
359
+ for (const radio of this.#counts) {
360
+ radio.checked = Number(radio.value) === count;
361
+ radio.disabled = layout !== 'stack';
362
+ }
250
363
  }
251
- #next() {
252
- this.#show(this.#playlist.next());
364
+ /** Shows the slots the layout uses, hides the rest, and fills any that are empty. */
365
+ #applyLayout() {
366
+ const active = this.#active;
367
+ this.#slots.forEach((slot, i) => {
368
+ slot.root.hidden = i >= active;
369
+ if (i >= active)
370
+ slot.clear();
371
+ });
372
+ this.#snapshots = [];
373
+ this.#turn = 0;
374
+ for (const slot of this.#activeSlots) {
375
+ if (slot.index === undefined)
376
+ this.#replace(slot);
377
+ }
378
+ const [first] = this.#slots;
379
+ if (this.#state.layout === 'slideshow' && first?.index !== undefined) {
380
+ this.#renderCounter(first.index);
381
+ }
382
+ this.#schedule();
253
383
  }
254
- #previous() {
255
- this.#show(this.#playlist.previous());
384
+ /** The next image from the playlist that hasn't failed and isn't in `exclude`. */
385
+ #draw(exclude) {
386
+ // Every cycle holds each image once, so two cycles always reach any free image.
387
+ for (let tries = 0; tries < this.#size * 2; tries++) {
388
+ const index = this.#playlist.next();
389
+ if (!this.#failed.has(index) && !exclude.has(index))
390
+ return index;
391
+ }
392
+ return undefined;
256
393
  }
257
- /** Loads into the hidden layer and cross-fades once it is decoded; stale loads are ignored. */
258
- #show(index) {
394
+ /** Images on screen in the slots in use, except in `slot`. */
395
+ #onScreen(except) {
396
+ const shown = new Set();
397
+ for (const slot of this.#activeSlots) {
398
+ if (slot !== except && slot.index !== undefined)
399
+ shown.add(slot.index);
400
+ }
401
+ return shown;
402
+ }
403
+ /** Puts a different image, not on screen elsewhere, in one slot. */
404
+ #replace(slot) {
405
+ const exclude = this.#onScreen(slot);
406
+ if (slot.index !== undefined)
407
+ exclude.add(slot.index);
408
+ const index = this.#draw(exclude);
409
+ if (index !== undefined)
410
+ this.#load(slot, index);
411
+ }
412
+ #load(slot, index) {
259
413
  const image = this.#config.images[index];
260
414
  if (!image)
261
415
  return;
262
- const request = ++this.#request;
263
- const back = this.#layers[this.#front === 0 ? 1 : 0];
264
- const front = this.#layers[this.#front];
265
- const photo = query('.photo', HTMLImageElement, back);
266
- const backdrop = query('.backdrop', HTMLImageElement, back);
267
- photo.onload = () => {
268
- if (request !== this.#request)
269
- return;
270
- backdrop.src = image.src;
271
- back.classList.toggle('alternate', this.#shown++ % 2 === 1);
272
- // Flushes the removal of "animate" so the zoom restarts even if no frame ran in between.
273
- back.getBoundingClientRect();
274
- back.classList.add('visible', 'animate');
275
- front.classList.remove('visible');
276
- this.#front = this.#front === 0 ? 1 : 0;
277
- this.#caption.textContent = image.label;
278
- this.#renderCounter(index);
416
+ slot.show(image, index, () => {
417
+ if (this.#state.layout === 'slideshow')
418
+ this.#renderCounter(index);
279
419
  this.#schedule();
280
- };
281
- photo.onerror = () => {
282
- if (request !== this.#request)
283
- return;
420
+ }, () => {
284
421
  this.#failed.add(index);
422
+ // Keeps showing what was there while another image loads.
423
+ slot.index = undefined;
285
424
  if (this.#failed.size >= this.#size) {
286
425
  clearTimeout(this.#timer);
287
426
  showMessage(this.#t.allFailed);
288
427
  return;
289
428
  }
290
- this.#next();
291
- };
292
- // The outgoing layer keeps its zoom while it fades; this one restarts it once loaded.
293
- back.classList.remove('animate');
294
- photo.alt = image.label;
295
- photo.src = image.src;
429
+ this.#replace(slot);
430
+ });
431
+ }
432
+ /** Saves what is on screen so "previous" in the stack layout can bring it back. */
433
+ #remember() {
434
+ const current = this.#activeSlots.map((slot) => slot.index);
435
+ if (current.some((index) => index === undefined))
436
+ return;
437
+ this.#snapshots.push(current);
438
+ if (this.#snapshots.length > MAX_SNAPSHOTS)
439
+ this.#snapshots.shift();
440
+ }
441
+ #next() {
442
+ if (this.#state.layout === 'slideshow') {
443
+ const index = this.#draw(new Set());
444
+ const [slot] = this.#slots;
445
+ if (index !== undefined && slot)
446
+ this.#load(slot, index);
447
+ return;
448
+ }
449
+ // A whole new set, with no image twice.
450
+ this.#remember();
451
+ const chosen = new Set();
452
+ for (const slot of this.#activeSlots) {
453
+ const index = this.#draw(chosen);
454
+ if (index === undefined)
455
+ continue;
456
+ chosen.add(index);
457
+ this.#load(slot, index);
458
+ }
459
+ }
460
+ #previous() {
461
+ if (this.#state.layout === 'slideshow') {
462
+ const [slot] = this.#slots;
463
+ if (slot)
464
+ this.#load(slot, this.#playlist.previous());
465
+ return;
466
+ }
467
+ const snapshot = this.#snapshots.pop();
468
+ if (!snapshot)
469
+ return;
470
+ this.#activeSlots.forEach((slot, i) => {
471
+ const index = snapshot[i];
472
+ if (index !== undefined && index !== slot.index)
473
+ this.#load(slot, index);
474
+ });
475
+ }
476
+ /** What the timer does: the next image, the next set, or the next slot's turn. */
477
+ #advance() {
478
+ if (this.#state.layout === 'stack' && this.#state.rotate === 'one') {
479
+ const slots = this.#activeSlots;
480
+ const slot = slots[this.#turn % slots.length];
481
+ this.#turn = (this.#turn + 1) % slots.length;
482
+ if (!slot)
483
+ return;
484
+ this.#remember();
485
+ this.#replace(slot);
486
+ return;
487
+ }
488
+ this.#next();
296
489
  }
297
490
  #schedule() {
298
491
  clearTimeout(this.#timer);
299
492
  this.#timer = undefined;
300
- const rotates = this.#state.intervalMs > 0 && this.#size > 1;
493
+ const rotates = this.#state.intervalMs > 0 && this.#size > this.#active;
301
494
  if (rotates && !this.#paused && !document.hidden) {
302
495
  this.#timer = setTimeout(() => {
303
- this.#next();
496
+ this.#advance();
304
497
  }, this.#state.intervalMs);
305
498
  }
306
499
  }
@@ -40,6 +40,26 @@ body {
40
40
  overflow: hidden;
41
41
  }
42
42
 
43
+ /* One slot for the slideshow; up to four stacked top to bottom for the stack layout. */
44
+ .slots {
45
+ position: absolute;
46
+ inset: 0;
47
+ display: flex;
48
+ flex-direction: column;
49
+ gap: 2px;
50
+ }
51
+
52
+ .slot {
53
+ position: relative;
54
+ flex: 1 1 0;
55
+ min-height: 0;
56
+ overflow: hidden;
57
+ }
58
+
59
+ .slot[hidden] {
60
+ display: none;
61
+ }
62
+
43
63
  .layer {
44
64
  position: absolute;
45
65
  inset: 0;
@@ -144,6 +164,7 @@ body.message .message-text {
144
164
  }
145
165
 
146
166
  body.message .controls,
167
+ body.message .slots,
147
168
  body.message .layer,
148
169
  body.message .caption {
149
170
  display: none;
@@ -344,6 +365,72 @@ body.single .counter {
344
365
  transform: translateX(12px);
345
366
  }
346
367
 
368
+ /* The stack shows several images, so a single position means nothing. */
369
+ body[data-layout='stack'] .counter {
370
+ display: none;
371
+ }
372
+
373
+ .count-row {
374
+ display: flex;
375
+ align-items: center;
376
+ justify-content: space-between;
377
+ gap: 8px;
378
+ padding: 5px 0;
379
+ }
380
+
381
+ .segmented {
382
+ display: inline-flex;
383
+ padding: 2px;
384
+ border-radius: calc(var(--gallery-radius) - 4px);
385
+ background: var(--gallery-input);
386
+ }
387
+
388
+ .segmented label {
389
+ position: relative;
390
+ cursor: pointer;
391
+ }
392
+
393
+ .segmented input {
394
+ position: absolute;
395
+ inset: 0;
396
+ margin: 0;
397
+ opacity: 0;
398
+ cursor: pointer;
399
+ }
400
+
401
+ .segmented span {
402
+ display: block;
403
+ min-width: 22px;
404
+ padding: 1px 6px;
405
+ border-radius: calc(var(--gallery-radius) - 6px);
406
+ text-align: center;
407
+ font-variant-numeric: tabular-nums;
408
+ }
409
+
410
+ .segmented input:checked + span {
411
+ background: var(--gallery-primary);
412
+ color: var(--gallery-primary-fg);
413
+ font-weight: 600;
414
+ }
415
+
416
+ .segmented input:focus-visible + span {
417
+ outline: 2px solid var(--gallery-ring);
418
+ outline-offset: 1px;
419
+ }
420
+
421
+ /* Count and turn-taking only apply to the stack. */
422
+ .switch:has(input:disabled),
423
+ .count-row:has(input:disabled) {
424
+ opacity: 0.45;
425
+ }
426
+
427
+ .switch:has(input:disabled),
428
+ .switch input:disabled,
429
+ .segmented input:disabled,
430
+ .segmented label:has(input:disabled) {
431
+ cursor: default;
432
+ }
433
+
347
434
  .hint {
348
435
  margin: 6px 0 0;
349
436
  color: var(--gallery-muted);
@@ -10,18 +10,24 @@
10
10
  </head>
11
11
  <body>
12
12
  <main class="stage">
13
- <div id="layer-a" class="layer">
14
- <img class="backdrop" alt="" aria-hidden="true" />
15
- <img class="photo" alt="" />
16
- </div>
17
- <div id="layer-b" class="layer">
18
- <img class="backdrop" alt="" aria-hidden="true" />
19
- <img class="photo" alt="" />
20
- </div>
21
- <p id="caption" class="caption"></p>
13
+ <div id="slots" class="slots"></div>
22
14
  <p id="message" class="message-text" role="status"></p>
23
15
  </main>
24
16
 
17
+ <template id="slot-template">
18
+ <div class="slot">
19
+ <div class="layer">
20
+ <img class="backdrop" alt="" aria-hidden="true" />
21
+ <img class="photo" alt="" />
22
+ </div>
23
+ <div class="layer">
24
+ <img class="backdrop" alt="" aria-hidden="true" />
25
+ <img class="photo" alt="" />
26
+ </div>
27
+ <p class="caption"></p>
28
+ </div>
29
+ </template>
30
+
25
31
  <aside id="demo-note" class="demo-note" aria-labelledby="demo-title" hidden>
26
32
  <p id="demo-title" class="demo-title"></p>
27
33
  <p id="demo-run"></p>
@@ -115,6 +121,22 @@
115
121
  <input type="checkbox" role="switch" data-setting="caption" />
116
122
  <span></span>
117
123
  </label>
124
+ <label class="switch">
125
+ <input type="checkbox" role="switch" data-setting="stack" />
126
+ <span></span>
127
+ </label>
128
+ <div class="count-row" role="radiogroup" aria-labelledby="count-label">
129
+ <span id="count-label"></span>
130
+ <span class="segmented">
131
+ <label><input type="radio" name="count" value="2" /><span>2</span></label>
132
+ <label><input type="radio" name="count" value="3" /><span>3</span></label>
133
+ <label><input type="radio" name="count" value="4" /><span>4</span></label>
134
+ </span>
135
+ </div>
136
+ <label class="switch">
137
+ <input type="checkbox" role="switch" data-setting="rotateOne" />
138
+ <span></span>
139
+ </label>
118
140
  <p id="settings-note" class="hint"></p>
119
141
  </div>
120
142
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orca-gallery-panel",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Background images for the Orca agent IDE: a side panel with a rotating gallery of your own pictures.",
5
5
  "keywords": [
6
6
  "orca",