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 | 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>