mp 0.1.3 → 0.1.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0e1a8144762d89c2522e81fbb0b6e979802ea3989730337d45e49c31cbe6c2f
4
- data.tar.gz: 86aced39b324abbbc2b7dcdb20d42b7dd8ede154055bfaec651997d93ba08f7b
3
+ metadata.gz: 6b1c5ee524e99fecc018dbcc19184f1ae96c0c6a5c6b41d7f4809e754a8b9d1e
4
+ data.tar.gz: ba755bb7c1dd73f7cf62ba0b14c07012811dc961750bcace65811df1cd184813
5
5
  SHA512:
6
- metadata.gz: c9907e65113fe1743a3f09bee33ad883d3b01dafc792fa6b78971549d5a1323e75062f1e6c0ef59c9b4d767e5ebd63ece2859745c54fdb0a4e4449c86c48554e
7
- data.tar.gz: 51943720c9294c0af440068ade78de35a1b95468b6fbcc944dbe3a1fa1d01c9933cec15228a8cd6892900561e0b4c2e7c0c54e3376f62c2a645c57f12d5c56ad
6
+ metadata.gz: ff969b7c6e35a011c35d936cb7b1eafe00c55fcafda636bf7b3ff417b6da0b7483b9421dcbdbe73eb3155099d9a7bc6a3f6d41f632021f28e769f9168bd76964
7
+ data.tar.gz: 300a94ef4e03844a3b74151815d72f1e1ddc90902e0acd952954cc4b7e4ae3da5c9ea577b886377a9894f7424b0897a5ea9c31874efc9d80049b1cc6050d41e1
data/README.md CHANGED
@@ -95,6 +95,7 @@ theme: default # default / dark / ./my-theme.css
95
95
  size: 16:9 # 16:9 / 4:3
96
96
  title: タイトル
97
97
  paginate: true
98
+ duration: 20 # トークの想定時間(分)。"20:00" "90s" も可
98
99
  header: ヘッダー
99
100
  footer: フッター
100
101
  style: |
@@ -105,6 +106,13 @@ style: |
105
106
  <!-- _backgroundColor: black --> このスライドだけに適用(先頭に _)
106
107
  ```
107
108
 
109
+ `duration` を指定すると、スライドを表示した時点からタイマーが動き、右下に「ページ番号 / 総枚数」「このページの残り」「全体の残り」を表示します(`paginate` のページ番号はタイマーの中に移ります)。
110
+
111
+ - 1 ページの持ち時間は、全体の時間を枚数で均等に割った長さです(20 分で 10 枚なら 2 分)
112
+ - ページごとに使った時間を積算するので、戻って再び開くと続きから数えます
113
+ - 全体の残りは残り 2 分(短いトークでは全体の 2 割)、ページの残りは持ち時間の 2 割を切ると色が変わり、超過すると赤で超過時間を表示します
114
+ デッキを保存して再読み込みしてもタイマーは続きます。発表者ビューにも残り時間が出て、`r` で両方をリセットできます。
115
+
108
116
  使えるディレクティブは `paginate` `header` `footer` `class` `color` `backgroundColor` `backgroundImage` `backgroundPosition` `backgroundRepeat` `backgroundSize` です。クラスには `lead`(中央寄せ)、`left`(左寄せ)、`right`(本文だけ右寄せ)、`invert`(配色反転)、`columns`(2 列)があります。
109
117
 
110
118
  **画像**
@@ -149,7 +157,7 @@ bundle exec exe/mp --presenter examples/demo.md
149
157
  | `Home` / `End` | 最初 / 最後 |
150
158
  | 数字 + `Enter` | そのページへ |
151
159
  | `Enter` | 台本のコマンドを実行 |
152
- | `r` | 経過時間をリセット |
160
+ | `r` | 経過時間をリセット(`duration` のタイマーも戻す) |
153
161
  | `+` / `-` | ノートの文字サイズ |
154
162
  | `q` | 終了 |
155
163
 
data/lib/mp/deck.rb CHANGED
@@ -15,7 +15,7 @@ module Mp
15
15
  # kramdown に渡す前に行単位で走査する。kramdown は info 文字列に属性を持つ
16
16
  # フェンスを認識せず、`$x$` も扱えないため、独自記法はここでトークンに置換する。
17
17
  class Deck
18
- GLOBAL_DIRECTIVES = %w[theme size style title].freeze
18
+ GLOBAL_DIRECTIVES = %w[theme size style title duration].freeze
19
19
  LOCAL_DIRECTIVES = %w[
20
20
  paginate header footer class color
21
21
  backgroundColor backgroundImage backgroundPosition backgroundRepeat backgroundSize
@@ -48,6 +48,17 @@ module Mp
48
48
  SIZES.fetch(@globals["size"].to_s, SIZES["16:9"])
49
49
  end
50
50
 
51
+ # トークの想定時間(秒)。数値は分として扱い、"20:00" "90s" "20m" も受け付ける
52
+ def duration
53
+ seconds = case (value = @globals["duration"])
54
+ when Numeric then value * 60
55
+ when /\A\s*(\d+):([0-5]\d)\s*\z/ then (Regexp.last_match(1).to_i * 60) + Regexp.last_match(2).to_i
56
+ when /\A\s*(\d+(?:\.\d+)?)\s*(?:m|min|分)?\s*\z/ then Regexp.last_match(1).to_f * 60
57
+ when /\A\s*(\d+)\s*(?:s|sec|秒)\s*\z/ then Regexp.last_match(1).to_i
58
+ end
59
+ seconds.round if seconds&.positive?
60
+ end
61
+
51
62
  def title
52
63
  @globals["title"] || @slides.find { _1[:kind] == :cover }&.fetch(:title) || "mp"
53
64
  end
@@ -57,6 +68,7 @@ module Mp
57
68
  {
58
69
  title: title,
59
70
  size: size,
71
+ duration: duration,
60
72
  style: @globals["style"].to_s,
61
73
  slides: @slides.map { _1.slice(:html, :notes) },
62
74
  embeds: @embeds.transform_values { _1.except(:cwd) }
@@ -73,9 +85,9 @@ module Mp
73
85
  [match.post_match, front.is_a?(Hash) ? front : {}]
74
86
  end
75
87
 
76
- # YAML 1.1 では `4:3` が 60 進数の 243 になるので、文字列のまま読ませる
88
+ # YAML 1.1 では `4:3` や `20:00` が 60 進数の数値になるので、文字列のまま読ませる
77
89
  def load_yaml(text)
78
- YAML.safe_load(text.gsub(/^(\s*_?size:\s*)(\d+:\d+)\s*$/, '\1"\2"'))
90
+ YAML.safe_load(text.gsub(/^(\s*_?(?:size|duration):\s*)(\d+:\d+)\s*(#.*)?$/, '\1"\2"'))
79
91
  end
80
92
 
81
93
  def scan(body)
data/lib/mp/server.rb CHANGED
@@ -118,9 +118,12 @@ module Mp
118
118
  def deck_json
119
119
  deck = @deck.to_h
120
120
  embeds = deck[:embeds].transform_values { |embed| embed[:type] == "web" ? embed.merge(src: embed_url(embed[:src])) : embed }
121
- deck.merge(embeds: embeds, themeCss: theme_css)
121
+ @started_at ||= Time.now # 最初にスライドを表示した時刻からタイマーを始める
122
+ deck.merge(embeds: embeds, themeCss: theme_css, startedAt: timer_start)
122
123
  end
123
124
 
125
+ def timer_start = (@started_at.to_f * 1000).round
126
+
124
127
  def embed_url(src)
125
128
  return src if src.empty? || src.match?(%r{\Ahttps?://}i)
126
129
 
@@ -211,6 +214,9 @@ module Mp
211
214
  case JSON.parse(message)["type"]
212
215
  when "quit" then @on_quit&.call
213
216
  when "open-presenter" then @on_open_presenter&.call
217
+ when "timer-reset"
218
+ @started_at = Time.now
219
+ broadcast(JSON.generate(type: "timer", startedAt: timer_start))
214
220
  when "frame" then @on_frame_request&.call
215
221
  else broadcast(message, except: socket)
216
222
  end
data/lib/mp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mp
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
data/public/app.js CHANGED
@@ -10,6 +10,12 @@ let slides = [];
10
10
  let current = 0;
11
11
  let terminals = new Map(); // embed id -> { term, socket, fit, host, slot, font }
12
12
  let scale = 1;
13
+ let startedAt = null;
14
+ const slideSpent = new Map(); // ページ番号 -> そのページで使った時間(ミリ秒)。戻っても続きから数える
15
+ let enteredAt = Date.now();
16
+ const timer = document.createElement("div");
17
+ timer.id = "timer";
18
+ timer.innerHTML = `<span class="timer-pages"></span><span class="timer-slide"><small>ページ</small><b></b></span><span class="timer-total"><small>全体</small><b></b></span>`;
13
19
  let activeTerminal = null;
14
20
  let digits = "";
15
21
 
@@ -25,10 +31,39 @@ async function loadDeck() {
25
31
 
26
32
  deck = await MpSlides.load(stage);
27
33
  slides = [...stage.querySelectorAll(":scope > section.slide")];
34
+ startedAt = deck.startedAt;
35
+ stage.append(timer); // スライドと一緒に拡大縮小させる
36
+ stage.classList.toggle("timed", !!deck.duration);
37
+ renderTimer();
28
38
  layout();
29
39
  show(Math.min(Number(location.hash.slice(1) || 1) - 1, slides.length - 1), { fragments: "none" });
30
40
  }
31
41
 
42
+ function spentOnCurrent(now = Date.now()) {
43
+ return (slideSpent.get(current) ?? 0) + (now - enteredAt);
44
+ }
45
+
46
+ function renderTimer() {
47
+ timer.hidden = !deck?.duration || !slides.length;
48
+ if (timer.hidden) return;
49
+ const total = MpSlides.remaining(deck, startedAt);
50
+ const budget = MpSlides.slideBudget(deck, slides.length);
51
+ const slide = MpSlides.slideRemaining(budget, spentOnCurrent());
52
+ timer.querySelector(".timer-pages").textContent = `${current + 1} / ${slides.length}`;
53
+ timer.querySelector(".timer-slide b").textContent = MpSlides.formatTime(slide);
54
+ timer.querySelector(".timer-slide").dataset.state = MpSlides.slideState(budget, slide);
55
+ timer.querySelector(".timer-total b").textContent = MpSlides.formatTime(total);
56
+ timer.querySelector(".timer-total").dataset.state = MpSlides.timerState(deck, total);
57
+ }
58
+
59
+ function resetTimer(at) {
60
+ startedAt = at;
61
+ slideSpent.clear();
62
+ enteredAt = Date.now();
63
+ renderTimer();
64
+ sendState();
65
+ }
66
+
32
67
  function layout() {
33
68
  if (!deck) return;
34
69
  scale = Math.min(innerWidth / deck.size[0], innerHeight / deck.size[1]);
@@ -64,6 +99,10 @@ function show(index, { fragments }) {
64
99
  releaseFocus();
65
100
  slides[current]?.classList.remove("active");
66
101
  slides[current]?.querySelectorAll("iframe").forEach((frame) => frame.blur());
102
+ if (index !== current) {
103
+ slideSpent.set(current, spentOnCurrent());
104
+ enteredAt = Date.now();
105
+ }
67
106
  current = index;
68
107
  const slide = slides[current];
69
108
  slide.classList.add("active");
@@ -71,6 +110,7 @@ function show(index, { fragments }) {
71
110
  history.replaceState(null, "", `#${current + 1}`);
72
111
  mountEmbeds(slide);
73
112
  renderNotes();
113
+ renderTimer();
74
114
  sendState();
75
115
  }
76
116
 
@@ -81,6 +121,7 @@ function sendState() {
81
121
  const terminal = currentTerminal();
82
122
  events.send(JSON.stringify({
83
123
  type: "state", slide: current, step, script: terminal?.script() ?? null, ready: terminal?.ready() ?? false,
124
+ slideSpent: spentOnCurrent(), sentAt: Date.now(), // 発表者ビューはここから経過を数える
84
125
  }));
85
126
  }
86
127
 
@@ -300,7 +341,9 @@ events.onmessage = (event) => {
300
341
  const message = JSON.parse(event.data);
301
342
  if (message.type === "reload") loadDeck();
302
343
  else if (message.type === "hello") sendState();
344
+ else if (message.type === "timer") resetTimer(message.startedAt);
303
345
  else if (message.type === "command") commands[message.cmd]?.(message);
304
346
  };
305
347
 
348
+ setInterval(renderTimer, 250);
306
349
  loadDeck();
data/public/presenter.css CHANGED
@@ -29,5 +29,8 @@ body.presenter {
29
29
  #speaker-notes { flex: 1; min-height: 0; overflow: auto; line-height: 1.6; white-space: pre-wrap; }
30
30
 
31
31
  #elapsed { font: 600 34px Menlo, monospace; }
32
+ #remaining, #slide-remaining { font: 600 24px Menlo, "Hiragino Sans", monospace; color: #9fc4ff; }
33
+ :is(#remaining, #slide-remaining)[data-state="warn"] { color: #ffc861; }
34
+ :is(#remaining, #slide-remaining)[data-state="over"] { color: #ff7a7a; }
32
35
  #clock { font: 20px Menlo, monospace; color: #98a1b3; }
33
36
  .hints { margin-left: auto; font-size: 13px; color: #6f7889; }
@@ -31,6 +31,8 @@
31
31
  </div>
32
32
  <div class="status">
33
33
  <span id="elapsed">00:00</span>
34
+ <span id="slide-remaining" hidden></span>
35
+ <span id="remaining" hidden></span>
34
36
  <span id="clock"></span>
35
37
  <span class="hints">→ 次へ ・ ← 前へ ・ Enter コマンド実行 ・ r タイマー ・ +/- ノート文字 ・ q 終了</span>
36
38
  </div>
data/public/presenter.js CHANGED
@@ -6,7 +6,7 @@ const FRAME_INTERVAL = 250;
6
6
  let deck = null;
7
7
  let state = { slide: 0, step: 0, script: null, ready: false };
8
8
  let digits = "";
9
- let startedAt = Date.now();
9
+ let startedAt = Date.now(); // サーバの開始時刻を受け取るまでの仮の値
10
10
  let noteSize = 26;
11
11
  let mirrorRunning = false;
12
12
  let mirrorSlide = null;
@@ -19,6 +19,7 @@ const command = (cmd, args = {}) => send({ type: "command", cmd, ...args });
19
19
 
20
20
  async function loadDeck() {
21
21
  deck = await MpSlides.load($("current"), [$("current"), $("next")]);
22
+ startedAt = deck.startedAt;
22
23
  document.title = `発表者ビュー - ${deck.title}`;
23
24
  for (const stage of [$("current"), $("next")]) {
24
25
  stage.querySelectorAll(".embed").forEach(describeEmbed);
@@ -110,10 +111,19 @@ function onFrame(data) {
110
111
  }
111
112
 
112
113
  function tick() {
113
- const seconds = Math.floor((Date.now() - startedAt) / 1000);
114
- const pad = (n) => String(n).padStart(2, "0");
115
- $("elapsed").textContent = `${seconds >= 3600 ? `${Math.floor(seconds / 3600)}:` : ""}${pad(Math.floor(seconds / 60) % 60)}:${pad(seconds % 60)}`;
114
+ $("elapsed").textContent = MpSlides.formatTime(Math.floor((Date.now() - startedAt) / 1000));
116
115
  $("clock").textContent = new Date().toLocaleTimeString("ja-JP", { hour: "2-digit", minute: "2-digit" });
116
+ $("remaining").hidden = $("slide-remaining").hidden = !deck?.duration;
117
+ if (!deck?.duration) return;
118
+ const remaining = MpSlides.remaining(deck, startedAt);
119
+ $("remaining").textContent = remaining < 0 ? `超過 ${MpSlides.formatTime(-remaining)}` : `残り ${MpSlides.formatTime(remaining)}`;
120
+ $("remaining").dataset.state = MpSlides.timerState(deck, remaining);
121
+
122
+ // 聴衆画面が送った使用時間に、送ってからの経過を足す
123
+ const budget = MpSlides.slideBudget(deck, deck.slides.length);
124
+ const slide = MpSlides.slideRemaining(budget, (state.slideSpent ?? 0) + (Date.now() - (state.sentAt ?? Date.now())));
125
+ $("slide-remaining").textContent = `このページ ${slide < 0 ? `超過 ${MpSlides.formatTime(-slide)}` : MpSlides.formatTime(slide)}`;
126
+ $("slide-remaining").dataset.state = MpSlides.slideState(budget, slide);
117
127
  }
118
128
 
119
129
  function setNoteSize(size) {
@@ -128,7 +138,7 @@ window.addEventListener("keydown", (event) => {
128
138
  ArrowLeft: () => command("prev"), ArrowUp: () => command("prev"), PageUp: () => command("prev"),
129
139
  Home: () => command("first"),
130
140
  End: () => command("last"),
131
- r: () => { startedAt = Date.now(); tick(); },
141
+ r: () => send({ type: "timer-reset" }), // 聴衆画面のタイマーも一緒に戻す
132
142
  "+": () => setNoteSize(noteSize + 2),
133
143
  "=": () => setNoteSize(noteSize + 2),
134
144
  "-": () => setNoteSize(noteSize - 2),
@@ -154,8 +164,12 @@ socket.onmessage = (event) => {
154
164
  if (message.type === "state") {
155
165
  state = message;
156
166
  render();
167
+ tick();
157
168
  } else if (message.type === "frame") {
158
169
  onFrame(message.data);
170
+ } else if (message.type === "timer") {
171
+ startedAt = message.startedAt;
172
+ tick();
159
173
  } else if (message.type === "reload") {
160
174
  loadDeck();
161
175
  }
data/public/slides.js CHANGED
@@ -20,6 +20,40 @@ const MpSlides = {
20
20
  return deck;
21
21
  },
22
22
 
23
+ // 残り時間。超過すると負になる
24
+ remaining(deck, startedAt, now = Date.now()) {
25
+ return deck.duration - Math.floor((now - startedAt) / 1000);
26
+ },
27
+
28
+ formatTime(seconds) {
29
+ const sign = seconds < 0 ? "+" : "";
30
+ const s = Math.abs(seconds);
31
+ const pad = (n) => String(n).padStart(2, "0");
32
+ const hours = Math.floor(s / 3600);
33
+ return `${sign}${hours ? `${hours}:${pad(Math.floor(s / 60) % 60)}` : pad(Math.floor(s / 60))}:${pad(s % 60)}`;
34
+ },
35
+
36
+ // 残り 2 分、短いトークでは全体の 2 割を切ったら注意を促す
37
+ timerState(deck, remaining) {
38
+ if (remaining < 0) return "over";
39
+ return remaining <= Math.min(120, deck.duration * 0.2) ? "warn" : "normal";
40
+ },
41
+
42
+ // 1 ページの持ち時間(秒)。全体の時間を枚数で均等に割る
43
+ slideBudget(deck, count) {
44
+ return deck.duration / count;
45
+ },
46
+
47
+ // そのページで使った時間を引いた残り。持ち時間の 2 割を切ったら注意を促す
48
+ slideRemaining(budget, spentMs) {
49
+ return Math.floor(budget - Math.floor(spentMs / 1000)); // 全体の残りと同じく経過秒で刻む
50
+ },
51
+
52
+ slideState(budget, remaining) {
53
+ if (remaining < 0) return "over";
54
+ return remaining <= budget * 0.2 ? "warn" : "normal";
55
+ },
56
+
23
57
  // `# <!-- fit --> 見出し` を横幅いっぱいにする
24
58
  fitHeadings(stage) {
25
59
  stage.querySelectorAll(".fit").forEach((heading) => {
data/public/style.css CHANGED
@@ -83,6 +83,28 @@ section.slide > header { top: 20px; }
83
83
  section.slide > footer { bottom: 20px; }
84
84
  section.slide > .page-number { bottom: 20px; left: auto; right: 32px; }
85
85
 
86
+ /* duration を指定したときの「ページ番号 / このページの残り / 全体の残り」 */
87
+ #timer {
88
+ position: absolute;
89
+ right: 24px;
90
+ bottom: 14px;
91
+ z-index: 3;
92
+ display: flex;
93
+ overflow: hidden;
94
+ border-radius: 6px;
95
+ background: rgba(20, 22, 27, .62);
96
+ color: #fff;
97
+ font: 600 18px/1.3 Menlo, monospace;
98
+ }
99
+ #timer[hidden] { display: none; }
100
+ #timer > span { padding: 3px 10px; }
101
+ #timer > span + span { border-left: 1px solid rgba(255, 255, 255, .25); }
102
+ #timer small { margin-right: 6px; font: 11px "Hiragino Sans", system-ui, sans-serif; opacity: .8; }
103
+ #timer [data-state="warn"] { background: rgba(196, 120, 0, .9); }
104
+ #timer [data-state="over"] { background: rgba(200, 40, 40, .92); }
105
+ /* ページ番号はタイマーの中に出す */
106
+ #stage.timed section.slide > .page-number { display: none; }
107
+
86
108
  .embed {
87
109
  position: relative;
88
110
  flex: 1;
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - takkanm