// ==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 = '' + currentZipFiles.map((file, index) => {
const isSupported = supportedZipExtensions.test(file.name);
return `- ${file.name}
`;
}).join('') + '
';
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);
});
})();