// ==UserScript== // @name ふたばアップローダー メディアビューア // @namespace https://github.com/ // @version 3.3 // @description dec.2chan.netのアップローダーでメディアをインライン表示し、快適な閲覧体験を提供します。 // @author AI Assistant // @match https://dec.2chan.net/up/all.htm // @match https://dec.2chan.net/up2/all.htm // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @grant none // @run-at document-idle // ==/UserScript== (function() { 'use strict'; // --- 状態管理用の変数 --- let currentIndex = -1, currentZipObject = null, currentZipFiles = [], currentZipIndex = -1, isZipViewerMode = false, currentBlobUrl = null; let slideshowIntervalId = null; // --- 定数定義 --- const SETTINGS_KEY = 'futabaViewerSettings'; const VISITED_KEY = 'futabaViewerVisited'; const VISITED_EXPIRATION_DAYS = 15; // 既読情報の保存日数 // --- ユーティリティ関数 --- function preloadImage(url) { new Image().src = url; } const supportedExtensions = /\.(jpe?g|png|gif|webp|mp4|webm|txt|mp3)$/i; const supportedZipExtensions = /\.(jpe?g|png|gif|webp|txt|mp3)$/i; const videoExtensions = /\.(mp4|webm)$/i; const unloadListener = (e) => { e.preventDefault(); e.returnValue = ''; }; // --- スタイル定義 --- const style = document.createElement('style'); style.textContent = ` #media-viewer-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.85); display: none; justify-content: center; align-items: center; z-index: 9999; } #media-viewer-content { position: relative; display: flex; justify-content: center; align-items: center; } #media-viewer-overlay img, #media-viewer-overlay video { max-width: 95vw; max-height: 95vh; object-fit: contain; display: none; border: none; } #media-viewer-overlay img { cursor: pointer; } #media-viewer-overlay audio { display: none; max-width: 90vw; } #media-viewer-overlay pre { display: none; max-width: 90vw; max-height: 90vh; overflow: auto; background-color: #f4f4f4; color: #111; padding: 1em; border-radius: 5px; white-space: pre-wrap; word-break: break-all; } #media-viewer-close { position: absolute; top: 10px; right: 25px; color: white; font-size: 40px; font-weight: bold; cursor: pointer; line-height: 1; z-index: 10001; } #media-viewer-nav-prev, #media-viewer-nav-next { position: fixed; top: 50%; transform: translateY(-50%); color: white; font-size: 50px; font-weight: bold; cursor: pointer; padding: 20px 15px; background-color: rgba(0,0,0,0.3); border-radius: 5px; user-select: none; z-index: 10000; } #media-viewer-nav-prev:hover, #media-viewer-nav-next:hover { background-color: rgba(0,0,0,0.6); } #media-viewer-nav-prev { left: 15px; } #media-viewer-nav-next { right: 15px; } #media-viewer-counter { position: fixed; bottom: 15px; left: 50%; transform: translateX(-50%); color: white; font-size: 18px; background-color: rgba(0,0,0,0.5); padding: 5px 10px; border-radius: 5px; z-index: 10001; } .visited-media-link a { color: #888 !important; } #viewer-zip-list { display: none; max-width: 80vw; max-height: 90vh; overflow-y: auto; background-color: #333; color: white; border-radius: 5px; padding: 10px; } #viewer-zip-list ul { list-style: none; margin: 0; padding: 0; } #viewer-zip-list li { padding: 8px 12px; border-bottom: 1px solid #555; cursor: pointer; word-break: break-all; } #viewer-zip-list li:hover { background-color: #555; } #viewer-zip-list li.unsupported { color: #888; cursor: not-allowed; } #viewer-zip-back { display: none; position: absolute; top: 15px; left: 25px; color: white; font-size: 18px; background-color: rgba(0,0,0,0.5); padding: 8px 12px; border-radius: 5px; cursor: pointer; z-index: 10001; } #viewer-loader { display: none; position: absolute; top: 50%; left: 50%; width: 60px; height: 60px; margin: -30px 0 0 -30px; border: 8px solid #f3f3f3; border-radius: 50%; border-top: 8px solid #555; animation: spin 1s linear infinite; z-index: 10002; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* 設定UI */ #viewer-settings { position: fixed; bottom: 15px; right: 15px; color: white; font-size: 14px; background-color: rgba(0,0,0,0.5); padding: 5px 10px; border-radius: 5px; z-index: 10001; display: flex; align-items: center; gap: 15px; } #viewer-settings label { display: flex; align-items: center; gap: 5px; cursor: pointer; } #viewer-settings button { background: none; border: none; color: white; font-size: 18px; cursor: pointer; padding: 0 5px; } #viewer-txt-encoding-selector { display: none; } /* スライドショーポップアップ */ #slideshow-popup-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: none; justify-content: center; align-items: center; z-index: 10002; } #slideshow-popup { background-color: #2c2c2c; color: white; padding: 20px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); width: 320px; } #slideshow-popup h3 { margin-top: 0; text-align: center; } #slideshow-popup .setting-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; } #slideshow-popup input[type="number"] { width: 60px; } #slideshow-popup-actions { text-align: center; margin-top: 20px; } #slideshow-play-button { padding: 10px 20px; font-size: 16px; cursor: pointer; } `; document.head.appendChild(style); // --- HTML要素の定義 --- const overlay = document.createElement('div'); overlay.id = 'media-viewer-overlay'; overlay.innerHTML = ` ×
< 一覧へ戻る
<
>

            

スライドショー設定

`; document.body.appendChild(overlay); const elements = { img: document.getElementById('viewer-img'), vid: document.getElementById('viewer-vid'), aud: document.getElementById('viewer-aud'), txt: document.getElementById('viewer-txt'), zipList: document.getElementById('viewer-zip-list'), zipBack: document.getElementById('viewer-zip-back'), counter: document.getElementById('media-viewer-counter'), close: document.getElementById('media-viewer-close'), prev: document.getElementById('media-viewer-nav-prev'), next: document.getElementById('media-viewer-nav-next'), loader: document.getElementById('viewer-loader'), encodingSelector: document.getElementById('viewer-txt-encoding-selector'), unloadProtect: document.getElementById('viewer-unload-protect'), slideshow: { openBtn: document.getElementById('slideshow-open-button'), popupOverlay: document.getElementById('slideshow-popup-overlay'), popup: document.getElementById('slideshow-popup'), playBtn: document.getElementById('slideshow-play-button'), direction: document.getElementById('ss-direction'), interval: document.getElementById('ss-interval'), skipVideo: document.getElementById('ss-skip-video') } }; const mediaLinks = Array.from(document.querySelectorAll('table.files tr .fnm a[href^="src/"]')).filter(a => supportedExtensions.test(a.href) || /\.zip$/i.test(a.href)); if (mediaLinks.length === 0) return; // --- 既読状態の管理 --- function saveVisitedStatus(url) { const visited = JSON.parse(localStorage.getItem(VISITED_KEY) || '{}'); visited[url] = Date.now(); localStorage.setItem(VISITED_KEY, JSON.stringify(visited)); } function loadAndApplyVisitedStatus() { const visited = JSON.parse(localStorage.getItem(VISITED_KEY) || '{}'); const now = Date.now(); const expirationMs = VISITED_EXPIRATION_DAYS * 24 * 60 * 60 * 1000; let isUpdated = false; for (const url in visited) { if (now - visited[url] > expirationMs) { delete visited[url]; isUpdated = true; } } if (isUpdated) { localStorage.setItem(VISITED_KEY, JSON.stringify(visited)); } mediaLinks.forEach(link => { if (visited[link.href]) { link.closest('.fnm')?.classList.add('visited-media-link'); } }); } // --- 設定の保存と読み込み --- let settings = {}; function loadSettings() { const saved = localStorage.getItem(SETTINGS_KEY); settings = saved ? JSON.parse(saved) : {}; settings.unloadProtect = settings.unloadProtect ?? true; settings.slideshow = settings.slideshow ?? {}; settings.slideshow.direction = settings.slideshow.direction ?? '1'; settings.slideshow.interval = settings.slideshow.interval ?? 5; settings.slideshow.skipVideo = settings.slideshow.skipVideo ?? false; elements.unloadProtect.checked = settings.unloadProtect; elements.slideshow.direction.value = settings.slideshow.direction; elements.slideshow.interval.value = settings.slideshow.interval; elements.slideshow.skipVideo.checked = settings.slideshow.skipVideo; } function saveSettings() { settings.unloadProtect = elements.unloadProtect.checked; settings.slideshow.direction = elements.slideshow.direction.value; settings.slideshow.interval = elements.slideshow.interval.value; settings.slideshow.skipVideo = elements.slideshow.skipVideo.checked; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } // --- 主要な関数 --- function hideAllViewers() { ['img','vid','aud','txt','zipList','zipBack','loader'].forEach(key => elements[key].style.display = 'none'); elements.encodingSelector.style.display = 'none'; if (!elements.vid.paused) elements.vid.pause(); if (!elements.aud.paused) elements.aud.pause(); if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl); currentBlobUrl = null; } async function displayTextWithEncoding(blob, encoding = 'auto') { const arrayBuffer = await blob.arrayBuffer(); try { if (encoding !== 'auto') { elements.txt.textContent = new TextDecoder(encoding).decode(arrayBuffer); return; } try { elements.txt.textContent = new TextDecoder('utf-8', { fatal: true }).decode(arrayBuffer); } catch (e) { const bytes = new Uint8Array(arrayBuffer); const sampleSize = Math.min(bytes.length, 512); let nullCount = 0; for (let i = 0; i < sampleSize; i++) { if (bytes[i] === 0x00) nullCount++; } if (sampleSize > 0 && (nullCount / sampleSize) > 0.05) { elements.txt.textContent = new TextDecoder('utf-16le').decode(arrayBuffer); } else { elements.txt.textContent = new TextDecoder('shift_jis').decode(arrayBuffer); } } } catch(err) { elements.txt.textContent = `文字コード[${encoding}]でのデコードに失敗しました。\n${err.message}`; } } async function showZipContent(zipFileObject) { hideAllViewers(); elements.loader.style.display = 'block'; try { const url = zipFileObject.name; const blob = await zipFileObject.async('blob'); currentBlobUrl = URL.createObjectURL(blob); if (/\.(mp3)$/i.test(url)) { elements.aud.src = currentBlobUrl; elements.aud.onloadeddata = () => elements.loader.style.display = 'none'; elements.aud.style.display = 'block'; elements.aud.play(); } else if (/\.(txt)$/i.test(url)) { await displayTextWithEncoding(blob); elements.txt.style.display = 'block'; elements.encodingSelector.style.display = 'inline-block'; elements.loader.style.display = 'none'; } else { elements.img.src = currentBlobUrl; elements.img.onload = () => elements.loader.style.display = 'none'; elements.img.style.display = 'block'; } } catch(err) { elements.txt.textContent = `ZIP内ファイルの表示に失敗しました。\n${err.message}`; elements.txt.style.display = 'block'; elements.loader.style.display = 'none'; } elements.zipBack.style.display = 'block'; elements.counter.textContent = `ZIP: ${currentZipIndex + 1} / ${currentZipFiles.length}`; } function showZipList() { hideAllViewers(); isZipViewerMode = true; currentZipIndex = -1; elements.zipList.innerHTML = ''; elements.zipList.style.display = 'block'; elements.counter.textContent = `ZIP: ${currentZipFiles.length}件のファイル`; } async function showMedia(index) { if (index < 0 || index >= mediaLinks.length) return; currentIndex = index; const link = mediaLinks[index]; const url = link.href; saveVisitedStatus(url); link.closest('.fnm')?.classList.add('visited-media-link'); hideAllViewers(); isZipViewerMode = false; elements.loader.style.display = 'block'; const hideLoader = () => { elements.loader.style.display = 'none'; }; const showError = (msg) => { elements.txt.textContent = msg; elements.txt.style.display = 'block'; hideLoader(); }; if (/\.zip$/i.test(url)) { elements.counter.textContent = 'ZIPファイルを読み込み中...'; try { const response = await fetch(url); const data = await response.arrayBuffer(); currentZipObject = await JSZip.loadAsync(data); currentZipFiles = Object.values(currentZipObject.files).filter(f => !f.dir); showZipList(); } catch (err) { showError(`ZIPファイルの読み込みに失敗しました。\n${err.message}`); } finally { hideLoader(); } } else if (supportedExtensions.test(url)) { elements.counter.textContent = `${currentIndex + 1} / ${mediaLinks.length}`; try { if (videoExtensions.test(url)) { elements.vid.onloadeddata = hideLoader; elements.vid.onerror = () => showError('動画の読み込みに失敗しました。'); elements.vid.src = url; elements.vid.style.display = 'block'; elements.vid.play().catch(() => {}); } else if (/\.(mp3)$/i.test(url)) { elements.aud.onloadeddata = hideLoader; elements.aud.onerror = () => showError('音声の読み込みに失敗しました。'); elements.aud.src = url; elements.aud.style.display = 'block'; elements.aud.play().catch(() => {}); } else if (/\.(txt)$/i.test(url)) { const blob = await fetch(url).then(res => res.blob()); await displayTextWithEncoding(blob); elements.txt.style.display = 'block'; elements.encodingSelector.style.display = 'inline-block'; hideLoader(); } else { elements.img.onload = hideLoader; elements.img.onerror = () => showError('画像の読み込みに失敗しました。'); elements.img.src = url; elements.img.style.display = 'block'; [ -2, -1, 1, 2 ].forEach(offset => { const preloadIndex = (currentIndex + offset + mediaLinks.length) % mediaLinks.length; if (/\.(jpe?g|png|gif|webp)$/i.test(mediaLinks[preloadIndex].href)) { preloadImage(mediaLinks[preloadIndex].href); } }); } } catch (err) { showError(`ファイルの表示中にエラーが発生しました。\n${err.message}`); } } } function openViewer(index) { overlay.style.display = 'flex'; document.body.style.overflow = 'hidden'; if (elements.unloadProtect.checked) window.addEventListener('beforeunload', unloadListener, {capture: true}); showMedia(index); } function closeViewer() { stopSlideshow(); overlay.style.display = 'none'; document.body.style.overflow = ''; window.removeEventListener('beforeunload', unloadListener, {capture: true}); hideAllViewers(); currentIndex = -1; currentZipObject = null; currentZipFiles = []; currentZipIndex = -1; isZipViewerMode = false; } function navigate(direction) { stopSlideshow(); if (isZipViewerMode && currentZipIndex !== -1) { let newIndex = (currentZipIndex + direction + currentZipFiles.length) % currentZipFiles.length; currentZipIndex = newIndex; showZipContent(currentZipFiles[currentZipIndex]); } else { let newIndex = (currentIndex + direction + mediaLinks.length) % mediaLinks.length; showMedia(newIndex); } } function stopSlideshow() { if (slideshowIntervalId) { clearInterval(slideshowIntervalId); slideshowIntervalId = null; elements.slideshow.playBtn.textContent = '再生'; } } async function advanceSlideshow() { const direction = parseInt(settings.slideshow.direction, 10); let nextIndex = (currentIndex + direction + mediaLinks.length) % mediaLinks.length; if (settings.slideshow.skipVideo) { let attempts = 0; while (videoExtensions.test(mediaLinks[nextIndex].href) && attempts < mediaLinks.length) { nextIndex = (nextIndex + direction + mediaLinks.length) % mediaLinks.length; attempts++; } } await showMedia(nextIndex); } function startSlideshow() { stopSlideshow(); saveSettings(); const interval = Math.max(1, settings.slideshow.interval) * 1000; elements.slideshow.playBtn.textContent = '停止'; slideshowIntervalId = setInterval(advanceSlideshow, interval); elements.slideshow.popupOverlay.style.display = 'none'; } // --- 初期化処理 --- loadAndApplyVisitedStatus(); loadSettings(); // --- イベントリスナー設定 --- mediaLinks.forEach((link, index) => { link.addEventListener('click', (e) => { e.preventDefault(); openViewer(index); }); }); elements.close.addEventListener('click', closeViewer); elements.prev.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); }); elements.next.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); }); document.addEventListener('keydown', (e) => { if (overlay.style.display !== 'flex') return; switch (e.key) { case 'ArrowRight': navigate(1); break; case 'ArrowLeft': navigate(-1); break; case 'Escape': closeViewer(); break; } }); elements.zipList.addEventListener('click', (e) => { const li = e.target.closest('li'); if (li && !li.classList.contains('unsupported')) { currentZipIndex = parseInt(li.dataset.index, 10); showZipContent(currentZipFiles[currentZipIndex]); } }); elements.zipBack.addEventListener('click', (e) => { e.stopPropagation(); showZipList(); }); elements.img.addEventListener('click', () => { window.open(elements.img.src, '_blank'); }); elements.encodingSelector.addEventListener('change', async (e) => { const encoding = e.target.value; const blob = isZipViewerMode ? await currentZipFiles[currentZipIndex].async('blob') : await fetch(mediaLinks[currentIndex].href).then(res => res.blob()); displayTextWithEncoding(blob, encoding); }); elements.unloadProtect.addEventListener('change', (e) => { saveSettings(); if (e.target.checked) { if (overlay.style.display === 'flex') window.addEventListener('beforeunload', unloadListener, {capture: true}); } else { window.removeEventListener('beforeunload', unloadListener, {capture: true}); } }); elements.slideshow.openBtn.addEventListener('click', () => { elements.slideshow.popupOverlay.style.display = 'flex'; }); elements.slideshow.popupOverlay.addEventListener('click', (e) => { if (e.target === elements.slideshow.popupOverlay) elements.slideshow.popupOverlay.style.display = 'none'; }); elements.slideshow.playBtn.addEventListener('click', () => { if (slideshowIntervalId) stopSlideshow(); else startSlideshow(); }); ['direction', 'interval', 'skipVideo'].forEach(key => { elements.slideshow[key].addEventListener('change', saveSettings); }); })();