dsh-ambient-gallery 0.1.0 → 0.1.1

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
@@ -8,9 +8,10 @@
8
8
 
9
9
  ## 它做了什么
10
10
 
11
- - **阵列排布**:默认 4×3 = 12 个窗口,行列数、间距、圆角、模糊都可调。
11
+ - **点缀式阵列,不是表格**:默认 4×3 = 12 个窗口,但列宽、行高、每块尺寸、落位、倾角都是确定性地抖动过的 —— 大小不一、疏密错落,像客厅电视墙上的装饰画,而不是等分网格。每块另有上限(26% × 34%),无论行多行少都不会长成一大块板子。
12
+ - **只占面板中段**:整面墙横向默认落在面板宽度的 **20%–80%** 之间(可在设置里各自调),左右两侧是阅读留白;纵向从 7% 到 93%,左右边缘还有渐变淡出。
12
13
  - **每格动态轮播**:一个窗口里可以放最多 20 张图,按设定间隔交叉淡入(1.6s 过渡)。每个窗口的首帧延迟带确定性相位偏移,所以整面墙是「涟漪式」亮起,而不是整齐地闪。
13
- - **满 20 张之后**:再选图不会失败 —— 新图进、最旧的自动轮换出去(滚动窗口语义,两端都按 `/\.slice(-20)/` 执行)。
14
+ - **满 20 张之后**:再选图不会失败 —— 新图进、最旧的自动轮换出去(滚动窗口语义,两端都按 `.slice(-20)` 执行)。
14
15
  - **默认就有内容**:没有放图时,每个窗口用 canvas 现场生成 3 张不同母题(光池/条纹/弧线)的渐变图,同一格每次刷新都一致(种子来自窗口编号)。
15
16
  - **点选本地图片**:进入「布置」模式后,点击任一窗口即可多选本地图片,或直接把文件拖进窗口。
16
17
  - **隐蔽视频窗**:其中一个窗口可以是视频(默认第 12 个窗口,可改位置或关闭)。它常态下与其他窗口一样低浓度、静音循环;只有在布置模式下才会显示出播放控件。
@@ -27,7 +28,7 @@ dsh plugin --profile web add dsh-ambient-gallery
27
28
  ## 用
28
29
 
29
30
  1. 右下角有一个很淡的圆钮(悬停才明显),点它进入**布置模式**。
30
- 2. 布置模式下:点任意窗口选图(可多选)、拖文件进窗口、调列数/行数/浓度/间隔/模糊。
31
+ 2. 布置模式下:点任意窗口选图(可多选)、拖文件进窗口、调列数/行数/左边界/右边界/浓度/间隔/模糊。
31
32
  3. 点「完成」退出布置模式,图墙回到背景里。
32
33
 
33
34
  设置 → **氛围图墙** 里有同一组控件(还带说明),可以整体隐藏图墙、恢复默认、清空全部。
package/lib/client.js CHANGED
@@ -84,6 +84,8 @@ window.__ModuleLoader__.load({
84
84
  "ctl.visible": "显示图墙",
85
85
  "ctl.columns": "列数",
86
86
  "ctl.rows": "行数",
87
+ "ctl.bandLeft": "左边界",
88
+ "ctl.bandRight": "右边界",
87
89
  "ctl.opacity": "浓度",
88
90
  "ctl.blend": "混合",
89
91
  "ctl.blur": "模糊",
@@ -97,7 +99,7 @@ window.__ModuleLoader__.load({
97
99
  "ctl.removeFrame": "移除此图",
98
100
  "settings.title": "氛围图墙",
99
101
  "settings.desc":
100
- "在主面板背后铺一层会呼吸的图片墙。每个窗口最多 20 张图,按设定的节奏交替淡入;其中一个窗口可以播放本地视频,默认安静地循环,不打扰阅读。",
102
+ "在主面板中段铺一层会呼吸的图片点缀:横向只占 20%–80%,不铺满,每块大小、位置、倾角都不相同。每个窗口最多 20 张图,按设定的节奏交替淡入;其中一个窗口可以播放本地视频,默认安静地循环,不打扰阅读。",
101
103
  "settings.openEdit": "进入布置模式",
102
104
  "settings.aboutHint":
103
105
  "图墙以极低浓度混合在界面之上、输入框之下视觉层,不会拦截任何点击或按键;只有进入布置模式后,图墙才会临时接管鼠标。",
@@ -126,6 +128,8 @@ window.__ModuleLoader__.load({
126
128
  "ctl.visible": "Show the wall",
127
129
  "ctl.columns": "Columns",
128
130
  "ctl.rows": "Rows",
131
+ "ctl.bandLeft": "Band starts",
132
+ "ctl.bandRight": "Band ends",
129
133
  "ctl.opacity": "Density",
130
134
  "ctl.blend": "Blend",
131
135
  "ctl.blur": "Blur",
@@ -139,7 +143,7 @@ window.__ModuleLoader__.load({
139
143
  "ctl.removeFrame": "Remove this picture",
140
144
  "settings.title": "Ambient gallery",
141
145
  "settings.desc":
142
- "A breathing wall of pictures behind the main panel. Each window holds up to 20 images that cross-fade on a schedule; one window can play a local video, looping quietly so reading stays easy.",
146
+ "A breathing spread of pictures across the middle of the panel — only the 20%–80% band, never full-bleed, with every tile a different size, offset and tilt. Each window holds up to 20 images that cross-fade on a schedule; one window can play a local video, looping quietly so reading stays easy.",
143
147
  "settings.openEdit": "Enter arrange mode",
144
148
  "settings.aboutHint":
145
149
  "The wall blends in at very low density above the interface but below your attention, and it never intercepts a click or a keystroke. Only arrange mode briefly lends it the mouse.",
@@ -159,6 +163,8 @@ window.__ModuleLoader__.load({
159
163
  intervalMs: 9000,
160
164
  visible: true,
161
165
  videoCell: 11,
166
+ bandLeft: 20,
167
+ bandRight: 80,
162
168
  };
163
169
 
164
170
  const BLEND_OPTIONS = ["soft-light", "luminosity", "screen", "overlay", "normal"];
@@ -172,11 +178,14 @@ window.__ModuleLoader__.load({
172
178
  function stylesheet() {
173
179
  return [
174
180
  ".agg-root{position:absolute;inset:0;pointer-events:none;user-select:none;overflow:hidden}",
175
- ".agg-wall{position:absolute;inset:0;display:grid;padding:10px;box-sizing:border-box;",
176
- "-webkit-mask-image:radial-gradient(120% 100% at 50% 45%,#000 55%,transparent 100%);",
177
- "mask-image:radial-gradient(120% 100% at 50% 45%,#000 55%,transparent 100%);",
181
+ // The band is deliberately narrower than the column: the wall is
182
+ // decor on the middle of the panel, never a full-bleed backdrop.
183
+ ".agg-wall{position:absolute;top:7%;bottom:7%;",
184
+ "-webkit-mask-image:linear-gradient(to right,transparent,#000 9%,#000 91%,transparent);",
185
+ "mask-image:linear-gradient(to right,transparent,#000 9%,#000 91%,transparent);",
178
186
  "transition:opacity .45s ease}",
179
- ".agg-cell{position:relative;overflow:hidden;background:transparent;min-width:0;min-height:0;",
187
+ ".agg-cell{position:absolute;overflow:hidden;background:transparent;box-sizing:border-box;",
188
+ "transform:rotate(var(--agg-tilt,0deg));",
180
189
  "filter:blur(var(--agg-blur,0px));transition:transform .35s cubic-bezier(.2,.7,.3,1),box-shadow .35s ease}",
181
190
  ".agg-layer{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;",
182
191
  "opacity:0;transition:opacity 1.6s ease;will-change:opacity}",
@@ -184,8 +193,8 @@ window.__ModuleLoader__.load({
184
193
  ".agg-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;background:transparent}",
185
194
  ".agg-ring{position:absolute;inset:0;border-radius:inherit;pointer-events:none;",
186
195
  "box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-border-l2) 60%,transparent);opacity:0;transition:opacity .2s ease}",
187
- ".agg-edit .agg-cell{pointer-events:auto;cursor:pointer;transform:scale(.985)}",
188
- ".agg-edit .agg-cell:hover{transform:scale(1);box-shadow:0 10px 34px rgba(0,0,0,.28)}",
196
+ ".agg-edit .agg-cell{pointer-events:auto;cursor:pointer;transform:rotate(var(--agg-tilt,0deg)) scale(.97)}",
197
+ ".agg-edit .agg-cell:hover{transform:rotate(var(--agg-tilt,0deg)) scale(1.02);box-shadow:0 10px 34px rgba(0,0,0,.28);z-index:2}",
189
198
  ".agg-edit .agg-cell:hover .agg-ring{opacity:1}",
190
199
  ".agg-edit .agg-wall{opacity:1!important;mix-blend-mode:normal!important}",
191
200
  ".agg-edit .agg-layer,.agg-edit .agg-video{opacity:.55!important}",
@@ -743,8 +752,62 @@ window.__ModuleLoader__.load({
743
752
  );
744
753
  }
745
754
 
755
+ /**
756
+ * Decoration layout, not a table: column widths and row heights are
757
+ * drawn from a narrow random band, then each tile is inset again
758
+ * inside its own cell and tilted a degree or two. Nothing lines up
759
+ * with anything else, yet the tiles can never overlap, and every
760
+ * tile stays small enough to read as an ornament on a TV wall.
761
+ */
762
+ function placements(columns, rows, gap) {
763
+ const count = columns * rows;
764
+ const random = mulberry32(hashOf(`layout:${columns}x${rows}`));
765
+ const spread = (n, low, high) => {
766
+ const raw = [];
767
+ for (let index = 0; index < n; index += 1) raw.push(low + random() * (high - low));
768
+ const sum = raw.reduce((total, value) => total + value, 0);
769
+ return raw.map((value) => (value / sum) * 100);
770
+ };
771
+ const columnWidths = spread(columns, 0.78, 1.28);
772
+ const rowHeights = spread(rows, 0.76, 1.3);
773
+ const columnEdges = [0];
774
+ columnWidths.forEach((width) => columnEdges.push(columnEdges[columnEdges.length - 1] + width));
775
+ const rowEdges = [0];
776
+ rowHeights.forEach((height) => rowEdges.push(rowEdges[rowEdges.length - 1] + height));
777
+
778
+ const pad = Math.max(0.6, Math.min(4.5, gap * 0.13));
779
+ // A single window on a 1×1 wall would otherwise become a slab, so
780
+ // the tile itself is capped independently of how few there are.
781
+ const MAX_WIDTH = 26;
782
+ const MAX_HEIGHT = 34;
783
+ const places = [];
784
+ for (let index = 0; index < count; index += 1) {
785
+ const column = index % columns;
786
+ const row = Math.floor(index / columns);
787
+ const cellWidth = columnWidths[column];
788
+ const cellHeight = rowHeights[row];
789
+ // Two shakes: one for the size of the tile, one for where it
790
+ // sits inside the cell. Both stay inside the cell, so tiles
791
+ // never touch and the band keeps its outer margin.
792
+ const scaleX = 0.62 + random() * 0.33;
793
+ const scaleY = 0.6 + random() * 0.35;
794
+ const width = Math.min(MAX_WIDTH, Math.max(3, cellWidth - pad * 2) * scaleX);
795
+ const height = Math.min(MAX_HEIGHT, Math.max(3, cellHeight - pad * 2) * scaleY);
796
+ const slackX = Math.max(0, cellWidth - pad * 2 - width);
797
+ const slackY = Math.max(0, cellHeight - pad * 2 - height);
798
+ places.push({
799
+ left: columnEdges[column] + pad + slackX * random(),
800
+ top: rowEdges[row] + pad + slackY * random(),
801
+ width,
802
+ height,
803
+ tilt: (random() - 0.5) * 4.2,
804
+ });
805
+ }
806
+ return places;
807
+ }
808
+
746
809
  function Cell(props) {
747
- const { cellKey, index, isVideoCell, gated, picking } = props;
810
+ const { cellKey, index, isVideoCell, gated, picking, place } = props;
748
811
  const store = useStore();
749
812
  const current = config();
750
813
  const visible = useDocumentVisible();
@@ -800,6 +863,11 @@ window.__ModuleLoader__.load({
800
863
  opacity: edit ? 1 : current.opacity,
801
864
  mixBlendMode: edit ? "normal" : current.blend,
802
865
  "--agg-blur": `${current.blur}px`,
866
+ "--agg-tilt": `${place.tilt}deg`,
867
+ left: `${place.left}%`,
868
+ top: `${place.top}%`,
869
+ width: `${place.width}%`,
870
+ height: `${place.height}%`,
803
871
  };
804
872
 
805
873
  const children = [];
@@ -917,11 +985,16 @@ window.__ModuleLoader__.load({
917
985
 
918
986
  const columns = Math.max(1, Math.round(current.columns));
919
987
  const rows = Math.max(1, Math.round(current.rows));
988
+ // The band keeps the ornament off the reading gutters: 20% to 80%
989
+ // of the panel's width by default, and always at least a sliver.
990
+ const bandLeft = Math.min(Math.max(0, Number(current.bandLeft ?? 20)), 60);
991
+ const bandRight = Math.min(Math.max(bandLeft + 10, Number(current.bandRight ?? 80)), 100);
920
992
  const total = columns * rows;
921
993
  const videoIndex = current.videoCell !== null && current.videoCell !== undefined ? current.videoCell : -1;
922
994
  const showWall = current.visible !== false && !reduced;
923
995
 
924
996
  const cells = [];
997
+ const places = placements(columns, rows, current.gap);
925
998
  for (let index = 0; index < total; index += 1) {
926
999
  cells.push(
927
1000
  h(Cell, {
@@ -931,6 +1004,7 @@ window.__ModuleLoader__.load({
931
1004
  isVideoCell: index === videoIndex,
932
1005
  gated: !visible,
933
1006
  picking,
1007
+ place: places[index],
934
1008
  }),
935
1009
  );
936
1010
  }
@@ -951,9 +1025,8 @@ window.__ModuleLoader__.load({
951
1025
  {
952
1026
  className: "agg-wall",
953
1027
  style: {
954
- gridTemplateColumns: `repeat(${columns},minmax(0,1fr))`,
955
- gridTemplateRows: `repeat(${rows},minmax(0,1fr))`,
956
- gap: `${current.gap}px`,
1028
+ left: `${bandLeft}%`,
1029
+ right: `${100 - bandRight}%`,
957
1030
  opacity: 1,
958
1031
  visibility: current.visible === false && !store.edit ? "hidden" : "visible",
959
1032
  },
@@ -1012,6 +1085,24 @@ window.__ModuleLoader__.load({
1012
1085
  step: 1,
1013
1086
  onChange: (value) => updateConfig({ rows: value }),
1014
1087
  }),
1088
+ Field({
1089
+ label: translate("ctl.bandLeft"),
1090
+ value: Math.round(current.bandLeft ?? 20),
1091
+ min: 0,
1092
+ max: 60,
1093
+ step: 1,
1094
+ format: (value) => `${value}%`,
1095
+ onChange: (value) => updateConfig({ bandLeft: Math.min(value, Math.round(current.bandRight ?? 80) - 10) }),
1096
+ }),
1097
+ Field({
1098
+ label: translate("ctl.bandRight"),
1099
+ value: Math.round(current.bandRight ?? 80),
1100
+ min: 40,
1101
+ max: 100,
1102
+ step: 1,
1103
+ format: (value) => `${value}%`,
1104
+ onChange: (value) => updateConfig({ bandRight: Math.max(value, Math.round(current.bandLeft ?? 20) + 10) }),
1105
+ }),
1015
1106
  Field({
1016
1107
  label: translate("ctl.opacity"),
1017
1108
  value: Math.round(current.opacity * 100),
@@ -1144,6 +1235,24 @@ window.__ModuleLoader__.load({
1144
1235
  ),
1145
1236
  row(translate("ctl.columns"), Math.round(current.columns), 1, 8, 1, (value) => updateConfig({ columns: value })),
1146
1237
  row(translate("ctl.rows"), Math.round(current.rows), 1, 6, 1, (value) => updateConfig({ rows: value })),
1238
+ row(
1239
+ translate("ctl.bandLeft"),
1240
+ Math.round(current.bandLeft ?? 20),
1241
+ 0,
1242
+ 60,
1243
+ 1,
1244
+ (value) => updateConfig({ bandLeft: Math.min(value, Math.round(current.bandRight ?? 80) - 10) }),
1245
+ (value) => `${value}%`,
1246
+ ),
1247
+ row(
1248
+ translate("ctl.bandRight"),
1249
+ Math.round(current.bandRight ?? 80),
1250
+ 40,
1251
+ 100,
1252
+ 1,
1253
+ (value) => updateConfig({ bandRight: Math.max(value, Math.round(current.bandLeft ?? 20) + 10) }),
1254
+ (value) => `${value}%`,
1255
+ ),
1147
1256
  row(
1148
1257
  translate("ctl.opacity"),
1149
1258
  Math.round(current.opacity * 100),
package/lib/index.js CHANGED
@@ -77,6 +77,8 @@ const DEFAULT_CONFIG = {
77
77
  intervalMs: 9000,
78
78
  visible: true,
79
79
  videoCell: 11,
80
+ bandLeft: 20,
81
+ bandRight: 80,
80
82
  };
81
83
 
82
84
  const BLENDS = new Set(["soft-light", "luminosity", "screen", "overlay", "normal"]);
@@ -170,6 +172,10 @@ function clampNumber(value, min, max, fallback) {
170
172
 
171
173
  function sanitizeConfig(raw) {
172
174
  const source = raw !== null && typeof raw === "object" ? raw : {};
175
+ // The horizontal band is stored as a pair so it can never invert: the
176
+ // client clamps edges independently while the user drags a slider.
177
+ const bandLeft = clampInt(source.bandLeft, 0, 60, DEFAULT_CONFIG.bandLeft);
178
+ const bandRight = clampInt(source.bandRight, 40, 100, DEFAULT_CONFIG.bandRight);
173
179
  return {
174
180
  columns: clampInt(source.columns, 1, 8, DEFAULT_CONFIG.columns),
175
181
  rows: clampInt(source.rows, 1, 6, DEFAULT_CONFIG.rows),
@@ -181,6 +187,8 @@ function sanitizeConfig(raw) {
181
187
  intervalMs: clampInt(source.intervalMs, 1500, 120000, DEFAULT_CONFIG.intervalMs),
182
188
  visible: source.visible === false ? false : true,
183
189
  videoCell: clampInt(source.videoCell, -1, 47, DEFAULT_CONFIG.videoCell),
190
+ bandLeft: Math.min(bandLeft, bandRight - 10),
191
+ bandRight: Math.max(bandRight, bandLeft + 10),
184
192
  };
185
193
  }
186
194
 
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Ambient Gallery (dsh-ambient-gallery)",
4
- "description": "A breathing wall of pictures behind the main panel: up to twenty rotating images per window, generated art by default, your own local pictures on a click, and one discreet window that plays a local video."
4
+ "description": "Scattered picture tiles behind the main panel — uneven by design, kept to the middle 20-80% of the width, up to twenty rotating images per tile, generated art by default, your own local pictures on a click, and one discreet tile that plays a local video."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "氛围图墙 (dsh-ambient-gallery)",
4
- "description": "主面板背后的一层会呼吸的图片墙:每个窗口最多 20 张图轮播,默认是程序生成的简易图,点一下即可换成自己的本地图片,其中一个窗口还能安静地播放本地视频。"
4
+ "description": "主面板背后散落的一层图片点缀:大小不一、错落排布,只占面板宽度的 20%–80%;每个窗口最多 20 张图轮播,默认是程序生成的简易图,点一下即可换成自己的本地图片,其中一个窗口还能安静地播放本地视频。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ambient-gallery",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Ambient Gallery: a breathing wall of pictures behind the main panel, with up to twenty rotating images per window and one discreet window that plays a local video.",
6
6
  "license": "MIT",