Recently Written · git

subread.space

read subs in space

git clone https://github.com/equwal/subread.space

Log | Files | Refs


index.html (8498 bytes)

1 <!DOCTYPE html>
2 <html lang="ja">
3 <head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Subtitle Viewer</title>
7 <style>
8   * { box-sizing: border-box; margin: 0; padding: 0; }
9 
10   body {
11     background: #111;
12     color: #ddd;
13     font-family: monospace;
14     height: 100vh;
15     display: flex;
16     flex-direction: column;
17     overflow: hidden;
18   }
19 
20   #controls {
21     flex-shrink: 0;
22     display: flex;
23     gap: 16px;
24     align-items: center;
25     padding: 8px 14px;
26     border-bottom: 1px solid #2a2a2a;
27     flex-wrap: wrap;
28   }
29 
30   label {
31     display: flex;
32     align-items: center;
33     gap: 6px;
34     cursor: pointer;
35     font-size: 13px;
36     color: #aaa;
37   }
38 
39   select {
40     background: #1a1a1a;
41     color: #ddd;
42     border: 1px solid #444;
43     padding: 3px 7px;
44     font-family: monospace;
45     font-size: 13px;
46     cursor: pointer;
47   }
48 
49   #hint { margin-left: auto; font-size: 11px; color: #444; }
50   #progress { font-size: 11px; color: #555; }
51 
52   /* ── DROP ZONE ── */
53   #dropzone {
54     flex: 1;
55     display: flex;
56     align-items: center;
57     justify-content: center;
58   }
59 
60   #drop-inner {
61     border: 2px dashed #444;
62     padding: 40px 60px;
63     text-align: center;
64     color: #666;
65     font-size: 13px;
66     cursor: pointer;
67     transition: border-color 0.15s, color 0.15s;
68   }
69   #drop-inner.over { border-color: #888; color: #aaa; }
70   #dropzone input { display: none; }
71 
72   /* ── WALL: horizontal (default) ── */
73   #wall-wrap {
74     flex: 1;
75     overflow-y: auto;
76     overflow-x: hidden;
77     display: none;
78     position: relative;
79   }
80 
81   #wall {
82     display: flex;
83     flex-direction: column;
84     padding: 16px 24px 24px;
85     gap: 0;
86     min-height: 100%;
87     justify-content: flex-end;
88   }
89 
90   /* Using <p> elements as requested */
91   .sub-entry {
92     font-size: 20px;
93     line-height: 1.7;
94     white-space: pre-wrap;
95     color: #ddd;
96     padding: 6px 10px;
97     border-top: 1px solid #1e1e1e;
98     width: 100%;
99     visibility: hidden;
100     user-select: none;
101     pointer-events: none;
102     background: transparent;
103   }
104   .sub-entry.revealed {
105     visibility: visible;
106     user-select: auto;
107     pointer-events: auto;
108   }
109   .sub-entry.current {
110     visibility: visible;
111     user-select: auto;
112     pointer-events: auto;
113     background: #1a2a1a;
114   }
115 
116   /* ── WALL: vertical ── */
117   body.vertical #wall-wrap {
118     overflow-x: auto;
119     overflow-y: hidden;
120   }
121 
122   body.vertical #wall {
123     flex-direction: row-reverse;
124     justify-content: flex-end;
125     align-items: flex-start;
126     min-height: unset;
127     height: 100%;
128     padding: 12px 16px;
129     width: max-content;
130   }
131 
132   body.vertical .sub-entry {
133     writing-mode: vertical-rl;
134     text-orientation: mixed;
135     white-space: normal;
136     width: auto;
137     height: 100%;
138     border-top: none;
139     border-right: 1px solid #1e1e1e;
140     padding: 10px 8px;
141   }
142 </style>
143 </head>
144 <body>
145 
146 <div id="controls">
147   <label>
148     <input type="checkbox" id="verticalCheck">
149     vertical text
150   </label>
151   <label>
152     font:
153     <select id="fontSelect">
154       <option value="noto">Noto</option>
155       <option value="iming">I. Ming / Seiji</option>
156     </select>
157   </label>
158   <span id="progress"></span>
159   <span id="hint">space → next &nbsp;|&nbsp; shift+space → prev</span>
160 </div>
161 
162 <div id="dropzone">
163   <div id="drop-inner">
164     <input type="file" id="fileInput" accept=".srt,.ass,.ssa">
165     drop .srt or .ass file here<br>or click to browse
166   </div>
167 </div>
168 
169 <div id="wall-wrap">
170   <div id="wall"></div>
171 </div>
172 
173 <script>
174   const dropzone      = document.getElementById('dropzone');
175   const dropInner     = document.getElementById('drop-inner');
176   const fileInput     = document.getElementById('fileInput');
177   const wallWrap      = document.getElementById('wall-wrap');
178   const wall          = document.getElementById('wall');
179   const progress      = document.getElementById('progress');
180   const verticalCheck = document.getElementById('verticalCheck');
181   const fontSelect    = document.getElementById('fontSelect');
182 
183   let entries = [];
184   let idx = -1;
185 
186   const fonts = {
187     noto:  '"Noto Sans CJK JP", "Noto Sans JP", "Noto Serif CJK JP", "Noto Sans", sans-serif',
188     iming: '"I.Ming", "IMinghei", "Seiji", "HanaMinA", "HanaMin", serif'
189   };
190 
191   function applyFont() { wall.style.fontFamily = fonts[fontSelect.value]; }
192   fontSelect.addEventListener('change', applyFont);
193   applyFont();
194 
195   verticalCheck.addEventListener('change', () => {
196     document.body.classList.toggle('vertical', verticalCheck.checked);
197     scrollToCurrent();
198   });
199 
200   dropInner.addEventListener('click', () => fileInput.click());
201   dropzone.addEventListener('dragover', e => { e.preventDefault(); dropInner.classList.add('over'); });
202   dropzone.addEventListener('dragleave', () => dropInner.classList.remove('over'));
203   dropzone.addEventListener('drop', e => {
204     e.preventDefault();
205     dropInner.classList.remove('over');
206     if (e.dataTransfer.files[0]) readFile(e.dataTransfer.files[0]);
207   });
208   fileInput.addEventListener('change', () => {
209     if (fileInput.files[0]) readFile(fileInput.files[0]);
210   });
211 
212   function readFile(file) {
213     const reader = new FileReader();
214     reader.onload = e => {
215       const text = e.target.result;
216       const subs = file.name.toLowerCase().endsWith('.srt') ? parseSRT(text) : parseASS(text);
217       if (!subs.length) return;
218 
219       wall.innerHTML = '';
220       const frag = document.createDocumentFragment();
221       subs.forEach(s => {
222         const el = document.createElement('p'); // Changed to <p>
223         el.className = 'sub-entry';
224         el.innerHTML = s.text; // Preserves <span> tags
225         frag.appendChild(el);
226       });
227       wall.appendChild(frag);
228       entries = Array.from(wall.querySelectorAll('.sub-entry'));
229       idx = -1;
230 
231       dropzone.style.display = 'none';
232       wallWrap.style.display = 'block';
233 
234       if (document.body.classList.contains('vertical')) measureVerticalWidths();
235 
236       advance();
237     };
238     reader.readAsText(file, 'UTF-8');
239   }
240 
241   function measureVerticalWidths() {
242     entries.forEach(el => {
243       el.style.visibility = 'visible';
244       el.style.width = 'auto';
245       const w = el.scrollWidth;
246       el.style.visibility = '';
247       el.style.width = w + 'px';
248     });
249   }
250 
251   // ── PARSERS ──
252   // Modified to leave HTML/angle-brackets alone
253   function stripASSTags(s) {
254     return s.replace(/\{[^}]*\}/g, '').trim();
255   }
256 
257   function parseSRT(text) {
258     return text.replace(/\r\n/g, '\n').split(/\n\n+/).map(b => {
259       const lines = b.trim().split('\n');
260       return lines.length >= 3 ? { text: lines.slice(2).join('\n') } : null;
261     }).filter(Boolean);
262   }
263 
264   function parseASS(text) {
265     const lines = text.replace(/\r\n/g, '\n').split('\n');
266     const result = []; let inEvents = false, textIdx = -1;
267     for (const line of lines) {
268       if (line.trim().toLowerCase() === '[events]') { inEvents = true; continue; }
269       if (inEvents && line.startsWith('Format:')) {
270         textIdx = line.replace('Format:', '').split(',').map(s => s.trim().toLowerCase()).indexOf('text');
271         continue;
272       }
273       if (inEvents && line.startsWith('Dialogue:') && textIdx >= 0) {
274         const parts = line.replace('Dialogue:', '').split(',');
275         const clean = stripASSTags(parts.slice(textIdx).join(',')).replace(/\\N/gi, '\n').replace(/\\n/gi, '\n');
276         if (clean) result.push({ text: clean });
277       }
278     }
279     return result;
280   }
281 
282   function advance() {
283     if (idx >= entries.length - 1) return;
284     if (idx >= 0) entries[idx].classList.replace('current', 'revealed');
285     idx++;
286     entries[idx].classList.add('revealed', 'current');
287     scrollToCurrent();
288     updateProgress();
289   }
290 
291   function retreat() {
292     if (idx <= 0) return;
293     entries[idx].classList.remove('revealed', 'current');
294     idx--;
295     entries[idx].classList.add('current');
296     entries[idx].classList.remove('revealed');
297     scrollToCurrent();
298     updateProgress();
299   }
300 
301   function scrollToCurrent() {
302     if (idx < 0 || !entries[idx]) return;
303     const vertical = document.body.classList.contains('vertical');
304     entries[idx].scrollIntoView({
305       block: 'nearest',
306       inline: vertical ? 'start' : 'nearest'
307     });
308   }
309 
310   function updateProgress() {
311     progress.textContent = `${idx + 1} / ${entries.length}`;
312   }
313 
314   document.addEventListener('keydown', e => {
315     if (!entries.length) return;
316     if (e.code === 'Space') {
317       e.preventDefault();
318       e.shiftKey ? retreat() : advance();
319     }
320   });
321 </script>
322 </body>
323 </html>