C36-VCT commited on
Commit
6d10fa1
·
verified ·
1 Parent(s): 16e6f4a

Update static/script.js

Browse files
Files changed (1) hide show
  1. static/script.js +123 -262
static/script.js CHANGED
@@ -1,318 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
1
  document.addEventListener('DOMContentLoaded', () => {
2
  const socket = io();
3
  const startForm = document.getElementById('startForm');
4
  const urlInput = document.getElementById('url');
5
  const stopBtn = document.getElementById('stopBtn');
6
- const videoFrame = document.getElementById('video-frame');
7
  const subtitleContainer = document.getElementById('subtitle-container');
8
- const historyList = document.getElementById('history-list');
9
-
10
- // 話者データキャッシュ
11
- let speakerData = {};
12
-
13
- // UI要素
14
- const hamburger = document.querySelector('.hamburger');
15
- const nav = document.querySelector('.nav');
16
- const subtitleSizeSelect = document.getElementById('subtitle_size');
17
- const subtitleLangSelect = document.getElementById('subtitle_lang');
18
- const maxSpeakersSelect = document.getElementById('max_speakers');
19
- const textBackgroundCheck = document.getElementById('textBackground_check');
20
- const textBackground = document.getElementById('textBackground');
21
- const textBgPreview = document.getElementById('textBgPreview');
22
- const transparency = document.getElementById('textBackground_transparency');
23
  const theaterBtn = document.getElementById('theaterToggle');
24
  const closeTheaterBtn = document.getElementById('closeTheaterBtn');
25
- const bgSelect = document.getElementById('backgroundColor');
26
- const resetSpeakersBtn = document.getElementById('resetSpeakersBtn');
27
-
28
- let currentFontSize = '24px';
29
 
30
- // 初期化
31
- if(localStorage.getItem("savedSubtitleSize")) {
32
- const s = localStorage.getItem("savedSubtitleSize");
33
- if(subtitleSizeSelect) subtitleSizeSelect.value = s;
34
- currentFontSize = s + 'px';
35
- }
36
- if(urlInput && localStorage.getItem("savedUrlInput")) {
37
- urlInput.value = localStorage.getItem("savedUrlInput");
38
- }
39
-
40
- // テーマ設定
41
- function updateTheme() {
42
- const val = bgSelect.value;
43
- const body = document.body;
44
- body.classList.remove('light-mode', 'dark-mode');
45
- if (val === 'black') body.classList.add('dark-mode');
46
- else if (val === 'device') {
47
- if (window.matchMedia('(prefers-color-scheme: dark)').matches) body.classList.add('dark-mode');
48
- }
49
- }
50
- if (bgSelect) bgSelect.addEventListener('change', updateTheme);
51
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme);
52
- updateTheme();
53
-
54
- // --- Socketイベント ---
55
- socket.on('connect', () => {
56
- socket.emit('request_speaker_profiles');
57
- });
58
-
59
- socket.on('initial_speaker_profiles', (profiles) => {
60
- speakerData = profiles || {};
61
- updateSpeakerColorUI();
62
- });
63
 
64
- socket.on('new_subtitle', (data) => {
65
- if (!data || !subtitleContainer) return;
 
66
 
67
- if (!speakerData[data.speaker_id]) {
68
- socket.emit('request_speaker_profiles');
69
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
- const info = speakerData[data.speaker_id] || { name: data.speaker, color: data.color };
72
- const displayName = info.name;
73
- const displayColor = info.color;
 
 
 
 
74
 
75
- // 履歴
76
- if (historyList) {
77
- const li = document.createElement('li');
78
- li.innerHTML = `<span style="font-weight:bold; color:${displayColor}">${displayName}:</span> ${data.translated}`;
79
- historyList.prepend(li);
80
- if (historyList.children.length > 50) historyList.removeChild(historyList.lastChild);
81
- }
82
 
83
- // 字幕要素作成
 
 
84
  const p = document.createElement('p');
85
  p.className = 'subtitle-line';
86
- p.style.fontSize = currentFontSize;
87
- p.style.color = displayColor;
88
- p.textContent = `${displayName}: ${data.translated}`;
89
- applySubtitleBackground(p);
 
 
 
 
 
 
 
90
 
91
- // 字幕残留対策: 表示数を制限
92
  subtitleContainer.appendChild(p);
93
 
 
94
  while (subtitleContainer.children.length > 6) {
95
  subtitleContainer.removeChild(subtitleContainer.firstChild);
96
  }
97
 
98
- // 表示時間を設定
99
  setTimeout(() => p.style.opacity = '0', 4500);
100
  setTimeout(() => p.remove(), 5000);
101
- });
102
-
103
- socket.on("speaker_name_changed", data => {
104
- const { id, new_name } = data;
105
- if (speakerData[id]) {
106
- speakerData[id].name = new_name;
107
- const input = document.getElementById(`name-input-${id}`);
108
- if (input && input.value !== new_name) input.value = new_name;
109
- }
110
- });
111
 
112
- socket.on("speaker_color_changed", data => {
113
- const { id, color } = data;
114
- if (speakerData[id]) {
115
- speakerData[id].color = color;
116
- const bgDiv = document.getElementById(`color-box-${id}`);
117
- const input = document.getElementById(`color-input-${id}`);
118
- if (bgDiv) bgDiv.style.backgroundColor = color;
119
- if (input) input.value = color;
120
- }
 
121
  });
122
 
 
123
  socket.on('error_msg', (d) => alert(d.msg));
124
 
125
- // --- UI更新ロジック ---
126
- function updateSpeakerColorUI() {
127
- const container = document.getElementById("speakerColorSettings");
128
- if (!container) return;
129
-
130
- const existingIds = new Set();
131
- container.querySelectorAll('.speaker-setting-row').forEach(el => {
132
- existingIds.add(el.dataset.speakerId);
133
- });
134
-
135
- Object.keys(speakerData).forEach(speakerId => {
136
- const info = speakerData[speakerId];
137
- if (existingIds.has(speakerId)) {
138
- existingIds.delete(speakerId);
139
- return;
140
- }
141
-
142
- const row = document.createElement("div");
143
- row.className = "speaker-setting-row";
144
- row.dataset.speakerId = speakerId;
145
-
146
- const nameInput = document.createElement("input");
147
- nameInput.type = "text";
148
- nameInput.value = info.name;
149
- nameInput.id = `name-input-${speakerId}`;
150
- nameInput.className = "speaker-name-input";
151
-
152
- nameInput.addEventListener("change", () => {
153
- const newName = nameInput.value.trim();
154
- if (newName) {
155
- socket.emit("update_speaker_name", { id: speakerId, new_name: newName });
156
- } else {
157
- nameInput.value = speakerData[speakerId].name;
158
- }
159
- });
160
-
161
- const colorBox = document.createElement("div");
162
- colorBox.className = "color-preview-box";
163
- colorBox.id = `color-box-${speakerId}`;
164
- colorBox.style.backgroundColor = info.color;
165
-
166
- const colorInput = document.createElement("input");
167
- colorInput.type = "color";
168
- colorInput.value = info.color;
169
- colorInput.id = `color-input-${speakerId}`;
170
- colorInput.className = "color-picker-hidden";
171
-
172
- colorInput.addEventListener("input", (e) => {
173
- colorBox.style.backgroundColor = e.target.value;
174
- });
175
- colorInput.addEventListener("change", (e) => {
176
- const newColor = e.target.value;
177
- speakerData[speakerId].color = newColor;
178
- socket.emit("update_speaker_color", { id: speakerId, color: newColor });
179
- });
180
-
181
- colorBox.appendChild(colorInput);
182
- row.appendChild(nameInput);
183
- row.appendChild(colorBox);
184
- container.appendChild(row);
185
- });
186
-
187
- existingIds.forEach(oldId => {
188
- const el = container.querySelector(`.speaker-setting-row[data-speaker-id="${oldId}"]`);
189
- if (el) el.remove();
190
- });
191
- }
192
-
193
- // --- ボタン操作 ---
194
- if (resetSpeakersBtn) {
195
- resetSpeakersBtn.addEventListener('click', () => {
196
- if(confirm("話者設定をリセットしますか?")) {
197
- speakerData = {};
198
- document.getElementById("speakerColorSettings").innerHTML = "";
199
- socket.emit('reset_speakers');
200
- }
201
- });
202
- }
203
-
204
  if (startForm) {
205
  startForm.addEventListener('submit', (e) => {
206
  e.preventDefault();
207
  const url = urlInput.value.trim();
208
  if (!url) return alert("URLを入力してください");
209
- localStorage.setItem("savedUrlInput", url);
210
 
211
- // 汎用Iframe埋め込み (YouTube以外も対応可能)
212
- let embed = url;
213
- if(url.includes('twitch.tv')) {
214
- const m = url.match(/twitch\.tv\/([^\/]+)/);
215
- if(m) embed = `https://player.twitch.tv/?channel=${m[1]}&parent=${location.hostname}&autoplay=true&muted=true`;
216
- } else if(url.includes('youtube.com') || url.includes('youtu.be')) {
217
- const m = url.match(/(?:v=|\/)([a-zA-Z0-9_-]{11})/);
218
- if(m) embed = `https://www.youtube.com/embed/${m[1]}?autoplay=1`;
219
  }
220
- videoFrame.src = embed;
221
- videoFrame.style.display = 'block';
222
- subtitleContainer.innerHTML = '';
223
- historyList.innerHTML = '';
224
 
225
- socket.emit('start_stream', {
226
- url: url,
227
- target_lang: subtitleLangSelect.value,
228
- max_speakers: maxSpeakersSelect ? parseInt(maxSpeakersSelect.value) : 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  });
 
230
  });
231
  }
232
 
233
  if (stopBtn) {
234
  stopBtn.addEventListener('click', () => {
235
- videoFrame.src = '';
236
- videoFrame.style.display = 'none';
237
  socket.emit('stop_stream');
238
  });
239
  }
240
 
241
- function sendSettings() {
242
- socket.emit('update_settings', {
243
- target_lang: subtitleLangSelect.value,
244
- max_speakers: maxSpeakersSelect ? parseInt(maxSpeakersSelect.value) : 2
245
- });
246
- }
247
- if (subtitleLangSelect) subtitleLangSelect.addEventListener('change', sendSettings);
248
-
249
- if (maxSpeakersSelect) maxSpeakersSelect.addEventListener('change', () => {
250
- sendSettings();
251
- });
252
-
253
- // デザイン反映
254
- function hexToRgba(hex, alpha) {
255
- hex = hex.replace(/^#/, '');
256
- if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
257
- const r = parseInt(hex.slice(0, 2), 16), g = parseInt(hex.slice(2, 4), 16), b = parseInt(hex.slice(4, 6), 16);
258
- return `rgba(${r}, ${g}, ${b}, ${alpha / 100})`;
259
- }
260
- function applySubtitleBackground(el) {
261
- if (!textBackgroundCheck.checked) {
262
- el.style.backgroundColor = 'transparent'; el.style.padding = '0'; return;
263
- }
264
- const alpha = transparency.value;
265
- el.style.backgroundColor = hexToRgba(textBackground.value, alpha);
266
- el.style.padding = '2px 4px';
267
- el.style.borderRadius = '4px';
268
  }
269
 
270
- if (textBackgroundCheck) {
271
- const updateAll = () => document.querySelectorAll('.subtitle-line').forEach(applySubtitleBackground);
272
- const updatePreview = () => { if(textBgPreview) textBgPreview.style.backgroundColor = textBackground.value; };
273
-
274
- textBackgroundCheck.addEventListener('change', updateAll);
275
- textBackground.addEventListener('input', () => { updateAll(); updatePreview(); });
276
- transparency.addEventListener('input', updateAll);
277
- updatePreview();
278
- }
279
-
280
- if (subtitleSizeSelect) {
281
- subtitleSizeSelect.addEventListener('input', () => {
282
- currentFontSize = subtitleSizeSelect.value + 'px';
283
- localStorage.setItem("savedSubtitleSize", subtitleSizeSelect.value);
284
- document.querySelectorAll('.subtitle-line').forEach(p => p.style.fontSize = currentFontSize);
285
- });
286
- }
287
-
288
- if (hamburger && nav) {
289
  hamburger.addEventListener('click', () => {
290
  hamburger.classList.toggle('active');
291
  nav.classList.toggle('active');
292
  });
293
- document.addEventListener('click', (e) => {
294
- if (!e.target.closest('.nav') && !e.target.closest('.hamburger') && nav.classList.contains('active')) {
295
- hamburger.classList.remove('active');
296
- nav.classList.remove('active');
297
- }
298
- });
299
- }
300
-
301
- if (document.getElementById('video-container')) {
302
- const container = document.getElementById('video-container');
303
-
304
- if (theaterBtn) {
305
- theaterBtn.addEventListener('click', () => {
306
- container.classList.add('theater-mode');
307
- container.classList.remove('normal-mode');
308
- });
309
- }
310
-
311
- if (closeTheaterBtn) {
312
- closeTheaterBtn.addEventListener('click', () => {
313
- container.classList.remove('theater-mode');
314
- container.classList.add('normal-mode');
315
- });
316
- }
317
  }
318
  });
 
1
+ // YouTube Player API
2
+ let player = null;
3
+ let subtitleQueue = [];
4
+ let syncInterval = null;
5
+ let baseTimeOffset = 0; // ライブ配信時の基準時間オフセット
6
+
7
+ // API準備完了時
8
+ function onYouTubeIframeAPIReady() {
9
+ // プレイヤー作成はstartForm送信時に行う
10
+ }
11
+
12
  document.addEventListener('DOMContentLoaded', () => {
13
  const socket = io();
14
  const startForm = document.getElementById('startForm');
15
  const urlInput = document.getElementById('url');
16
  const stopBtn = document.getElementById('stopBtn');
 
17
  const subtitleContainer = document.getElementById('subtitle-container');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  const theaterBtn = document.getElementById('theaterToggle');
19
  const closeTheaterBtn = document.getElementById('closeTheaterBtn');
 
 
 
 
20
 
21
+ let speakerData = {};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
+ // --- 同期エンジンの核心 ---
24
+ function startSyncEngine() {
25
+ if (syncInterval) clearInterval(syncInterval);
26
 
27
+ // 0.1秒ごとにチェック
28
+ syncInterval = setInterval(() => {
29
+ if (!player || !player.getCurrentTime) return;
30
+
31
+ // 現在の動画時間
32
+ const currentTime = player.getCurrentTime();
33
+
34
+ // ライブ配信の場合、再生時間が「15000秒」など巨大になるため
35
+ // 処理開始時の時間を0秒として補正する
36
+ // (動画ならbaseTimeOffsetはほぼ0に近い)
37
+ const relativeTime = currentTime - baseTimeOffset;
38
+
39
+ // キューにある字幕をチェック
40
+ // 「字幕の指定時間」 <= 「現在の経過時間」 なら表示
41
+ const toShow = subtitleQueue.filter(item => {
42
+ // 許容範囲: 指定時間より0.5秒前から表示OK、10秒以上過ぎた過去のものは捨てる
43
+ return item.timestamp <= relativeTime + 0.5;
44
+ });
45
 
46
+ toShow.forEach(item => {
47
+ // 10秒以上遅れてる(シークで飛ばした等)字幕は表示せずに捨てる
48
+ if (relativeTime - item.timestamp < 10.0) {
49
+ renderSubtitle(item.data);
50
+ }
51
+ item.displayed = true;
52
+ });
53
 
54
+ // 表示済みをキューから削除
55
+ subtitleQueue = subtitleQueue.filter(item => !item.displayed);
56
+
57
+ }, 100);
58
+ }
 
 
59
 
60
+ // --- 字幕レンダリング ---
61
+ function renderSubtitle(data) {
62
+ if (!subtitleContainer) return;
63
  const p = document.createElement('p');
64
  p.className = 'subtitle-line';
65
+ p.style.color = data.color;
66
+ p.style.fontSize = '24px';
67
+ p.textContent = `${data.speaker}: ${data.translated}`;
68
+
69
+ // 背景色適用
70
+ const bgCheck = document.getElementById('textBackground_check');
71
+ if(bgCheck && bgCheck.checked) {
72
+ p.style.backgroundColor = 'rgba(128, 128, 128, 0.75)';
73
+ p.style.padding = '2px 4px';
74
+ p.style.borderRadius = '4px';
75
+ }
76
 
 
77
  subtitleContainer.appendChild(p);
78
 
79
+ // 最大行数制限 (6行)
80
  while (subtitleContainer.children.length > 6) {
81
  subtitleContainer.removeChild(subtitleContainer.firstChild);
82
  }
83
 
84
+ // 5秒でフェードアウト
85
  setTimeout(() => p.style.opacity = '0', 4500);
86
  setTimeout(() => p.remove(), 5000);
87
+ }
 
 
 
 
 
 
 
 
 
88
 
89
+ // --- Socket受信 ---
90
+ socket.on('new_subtitle', (data) => {
91
+ // 受信した字幕を即表示せず、タイムスタンプ付きでキューに入れる
92
+ subtitleQueue.push({
93
+ data: data,
94
+ timestamp: data.timestamp, // サーバーからの経過時間(0, 8, 16...)
95
+ displayed: false
96
+ });
97
+ // 並び替え(念のため)
98
+ subtitleQueue.sort((a, b) => a.timestamp - b.timestamp);
99
  });
100
 
101
+ socket.on('initial_speaker_profiles', (profiles) => { speakerData = profiles; });
102
  socket.on('error_msg', (d) => alert(d.msg));
103
 
104
+ // --- プレイヤー制御 ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  if (startForm) {
106
  startForm.addEventListener('submit', (e) => {
107
  e.preventDefault();
108
  const url = urlInput.value.trim();
109
  if (!url) return alert("URLを入力してください");
 
110
 
111
+ // YouTube ID抽出
112
+ let videoId = null;
113
+ const m = url.match(/(?:v=|\/)([^"&?\/\s]{11})/);
114
+ if (m) videoId = m[1];
115
+
116
+ if (!videoId) {
117
+ alert("現在はYouTubeのみ対応しています(同期機能のため)");
118
+ return;
119
  }
 
 
 
 
120
 
121
+ // UIリセット
122
+ subtitleContainer.innerHTML = '';
123
+ subtitleQueue = [];
124
+ baseTimeOffset = 0; // リセット
125
+
126
+ // プレイヤー作成
127
+ if (player) { player.destroy(); }
128
+ player = new YT.Player('player-placeholder', {
129
+ height: '100%',
130
+ width: '100%',
131
+ videoId: videoId,
132
+ playerVars: { 'autoplay': 1, 'playsinline': 1 },
133
+ events: {
134
+ 'onReady': (event) => {
135
+ event.target.playVideo();
136
+ // 再生開始時点の時間を基準(0秒地点)とする
137
+ // VODならほぼ0、Liveならその瞬間の絶対時間が入る
138
+ baseTimeOffset = event.target.getCurrentTime();
139
+ console.log("Base Time Offset:", baseTimeOffset);
140
+ startSyncEngine();
141
+
142
+ // サーバーへ開始合図
143
+ socket.emit('start_stream', { url: url });
144
+ }
145
+ }
146
  });
147
+ document.getElementById('video-container').style.display = 'block';
148
  });
149
  }
150
 
151
  if (stopBtn) {
152
  stopBtn.addEventListener('click', () => {
153
+ if (player && player.stopVideo) player.stopVideo();
154
+ if (syncInterval) clearInterval(syncInterval);
155
  socket.emit('stop_stream');
156
  });
157
  }
158
 
159
+ // --- その他UI (シアター等) ---
160
+ if (theaterBtn) {
161
+ const container = document.getElementById('video-container');
162
+ const toggle = () => {
163
+ container.classList.toggle('theater-mode');
164
+ container.classList.toggle('normal-mode');
165
+ };
166
+ theaterBtn.addEventListener('click', toggle);
167
+ if(closeTheaterBtn) closeTheaterBtn.addEventListener('click', toggle);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  }
169
 
170
+ // ハンバーガーメニュー
171
+ const hamburger = document.querySelector('.hamburger');
172
+ const nav = document.querySelector('.nav');
173
+ if(hamburger) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  hamburger.addEventListener('click', () => {
175
  hamburger.classList.toggle('active');
176
  nav.classList.toggle('active');
177
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  }
179
  });