Sleazy Fork is available in English.
TokyoMotionをより便利にするスクリプト - 投稿者ブロック、単語ミュート、高評価保存、視聴履歴、プレイリスト、購読・友達動画の簡易閲覧
// ==UserScript==
// @name TokyoMotion Enhancer
// @namespace http://tampermonkey.net/
// @version 3.4
// @description TokyoMotionをより便利にするスクリプト - 投稿者ブロック、単語ミュート、高評価保存、視聴履歴、プレイリスト、購読・友達動画の簡易閲覧
// @author meranoa
// @license MIT
// @homepageURL https://greasyfork.org/ja/scripts/564032-tokyomotion-enhancer
// @supportURL https://greasyfork.org/ja/scripts/564032-tokyomotion-enhancer/feedback
// @match https://www.tokyomotion.net/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=tokyomotion.net
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_openInTab
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
console.log('[TM Enhancer] Userscript started (Version 3.4)');
// ========================================
// 言語・翻訳管理 (Localization)
// ========================================
const TranslationManager = {
getLanguage() {
const config = GM_getValue('appLanguage', 'auto');
if (config === 'auto') {
const navLang = (navigator.language || navigator.userLanguage || 'ja').toLowerCase();
return navLang.startsWith('ja') ? 'ja' : 'en';
}
return config;
},
setLanguage(lang) {
GM_setValue('appLanguage', lang);
},
getText(key, replacements = {}) {
const lang = this.getLanguage();
let text = (TRANSLATIONS[lang] && TRANSLATIONS[lang][key]) || TRANSLATIONS['en'][key] || key;
Object.keys(replacements).forEach(k => {
text = text.replace(`{${k}}`, replacements[k]);
});
return text;
}
};
const t = (key, repl) => TranslationManager.getText(key, repl);
const FETCH_RANGE_TEXTS = {
ja: {
title: '購読・友達動画の取得範囲',
feedTitle: '購読フィード',
friendsTitle: '友達フィード',
modePages: '指定したページ数まで取得',
modeDays: '指定した日数以内の動画を取得',
modeSinceLast: '前回取得した時刻以降の動画を取得',
pagesUnit: 'ページ',
daysUnit: '日以内',
activePages: 'ページ数で取得します。',
activeDays: '{days}日前以降の動画を取得します。',
activeSinceLast: '前回取得時刻以降の動画を取得します。',
conflict: '取得方式は1つだけオンにしてください。',
none: '取得方式がオフです。取得する時は1つだけオンにしてください。',
noLastFetch: '前回取得時刻がまだありません。先にページ数か日数指定で一度取得してください。'
},
en: {
title: 'Feed/Friends fetch range',
feedTitle: 'Subscription feed',
friendsTitle: 'Friends feed',
modePages: 'Fetch by page count',
modeDays: 'Fetch videos within the last N days',
modeSinceLast: 'Fetch videos posted since the previous fetch',
pagesUnit: 'pages',
daysUnit: 'days',
activePages: 'Fetching by page count.',
activeDays: 'Fetching videos from the last {days} days.',
activeSinceLast: 'Fetching videos since the previous fetch.',
conflict: 'Please enable only one fetch method.',
none: 'No fetch method is enabled. Enable exactly one before updating.',
noLastFetch: 'No previous fetch time exists yet. Fetch once by page count or day range first.'
}
};
function fetchRangeText(key, replacements = {}) {
const lang = TranslationManager.getLanguage() === 'ja' ? 'ja' : 'en';
let text = (FETCH_RANGE_TEXTS[lang] && FETCH_RANGE_TEXTS[lang][key]) || FETCH_RANGE_TEXTS.en[key] || key;
Object.keys(replacements).forEach(k => {
text = text.replace(`{${k}}`, replacements[k]);
});
return text;
}
const TRANSLATIONS = {
ja: {
'tab_liked': '❤️ 高評価',
'tab_history': '📺 履歴',
'tab_playlists': '📁 リスト',
'tab_feed': '📡 購読',
'tab_friends': '🤝 友達',
'tab_settings': '⚙️ 設定',
'btn_close': '閉じる',
'btn_update': '更新',
'btn_create': '作成',
'btn_delete': '削除',
'btn_remove': '削除',
'btn_export': '📥 エクスポート',
'btn_import': '📤 インポート',
'btn_clear_all': '🗑️ データ全削除',
'btn_back': '← 戻る',
'btn_rename': '名前を変更',
'btn_sort_new': '▼ 新しい順',
'btn_sort_old': '▲ 古い順',
'label_private': 'PRIVATE',
'msg_empty_liked': 'まだ高評価した動画がありません',
'msg_empty_history': 'まだ視聴履歴がありません',
'msg_empty_playlist': 'プレイリストがありません',
'msg_empty_videos': '動画がありません',
'msg_empty_feed': 'なし',
'msg_saved_liked': '高評価に保存しました',
'msg_added_to': '「{name}」に追加',
'msg_removed_from': '「{name}」から削除',
'msg_created_added': '「{name}」を作成して追加',
'msg_fetching': '取得中...',
'msg_fetching_users': '{count}人の動画を取得中...',
'msg_fetching_progress': '取得中... ({current}/{total}人)',
'msg_complete': '完了 ({count}件)',
'msg_error': 'エラー: {msg}',
'msg_no_users': 'ユーザーなし',
'msg_auto_login': '自動ログイン中...',
'msg_import_done': 'インポート完了。ページをリロードします。',
'msg_import_error': '読み込みエラー: {msg}',
'msg_data_cleared': '全データを削除しました。',
'msg_scroll_reset': '長時間経過のためスクロールをリセットしました',
'confirm_delete_liked': '高評価した動画消しますか?',
'confirm_delete_playlist': 'プレイリスト「{name}」を削除しますか?',
'confirm_settings_hidden': '設定タブを非表示にすると、後から再表示するのが難しくなります。本当によろしいですか?',
'confirm_overwrite': '現在のデータを上書きします。よろしいですか?',
'confirm_clear_all': '全てのデータ(高評価、履歴、プレイリスト)を削除しますか?\nこの操作は取り消せません。',
'prompt_playlist_name': '新しいプレイリスト名を入力してください',
'alert_exists': '既に存在します',
'alert_name_used': 'その名前は既に使用されています。',
'stg_language': '言語 / Language',
'stg_startup_tab': '起動時に表示するタブ',
'stg_tab_last_open': '🔄 最後に開いた項目',
'stg_auto_login': '🔐 自動ログイン',
'stg_auto_login_desc': 'ログインページを開いた際に自動でログインボタンを押します。',
'stg_ui_mode': '📱 表示・操作モード',
'stg_ui_mode_desc': '自動判定では画面幅とタッチ操作の種類に合わせます。PC向けは動画ページで常時表示、スマホ向けは他のページと同じく閉じた状態で開始します。',
'stg_ui_mode_auto': '自動判定(推奨)',
'stg_ui_mode_desktop': 'PC向け',
'stg_ui_mode_mobile': 'スマホ向け',
'stg_tab_visibility': '📑 タブ表示設定',
'stg_grid_cols': '動画一覧の列数',
'stg_feed_pages': '購読フィード取得ページ数',
'stg_friend_pages': '友達フィード取得ページ数',
'stg_page_unit': 'ページ',
'stg_unlimited': '無制限 (全て取得)',
'stg_col_unit': '列',
'stg_scroll_reset': '非アクティブ時のスクロールリセット時間',
'unit_sec': '秒',
'unit_min': '分',
'unit_hour': '時間',
'time_just_now': 'たった今',
'time_min_ago': '分前',
'time_hour_ago': '時間前',
'time_day_ago': '日前',
'time_long_ago': 'かなり前',
'time_videos_count': '{count} 動画',
'modal_title': 'Myリスト',
'placeholder_new_playlist': '新規リスト名',
'filter_title': '🚫 検索結果フィルター',
'filter_desc': '検索結果や動画一覧から、指定した投稿者またはタイトル語句に一致する動画を隠します。投稿者名は完全一致、タイトル語句は部分一致です。1行に1件ずつ入力してください。',
'filter_enabled': 'フィルターを有効にする',
'filter_uploaders': 'ブロックする投稿者',
'filter_uploaders_placeholder': '例:spam_user\nannoying_poster',
'filter_words': 'ミュートするタイトル語句',
'filter_words_placeholder': '例:無関係なタグ\n広告',
'filter_save': '保存して適用',
'filter_clear': 'フィルターを全削除',
'filter_saved': 'フィルターを保存しました',
'filter_cleared': 'フィルターを削除しました',
'filter_hidden_count': '{count}件を非表示',
'filter_revealed_count': '{count}件を一時表示中',
'filter_show_temporarily': '一時表示',
'filter_hide_again': '再び隠す',
'filter_block_uploader': 'この投稿者をブロック',
'filter_blocked_uploader': '「{name}」をブロックしました',
'filter_confirm_clear': '投稿者ブロックとタイトルミュートをすべて削除しますか?',
'drag_thumbnail_hint': '表示される領域へドロップすると、画面を切り替えず別タブで開きます',
'drag_drop_new_tab': 'ここにドロップしてバックグラウンドで開く',
},
en: {
'tab_liked': '❤️ Liked',
'tab_history': '📺 History',
'tab_playlists': '📁 Playlists',
'tab_feed': '📡 Feed',
'tab_friends': '🤝 Friends',
'tab_settings': '⚙️ Settings',
'btn_close': 'Close',
'btn_update': 'Update',
'btn_create': 'Create',
'btn_delete': 'Delete',
'btn_remove': 'Remove',
'btn_export': '📥 Export',
'btn_import': '📤 Import',
'btn_clear_all': '🗑️ Clear All Data',
'btn_back': '← Back',
'btn_rename': 'Rename',
'btn_sort_new': '▼ Newest',
'btn_sort_old': '▲ Oldest',
'label_private': 'PRIVATE',
'msg_empty_liked': 'No liked videos yet.',
'msg_empty_history': 'No watch history yet.',
'msg_empty_playlist': 'No playlists created.',
'msg_empty_videos': 'No videos found.',
'msg_empty_feed': 'Empty',
'msg_saved_liked': 'Saved to Liked Videos',
'msg_added_to': 'Added to "{name}"',
'msg_removed_from': 'Removed from "{name}"',
'msg_created_added': 'Created "{name}" and added video',
'msg_fetching': 'Fetching...',
'msg_fetching_users': 'Fetching videos from {count} users...',
'msg_fetching_progress': 'Fetching... ({current}/{total} users)',
'msg_complete': 'Done ({count} videos)',
'msg_error': 'Error: {msg}',
'msg_no_users': 'No users found',
'msg_auto_login': 'Auto logging in...',
'msg_import_done': 'Import complete. Reloading page.',
'msg_import_error': 'Import Error: {msg}',
'msg_data_cleared': 'All data cleared.',
'msg_scroll_reset': 'Inactive for too long. Scroll reset.',
'confirm_delete_liked': 'Remove this video from Liked?',
'confirm_delete_playlist': 'Delete playlist "{name}"?',
'confirm_settings_hidden': 'If you hide the Settings tab, it will be difficult to show it again. Are you sure?',
'confirm_overwrite': 'This will overwrite current data. Are you sure?',
'confirm_clear_all': 'Delete ALL data (Liked, History, Playlists)?\nThis cannot be undone.',
'prompt_playlist_name': 'Enter new playlist name',
'alert_exists': 'Already exists',
'alert_name_used': 'That name is already taken.',
'stg_language': 'Language / 言語',
'stg_startup_tab': 'Startup Tab',
'stg_tab_last_open': '🔄 Last Opened',
'stg_auto_login': '🔐 Auto Login',
'stg_auto_login_desc': 'Automatically clicks the login button when opening the login modal.',
'stg_ui_mode': '📱 Display and interaction mode',
'stg_ui_mode_desc': 'Auto follows the viewport and pointer type. Desktop keeps the panel open on video pages; Mobile starts closed like other pages.',
'stg_ui_mode_auto': 'Auto (recommended)',
'stg_ui_mode_desktop': 'Desktop',
'stg_ui_mode_mobile': 'Mobile',
'stg_tab_visibility': '📑 Tab Visibility',
'stg_grid_cols': 'Video Grid Columns',
'stg_feed_pages': 'Feed Fetch Pages',
'stg_friend_pages': 'Friends Fetch Pages',
'stg_page_unit': ' pages',
'stg_unlimited': 'Unlimited',
'stg_col_unit': ' cols',
'stg_scroll_reset': 'Scroll Reset Time (Inactive)',
'unit_sec': 'Seconds',
'unit_min': 'Minutes',
'unit_hour': 'Hours',
'time_just_now': 'Just now',
'time_min_ago': 'm ago',
'time_hour_ago': 'h ago',
'time_day_ago': 'd ago',
'time_long_ago': 'Long ago',
'time_videos_count': '{count} videos',
'modal_title': 'My Playlists',
'placeholder_new_playlist': 'New Playlist Name',
'filter_title': '🚫 Search Result Filter',
'filter_desc': 'Hide videos in search results and listings when the uploader or title matches. Uploader names use exact matching; title terms use substring matching. Enter one item per line.',
'filter_enabled': 'Enable filtering',
'filter_uploaders': 'Blocked uploaders',
'filter_uploaders_placeholder': 'Example: spam_user\nannoying_poster',
'filter_words': 'Muted title terms',
'filter_words_placeholder': 'Example: unrelated tag\nadvertisement',
'filter_save': 'Save and apply',
'filter_clear': 'Clear filters',
'filter_saved': 'Filters saved',
'filter_cleared': 'Filters cleared',
'filter_hidden_count': '{count} hidden',
'filter_revealed_count': '{count} temporarily visible',
'filter_show_temporarily': 'Show temporarily',
'filter_hide_again': 'Hide again',
'filter_block_uploader': 'Block this uploader',
'filter_blocked_uploader': 'Blocked "{name}"',
'filter_confirm_clear': 'Clear all blocked uploaders and muted title terms?',
'drag_thumbnail_hint': 'Drop on the target to open in a background tab without switching',
'drag_drop_new_tab': 'Drop here to open in the background',
}
};
// ========================================
// 定数・初期設定
// ========================================
const DEFAULT_TAB_ORDER = ['liked', 'history', 'playlists', 'feed', 'friends', 'settings'];
const SCROLLBAR_MARGIN = 25;
// ========================================
// ストレージマネージャー
// ========================================
const StorageManager = {
async getLikedVideos() { return GM_getValue('likedVideos', []); },
async addLikedVideo(videoData) {
const videos = await this.getLikedVideos();
if (!videos.some(v => v.id === videoData.id)) {
videos.unshift(videoData);
GM_setValue('likedVideos', videos);
}
},
async removeLikedVideo(videoId) {
let videos = await this.getLikedVideos();
videos = videos.filter(v => v.id !== videoId);
GM_setValue('likedVideos', videos);
},
async getHistory() { return GM_getValue('history', []); },
async addToHistory(videoData) {
let history = await this.getHistory();
history.unshift({ ...videoData, watchedAt: Date.now() });
GM_setValue('history', history);
},
async clearHistory() { GM_setValue('history', []); },
async getPlaylists() { return GM_getValue('playlists', {}); },
async createPlaylist(name) {
const playlists = await this.getPlaylists();
if (playlists[name]) return false;
playlists[name] = [];
GM_setValue('playlists', playlists);
return true;
},
async deletePlaylist(name) {
const playlists = await this.getPlaylists();
delete playlists[name];
GM_setValue('playlists', playlists);
if (this.getActivePlaylist() === name) {
this.setActivePlaylist(null);
}
},
async renamePlaylist(oldName, newName) {
if (oldName === newName) return true;
const playlists = await this.getPlaylists();
if (playlists[newName]) return false;
playlists[newName] = playlists[oldName];
delete playlists[oldName];
GM_setValue('playlists', playlists);
const order = this.getPlaylistOrder();
const idx = order.indexOf(oldName);
if (idx !== -1) {
order[idx] = newName;
GM_setValue('playlistOrder', order);
}
if (this.getActivePlaylist() === oldName) {
this.setActivePlaylist(newName);
}
return true;
},
async addToPlaylist(name, videoData) {
const playlists = await this.getPlaylists();
if (!playlists[name]) return;
if (!playlists[name].some(v => v.id === videoData.id)) {
playlists[name].unshift(videoData);
GM_setValue('playlists', playlists);
}
},
async removeFromPlaylist(name, videoId) {
const playlists = await this.getPlaylists();
if (!playlists[name]) return;
playlists[name] = playlists[name].filter(v => v.id !== videoId);
GM_setValue('playlists', playlists);
},
getPrivateCache() { return GM_getValue('privateVideoCache', {}); },
addPrivateToCache(ids) {
const cache = this.getPrivateCache();
let changed = false;
ids.forEach(id => {
if (!cache[id]) { cache[id] = 1; changed = true; }
});
if (changed) GM_setValue('privateVideoCache', cache);
},
isPrivateCached(id) {
const cache = this.getPrivateCache();
return !!cache[id];
},
getDefaultTab() { return GM_getValue('defaultTab', 'liked'); },
setDefaultTab(tab) { GM_setValue('defaultTab', tab); },
getLastActiveTab() { return GM_getValue('lastActiveTab', 'liked'); },
setLastActiveTab(tab) { GM_setValue('lastActiveTab', tab); },
isAutoLoginEnabled() { return GM_getValue('autoLoginEnabled', false); },
setAutoLoginEnabled(enabled) { GM_setValue('autoLoginEnabled', enabled); },
getPlaylistOrder() { return GM_getValue('playlistOrder', []); },
setPlaylistOrder(order) { GM_setValue('playlistOrder', order); },
async getOrderedPlaylistNames() {
const playlists = await this.getPlaylists();
const savedOrder = this.getPlaylistOrder();
const allNames = Object.keys(playlists);
const ordered = savedOrder.filter(name => allNames.includes(name));
const remaining = allNames.filter(name => !ordered.includes(name));
return [...ordered, ...remaining];
},
getFeedData() { return GM_getValue('feedData', []); },
setFeedData(data) { GM_setValue('feedData', data); },
getFeedLastUpdated() { return GM_getValue('feedLastUpdated', 0); },
setFeedLastUpdated(time) { GM_setValue('feedLastUpdated', time); },
getFriendsFeedData() { return GM_getValue('friendsFeedData', []); },
setFriendsFeedData(data) { GM_setValue('friendsFeedData', data); },
getFriendsLastUpdated() { return GM_getValue('friendsLastUpdated', 0); },
setFriendsLastUpdated(time) { GM_setValue('friendsLastUpdated', time); },
getFeedMaxPages() { return GM_getValue('feedMaxPages', 1); },
setFeedMaxPages(pages) { GM_setValue('feedMaxPages', pages); },
getFriendsMaxPages() { return GM_getValue('friendsMaxPages', 1); },
setFriendsMaxPages(pages) { GM_setValue('friendsMaxPages', pages); },
getDefaultFetchModes() { return { pages: true, days: false, sinceLast: false }; },
normalizeFetchModes(modes) {
const merged = { pages: false, days: false, sinceLast: false, ...(modes || {}) };
const selected = ['pages', 'days', 'sinceLast'].find(key => !!merged[key]) || 'pages';
return { pages: selected === 'pages', days: selected === 'days', sinceLast: selected === 'sinceLast' };
},
getFeedFetchModes() { return this.normalizeFetchModes(GM_getValue('feedFetchModes', this.getDefaultFetchModes())); },
setFeedFetchModes(modes) { GM_setValue('feedFetchModes', this.normalizeFetchModes(modes)); },
getFriendsFetchModes() { return this.normalizeFetchModes(GM_getValue('friendsFetchModes', this.getDefaultFetchModes())); },
setFriendsFetchModes(modes) { GM_setValue('friendsFetchModes', this.normalizeFetchModes(modes)); },
getFeedMaxDays() { return GM_getValue('feedMaxDays', 3); },
setFeedMaxDays(days) { GM_setValue('feedMaxDays', days); },
getFriendsMaxDays() { return GM_getValue('friendsMaxDays', 3); },
setFriendsMaxDays(days) { GM_setValue('friendsMaxDays', days); },
getModalCols() { return GM_getValue('modalCols', 3); },
setModalCols(cols) { GM_setValue('modalCols', cols); },
getPlaylistGridCols() { return GM_getValue('playlistGridCols', 2); },
setPlaylistGridCols(cols) { GM_setValue('playlistGridCols', cols); },
getVideoGridCols() { return GM_getValue('videoGridCols', 2); },
setVideoGridCols(cols) { GM_setValue('videoGridCols', cols); },
getUIMode() {
const mode = GM_getValue('uiMode', 'auto');
return ['auto', 'desktop', 'mobile'].includes(mode) ? mode : 'auto';
},
setUIMode(mode) { GM_setValue('uiMode', ['auto', 'desktop', 'mobile'].includes(mode) ? mode : 'auto'); },
getTabOrder() { return GM_getValue('tabOrder', DEFAULT_TAB_ORDER); },
setTabOrder(order) { GM_setValue('tabOrder', order); },
getTabVisibility() {
const defaults = {};
DEFAULT_TAB_ORDER.forEach(k => defaults[k] = true);
const visibility = { ...defaults, ...GM_getValue('tabVisibility', defaults) };
// 設定画面への入口を失わないよう、設定タブは常に表示する。
visibility.settings = true;
return visibility;
},
setTabVisibility(vis) { GM_setValue('tabVisibility', { ...vis, settings: true }); },
getPanelState(mobile = false) { return GM_getValue(mobile ? 'mobilePanelState' : 'panelState', null); },
setPanelState(state, mobile = false) { GM_setValue(mobile ? 'mobilePanelState' : 'panelState', state); },
getBtnPosition(mobile = false) { return GM_getValue(mobile ? 'mobileBtnPosition' : 'btnPosition', null); },
setBtnPosition(pos, mobile = false) { GM_setValue(mobile ? 'mobileBtnPosition' : 'btnPosition', pos); },
getTabScroll(tab) {
const scrolls = GM_getValue('tabScrolls', {});
return scrolls[tab] || 0;
},
setTabScroll(tab, val) {
const scrolls = GM_getValue('tabScrolls', {});
scrolls[tab] = val;
GM_setValue('tabScrolls', scrolls);
},
getActivePlaylist() { return GM_getValue('activePlaylist', null); },
setActivePlaylist(name) { GM_setValue('activePlaylist', name); },
// スクロールリセット設定用
getScrollResetValue() { return GM_getValue('scrollResetValue', 5); }, // デフォルト5
setScrollResetValue(val) { GM_setValue('scrollResetValue', val); },
getScrollResetUnit() { return GM_getValue('scrollResetUnit', 'minutes'); }, // デフォルトminutes
setScrollResetUnit(unit) { GM_setValue('scrollResetUnit', unit); },
getLastClosedTime() { return GM_getValue('lastClosedTime', 0); },
setLastClosedTime(time) { GM_setValue('lastClosedTime', time); },
// 検索結果・動画一覧フィルター
isContentFilterEnabled() { return GM_getValue('contentFilterEnabled', true); },
setContentFilterEnabled(enabled) { GM_setValue('contentFilterEnabled', !!enabled); },
getBlockedUploaders() { return GM_getValue('blockedUploaders', []); },
setBlockedUploaders(names) { GM_setValue('blockedUploaders', names); },
getMutedTitleTerms() { return GM_getValue('mutedTitleTerms', []); },
setMutedTitleTerms(terms) { GM_setValue('mutedTitleTerms', terms); },
getVideoUploaderCache() { return GM_getValue('videoUploaderCache', {}); },
setVideoUploaderCache(cache) { GM_setValue('videoUploaderCache', cache); },
};
// リセット時間を計算するヘルパー
function getScrollResetMs() {
const val = StorageManager.getScrollResetValue();
const unit = StorageManager.getScrollResetUnit();
let multiplier = 1000; // seconds
if (unit === 'minutes') multiplier = 60 * 1000;
if (unit === 'hours') multiplier = 60 * 60 * 1000;
console.log(`[TokyoMotion Enhancer] Reset time: ${val} ${unit} = ${val * multiplier}ms`); // Debug log
return val * multiplier;
}
// ========================================
// Privateスキャナー
// ========================================
const PrivateScanner = {
scan() {
// TokyoMotion applies a WebKit filter to PRIVATE thumbnails.
// Apply the override directly as well as through GM_addStyle so that it
// also works in iOS Safari and for thumbnails inserted after page load.
document.querySelectorAll('.img-private').forEach(thumbnail => {
thumbnail.style.setProperty('-webkit-filter', 'unset', 'important');
thumbnail.style.setProperty('filter', 'none', 'important');
});
const privateIds = [];
const cards = document.querySelectorAll('.col-sm-4, .video-card, .thumb-block');
cards.forEach(card => {
const isPrivate = card.querySelector('.label-private') ||
card.querySelector('.img-private') ||
(card.textContent && card.textContent.includes('PRIVATE'));
if (isPrivate) {
const link = card.querySelector('a[href*="/video/"]');
if (link) {
const match = link.getAttribute('href').match(/\/video\/(\d+)/);
if (match && match[1]) privateIds.push(match[1]);
}
}
});
if (privateIds.length > 0) StorageManager.addPrivateToCache(privateIds);
},
startObserver() {
this.scan();
new MutationObserver(() => this.scan()).observe(document.body, { childList: true, subtree: true });
}
};
// ========================================
// 購読(フォロー)マネージャー
// ========================================
const SubscriptionManager = {
async getMyProfileUrl() {
const profileLink = document.querySelector('a[href^="/user/"]:not([href*="logout"]):not([href*="login"])');
if (profileLink) return profileLink.href;
const userLink = document.querySelector('.username a');
if (userLink) return userLink.href;
const avatarLink = document.querySelector('.avatar-container a, .header-avatar a');
if (avatarLink) return avatarLink.href;
return null;
},
async getSubscriptionsBaseUrl() {
const profileUrl = await this.getMyProfileUrl();
if (!profileUrl) return null;
return profileUrl.split('/').slice(0, 5).join('/') + '/subscriptions';
},
async getFriendsBaseUrl() {
const profileUrl = await this.getMyProfileUrl();
if (!profileUrl) return null;
return profileUrl.split('/').slice(0, 5).join('/') + '/friends';
},
async fetchDocument(url) {
try {
const response = await fetch(url);
return new DOMParser().parseFromString(await response.text(), 'text/html');
} catch (e) { return null; }
},
async getFollowedUsers(statusCallback) {
const baseUrl = await this.getSubscriptionsBaseUrl();
if (!baseUrl) throw new Error('ログインしていないか、プロフィールが見つかりません');
return this._getUsersFromPages(baseUrl, statusCallback);
},
async getFriends(statusCallback) {
const baseUrl = await this.getFriendsBaseUrl();
if (!baseUrl) throw new Error('ログインしていないか、プロフィールが見つかりません');
return this._getUsersFromPages(baseUrl, statusCallback);
},
async _getUsersFromPages(baseUrl, statusCallback) {
const usersMap = new Map();
let page = 1;
let hasNextPage = true;
const myUsernameMatch = baseUrl.match(/\/user\/([^\/]+)/);
const myUsername = myUsernameMatch ? myUsernameMatch[1] : null;
while (hasNextPage) {
const url = page === 1 ? baseUrl : `${baseUrl}?page=${page}`;
if (statusCallback) statusCallback(`${t('msg_fetching')} (${page} p)`);
const doc = await this.fetchDocument(url);
if (!doc) break;
const userCards = Array.from(doc.querySelectorAll('.thumb-block, .user-card, .col-sm-6, .col-sm-4, .col-xs-6'));
userCards.forEach(card => {
const userLink = card.querySelector('a[href*="/user/"]');
if (!userLink) return;
const href = userLink.getAttribute('href');
if (href.includes('/video/')) return;
const userMatch = href.match(/\/user\/([^\/\?#]+)/);
if (!userMatch || !userMatch[1]) return;
const username = userMatch[1];
if (myUsername && username.toLowerCase() === myUsername.toLowerCase()) return;
const invalidUsernames = ['edit', 'avatar', 'logout', 'login', 'register', 'settings', 'upload', 'search', 'help', 'contact', 'about', 'terms', 'privacy', 'dmca'];
if (invalidUsernames.includes(username.toLowerCase())) return;
const normalizedUrl = `https://www.tokyomotion.net/user/${username}`;
if (usersMap.has(normalizedUrl)) return;
let iconSrc = '';
const img = card.querySelector('img');
if (img) iconSrc = img.src || img.dataset.src || '';
if (!iconSrc) iconSrc = 'https://www.tokyomotion.net/img/user-avatar.png';
usersMap.set(normalizedUrl, { url: normalizedUrl, icon: iconSrc });
});
const userLinks = Array.from(doc.querySelectorAll('a[href*="/user/"]'));
userLinks.forEach(a => {
const href = a.getAttribute('href');
if (!href || href.includes('/video/') || href.includes('/subscriptions') || href.includes('/friends') || href.includes('/favorites')) return;
const userMatch = href.match(/\/user\/([^\/\?#]+)/);
if (!userMatch || !userMatch[1]) return;
const username = userMatch[1];
if (myUsername && username.toLowerCase() === myUsername.toLowerCase()) return;
const invalidUsernames = ['edit', 'avatar', 'logout', 'login', 'register', 'settings', 'upload', 'search', 'help', 'contact', 'about', 'terms', 'privacy', 'dmca'];
if (invalidUsernames.includes(username.toLowerCase())) return;
const normalizedUrl = `https://www.tokyomotion.net/user/${username}`;
if (usersMap.has(normalizedUrl)) return;
let iconSrc = '';
const img = a.querySelector('img') || (a.parentElement ? a.parentElement.querySelector('img') : null);
if (img) iconSrc = img.src || img.dataset.src || '';
if (!iconSrc) iconSrc = 'https://www.tokyomotion.net/img/user-avatar.png';
usersMap.set(normalizedUrl, { url: normalizedUrl, icon: iconSrc });
});
const paginationLinks = Array.from(doc.querySelectorAll('.pagination a'));
const hasNext = paginationLinks.some(a => a.href.includes(`page=${page + 1}`));
if (hasNext) page++; else hasNextPage = false;
await new Promise(r => setTimeout(r, 500));
}
return Array.from(usersMap.values());
},
async getUserVideos(userData, fetchOptions = {}) {
if (typeof fetchOptions === 'number') fetchOptions = { mode: 'pages', maxPages: fetchOptions };
const mode = fetchOptions.mode || 'pages';
const maxPages = mode === 'pages' ? Math.max(1, parseInt(fetchOptions.maxPages || 1, 10)) : 99999;
const cutoffTimestamp = Number(fetchOptions.cutoffTimestamp || 0);
const baseTime = fetchOptions.baseTime || Date.now();
const userUrl = userData.url;
const userIcon = userData.icon;
const videosBaseUrl = userUrl.replace(/\/$/, '') + '/videos';
const allVideos = [];
let page = 1;
let hasNextPage = true;
while (hasNextPage && page <= maxPages) {
const targetUrl = page === 1 ? videosBaseUrl : `${videosBaseUrl}?page=${page}`;
try {
const doc = await this.fetchDocument(targetUrl);
if (!doc) break;
const videoLinks = Array.from(doc.querySelectorAll('a[href*="/video/"]'));
let foundInPage = 0;
let reachedCutoffInPage = false;
for (const link of videoLinks) {
try {
const img = link.querySelector('img') || (link.parentElement ? link.parentElement.querySelector('img') : null);
if (!img) continue;
const container = link.closest('.col-sm-4') || link.closest('.col-xs-6') || link.closest('.video-card') || link.closest('.thumb-block') || link.parentElement;
let title = '', duration = '', dateStr = '';
let isPrivate = false;
if (container) {
const titleEl = container.querySelector('.video-card-title, .title, .video-title, h4, h5');
if (titleEl) title = titleEl.innerText.trim();
const durationEl = container.querySelector('.duration');
if (durationEl) duration = durationEl.innerText.trim();
const dateEl = container.querySelector('.video-added');
if (dateEl) dateStr = dateEl.innerText.trim();
if (container.querySelector('.label-private') || container.querySelector('.img-private')) isPrivate = true;
const overlay = container.querySelector('.thumb-overlay');
if (!isPrivate && overlay && overlay.textContent.toUpperCase().includes('PRIVATE')) isPrivate = true;
}
if (!title && img.alt) title = img.alt.trim();
if (!duration) {
const insideDuration = link.querySelector('.duration');
if (insideDuration) duration = insideDuration.innerText.trim();
}
if (!title) title = 'Untitled';
const href = link.getAttribute('href');
const fullUrl = href.startsWith('http') ? href : (new URL(href, userUrl).href);
const idMatch = fullUrl.match(/\/video\/(\d+)/);
if (!idMatch) continue;
if (allVideos.some(v => v.id === idMatch[1])) continue;
foundInPage++;
const postedAt = parseTokyoMotionPostedAt(dateStr, baseTime);
if ((mode === 'days' || mode === 'sinceLast') && cutoffTimestamp > 0 && postedAt !== null && postedAt < cutoffTimestamp) {
reachedCutoffInPage = true;
break;
}
if (isPrivate) StorageManager.addPrivateToCache([idMatch[1]]);
allVideos.push({
id: idMatch[1],
title: title,
thumbnail: img.src || img.dataset.src,
url: fullUrl,
author: userUrl.split('/').pop(),
authorIcon: userIcon,
duration: duration,
date: dateStr,
uploadedAt: postedAt,
isPrivate: isPrivate,
timestamp: Date.now()
});
} catch (e) { }
}
const paginationLinks = Array.from(doc.querySelectorAll('.pagination a'));
const hasNext = paginationLinks.some(a => a.href.includes(`page=${page + 1}`));
if (reachedCutoffInPage) hasNextPage = false;
else if (hasNext && foundInPage > 0) page++;
else hasNextPage = false;
await new Promise(r => setTimeout(r, 500));
} catch (err) { break; }
}
return allVideos;
}
};
function formatRelativeTime(rawTime) {
if (!rawTime) return '';
let cleaned = rawTime.replace(/\s+/g, '').trim();
if (cleaned.match(/^\d+時前$/)) cleaned = cleaned.replace('時前', '時間前');
cleaned = cleaned.replace(/時\s*前/g, '時間前').replace(/分\s*前/g, '分前').replace(/日\s*前/g, '日前').replace(/週\s*前/g, '週間前').replace(/月\s*前/g, 'ヶ月前').replace(/年\s*前/g, '年前');
return cleaned;
}
function calcTimeAgo(timestamp) {
if (!timestamp) return '-';
const diff = Date.now() - timestamp;
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return t('time_just_now');
if (diff < hour) return Math.floor(diff / minute) + t('time_min_ago');
if (diff < day) return Math.floor(diff / hour) + t('time_hour_ago');
if (diff < day * 30) return Math.floor(diff / day) + t('time_day_ago');
return t('time_long_ago');
}
function parseTokyoMotionAgeMs(rawTime) {
if (!rawTime) return null;
const text = String(rawTime)
.normalize('NFKC')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
if (!text) return null;
if (/たった今|今|just now|moments? ago/.test(text)) return 0;
if (!/前|ago/.test(text)) return null;
const numberMatch = text.match(/(\d+(?:\.\d+)?)/);
if (!numberMatch) return null;
const value = parseFloat(numberMatch[1]);
if (!Number.isFinite(value)) return null;
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (/秒|sec(?:ond)?s?\b/.test(text)) return value * 1000;
if (/分|mins?\b|minutes?\b/.test(text)) return value * minute;
if (/時|時間|hours?\b|\bhrs?\b/.test(text)) return value * hour;
if (/日|days?\b/.test(text)) return value * day;
if (/週|週間|weeks?\b/.test(text)) return value * 7 * day;
if (/ヶ月|か月|カ月|月|months?\b/.test(text)) return value * 30 * day;
if (/年|years?\b/.test(text)) return value * 365 * day;
return null;
}
function parseTokyoMotionPostedAt(rawTime, baseTime = Date.now()) {
const ageMs = parseTokyoMotionAgeMs(rawTime);
if (ageMs !== null) return baseTime - ageMs;
if (!rawTime) return null;
const normalized = String(rawTime).normalize('NFKC').trim();
const absoluteMatch = normalized.match(/(\d{4})[\/\-.年](\d{1,2})[\/\-.月](\d{1,2})/);
if (absoluteMatch) {
const year = parseInt(absoluteMatch[1], 10);
const month = parseInt(absoluteMatch[2], 10) - 1;
const day = parseInt(absoluteMatch[3], 10);
const date = new Date(year, month, day);
if (!Number.isNaN(date.getTime())) return date.getTime();
}
return null;
}
// ========================================
// スタイル注入
// ========================================
GM_addStyle(`
/* PRIVATEサムネイルの暗さを解除(iOS Safariでは接頭辞付き指定が必要) */
.img-private, .img-private img {
-webkit-filter: unset !important;
filter: none !important;
}
.tm-panel, .tm-panel *, .tm-modal-overlay, .tm-modal-overlay * { box-sizing: border-box; }
.tm-panel {
position: fixed; width: 500px; height: 600px; min-width: 300px; min-height: 250px;
background: #1a1a1a; border-radius: 12px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 999999; display: none; flex-direction: column; font-family: 'Segoe UI', sans-serif;
bottom: 90px; right: 20px;
}
.tm-panel.active { display: flex; }
.tm-panel-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 20px;
font-weight: 600; display: flex; justify-content: space-between; align-items: center;
cursor: move; user-select: none; touch-action: none; border-radius: 12px 12px 0 0;
}
.tm-panel-close {
-webkit-appearance: none; appearance: none; display: inline-flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.2); border: none; color: white; width: 28px; height: 28px;
flex: 0 0 28px; padding: 0; border-radius: 50%; line-height: 1; text-align: center; cursor: pointer;
}
.tm-toggle-btn {
position: fixed; width: 56px; height: 56px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-appearance: none; appearance: none; display: inline-flex; align-items: center; justify-content: center;
border: none; padding: 0; border-radius: 50%; color: white; line-height: 1; text-align: center; cursor: pointer;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); z-index: 999998; user-select: none; touch-action: none;
bottom: 20px; right: 20px;
}
.tm-toggle-icon, .tm-close-icon { display: block; flex: none; pointer-events: none; }
.tm-toggle-icon { width: 27px; height: 27px; }
.tm-close-icon { width: 16px; height: 16px; }
.tm-toggle-btn:hover { transform: scale(1.1); }
.tm-toggle-btn:active { transform: scale(0.95); }
.tm-tabs { display: flex; background: #252525; border-bottom: 2px solid #333; overflow-x: auto; }
.tm-tab {
flex: 1; padding: 12px 8px; background: transparent; border: none; color: #aaa; cursor: pointer; font-size: 12px;
border-bottom: 3px solid transparent; min-width: 60px; text-align: center; white-space: nowrap; user-select: none;
}
.tm-tab.active { background: #1a1a1a; border-bottom-color: #667eea; color: #fff; font-weight: 600; }
.tm-tab:hover { background: #333; }
.tm-tab.dragging { opacity: 0.5; background: #444; }
.tm-content { flex: 1; overflow-y: auto; padding: 10px; background: #111; min-height: 0; }
.tm-tab-content { display: none; height: 100%; }
.tm-tab-content.active { display: block; }
#tm-playlists.tm-tab-content { height: auto; min-height: 100%; }
.tm-playlist-detail-toolbar {
position: sticky; top: -10px; z-index: 40;
margin: -5px -5px 10px; padding: 5px 5px 8px;
background: linear-gradient(to bottom, #111 78%, rgba(17,17,17,0.92) 100%);
}
.tm-playlist-detail-toolbar #tm-back-to-playlists {
width: 100%; margin: 0; box-shadow: 0 3px 10px rgba(0,0,0,0.35);
}
.tm-feed-header { display: flex; justify-content: center; align-items: center; gap: 15px; padding: 10px; margin-bottom: 5px; }
.tm-time-label { font-size: 11px; color: #888; width: 90px; text-align: center; white-space: nowrap; }
.tm-grid-view { display: grid; grid-template-columns: repeat(var(--tm-video-cols, 2), 1fr); gap: 15px; padding: 5px; }
.tm-card { display: flex; flex-direction: column; background: transparent; cursor: pointer; border: none; position: relative; transition: opacity 0.2s; }
.tm-card:hover { opacity: 0.9; }
.tm-card-thumb-box { position: relative; width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 4px; overflow: hidden; margin-bottom: 6px; }
.tm-card-thumb { width: 100%; height: 100%; object-fit: cover; }
.tm-card-duration { position: absolute; bottom: 5px; right: 5px; background: rgba(0,0,0,0.7); color: #fff; padding: 2px 5px; font-size: 11px; border-radius: 3px; line-height: 1; }
.tm-card-private { position: absolute; bottom: 5px; left: 5px; background: #cc0000; color: #fff; padding: 2px 5px; font-size: 10px; border-radius: 3px; line-height: 1; font-weight: bold; }
.tm-card-title {
color: #ff5e5e; font-size: 13px; font-weight: 500; line-height: 1.3; max-height: 2.6em; overflow: hidden; margin-bottom: 5px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.tm-card:hover .tm-card-title { text-decoration: underline; }
.tm-card-meta { font-size: 11px; color: #888; line-height: 1.3; }
.tm-user-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; flex-wrap: wrap; }
.tm-user-icon { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; background: #333; flex-shrink: 0; }
.tm-user-link { color: #aaa; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100px; }
.tm-user-link:hover { color: #fff; text-decoration: underline; }
.tm-relative-time { color: #888; font-size: 10px; margin-left: 6px; white-space: nowrap; }
.tm-input { width: 100%; padding: 10px; background: #2d2d2d; border: 2px solid #444; border-radius: 6px; color: #e0e0e0; font-size: 13px; margin-bottom: 10px; }
.tm-btn-row { display: flex; gap: 8px; }
.tm-btn-primary, .tm-btn-secondary { flex: 1; padding: 10px; border: none; border-radius: 6px; cursor: pointer; color: white; }
.tm-btn-primary { background: #667eea; }
.tm-btn-secondary { background: #555; }
.tm-btn-danger { width: 100%; padding: 10px; background: #e74c3c; color: white; border: none; border-radius: 6px; cursor: pointer; margin-top: 10px; }
.tm-empty { text-align: center; color: #666; padding: 30px; }
.tm-toast {
position: fixed; bottom: 100px; left: 50%; transform: translateX(-50%) translateY(20px);
background: #333; color: #fff; padding: 12px 24px; border-radius: 8px;
opacity: 0; transition: all 0.3s; z-index: 9999999;
}
.tm-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.tm-card-remove {
position: absolute; top: 5px; right: 5px; background: rgba(231, 76, 60, 0.8); color: white;
border: none; width: 20px; height: 20px; border-radius: 50%; cursor: pointer; display: none; z-index: 10; font-size: 12px;
align-items: center; justify-content: center;
}
.tm-card:hover .tm-card-remove { display: flex; }
.tm-playlist-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.tm-playlist-card { padding: 15px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 8px; cursor: pointer; position: relative; transition: all 0.3s; }
.tm-playlist-name { font-weight: bold; margin-bottom: 5px; }
.tm-playlist-count { font-size: 12px; opacity: 0.9; }
.tm-playlist-delete { position: absolute; top: 5px; right: 5px; background: rgba(0,0,0,0.3); border: none; color: white; width: 22px; height: 22px; border-radius: 50%; cursor: pointer; }
.tm-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 9999990; display: flex; align-items: center; justify-content: center; }
.tm-modal-content { background: #1a1a1a; border-radius: 12px; width: auto; min-width: 320px; max-width: 90vw; transition: width 0.3s; }
.tm-modal-header { background: #667eea; color: white; padding: 15px; display: flex; justify-content: space-between; align-items: center; gap: 10px; border-radius: 12px 12px 0 0; }
.tm-playlist-list { padding: 10px; display: grid; gap: 8px; max-height: 350px; overflow-y: auto; }
.tm-playlist-item { display: flex; gap: 5px; padding: 8px; background: #2d2d2d; border-radius: 4px; color: #fff; cursor: pointer; }
.tm-modal-footer { padding: 15px; }
.tm-new-playlist-form { display: flex; gap: 5px; }
.tm-playlist-btn-inline { margin-left: 5px !important; cursor: pointer !important; }
.tm-toggle-switch { width: 44px; height: 24px; background: #555; border-radius: 12px; cursor: pointer; position: relative; }
.tm-toggle-switch.active { background: #667eea; }
.tm-toggle-switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: white; border-radius: 50%; transition: 0.3s; }
.tm-toggle-switch.active::after { transform: translateX(20px); }
.tm-col-select { background: rgba(0,0,0,0.2); color: white; border: 1px solid rgba(255,255,255,0.3); border-radius: 4px; padding: 2px 5px; font-size: 12px; cursor: pointer; outline: none; }
.tm-col-select option { background: #333; color: white; }
.tm-tab-visibility-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #333; }
.tm-tab-visibility-item:last-child { border-bottom: none; }
.tm-fetch-range-panel {
background: #1c1c1c; border: 1px solid #333; border-radius: 8px; padding: 10px; margin-bottom: 10px;
}
.tm-fetch-range-title { color: #e0e0e0; font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.tm-fetch-mode-row {
display: grid; grid-template-columns: auto 1fr minmax(76px, 120px); align-items: center; gap: 8px;
color: #ddd; font-size: 12px; margin: 7px 0;
}
.tm-fetch-mode-row input[type="checkbox"], .tm-fetch-mode-row input[type="radio"] { width: 16px; height: 16px; accent-color: #667eea; }
.tm-fetch-mode-row .tm-input { margin-bottom: 0; padding: 6px 8px; font-size: 12px; }
.tm-fetch-mode-status { min-height: 16px; margin-top: 6px; color: #f1c40f; font-size: 11px; line-height: 1.35; }
.tm-fetch-mode-status.ok { color: #2ecc71; }
.tm-fetch-mode-status.error { color: #ff6b6b; }
.tm-resizer { position: absolute; z-index: 100; touch-action: none; }
.tm-resizer.n { top: -5px; left: 0; right: 0; height: 10px; cursor: ns-resize; }
.tm-resizer.s { bottom: -5px; left: 0; right: 0; height: 10px; cursor: ns-resize; }
.tm-resizer.e { right: -5px; top: 0; bottom: 0; width: 10px; cursor: ew-resize; }
.tm-resizer.w { left: -5px; top: 0; bottom: 0; width: 10px; cursor: ew-resize; }
.tm-resizer.ne { top: -5px; right: -5px; width: 15px; height: 15px; cursor: nesw-resize; }
.tm-resizer.nw { top: -5px; left: -5px; width: 15px; height: 15px; cursor: nwse-resize; }
.tm-resizer.se { bottom: -5px; right: -5px; width: 15px; height: 15px; cursor: nwse-resize; }
.tm-resizer.sw { bottom: -5px; left: -5px; width: 15px; height: 15px; cursor: nesw-resize; }
body.tm-dragging { user-select: none; }
.tm-btn-icon { background: transparent; border: none; color: #aaa; cursor: pointer; font-size: 14px; margin-left: 8px; padding: 2px; transition: color 0.2s; }
.tm-btn-icon:hover { color: #fff; }
.tm-added-time { color: #888; font-size: 10px; margin-top: 3px; }
.tm-filter-textarea {
min-height: 82px; resize: vertical; line-height: 1.45; font-family: inherit;
}
.tm-filter-status {
position: fixed; left: 14px; bottom: 14px; z-index: 999997; display: none;
align-items: center; gap: 8px; padding: 7px 10px; border-radius: 999px;
background: rgba(20, 20, 20, 0.92); color: #fff; box-shadow: 0 3px 12px rgba(0,0,0,0.35);
font: 12px/1.2 'Segoe UI', sans-serif;
}
.tm-filter-status.active { display: flex; }
.tm-filter-status button {
border: 0; border-radius: 999px; padding: 4px 8px; background: #667eea; color: #fff; cursor: pointer;
}
.tm-filter-quick-block {
display: inline-flex; align-items: center; justify-content: center; margin-left: 4px; padding: 1px 4px;
border: 1px solid rgba(180, 40, 40, 0.55); border-radius: 4px; background: rgba(40, 40, 40, 0.85);
color: #d66; cursor: pointer; font-size: 11px; line-height: 1.2; vertical-align: middle;
}
.tm-filter-quick-block:hover { background: #8b1f1f; color: #fff; }
.tm-filter-resolved-row {
display: flex; align-items: center; gap: 4px; margin-top: 5px; color: #888; font-size: 11px;
overflow: hidden; white-space: nowrap;
}
.tm-filter-resolved-row span { overflow: hidden; text-overflow: ellipsis; }
.tm-filter-hidden { display: none !important; }
.tm-card-thumb-box[draggable="true"] { cursor: grab; }
.tm-card-thumb-box[draggable="true"]:active { cursor: grabbing; }
.tm-card-thumb-box.tm-thumbnail-dragging { opacity: 0.6; outline: 3px solid #8fa2ff; }
.tm-thumbnail-drop-zone {
position: fixed; top: 18px; left: 50%; transform: translate(-50%, -18px);
z-index: 10000000; display: flex; align-items: center; justify-content: center;
min-width: 300px; max-width: calc(100vw - 30px); padding: 16px 24px;
border: 2px dashed rgba(255,255,255,0.8); border-radius: 12px;
background: linear-gradient(135deg, rgba(102,126,234,0.96), rgba(118,75,162,0.96));
color: #fff; font: 600 14px/1.3 'Segoe UI', sans-serif; text-align: center;
box-shadow: 0 8px 28px rgba(0,0,0,0.45); opacity: 0; pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
body.tm-thumbnail-drag-active .tm-thumbnail-drop-zone {
opacity: 1; pointer-events: auto; transform: translate(-50%, 0);
}
body.tm-thumbnail-drag-active .tm-thumbnail-drop-zone.tm-drag-over {
background: linear-gradient(135deg, #27ae60, #1e8449); transform: translate(-50%, 0) scale(1.04);
}
/* スマホ向け表示。保存済みサイズは維持しつつ、必ず表示領域内に収める。 */
.tm-panel.tm-mobile-mode {
width: calc(100vw - 16px); height: calc(100vh - 16px); height: calc(100dvh - 16px);
min-width: 0; min-height: 0; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); max-height: calc(100dvh - 16px);
left: 8px; top: 8px; right: auto; bottom: auto; border-radius: 10px;
overscroll-behavior: contain;
}
.tm-panel.tm-mobile-mode .tm-panel-header { min-height: 48px; padding: 7px 9px 7px 12px; border-radius: 10px 10px 0 0; }
.tm-panel.tm-mobile-mode .tm-panel-close { width: 38px; height: 38px; flex: 0 0 38px; }
.tm-panel.tm-mobile-mode .tm-close-icon { width: 20px; height: 20px; }
.tm-panel.tm-mobile-mode .tm-tabs {
flex: 0 0 auto; overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; scrollbar-width: none;
}
.tm-panel.tm-mobile-mode .tm-tabs::-webkit-scrollbar { display: none; }
.tm-panel.tm-mobile-mode .tm-tab {
flex: 1 1 0; min-width: 50px; min-height: 44px; padding: 10px 3px; font-size: 11px; touch-action: manipulation;
}
.tm-panel.tm-mobile-mode .tm-content { padding: 8px; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
.tm-panel.tm-mobile-mode .tm-grid-view { gap: 10px; padding: 3px; }
.tm-panel.tm-mobile-mode .tm-card-remove { display: flex; width: 28px; height: 28px; font-size: 16px; }
.tm-panel.tm-mobile-mode .tm-btn-primary,
.tm-panel.tm-mobile-mode .tm-btn-secondary,
.tm-panel.tm-mobile-mode .tm-btn-danger { min-height: 42px; touch-action: manipulation; }
.tm-panel.tm-mobile-mode .tm-toggle-switch { flex: 0 0 44px; }
.tm-panel.tm-mobile-mode .tm-fetch-mode-row { grid-template-columns: auto minmax(0, 1fr) minmax(72px, 92px); }
.tm-panel.tm-mobile-mode .tm-resizer.n,
.tm-panel.tm-mobile-mode .tm-resizer.s { height: 22px; }
.tm-panel.tm-mobile-mode .tm-resizer.n { top: -8px; }
.tm-panel.tm-mobile-mode .tm-resizer.s { bottom: -8px; }
.tm-panel.tm-mobile-mode .tm-resizer.e,
.tm-panel.tm-mobile-mode .tm-resizer.w { width: 22px; }
.tm-panel.tm-mobile-mode .tm-resizer.e { right: -8px; }
.tm-panel.tm-mobile-mode .tm-resizer.w { left: -8px; }
.tm-panel.tm-mobile-mode .tm-resizer.ne,
.tm-panel.tm-mobile-mode .tm-resizer.nw,
.tm-panel.tm-mobile-mode .tm-resizer.se,
.tm-panel.tm-mobile-mode .tm-resizer.sw { width: 34px; height: 34px; }
.tm-panel.tm-mobile-mode .tm-resizer.ne { top: -8px; right: -8px; }
.tm-panel.tm-mobile-mode .tm-resizer.nw { top: -8px; left: -8px; }
.tm-panel.tm-mobile-mode .tm-resizer.se { bottom: -8px; right: -8px; }
.tm-panel.tm-mobile-mode .tm-resizer.sw { bottom: -8px; left: -8px; }
.tm-panel.tm-mobile-mode .tm-resizer.se::after {
content: ''; position: absolute; right: 9px; bottom: 9px; width: 12px; height: 12px;
border-right: 3px solid rgba(255,255,255,0.7); border-bottom: 3px solid rgba(255,255,255,0.7); border-radius: 1px;
}
.tm-toggle-btn.tm-mobile-mode {
width: 52px; height: 52px; right: calc(12px + env(safe-area-inset-right));
bottom: calc(12px + env(safe-area-inset-bottom));
}
.tm-toggle-btn.tm-mobile-mode .tm-toggle-icon { width: 25px; height: 25px; }
html.tm-enhancer-mobile .tm-modal-overlay { padding: 8px; }
html.tm-enhancer-mobile .tm-modal-content {
width: calc(100vw - 16px) !important; min-width: 0; max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px); max-height: calc(100dvh - 16px); overflow: hidden;
}
html.tm-enhancer-mobile .tm-playlist-list {
grid-template-columns: 1fr !important; max-height: calc(100vh - 170px); max-height: calc(100dvh - 170px);
-webkit-overflow-scrolling: touch;
}
html.tm-enhancer-mobile .tm-modal-header { padding: 9px 10px; }
html.tm-enhancer-mobile .tm-new-playlist-form { flex-wrap: wrap; }
html.tm-enhancer-mobile .tm-new-playlist-form .tm-input { min-width: 0; flex: 1 1 180px; }
`);
// ========================================
// ユーティリティ
// ========================================
function formatDate(timestamp) {
if (!timestamp) return '';
const d = new Date(timestamp);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatSeconds(seconds) {
if (!seconds || isNaN(seconds)) return null;
seconds = Math.floor(seconds);
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const pad = n => n.toString().padStart(2, '0');
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
return `${m}:${pad(s)}`;
}
function showToast(message) {
const existing = document.querySelector('.tm-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'tm-toast';
toast.textContent = message;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 3000); // 通知時間
}, 3000);
}
// ========================================
// 投稿者ブロック・タイトルミュート
// ========================================
const ContentFilter = {
observer: null,
scanTimer: null,
revealTemporarily: false,
uploaderCache: null,
uploaderQueue: [],
pendingVideoIds: new Set(),
activeUploaderFetches: 0,
maxConcurrentFetches: 5,
normalize(value) {
return String(value || '').normalize('NFKC').trim().toLocaleLowerCase();
},
parseLines(value) {
const seen = new Set();
return String(value || '').split(/\r?\n/).map(v => v.trim()).filter(v => {
const normalized = this.normalize(v);
if (!normalized || seen.has(normalized)) return false;
seen.add(normalized);
return true;
});
},
getVideoCards() {
const cards = new Set();
const cardSelector = [
'.thumb-block', '.video-card', '.video-item', '.video-box',
'.col-sm-4', '.col-sm-3', '.col-md-4', '.col-md-3', '.col-lg-3', '.col-xs-6',
'article'
].join(',');
document.querySelectorAll('a[href*="/video/"]').forEach(link => {
if (link.closest('.tm-panel, .tm-modal-overlay, .tm-filter-status')) return;
let card = link.closest(cardSelector);
if (!card) card = link.parentElement;
if (card && !card.classList.contains('tm-card')) cards.add(card);
});
return [...cards];
},
getVideoReference(card) {
const link = card.querySelector('a[href*="/video/"]');
if (!link) return null;
const href = link.getAttribute('href') || '';
const match = href.match(/\/video\/(\d+)/i);
if (!match) return null;
return { id: match[1], url: new URL(href, location.origin).href };
},
getTitle(card) {
const titleEl = card.querySelector('.video-card-title, .video-title, .thumb-title, .title, h3, h4, h5');
const videoLink = card.querySelector('a[href*="/video/"]');
const image = videoLink ? videoLink.querySelector('img') : card.querySelector('img');
const candidates = [
titleEl && (titleEl.getAttribute('title') || titleEl.textContent),
videoLink && videoLink.getAttribute('title'),
image && image.getAttribute('alt'),
videoLink && videoLink.textContent
];
return String(candidates.find(v => v && v.trim()) || '').trim();
},
getUploader(card) {
if (card.dataset.tmResolvedUploader) return card.dataset.tmResolvedUploader;
const userLink = card.querySelector('a[href*="/user/"]');
if (userLink) {
const match = (userLink.getAttribute('href') || '').match(/\/user\/([^/?#]+)/i);
if (match && match[1]) {
try { return decodeURIComponent(match[1]).trim(); } catch (e) { return match[1].trim(); }
}
if (userLink.textContent.trim()) return userLink.textContent.trim();
}
const dataOwner = card.getAttribute('data-username') || card.getAttribute('data-user') || card.dataset.username || card.dataset.user;
if (dataOwner) return String(dataOwner).trim();
const ownerEl = card.querySelector('.username, .user-name, .video-user, .video-author, .uploader, .author, .byline');
if (ownerEl) return ownerEl.textContent.replace(/^\s*(?:by|投稿者)\s*[::]?\s*/i, '').trim();
const video = this.getVideoReference(card);
if (!video) return '';
if (!this.uploaderCache) this.uploaderCache = StorageManager.getVideoUploaderCache();
const cached = this.uploaderCache[video.id];
const cachedName = typeof cached === 'string' ? cached : cached && cached.name;
if (cachedName) {
card.dataset.tmResolvedUploader = cachedName;
return cachedName;
}
return '';
},
queueUploaderResolution(card) {
const video = this.getVideoReference(card);
if (!video || this.pendingVideoIds.has(video.id)) return;
if (!this.uploaderCache) this.uploaderCache = StorageManager.getVideoUploaderCache();
if (Object.prototype.hasOwnProperty.call(this.uploaderCache, video.id)) {
const cached = this.uploaderCache[video.id];
const cachedName = typeof cached === 'string' ? cached : cached && cached.name;
const cachedAt = typeof cached === 'object' && cached ? cached.cachedAt || 0 : 0;
if (cachedName || Date.now() - cachedAt < 6 * 60 * 60 * 1000) return;
delete this.uploaderCache[video.id];
}
this.pendingVideoIds.add(video.id);
this.uploaderQueue.push(video);
this.pumpUploaderQueue();
},
pumpUploaderQueue() {
if (!StorageManager.isContentFilterEnabled()) {
this.uploaderQueue.forEach(video => this.pendingVideoIds.delete(video.id));
this.uploaderQueue = [];
return;
}
while (this.activeUploaderFetches < this.maxConcurrentFetches && this.uploaderQueue.length > 0) {
const video = this.uploaderQueue.shift();
this.activeUploaderFetches++;
this.fetchUploader(video).finally(() => {
this.activeUploaderFetches--;
this.pendingVideoIds.delete(video.id);
this.pumpUploaderQueue();
this.scheduleScan();
});
}
},
async fetchUploader(video) {
let name = '';
try {
const response = await fetch(video.url, { credentials: 'same-origin' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
const link = doc.querySelector([
'.user-container a[href*="/user/"]',
'.video-info a[href*="/user/"]',
'.user-info a[href*="/user/"]',
'a.username[href*="/user/"]'
].join(','));
if (link) {
const match = (link.getAttribute('href') || '').match(/\/user\/([^/?#]+)/i);
if (match && match[1]) {
try { name = decodeURIComponent(match[1]).trim(); } catch (e) { name = match[1].trim(); }
} else name = link.textContent.trim();
}
} catch (error) {
console.warn(`[TM Enhancer] Could not resolve uploader for video ${video.id}`, error);
}
this.uploaderCache[video.id] = { name, cachedAt: Date.now() };
const entries = Object.entries(this.uploaderCache);
if (entries.length > 1000) {
entries.sort((a, b) => (a[1].cachedAt || 0) - (b[1].cachedAt || 0));
entries.slice(0, entries.length - 1000).forEach(([id]) => delete this.uploaderCache[id]);
}
StorageManager.setVideoUploaderCache(this.uploaderCache);
if (name) {
document.querySelectorAll(`a[href*="/video/${video.id}"]`).forEach(link => {
const card = link.closest('.thumb-block, .video-card, .video-item, .video-box, .col-sm-4, .col-sm-3, .col-md-4, .col-md-3, .col-lg-3, .col-xs-6, article') || link.parentElement;
if (card) card.dataset.tmResolvedUploader = name;
});
}
},
getMatch(card, blockedUploaders, mutedTerms) {
const uploader = this.getUploader(card);
const normalizedUploader = this.normalize(uploader).replace(/^@/, '');
const uploaderMatched = normalizedUploader && blockedUploaders.has(normalizedUploader);
const title = this.getTitle(card);
const normalizedTitle = this.normalize(title);
const termMatched = normalizedTitle && mutedTerms.find(term => normalizedTitle.includes(term));
if (uploaderMatched) return { type: 'uploader', value: uploader };
if (termMatched) return { type: 'title', value: termMatched };
return null;
},
injectQuickBlock(card, uploader) {
if (!uploader || card.querySelector('.tm-filter-quick-block')) return;
let userLink = card.querySelector('a[href*="/user/"]') || card.querySelector('.username, .user-name, .video-user, .video-author, .uploader, .author, .tm-filter-resolved-row span');
if (!userLink || !userLink.parentNode) {
const row = document.createElement('div');
row.className = 'tm-filter-resolved-row';
userLink = document.createElement('span');
userLink.textContent = `@${uploader}`;
row.appendChild(userLink);
(card.querySelector('.well') || card).appendChild(row);
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'tm-filter-quick-block';
button.textContent = '🚫';
button.title = t('filter_block_uploader');
button.setAttribute('aria-label', t('filter_block_uploader'));
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.blockUploader(uploader);
});
userLink.insertAdjacentElement('afterend', button);
},
blockUploader(uploader) {
const current = StorageManager.getBlockedUploaders();
const normalized = this.normalize(uploader).replace(/^@/, '');
if (!normalized) return;
if (!current.some(name => this.normalize(name).replace(/^@/, '') === normalized)) current.push(uploader);
StorageManager.setBlockedUploaders(current);
StorageManager.setContentFilterEnabled(true);
this.revealTemporarily = false;
this.refreshSettingsFields();
this.scan();
showToast(t('filter_blocked_uploader', { name: uploader }));
},
refreshSettingsFields() {
const uploaderInput = document.getElementById('tm-filter-uploaders');
const wordInput = document.getElementById('tm-filter-words');
const toggle = document.getElementById('tm-content-filter-toggle');
if (uploaderInput) uploaderInput.value = StorageManager.getBlockedUploaders().join('\n');
if (wordInput) wordInput.value = StorageManager.getMutedTitleTerms().join('\n');
if (toggle) toggle.classList.toggle('active', StorageManager.isContentFilterEnabled());
},
updateStatus(matchedCount) {
let status = document.querySelector('.tm-filter-status');
if (!status) {
status = document.createElement('div');
status.className = 'tm-filter-status';
status.innerHTML = '<span></span><button type="button"></button>';
status.querySelector('button').addEventListener('click', () => {
this.revealTemporarily = !this.revealTemporarily;
this.scan();
});
document.body.appendChild(status);
}
status.classList.toggle('active', matchedCount > 0 && StorageManager.isContentFilterEnabled());
status.querySelector('span').textContent = t(this.revealTemporarily ? 'filter_revealed_count' : 'filter_hidden_count', { count: matchedCount });
status.querySelector('button').textContent = this.revealTemporarily ? t('filter_hide_again') : t('filter_show_temporarily');
},
scan() {
const enabled = StorageManager.isContentFilterEnabled();
const blockedUploaders = new Set(StorageManager.getBlockedUploaders().map(v => this.normalize(v).replace(/^@/, '')).filter(Boolean));
const mutedTerms = StorageManager.getMutedTitleTerms().map(v => this.normalize(v)).filter(Boolean);
let matchedCount = 0;
this.getVideoCards().forEach(card => {
if (!enabled) {
card.classList.remove('tm-filter-hidden');
delete card.dataset.tmFilterReason;
card.querySelectorAll('.tm-filter-quick-block').forEach(button => button.remove());
card.querySelectorAll('.tm-filter-resolved-row').forEach(row => row.remove());
return;
}
const uploader = this.getUploader(card);
this.injectQuickBlock(card, uploader);
const match = this.getMatch(card, blockedUploaders, mutedTerms);
if (!uploader && (!match || match.type !== 'title')) this.queueUploaderResolution(card);
if (match) {
matchedCount++;
card.dataset.tmFilterReason = match.type;
card.classList.toggle('tm-filter-hidden', !this.revealTemporarily);
} else {
card.classList.remove('tm-filter-hidden');
delete card.dataset.tmFilterReason;
}
});
this.updateStatus(enabled ? matchedCount : 0);
},
scheduleScan() {
clearTimeout(this.scanTimer);
this.scanTimer = setTimeout(() => this.scan(), 120);
},
start() {
this.uploaderCache = StorageManager.getVideoUploaderCache();
this.scan();
if (this.observer) this.observer.disconnect();
this.observer = new MutationObserver(mutations => {
if (mutations.some(mutation => [...mutation.addedNodes].some(node =>
node.nodeType === Node.ELEMENT_NODE && !node.closest?.('.tm-panel, .tm-filter-status')
))) this.scheduleScan();
});
this.observer.observe(document.body, { childList: true, subtree: true });
}
};
function extractVideoId() {
const match = window.location.pathname.match(/\/video\/(\d+)/);
return match ? match[1] : null;
}
function extractVideoData(forceDuration = null) {
const videoId = extractVideoId();
if (!videoId) return null;
let title = 'Untitled';
const titleEl = document.querySelector('h3.big-title-truncate, h4.big-title-truncate');
if (titleEl) title = titleEl.textContent.trim();
else if (document.title) title = document.title.replace(' - TokyoMotion', '').trim();
let duration = '';
const videoEl = document.querySelector('video');
if (forceDuration) {
duration = forceDuration;
} else if (videoEl && videoEl.duration && !isNaN(videoEl.duration) && videoEl.duration !== Infinity && videoEl.duration > 0) {
duration = formatSeconds(videoEl.duration) || '';
}
if (!duration) {
const durEl = document.querySelector('.vjs-duration-display') || document.querySelector('.duration');
if (durEl) {
const text = durEl.innerText.replace(/[^\d:]/g, '');
if (text && text !== '0:00' && text !== '00:00') duration = text;
}
}
let author = 'Unknown';
let authorIcon = '';
const userContainer = document.querySelector('.user-container');
if (userContainer) {
const link = userContainer.querySelector('a[href^="/user/"]');
if (link) {
const span = link.querySelector('span');
author = span ? span.textContent.trim() : link.textContent.trim();
const img = link.querySelector('img');
if (img) authorIcon = img.src;
}
} else {
const userLink = document.querySelector('.user-container a[href^="/user/"], .video-info a[href^="/user/"], .user-info a[href^="/user/"], a.username');
if (userLink) author = userLink.innerText.trim();
const avatarImg = document.querySelector('.avatar-container img, .video-info img.avatar, .user-avatar img');
if (avatarImg) authorIcon = avatarImg.src;
}
if (!authorIcon) authorIcon = 'https://www.tokyomotion.net/img/user-avatar.png';
let isPrivate = false;
if (StorageManager.isPrivateCached(videoId)) isPrivate = true;
if (!isPrivate) {
try {
if (document.querySelector('.label-private') || document.querySelector('.img-private')) isPrivate = true;
} catch (e) { }
}
return {
id: videoId,
title: title,
thumbnail: document.querySelector('video[poster]')?.getAttribute('poster') || '',
url: window.location.href,
duration: duration,
author: author,
authorIcon: authorIcon,
isPrivate: isPrivate,
timestamp: Date.now()
};
}
function getDurationFromPlayer() {
const videoEl = document.querySelector('video');
if (videoEl && videoEl.duration && !isNaN(videoEl.duration) && videoEl.duration !== Infinity && videoEl.duration > 0) {
const formatted = formatSeconds(videoEl.duration);
if (formatted) return formatted;
}
const durEl = document.querySelector('.vjs-duration-display');
if (durEl) {
const text = durEl.innerText.replace(/[^\d:]/g, '');
if (text && text !== '0:00' && text !== '00:00') return text;
}
return null;
}
// ========================================
// パネル・ボタン移動 & リサイズ
// ========================================
function shouldUseMobileUI() {
const configuredMode = StorageManager.getUIMode();
if (configuredMode === 'mobile') return true;
if (configuredMode === 'desktop') return false;
const narrowViewport = window.innerWidth <= 767;
const coarsePointer = typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
return narrowViewport || (coarsePointer && window.innerWidth <= 1024);
}
function isMobileUIMode() {
return document.documentElement.classList.contains('tm-enhancer-mobile');
}
function clearFloatingUIStyles(panel, btn) {
['width', 'height', 'left', 'top', 'right', 'bottom'].forEach(prop => panel.style.removeProperty(prop));
['left', 'top', 'right', 'bottom'].forEach(prop => btn.style.removeProperty(prop));
}
function applyUIMode(panel, btn) {
const mobile = shouldUseMobileUI();
clearFloatingUIStyles(panel, btn);
document.documentElement.classList.toggle('tm-enhancer-mobile', mobile);
panel.classList.toggle('tm-mobile-mode', mobile);
btn.classList.toggle('tm-mobile-mode', mobile);
restoreUIState(panel, btn, mobile);
applyVideoGridCols();
return mobile;
}
function setupDraggableButton(btn) {
let isDragging = false;
let startX, startY, initialLeft, initialTop;
const DRAG_THRESHOLD = 5;
let hasMoved = false;
let activePointerId = null;
let dragMobileMode = false;
btn.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
isDragging = true;
hasMoved = false;
activePointerId = e.pointerId;
dragMobileMode = isMobileUIMode();
const rect = btn.getBoundingClientRect();
btn.style.bottom = 'auto'; btn.style.right = 'auto';
btn.style.left = rect.left + 'px'; btn.style.top = rect.top + 'px';
startX = e.clientX; startY = e.clientY;
initialLeft = rect.left; initialTop = rect.top;
document.body.classList.add('tm-dragging');
try { btn.setPointerCapture(e.pointerId); } catch (_) { }
e.preventDefault();
});
window.addEventListener('pointermove', (e) => {
if (!isDragging || e.pointerId !== activePointerId) return;
const dx = e.clientX - startX; const dy = e.clientY - startY;
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) hasMoved = true;
const winW = window.innerWidth; const winH = window.innerHeight;
const btnW = btn.offsetWidth; const btnH = btn.offsetHeight;
let newLeft = initialLeft + dx; let newTop = initialTop + dy;
newLeft = Math.max(0, Math.min(newLeft, winW - btnW));
newTop = Math.max(0, Math.min(newTop, winH - btnH));
btn.style.left = newLeft + 'px'; btn.style.top = newTop + 'px';
});
const finishDrag = (e) => {
if (!isDragging || e.pointerId !== activePointerId) return;
isDragging = false; activePointerId = null; document.body.classList.remove('tm-dragging');
if (hasMoved) StorageManager.setBtnPosition({ left: btn.style.left, top: btn.style.top }, dragMobileMode);
};
window.addEventListener('pointerup', finishDrag);
window.addEventListener('pointercancel', finishDrag);
btn.addEventListener('click', (e) => {
if (hasMoved) { hasMoved = false; e.stopImmediatePropagation(); e.preventDefault(); }
}, true);
}
function setupDraggablePanel(panel) {
const header = panel.querySelector('.tm-panel-header');
let isDragging = false;
let startX, startY, initialLeft, initialTop;
let activePointerId = null;
let dragMobileMode = false;
header.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (e.target.closest('button') || e.target.closest('.tm-resizer')) return;
isDragging = true;
activePointerId = e.pointerId;
dragMobileMode = isMobileUIMode();
const rect = panel.getBoundingClientRect();
panel.style.bottom = 'auto'; panel.style.right = 'auto';
panel.style.left = rect.left + 'px'; panel.style.top = rect.top + 'px';
startX = e.clientX; startY = e.clientY;
initialLeft = rect.left; initialTop = rect.top;
document.body.classList.add('tm-dragging');
try { header.setPointerCapture(e.pointerId); } catch (_) { }
e.preventDefault();
});
window.addEventListener('pointermove', (e) => {
if (!isDragging || e.pointerId !== activePointerId) return;
const dx = e.clientX - startX; const dy = e.clientY - startY;
let newLeft = initialLeft + dx; let newTop = initialTop + dy;
const winW = window.innerWidth; const winH = window.innerHeight;
const panelW = panel.offsetWidth; const panelH = panel.offsetHeight;
const edgeGap = dragMobileMode ? 8 : 0;
const rightGap = dragMobileMode ? 8 : SCROLLBAR_MARGIN;
newLeft = Math.max(edgeGap, Math.min(newLeft, Math.max(edgeGap, winW - panelW - rightGap)));
newTop = Math.max(edgeGap, Math.min(newTop, Math.max(edgeGap, winH - panelH - edgeGap)));
panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px';
});
const finishDrag = (e) => {
if (!isDragging || e.pointerId !== activePointerId) return;
isDragging = false; activePointerId = null; document.body.classList.remove('tm-dragging'); savePanelState(panel, dragMobileMode);
};
window.addEventListener('pointerup', finishDrag);
window.addEventListener('pointercancel', finishDrag);
}
function setupResizablePanel(panel) {
const directions = ['n', 'e', 's', 'w', 'ne', 'nw', 'se', 'sw'];
directions.forEach(dir => {
const resizer = document.createElement('div');
resizer.className = `tm-resizer ${dir}`;
panel.appendChild(resizer);
resizer.addEventListener('pointerdown', (e) => initResize(e, dir));
});
let isResizing = false;
let currentDir = '';
let startX, startY, startW, startH, startLeft, startTop;
let activePointerId = null;
let resizeMobileMode = false;
function initResize(e, dir) {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault(); e.stopPropagation();
isResizing = true; currentDir = dir;
activePointerId = e.pointerId;
resizeMobileMode = isMobileUIMode();
const rect = panel.getBoundingClientRect();
startX = e.clientX; startY = e.clientY;
startW = rect.width; startH = rect.height;
startLeft = rect.left; startTop = rect.top;
panel.style.left = startLeft + 'px'; panel.style.top = startTop + 'px';
panel.style.right = 'auto'; panel.style.bottom = 'auto';
panel.style.width = startW + 'px'; panel.style.height = startH + 'px';
document.body.classList.add('tm-dragging');
document.body.style.cursor = window.getComputedStyle(e.target).cursor;
try { e.target.setPointerCapture(e.pointerId); } catch (_) { }
}
window.addEventListener('pointermove', (e) => {
if (!isResizing || e.pointerId !== activePointerId) return;
const dx = e.clientX - startX; const dy = e.clientY - startY;
const winW = window.innerWidth; const winH = window.innerHeight;
const edgeGap = resizeMobileMode ? 8 : 0;
const rightGap = resizeMobileMode ? 8 : SCROLLBAR_MARGIN;
let newW = startW; let newH = startH; let newLeft = startLeft; let newTop = startTop;
if (currentDir.includes('e')) newW = Math.min(startW + dx, winW - startLeft - rightGap);
if (currentDir.includes('w')) { const actualDx = Math.max(dx, edgeGap - startLeft); newW = startW - actualDx; newLeft = startLeft + actualDx; }
if (currentDir.includes('s')) newH = Math.min(startH + dy, winH - startTop - edgeGap);
if (currentDir.includes('n')) { const actualDy = Math.max(dy, edgeGap - startTop); newH = startH - actualDy; newTop = startTop + actualDy; }
const minW = Math.min(resizeMobileMode ? 280 : 300, Math.max(1, winW - edgeGap - rightGap));
const minH = Math.min(resizeMobileMode ? 220 : 200, Math.max(1, winH - (edgeGap * 2)));
if (newW < minW) { if (currentDir.includes('w')) newLeft = startLeft + (startW - minW); newW = minW; }
if (newH < minH) { if (currentDir.includes('n')) newTop = startTop + (startH - minH); newH = minH; }
panel.style.width = newW + 'px'; panel.style.height = newH + 'px';
panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px';
});
const finishResize = (e) => {
if (!isResizing || e.pointerId !== activePointerId) return;
isResizing = false; activePointerId = null; document.body.classList.remove('tm-dragging'); document.body.style.cursor = ''; savePanelState(panel, resizeMobileMode);
};
window.addEventListener('pointerup', finishResize);
window.addEventListener('pointercancel', finishResize);
}
function savePanelState(panel, mobile = isMobileUIMode()) {
const style = window.getComputedStyle(panel);
StorageManager.setPanelState({ width: style.width, height: style.height, left: style.left, top: style.top }, mobile);
}
function restoreUIState(panel, btn, mobile = isMobileUIMode()) {
const winW = window.innerWidth; const winH = window.innerHeight;
const edgeGap = mobile ? 8 : 0;
const rightGap = mobile ? 8 : SCROLLBAR_MARGIN;
const btnPos = StorageManager.getBtnPosition(mobile);
if (btnPos) {
const btnW = btn.offsetWidth || (mobile ? 52 : 56);
const btnH = btn.offsetHeight || (mobile ? 52 : 56);
const left = Math.max(0, Math.min(parseFloat(btnPos.left) || 0, Math.max(0, winW - btnW)));
const top = Math.max(0, Math.min(parseFloat(btnPos.top) || 0, Math.max(0, winH - btnH)));
btn.style.bottom = 'auto'; btn.style.right = 'auto'; btn.style.left = left + 'px'; btn.style.top = top + 'px';
}
const panelState = StorageManager.getPanelState(mobile);
if (panelState) {
panel.style.bottom = 'auto'; panel.style.right = 'auto';
let w = parseFloat(panelState.width); let h = parseFloat(panelState.height);
let l = parseFloat(panelState.left); let t = parseFloat(panelState.top);
if (![w, h, l, t].every(Number.isFinite)) return;
const maxW = Math.max(1, winW - edgeGap - rightGap);
const maxH = Math.max(1, winH - (edgeGap * 2));
const minW = Math.min(mobile ? 280 : 300, maxW);
const minH = Math.min(mobile ? 220 : 200, maxH);
w = Math.max(minW, Math.min(w, maxW)); h = Math.max(minH, Math.min(h, maxH));
l = Math.max(edgeGap, Math.min(l, Math.max(edgeGap, winW - w - rightGap)));
t = Math.max(edgeGap, Math.min(t, Math.max(edgeGap, winH - h - edgeGap)));
panel.style.left = l + 'px'; panel.style.top = t + 'px';
panel.style.width = w + 'px'; panel.style.height = h + 'px';
}
}
function setupScrollPersistence(panel) {
const content = panel.querySelector('.tm-content');
if (!content) return;
content.addEventListener('scroll', () => {
const currentTab = StorageManager.getLastActiveTab();
if (currentTab) {
if (content.scrollTimeout) clearTimeout(content.scrollTimeout);
content.scrollTimeout = setTimeout(() => {
StorageManager.setTabScroll(currentTab, content.scrollTop);
}, 100);
}
});
}
function restoreScrollPosition(panel, tabName) {
const content = panel.querySelector('.tm-content');
if (content) {
const savedScroll = StorageManager.getTabScroll(tabName);
setTimeout(() => { content.scrollTop = savedScroll; }, 50);
}
}
// ========================================
// メインUI
// ========================================
const UI_ICONS = Object.freeze({
toggle: `<svg class="tm-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7.5h16v11H4zM4 7.5l2.5-4h13.5l-2.5 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M10 10.4l5.2 3.1-5.2 3.1z" fill="currentColor"/></svg>`,
close: `<svg class="tm-close-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 7l10 10M17 7L7 17" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>`
});
function createMainUI() {
const toggleBtn = document.createElement('button');
toggleBtn.className = 'tm-toggle-btn';
toggleBtn.type = 'button';
toggleBtn.innerHTML = UI_ICONS.toggle;
toggleBtn.title = 'TokyoMotion Enhancer';
toggleBtn.setAttribute('aria-label', 'TokyoMotion Enhancer');
document.body.appendChild(toggleBtn);
const panel = document.createElement('div');
panel.className = 'tm-panel';
const isVideoPage = location.pathname.startsWith('/video/');
panel.innerHTML = `
<div class="tm-panel-header">
<span>🎬 TokyoMotion Enhancer</span>
<button type="button" class="tm-panel-close" aria-label="${t('btn_close')}" title="${t('btn_close')}">${UI_ICONS.close}</button>
</div>
<div class="tm-tabs" id="tm-tabs-container"></div>
<div class="tm-content">
<div class="tm-tab-content" id="tm-liked"></div>
<div class="tm-tab-content" id="tm-history"></div>
<div class="tm-tab-content" id="tm-playlists"></div>
<div class="tm-tab-content" id="tm-feed">
<div class="tm-feed-header">
<span id="tm-feed-time-ago" class="tm-time-label"></span>
<button class="tm-btn-primary" id="tm-feed-update">${t('tab_feed')} ${t('btn_update')}</button>
<span id="tm-feed-time-absolute" class="tm-time-label"></span>
</div>
<div id="tm-feed-status" style="margin:0 0 10px 0; text-align:center; font-size:11px; color:#888;"></div>
<div id="tm-feed-list"></div>
</div>
<div class="tm-tab-content" id="tm-friends">
<div class="tm-feed-header">
<span id="tm-friends-time-ago" class="tm-time-label"></span>
<button class="tm-btn-primary" id="tm-friends-update">${t('tab_friends')} ${t('btn_update')}</button>
<span id="tm-friends-time-absolute" class="tm-time-label"></span>
</div>
<div id="tm-friends-status" style="margin:0 0 10px 0; text-align:center; font-size:11px; color:#888;"></div>
<div id="tm-friends-list"></div>
</div>
<div class="tm-tab-content" id="tm-settings">
<!-- Settings will be rendered by JS -->
</div>
</div>
`;
document.body.appendChild(panel);
const initialMobileMode = applyUIMode(panel, toggleBtn);
panel.classList.toggle('active', isVideoPage && !initialMobileMode);
// 初期描画
renderTabs(panel);
renderSettingsTab(panel);
setupDraggableButton(toggleBtn);
setupDraggablePanel(panel);
setupResizablePanel(panel);
setupScrollPersistence(panel);
applyVideoGridCols();
// ----------------------------------------
// ★自動スクロールリセット機能
// ----------------------------------------
let leaveTime = 0;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// サイトを離れた(タブを隠した)時間を記録
leaveTime = Date.now();
} else {
// サイトに戻ってきた時
if (leaveTime > 0) {
const diff = Date.now() - leaveTime;
const threshold = getScrollResetMs();
// 設定時間を経過していたらリセット
if (diff > threshold) {
const content = panel.querySelector('.tm-content');
if (content) {
content.scrollTop = 0;
}
// 全タブのスクロール位置をリセット
const tabsToReset = ['liked', 'history', 'playlists', 'feed', 'friends'];
tabsToReset.forEach(tab => {
StorageManager.setTabScroll(tab, 0);
});
// プレイリストタブは詳細画面から一覧に戻す
StorageManager.setActivePlaylist(null);
// 現在のタブを再読み込みしてリセット反映
const currentTab = StorageManager.getLastActiveTab();
if (currentTab === 'liked') {
loadLikedVideos();
} else if (currentTab === 'history') {
loadHistory();
} else if (currentTab === 'playlists') {
loadPlaylists();
}
showToast(t('msg_scroll_reset')); // 通知
}
leaveTime = 0;
}
}
});
panel.addEventListener('click', (e) => e.stopPropagation());
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
panel.classList.toggle('active');
if (panel.classList.contains('active')) {
let defaultTab = StorageManager.getDefaultTab();
if (defaultTab === 'last_open') defaultTab = StorageManager.getLastActiveTab();
const visibility = StorageManager.getTabVisibility();
if (!visibility[defaultTab]) {
const order = StorageManager.getTabOrder();
defaultTab = order.find(t => visibility[t]) || 'settings';
}
switchToTab(panel, defaultTab);
}
});
panel.querySelector('.tm-panel-close').addEventListener('click', () => {
panel.classList.remove('active');
});
document.addEventListener('click', (e) => {
if (panel.classList.contains('active')) {
if (document.querySelector('.tm-modal-overlay')) return;
if (!isVideoPage || isMobileUIMode()) {
if (!panel.contains(e.target) && !toggleBtn.contains(e.target)) {
panel.classList.remove('active');
}
}
}
});
if (panel.classList.contains('active')) {
let defaultTab = StorageManager.getDefaultTab();
if (defaultTab === 'last_open') defaultTab = StorageManager.getLastActiveTab();
switchToTab(panel, defaultTab);
}
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement) {
panel.style.display = 'none';
toggleBtn.style.display = 'none';
} else {
panel.style.display = '';
toggleBtn.style.display = '';
}
});
let uiResizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(uiResizeTimer);
uiResizeTimer = setTimeout(() => applyUIMode(panel, toggleBtn), 120);
});
}
// ========================================
// 設定タブの動的描画
// ========================================
function renderSettingsTab(panel = document.querySelector('.tm-panel')) {
const container = panel.querySelector('#tm-settings');
if (!container) return;
// オプション生成ヘルパー
const generatePageOptions = () => {
let opts = '';
for (let i = 1; i <= 10; i++) {
opts += `<option value="${i}">${i}${t('stg_page_unit')}</option>`;
}
opts += `<option value="99999">${t('stg_unlimited')}</option>`;
return opts;
};
const generateFetchRangePanel = (type, title) => {
const modes = type === 'feed' ? StorageManager.getFeedFetchModes() : StorageManager.getFriendsFetchModes();
return `
<div class="tm-fetch-range-panel" data-fetch-range="${type}">
<div class="tm-fetch-range-title">${title}</div>
<label class="tm-fetch-mode-row">
<input type="radio" name="tm-${type}-fetch-mode" id="tm-${type}-mode-pages" value="pages" ${modes.pages ? 'checked' : ''}>
<span>${fetchRangeText('modePages')}</span>
<select id="tm-${type}-max-pages" class="tm-input">${generatePageOptions()}</select>
</label>
<label class="tm-fetch-mode-row">
<input type="radio" name="tm-${type}-fetch-mode" id="tm-${type}-mode-days" value="days" ${modes.days ? 'checked' : ''}>
<span>${fetchRangeText('modeDays')}</span>
<input type="number" id="tm-${type}-max-days" class="tm-input" min="1" max="365" step="1">
</label>
<label class="tm-fetch-mode-row">
<input type="radio" name="tm-${type}-fetch-mode" id="tm-${type}-mode-since-last" value="sinceLast" ${modes.sinceLast ? 'checked' : ''}>
<span>${fetchRangeText('modeSinceLast')}</span>
<span></span>
</label>
<div class="tm-fetch-mode-status" id="tm-${type}-fetch-mode-status"></div>
</div>
`;
};
const currentScrollVal = StorageManager.getScrollResetValue();
const currentScrollUnit = StorageManager.getScrollResetUnit();
container.innerHTML = `
<div style="margin-bottom:15px;">
<label style="color:#e0e0e0;font-size:13px;display:block;margin-bottom:8px;">${t('stg_language')}</label>
<select id="tm-language-selector" class="tm-input">
<option value="auto">Auto (自動)</option>
<option value="ja">日本語</option>
<option value="en">English</option>
</select>
</div>
<div style="border-top:1px solid #444; padding-top:15px; margin-bottom:15px;">
<label style="color:#e0e0e0;font-size:13px;display:block;margin-bottom:8px;">${t('stg_ui_mode')}</label>
<select id="tm-ui-mode" class="tm-input">
<option value="auto">${t('stg_ui_mode_auto')}</option>
<option value="desktop">${t('stg_ui_mode_desktop')}</option>
<option value="mobile">${t('stg_ui_mode_mobile')}</option>
</select>
<div style="font-size:12px;color:#888;line-height:1.5;">${t('stg_ui_mode_desc')}</div>
</div>
<div style="border-top:1px solid #444; padding-top:15px; margin-bottom:15px;">
<label style="color:#e0e0e0;font-size:13px;display:block;margin-bottom:8px;">${t('stg_startup_tab')}</label>
<select id="tm-default-tab" class="tm-input">
<option value="last_open">${t('stg_tab_last_open')}</option>
<option value="liked">${t('tab_liked')}</option>
<option value="history">${t('tab_history')}</option>
<option value="playlists">${t('tab_playlists')}</option>
<option value="feed">${t('tab_feed')}</option>
<option value="friends">${t('tab_friends')}</option>
<option value="settings">${t('tab_settings')}</option>
</select>
</div>
<div style="margin-top:15px; border-top:1px solid #444; padding-top:15px;">
<label style="color:#e0e0e0;font-size:13px;display:block;margin-bottom:8px;">${t('stg_scroll_reset')}</label>
<div style="display:flex; gap:10px;">
<input type="number" id="tm-scroll-val" class="tm-input" style="flex:1;" min="1" value="${currentScrollVal}">
<select id="tm-scroll-unit" class="tm-input" style="flex:1;">
<option value="seconds">${t('unit_sec')}</option>
<option value="minutes">${t('unit_min')}</option>
<option value="hours">${t('unit_hour')}</option>
</select>
</div>
</div>
<div class="tm-login-section" style="border-top:1px solid #444; padding-top:15px;">
<div style="color:#e0e0e0;font-size:14px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:8px;">
${t('stg_auto_login')} <div class="tm-toggle-switch" id="tm-auto-login-toggle"></div>
</div>
<div style="font-size:12px;color:#888;">${t('stg_auto_login_desc')}</div>
</div>
<div style="margin-top:15px; border-top:1px solid #444; padding-top:15px;">
<div style="color:#e0e0e0;font-size:14px;font-weight:600;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between;gap:8px;">
<span>${t('filter_title')}</span>
<div class="tm-toggle-switch" id="tm-content-filter-toggle" role="switch" aria-label="${t('filter_enabled')}"></div>
</div>
<div style="font-size:12px;color:#888;line-height:1.5;margin-bottom:12px;">${t('filter_desc')}</div>
<label style="color:#e0e0e0;font-size:13px;display:block;margin-bottom:6px;">${t('filter_uploaders')}</label>
<textarea id="tm-filter-uploaders" class="tm-input tm-filter-textarea" placeholder="${t('filter_uploaders_placeholder')}"></textarea>
<label style="color:#e0e0e0;font-size:13px;display:block;margin-bottom:6px;">${t('filter_words')}</label>
<textarea id="tm-filter-words" class="tm-input tm-filter-textarea" placeholder="${t('filter_words_placeholder')}"></textarea>
<div class="tm-btn-row">
<button class="tm-btn-primary" id="tm-filter-save">${t('filter_save')}</button>
<button class="tm-btn-secondary" id="tm-filter-clear">${t('filter_clear')}</button>
</div>
</div>
<div style="margin-top:15px; border-top:1px solid #444; padding-top:15px;">
<div style="color:#e0e0e0;font-size:14px;font-weight:600;margin-bottom:12px;">${t('stg_tab_visibility')}</div>
<div id="tm-tab-visibility-settings"></div>
</div>
<div style="margin-top:15px; border-top:1px solid #444; padding-top:15px;">
<label style="color:#e0e0e0;font-size:13px;display:block;margin-bottom:8px;">${t('stg_grid_cols')}</label>
<select id="tm-video-grid-cols" class="tm-input">
<option value="1">1${t('stg_col_unit')}</option>
<option value="2">2${t('stg_col_unit')}</option>
<option value="3">3${t('stg_col_unit')}</option>
<option value="4">4${t('stg_col_unit')}</option>
<option value="5">5${t('stg_col_unit')}</option>
</select>
</div>
<div style="margin-top:15px; border-top:1px solid #444; padding-top:15px;">
<div style="color:#e0e0e0;font-size:14px;font-weight:600;margin-bottom:12px;">${fetchRangeText('title')}</div>
${generateFetchRangePanel('feed', fetchRangeText('feedTitle'))}
${generateFetchRangePanel('friends', fetchRangeText('friendsTitle'))}
</div>
<div class="tm-btn-row" style="margin-top:20px;">
<button class="tm-btn-secondary" id="tm-export">${t('btn_export')}</button>
<button class="tm-btn-secondary" id="tm-import">${t('btn_import')}</button>
</div>
<button class="tm-btn-danger" id="tm-clear-all">${t('btn_clear_all')}</button>
`;
// イベントバインドの再実行
renderTabVisibilitySettings();
initLoginSettings();
initContentFilterSettings();
document.getElementById('tm-export').addEventListener('click', exportData);
document.getElementById('tm-import').addEventListener('click', importData);
document.getElementById('tm-clear-all').addEventListener('click', clearAllData);
const defaultTabSelect = document.getElementById('tm-default-tab');
defaultTabSelect.value = StorageManager.getDefaultTab();
defaultTabSelect.addEventListener('change', (e) => StorageManager.setDefaultTab(e.target.value));
const uiModeSelect = document.getElementById('tm-ui-mode');
uiModeSelect.value = StorageManager.getUIMode();
uiModeSelect.addEventListener('change', (e) => {
StorageManager.setUIMode(e.target.value);
const toggleBtn = document.querySelector('.tm-toggle-btn');
if (toggleBtn) applyUIMode(panel, toggleBtn);
});
const scrollValInput = document.getElementById('tm-scroll-val');
const saveScrollVal = (e) => {
const val = parseInt(e.target.value);
if (val > 0) {
StorageManager.setScrollResetValue(val);
console.log(`[TokyoMotion Enhancer] Saved scroll val: ${val}`);
}
};
// 'input'だと急な変更で保存が追いつかない場合があるので'change'も併用
scrollValInput.addEventListener('input', saveScrollVal);
scrollValInput.addEventListener('change', saveScrollVal);
const scrollUnitSelect = document.getElementById('tm-scroll-unit');
scrollUnitSelect.value = currentScrollUnit;
scrollUnitSelect.addEventListener('change', (e) => {
StorageManager.setScrollResetUnit(e.target.value);
console.log(`[TokyoMotion Enhancer] Saved scroll unit: ${e.target.value}`);
});
const videoGridColsSelect = document.getElementById('tm-video-grid-cols');
videoGridColsSelect.value = StorageManager.getVideoGridCols();
videoGridColsSelect.addEventListener('change', (e) => {
const val = parseInt(e.target.value);
StorageManager.setVideoGridCols(val);
applyVideoGridCols();
});
initFetchRangeSettings('feed');
initFetchRangeSettings('friends');
const langSelect = document.getElementById('tm-language-selector');
langSelect.value = GM_getValue('appLanguage', 'auto');
langSelect.addEventListener('change', (e) => {
TranslationManager.setLanguage(e.target.value);
// リロードではなく、描画関数を呼び出して即時反映
renderTabs(panel);
renderSettingsTab(panel);
// 現在のタブが設定以外(既に開いていたタブ)の場合、その内容も更新する必要がある
// ただし設定タブにいるので、次にフィードタブを開いたときに更新されればよい
// _setupFeedLogicがタブ切り替え時に呼ばれ、そこで言語更新を行うように修正済み
});
}
// ========================================
// タブ関連ロジック
// ========================================
function getFetchRangeStorage(type) {
if (type === 'feed') {
return {
getModes: () => StorageManager.getFeedFetchModes(),
setModes: (modes) => StorageManager.setFeedFetchModes(modes),
getPages: () => StorageManager.getFeedMaxPages(),
setPages: (pages) => StorageManager.setFeedMaxPages(pages),
getDays: () => StorageManager.getFeedMaxDays(),
setDays: (days) => StorageManager.setFeedMaxDays(days),
getLastUpdated: () => StorageManager.getFeedLastUpdated()
};
}
return {
getModes: () => StorageManager.getFriendsFetchModes(),
setModes: (modes) => StorageManager.setFriendsFetchModes(modes),
getPages: () => StorageManager.getFriendsMaxPages(),
setPages: (pages) => StorageManager.setFriendsMaxPages(pages),
getDays: () => StorageManager.getFriendsMaxDays(),
setDays: (days) => StorageManager.setFriendsMaxDays(days),
getLastUpdated: () => StorageManager.getFriendsLastUpdated()
};
}
function getEnabledFetchModes(modes) {
return ['pages', 'days', 'sinceLast'].filter(key => !!modes[key]);
}
function validateFetchRange(type, options = {}) {
const storage = getFetchRangeStorage(type);
const modes = storage.getModes();
const enabled = getEnabledFetchModes(modes);
const days = Math.max(1, parseInt(storage.getDays(), 10) || 1);
if (enabled.length > 1) return { valid: false, type: 'conflict', message: fetchRangeText('conflict') };
if (enabled.length === 0) return { valid: false, type: 'none', message: fetchRangeText('none') };
if (enabled[0] === 'sinceLast' && options.requireLastUpdated && storage.getLastUpdated() <= 0) {
return { valid: false, type: 'noLastFetch', message: fetchRangeText('noLastFetch') };
}
if (enabled[0] === 'pages') return { valid: true, mode: 'pages', message: fetchRangeText('activePages') };
if (enabled[0] === 'days') return { valid: true, mode: 'days', message: fetchRangeText('activeDays', { days }) };
return { valid: true, mode: 'sinceLast', message: fetchRangeText('activeSinceLast') };
}
function buildFetchOptions(type, baseTime, previousLastUpdated) {
const storage = getFetchRangeStorage(type);
const validation = validateFetchRange(type, { requireLastUpdated: true });
if (!validation.valid) return validation;
if (validation.mode === 'pages') {
return {
valid: true,
mode: 'pages',
maxPages: Math.max(1, parseInt(storage.getPages(), 10) || 1),
baseTime
};
}
if (validation.mode === 'days') {
const days = Math.max(1, parseInt(storage.getDays(), 10) || 1);
return {
valid: true,
mode: 'days',
cutoffTimestamp: baseTime - (days * 24 * 60 * 60 * 1000),
baseTime
};
}
return {
valid: true,
mode: 'sinceLast',
cutoffTimestamp: previousLastUpdated,
baseTime
};
}
function initFetchRangeSettings(type) {
const storage = getFetchRangeStorage(type);
const pagesToggle = document.getElementById(`tm-${type}-mode-pages`);
const daysToggle = document.getElementById(`tm-${type}-mode-days`);
const sinceLastToggle = document.getElementById(`tm-${type}-mode-since-last`);
const pagesSelect = document.getElementById(`tm-${type}-max-pages`);
const daysInput = document.getElementById(`tm-${type}-max-days`);
const status = document.getElementById(`tm-${type}-fetch-mode-status`);
if (!pagesToggle || !daysToggle || !sinceLastToggle || !pagesSelect || !daysInput || !status) return;
pagesSelect.value = String(storage.getPages());
daysInput.value = String(storage.getDays());
const ensureOneSelected = () => {
if (!pagesToggle.checked && !daysToggle.checked && !sinceLastToggle.checked) {
pagesToggle.checked = true;
}
};
const save = () => {
ensureOneSelected();
storage.setModes({
pages: pagesToggle.checked,
days: daysToggle.checked,
sinceLast: sinceLastToggle.checked
});
storage.setPages(parseInt(pagesSelect.value, 10) || 1);
storage.setDays(Math.max(1, parseInt(daysInput.value, 10) || 1));
updateFetchRangeStatus(type);
};
[pagesToggle, daysToggle, sinceLastToggle, pagesSelect, daysInput].forEach(el => {
el.addEventListener('change', save);
if (el === daysInput) el.addEventListener('input', save);
});
updateFetchRangeStatus(type);
}
function updateFetchRangeStatus(type) {
const status = document.getElementById(`tm-${type}-fetch-mode-status`);
if (!status) return;
const validation = validateFetchRange(type);
status.textContent = validation.message;
status.classList.toggle('ok', validation.valid);
status.classList.toggle('error', !validation.valid);
}
function renderTabs(panel) {
const container = panel.querySelector('#tm-tabs-container');
container.innerHTML = '';
const order = StorageManager.getTabOrder();
DEFAULT_TAB_ORDER.forEach(def => { if (!order.includes(def)) order.push(def); });
const visibility = StorageManager.getTabVisibility();
order.forEach(tabKey => {
if (!visibility[tabKey]) return;
const btn = document.createElement('button');
btn.className = 'tm-tab';
btn.dataset.tab = tabKey;
btn.textContent = t(`tab_${tabKey}`);
btn.draggable = true;
if (tabKey === StorageManager.getLastActiveTab()) btn.classList.add('active'); // アクティブ状態維持
btn.addEventListener('click', () => switchToTab(panel, tabKey));
btn.addEventListener('dragstart', (e) => {
btn.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', tabKey);
});
btn.addEventListener('dragend', () => { btn.classList.remove('dragging'); });
container.appendChild(btn);
});
container.addEventListener('dragover', (e) => {
e.preventDefault();
const afterElement = getDragAfterElementHorizontal(container, e.clientX);
const dragging = document.querySelector('.tm-tab.dragging');
if (afterElement == null) container.appendChild(dragging); else container.insertBefore(dragging, afterElement);
});
container.addEventListener('drop', (e) => {
e.preventDefault();
const newOrder = [...container.querySelectorAll('.tm-tab')].map(el => el.dataset.tab);
const currentFullOrder = StorageManager.getTabOrder();
const hiddenTabs = currentFullOrder.filter(t => !newOrder.includes(t));
const finalOrder = [...newOrder, ...hiddenTabs];
StorageManager.setTabOrder(finalOrder);
});
}
function getDragAfterElementHorizontal(container, x) {
const draggableElements = [...container.querySelectorAll('.tm-tab:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = x - box.left - box.width / 2;
if (offset < 0 && offset > closest.offset) return { offset: offset, element: child }; else return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function renderTabVisibilitySettings() {
const container = document.getElementById('tm-tab-visibility-settings');
if (!container) return;
container.innerHTML = '';
const visibility = StorageManager.getTabVisibility();
const order = StorageManager.getTabOrder();
DEFAULT_TAB_ORDER.forEach(def => { if (!order.includes(def)) order.push(def); });
order.forEach(tabKey => {
// 設定タブは常時表示のため、表示切り替えの対象には含めない。
if (tabKey === 'settings') return;
const row = document.createElement('div');
row.className = 'tm-tab-visibility-item';
const label = document.createElement('span');
label.textContent = t(`tab_${tabKey}`);
label.style.color = '#e0e0e0';
label.style.fontSize = '13px';
const toggle = document.createElement('div');
toggle.className = 'tm-toggle-switch';
if (visibility[tabKey]) toggle.classList.add('active');
toggle.addEventListener('click', () => {
const newVis = !toggle.classList.contains('active');
if (newVis) toggle.classList.add('active'); else toggle.classList.remove('active');
const currentVis = StorageManager.getTabVisibility();
currentVis[tabKey] = newVis;
StorageManager.setTabVisibility(currentVis);
const panel = document.querySelector('.tm-panel');
renderTabs(panel);
});
row.appendChild(label); row.appendChild(toggle); container.appendChild(row);
});
}
function initLoginSettings() {
const toggle = document.getElementById('tm-auto-login-toggle');
if (!toggle) return;
if (StorageManager.isAutoLoginEnabled()) toggle.classList.add('active');
toggle.addEventListener('click', () => {
const newState = !StorageManager.isAutoLoginEnabled();
StorageManager.setAutoLoginEnabled(newState);
toggle.classList.toggle('active', newState);
});
}
function initContentFilterSettings() {
const toggle = document.getElementById('tm-content-filter-toggle');
const saveButton = document.getElementById('tm-filter-save');
const clearButton = document.getElementById('tm-filter-clear');
const uploaderInput = document.getElementById('tm-filter-uploaders');
const wordInput = document.getElementById('tm-filter-words');
if (!toggle || !saveButton || !clearButton || !uploaderInput || !wordInput) return;
ContentFilter.refreshSettingsFields();
toggle.classList.toggle('active', StorageManager.isContentFilterEnabled());
toggle.setAttribute('aria-checked', String(StorageManager.isContentFilterEnabled()));
toggle.addEventListener('click', () => {
const enabled = !StorageManager.isContentFilterEnabled();
StorageManager.setContentFilterEnabled(enabled);
ContentFilter.revealTemporarily = false;
toggle.classList.toggle('active', enabled);
toggle.setAttribute('aria-checked', String(enabled));
ContentFilter.scan();
});
saveButton.addEventListener('click', () => {
StorageManager.setBlockedUploaders(ContentFilter.parseLines(uploaderInput.value));
StorageManager.setMutedTitleTerms(ContentFilter.parseLines(wordInput.value));
ContentFilter.revealTemporarily = false;
ContentFilter.refreshSettingsFields();
ContentFilter.scan();
showToast(t('filter_saved'));
});
clearButton.addEventListener('click', () => {
if (!confirm(t('filter_confirm_clear'))) return;
StorageManager.setBlockedUploaders([]);
StorageManager.setMutedTitleTerms([]);
ContentFilter.revealTemporarily = false;
ContentFilter.refreshSettingsFields();
ContentFilter.scan();
showToast(t('filter_cleared'));
});
}
function switchToTab(panel, tabName) {
panel.querySelectorAll('.tm-tab').forEach(t => t.classList.remove('active'));
panel.querySelectorAll('.tm-tab-content').forEach(c => c.classList.remove('active'));
const targetBtn = panel.querySelector(`.tm-tab[data-tab="${tabName}"]`);
if (targetBtn) targetBtn.classList.add('active');
const targetContent = document.getElementById(`tm-${tabName}`);
if (targetContent) targetContent.classList.add('active');
StorageManager.setLastActiveTab(tabName);
const restore = () => restoreScrollPosition(panel, tabName);
if (tabName === 'liked') loadLikedVideos().then(restore);
else if (tabName === 'history') loadHistory().then(restore);
else if (tabName === 'playlists') loadPlaylists().then(restore);
else if (tabName === 'feed') { setupFeedTab(); restore(); } // restoreを追加
else if (tabName === 'friends') { setupFriendsTab(); restore(); } // restoreを追加
else restore();
}
// ========================================
// HTML生成 & イベント (既存機能)
// ========================================
function generateVideoCard(v, extraButton = '', addedTime = null) {
const iconSrc = v.authorIcon || 'https://www.tokyomotion.net/img/user-avatar.png';
const authorLink = v.author ? `/user/${v.author}/videos` : '#';
const relativeTime = formatRelativeTime(v.date);
const privateLabel = v.isPrivate ? `<div class="tm-card-private">${t('label_private')}</div>` : '';
const addedTimeDisplay = addedTime ? `<div class="tm-added-time">${formatDate(addedTime)}</div>` : '';
return `
<div class="tm-card" data-url="${v.url}">
${extraButton}
<div class="tm-card-thumb-box">
<img src="${v.thumbnail || ''}" class="tm-card-thumb" onerror="this.style.display='none'">
${v.duration ? `<div class="tm-card-duration">${v.duration}</div>` : ''}
${privateLabel}
</div>
<div class="tm-card-title">${v.title}</div>
<div class="tm-card-meta">
${v.author ? `
<div class="tm-user-row">
<img src="${iconSrc}" class="tm-user-icon" onerror="this.onerror=null;this.src='https://www.tokyomotion.net/img/user-avatar.png'">
<a href="${authorLink}" class="tm-user-link" target="_blank">${v.author}</a>
${relativeTime ? `<span class="tm-relative-time">${relativeTime}</span>` : ''}
</div>
` : ''}
${addedTimeDisplay}
</div>
</div>
`;
}
let draggedThumbnailUrl = null;
function resetThumbnailDragUI() {
draggedThumbnailUrl = null;
document.body.classList.remove('tm-thumbnail-drag-active');
document.querySelector('.tm-thumbnail-drop-zone')?.classList.remove('tm-drag-over');
document.querySelectorAll('.tm-thumbnail-dragging').forEach(el => el.classList.remove('tm-thumbnail-dragging'));
}
function ensureThumbnailDropZone() {
let zone = document.querySelector('.tm-thumbnail-drop-zone');
if (!zone) {
zone = document.createElement('div');
zone.className = 'tm-thumbnail-drop-zone';
zone.setAttribute('role', 'button');
zone.addEventListener('dragenter', (event) => {
event.preventDefault();
zone.classList.add('tm-drag-over');
});
zone.addEventListener('dragover', (event) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'link';
zone.classList.add('tm-drag-over');
});
zone.addEventListener('dragleave', () => zone.classList.remove('tm-drag-over'));
zone.addEventListener('drop', (event) => {
event.preventDefault();
event.stopPropagation();
const transferredUrl = event.dataTransfer && event.dataTransfer.getData('application/x-tm-video-url');
const url = transferredUrl || draggedThumbnailUrl;
resetThumbnailDragUI();
if (url) GM_openInTab(url, { active: false, insert: true, setParent: true });
});
document.body.appendChild(zone);
}
zone.textContent = `↗ ${t('drag_drop_new_tab')}`;
zone.setAttribute('aria-label', t('drag_drop_new_tab'));
return zone;
}
function setupThumbnailDrag(card) {
const thumbnail = card.querySelector('.tm-card-thumb-box');
if (!thumbnail || thumbnail.dataset.tmDragReady === 'true' || !card.dataset.url) return;
let videoUrl;
try { videoUrl = new URL(card.dataset.url, location.href).href; } catch (error) { return; }
thumbnail.dataset.tmDragReady = 'true';
thumbnail.draggable = true;
thumbnail.title = t('drag_thumbnail_hint');
const image = thumbnail.querySelector('img');
if (image) image.draggable = false;
thumbnail.addEventListener('dragstart', (event) => {
if (!event.dataTransfer) return;
draggedThumbnailUrl = videoUrl;
event.dataTransfer.effectAllowed = 'link';
event.dataTransfer.setData('application/x-tm-video-url', videoUrl);
thumbnail.classList.add('tm-thumbnail-dragging');
ensureThumbnailDropZone();
document.body.classList.add('tm-thumbnail-drag-active');
});
thumbnail.addEventListener('dragend', () => setTimeout(resetThumbnailDragUI, 0));
}
function attachCardEvents(container) {
container.querySelectorAll('.tm-card').forEach(card => {
setupThumbnailDrag(card);
card.addEventListener('click', (e) => {
if (e.target.closest('a') || e.target.closest('button')) return;
window.location.href = card.dataset.url;
});
});
container.querySelectorAll('.tm-user-link').forEach(link => { link.addEventListener('click', (e) => e.stopPropagation()); });
container.querySelectorAll('.tm-card-thumb').forEach(img => {
if (!img.src || !img.src.includes('/media/videos/')) return;
const baseUrl = img.src.substring(0, img.src.lastIndexOf('/') + 1);
img.dataset.baseurl = baseUrl;
img.addEventListener('mousemove', function (e) {
if (!this.dataset.preloaded) {
this.dataset.preloaded = 'true';
for (let i = 1; i <= 20; i++) (new Image()).src = `${this.dataset.baseurl}${i}.jpg`;
}
const rect = this.getBoundingClientRect(); const x = e.clientX - rect.left;
let percent = (x / rect.width) * 100; let num = Math.ceil(percent / 5); num = Math.max(1, Math.min(20, num));
const targetSrc = `${this.dataset.baseurl}${num}.jpg`;
if (this.src !== targetSrc) this.src = targetSrc;
});
});
}
async function loadLikedVideos(sortOrder = 'desc') {
const container = document.getElementById('tm-liked');
let videos = await StorageManager.getLikedVideos();
if (videos.length === 0) { container.innerHTML = `<div class="tm-empty">${t('msg_empty_liked')}</div>`; return; }
videos.sort((a, b) => sortOrder === 'desc' ? b.timestamp - a.timestamp : a.timestamp - b.timestamp);
container.innerHTML = `
<div style="margin-bottom:10px; text-align:right;">
<button class="tm-btn-secondary" id="tm-sort-liked" style="width:auto; padding:4px 8px; font-size:11px;">${sortOrder === 'desc' ? t('btn_sort_new') : t('btn_sort_old')}</button>
</div>
<div class="tm-grid-view">
${videos.map(v => generateVideoCard(v, `<button class="tm-card-remove" data-remove-liked="${v.id}" title="${t('btn_remove')}">×</button>`, v.timestamp)).join('')}
</div>
`;
document.getElementById('tm-sort-liked').addEventListener('click', (e) => { e.stopPropagation(); loadLikedVideos(sortOrder === 'desc' ? 'asc' : 'desc'); });
container.querySelectorAll('[data-remove-liked]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(t('confirm_delete_liked'))) { await StorageManager.removeLikedVideo(btn.dataset.removeLiked); loadLikedVideos(sortOrder); }
});
});
attachCardEvents(container);
}
async function loadHistory(sortOrder = 'desc') {
const container = document.getElementById('tm-history');
let videos = await StorageManager.getHistory();
if (videos.length === 0) { container.innerHTML = `<div class="tm-empty">${t('msg_empty_history')}</div>`; return; }
videos.sort((a, b) => sortOrder === 'desc' ? b.watchedAt - a.watchedAt : a.watchedAt - b.watchedAt);
container.innerHTML = `
<div style="margin-bottom:10px; text-align:right;">
<button class="tm-btn-secondary" id="tm-sort-history" style="width:auto; padding:4px 8px; font-size:11px;">${sortOrder === 'desc' ? t('btn_sort_new') : t('btn_sort_old')}</button>
</div>
<div class="tm-grid-view">
${videos.map(v => generateVideoCard(v, '', v.watchedAt)).join('')}
</div>
`;
document.getElementById('tm-sort-history').addEventListener('click', (e) => { e.stopPropagation(); loadHistory(sortOrder === 'desc' ? 'asc' : 'desc'); });
attachCardEvents(container);
}
async function loadPlaylists() {
const activeName = StorageManager.getActivePlaylist();
const playlists = await StorageManager.getPlaylists();
if (activeName && playlists[activeName]) { showPlaylistDetail(activeName); return; }
const container = document.getElementById('tm-playlists');
const names = await StorageManager.getOrderedPlaylistNames();
const currentCols = StorageManager.getPlaylistGridCols();
const effectiveCols = isMobileUIMode() ? Math.min(currentCols, 2) : currentCols;
container.innerHTML = `
<div style="margin-bottom:15px; display:flex; gap:5px; align-items:center;">
<input type="text" class="tm-input" id="tm-new-playlist-name" placeholder="${t('placeholder_new_playlist')}" style="margin:0; flex:1;">
<button class="tm-btn-primary" id="tm-create-playlist" style="flex:0 0 60px;">${t('btn_create')}</button>
<select id="tm-playlist-col-selector" class="tm-col-select" style="margin-left:auto; background:#333;">
<option value="1">1${t('stg_col_unit')}</option>
<option value="2">2${t('stg_col_unit')}</option>
<option value="3">3${t('stg_col_unit')}</option>
<option value="4">4${t('stg_col_unit')}</option>
<option value="5">5${t('stg_col_unit')}</option>
</select>
</div>
${names.length === 0 ? `<div class="tm-empty">${t('msg_empty_playlist')}</div>` : `
<div class="tm-playlist-grid" id="tm-playlist-grid" style="grid-template-columns: repeat(${effectiveCols}, 1fr);">
${names.map((name) => `
<div class="tm-playlist-card" data-playlist="${name}" draggable="true">
<button class="tm-playlist-delete" data-delete="${name}">×</button>
<div class="tm-playlist-name">${name}</div>
<div class="tm-playlist-count">${t('time_videos_count', { count: playlists[name].length })}</div>
</div>
`).join('')}
</div>
`}
`;
const colSelector = document.getElementById('tm-playlist-col-selector');
if (colSelector) {
colSelector.value = currentCols;
colSelector.addEventListener('change', (e) => {
const val = parseInt(e.target.value); StorageManager.setPlaylistGridCols(val); loadPlaylists();
});
}
document.getElementById('tm-create-playlist').addEventListener('click', async (e) => {
e.stopPropagation();
const input = document.getElementById('tm-new-playlist-name'); const name = input.value.trim();
if (!name) return;
if (await StorageManager.createPlaylist(name)) {
const order = StorageManager.getPlaylistOrder(); order.push(name); StorageManager.setPlaylistOrder(order); loadPlaylists();
} else alert(t('alert_exists'));
});
document.getElementById('tm-new-playlist-name').addEventListener('click', e => e.stopPropagation());
container.querySelectorAll('[data-playlist]').forEach(card => {
card.addEventListener('click', (e) => {
e.stopPropagation(); if (!e.target.dataset.delete) showPlaylistDetail(card.dataset.playlist);
});
setupDragAndDrop(card, container);
});
container.querySelectorAll('[data-delete]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(t('confirm_delete_playlist', { name: btn.dataset.delete }))) {
await StorageManager.deletePlaylist(btn.dataset.delete);
const order = StorageManager.getPlaylistOrder().filter(n => n !== btn.dataset.delete);
StorageManager.setPlaylistOrder(order); loadPlaylists();
}
});
});
}
function setupDragAndDrop(card, container) {
card.addEventListener('dragstart', (e) => { e.dataTransfer.effectAllowed = 'move'; container.draggedEl = card; card.style.opacity = '0.5'; });
card.addEventListener('dragend', () => { card.style.opacity = '1'; container.draggedEl = null; });
card.addEventListener('dragover', (e) => e.preventDefault());
card.addEventListener('drop', (e) => {
e.preventDefault(); e.stopPropagation();
if (container.draggedEl && container.draggedEl !== card) {
const dragged = container.draggedEl; const target = card; const parent = target.parentNode;
const temp = document.createTextNode('');
parent.insertBefore(temp, target); parent.insertBefore(target, dragged); parent.insertBefore(dragged, temp); temp.remove();
const newOrder = [...container.querySelectorAll('.tm-playlist-card')].map(c => c.dataset.playlist);
StorageManager.setPlaylistOrder(newOrder);
}
});
}
async function showPlaylistDetail(name) {
StorageManager.setActivePlaylist(name);
const container = document.getElementById('tm-playlists');
const playlists = await StorageManager.getPlaylists();
const videos = playlists[name] || [];
container.innerHTML = `
<div class="tm-playlist-detail-toolbar">
<button class="tm-btn-secondary" id="tm-back-to-playlists">${t('btn_back')}</button>
</div>
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
<h3 style="color:#e0e0e0; margin:0;">${name}</h3>
<button class="tm-btn-icon" id="tm-rename-playlist" title="${t('btn_rename')}">✏️</button>
</div>
${videos.length === 0 ? `<div class="tm-empty">${t('msg_empty_videos')}</div>` : `
<div class="tm-grid-view">
${videos.map(v => generateVideoCard(v, `<button class="tm-card-remove" data-remove-from="${v.id}" title="${t('btn_remove')}">×</button>`, v.timestamp)).join('')}
</div>
`}
`;
document.getElementById('tm-back-to-playlists').addEventListener('click', (e) => {
e.stopPropagation();
const content = e.currentTarget.closest('.tm-content');
if (content) content.scrollTop = 0;
StorageManager.setTabScroll('playlists', 0);
StorageManager.setActivePlaylist(null);
loadPlaylists();
});
document.getElementById('tm-rename-playlist').addEventListener('click', async (e) => {
e.stopPropagation();
const newName = prompt(t('prompt_playlist_name'), name);
if (newName && newName.trim() && newName !== name) {
const success = await StorageManager.renamePlaylist(name, newName.trim());
if (success) showPlaylistDetail(newName.trim()); else alert(t('alert_name_used'));
}
});
container.querySelectorAll('[data-remove-from]').forEach(btn => {
btn.addEventListener('click', async (e) => { e.stopPropagation(); await StorageManager.removeFromPlaylist(name, btn.dataset.removeFrom); showPlaylistDetail(name); });
});
attachCardEvents(container);
}
function setupFeedTab() {
_setupFeedLogic('feed', document.getElementById('tm-feed-update'), document.getElementById('tm-feed-status'), document.getElementById('tm-feed-list'), document.getElementById('tm-feed-time-ago'), document.getElementById('tm-feed-time-absolute'));
}
function setupFriendsTab() {
_setupFeedLogic('friends', document.getElementById('tm-friends-update'), document.getElementById('tm-friends-status'), document.getElementById('tm-friends-list'), document.getElementById('tm-friends-time-ago'), document.getElementById('tm-friends-time-absolute'));
}
function _setupFeedLogic(type, btn, status, list, timeAgoEl, timeAbsEl) {
if (!btn) return;
// 言語設定に合わせてボタンのテキストを更新
const labelKey = type === 'feed' ? 'tab_feed' : 'tab_friends';
btn.textContent = `${t(labelKey)} ${t('btn_update')}`;
const saved = type === 'feed' ? StorageManager.getFeedData() : StorageManager.getFriendsFeedData();
const lastUpdated = type === 'feed' ? StorageManager.getFeedLastUpdated() : StorageManager.getFriendsLastUpdated();
const updateTimeDisplay = (timestamp) => {
if (timestamp > 0) { timeAgoEl.innerText = calcTimeAgo(timestamp); timeAbsEl.innerText = formatDate(timestamp); }
else { timeAgoEl.innerText = '-'; timeAbsEl.innerText = '-'; }
};
updateTimeDisplay(lastUpdated);
if (saved.length) {
const privateIds = saved.filter(v => v.isPrivate).map(v => v.id);
if (privateIds.length > 0) StorageManager.addPrivateToCache(privateIds);
renderFeed(saved, list);
}
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
newBtn.disabled = false;
newBtn.dataset.tmFetching = 'false';
newBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (newBtn.dataset.tmFetching === 'true') return;
const setStatus = (message) => { if (status) status.innerText = message; };
const setBusy = (busy) => {
newBtn.dataset.tmFetching = busy ? 'true' : 'false';
newBtn.disabled = !!busy;
};
setBusy(true);
try {
setStatus(t('msg_fetching'));
const previousLastUpdated = type === 'feed' ? StorageManager.getFeedLastUpdated() : StorageManager.getFriendsLastUpdated();
const fetchStartedAt = Date.now();
const fetchOptions = buildFetchOptions(type, fetchStartedAt, previousLastUpdated);
updateFetchRangeStatus(type);
if (!fetchOptions.valid) { setStatus(fetchOptions.message); return; }
const users = type === 'feed' ? await SubscriptionManager.getFollowedUsers(msg => setStatus(msg)) : await SubscriptionManager.getFriends(msg => setStatus(msg));
if (users.length === 0) { setStatus(t('msg_no_users')); return; }
setStatus(t('msg_fetching_users', { count: users.length }));
let allVideos = [], completed = 0;
for (let i = 0; i < users.length; i += 5) {
const chunk = users.slice(i, i + 5);
await Promise.all(chunk.map(async u => { try { allVideos.push(...await SubscriptionManager.getUserVideos(u, fetchOptions)); } catch (e) { } }));
completed += chunk.length;
setStatus(t('msg_fetching_progress', { current: Math.min(completed, users.length), total: users.length }));
}
setStatus(t('msg_complete', { count: allVideos.length }));
const now = Date.now();
if (type === 'feed') { StorageManager.setFeedData(allVideos); StorageManager.setFeedLastUpdated(now); }
else { StorageManager.setFriendsFeedData(allVideos); StorageManager.setFriendsLastUpdated(now); }
updateTimeDisplay(now); renderFeed(allVideos, list);
} catch (e) { setStatus(t('msg_error', { msg: e.message })); } finally { setBusy(false); }
});
}
function renderFeed(videos, container) {
if (!videos.length) { container.innerHTML = `<div class="tm-empty">${t('msg_empty_feed')}</div>`; return; }
const unique = []; const seen = new Set();
videos.forEach(v => { if (!seen.has(v.id)) { seen.add(v.id); unique.push(v); } });
unique.sort((a, b) => parseInt(b.id) - parseInt(a.id));
container.innerHTML = `<div class="tm-grid-view">${unique.map(v => generateVideoCard(v)).join('')}</div>`;
attachCardEvents(container);
}
function setupVideoPage() {
const videoId = extractVideoId();
if (!videoId) return;
let historyRecorded = false;
const handleVideoElement = (videoEl) => {
if (videoEl.dataset.tmEnhanced) return;
videoEl.dataset.tmEnhanced = 'true';
const recordHistory = () => {
if (historyRecorded) return;
if (!videoEl.duration || isNaN(videoEl.duration) || videoEl.duration === Infinity) {
const waitForMeta = () => {
if (historyRecorded) return;
if (videoEl.duration && !isNaN(videoEl.duration) && videoEl.duration !== Infinity) {
save(); videoEl.removeEventListener('loadedmetadata', waitForMeta); videoEl.removeEventListener('durationchange', waitForMeta);
}
};
videoEl.addEventListener('loadedmetadata', waitForMeta); videoEl.addEventListener('durationchange', waitForMeta);
return;
}
save();
};
const save = () => {
let duration = formatSeconds(videoEl.duration); if (!duration) duration = getDurationFromPlayer();
const videoData = extractVideoData(duration);
if (videoData) { StorageManager.addToHistory(videoData); historyRecorded = true; }
};
videoEl.addEventListener('play', recordHistory);
videoEl.addEventListener('timeupdate', () => { if (!historyRecorded && videoEl.currentTime > 0.5) recordHistory(); });
};
const v = document.querySelector('video'); if (v) handleVideoElement(v);
new MutationObserver((mutations) => {
for (const m of mutations) for (const n of m.addedNodes) {
if (n.tagName === 'VIDEO') handleVideoElement(n);
if (n.querySelector) { const v = n.querySelector('video'); if (v) handleVideoElement(v); }
}
}).observe(document.body, { childList: true, subtree: true });
setupLikeObserver(videoId);
injectPlaylistButton(videoId);
}
function setupLikeObserver(videoId) {
const observer = new MutationObserver(() => {
const likeCountEl = document.querySelector('#video_likes');
if (likeCountEl && !likeCountEl.dataset.observed) {
likeCountEl.dataset.observed = 'true';
let lastCount = parseInt(likeCountEl.innerText) || 0;
new MutationObserver(() => {
const current = parseInt(likeCountEl.innerText);
if (current > lastCount) {
const data = extractVideoData();
if (data) { StorageManager.addLikedVideo(data); showToast(t('msg_saved_liked')); }
}
lastCount = current;
}).observe(likeCountEl, { childList: true, characterData: true, subtree: true });
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function injectPlaylistButton(videoId) {
const findTarget = () => {
const selectors = [`#favorite_video_${videoId}`, `#vote_like_${videoId}`, '.fa-heart', '.fa-thumbs-up', '.fa-share-alt'];
for (const sel of selectors) { const el = document.querySelector(sel); if (el) return el.closest('a, button') || el; }
return document.querySelector('.video-actions') || document.querySelector('.video-info .pull-right');
};
const attemptInject = () => {
if (document.querySelector('.tm-playlist-btn-inline')) return true;
const targetBtn = findTarget();
if (targetBtn && targetBtn.parentNode) {
const btn = document.createElement('a');
const btnClass = targetBtn.tagName === 'BUTTON' ? 'btn btn-default' : (targetBtn.className || 'btn btn-default');
btn.className = btnClass + ' tm-playlist-btn-inline';
btn.href = 'javascript:void(0);';
btn.innerHTML = `<i class="fa fa-folder-open"></i><span style="margin-left:4px;">${t('modal_title')}</span>`;
btn.title = 'TokyoMotion Enhancer List';
btn.style.marginLeft = '5px';
btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openPlaylistModal(videoId); });
targetBtn.parentNode.insertBefore(btn, targetBtn.nextSibling);
return true;
}
return false;
};
if (!attemptInject()) {
const observer = new MutationObserver((mutations, obs) => { if (attemptInject()) obs.disconnect(); });
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
}
}
async function openPlaylistModal(videoId) {
const existing = document.querySelector('.tm-modal-overlay'); if (existing) existing.remove();
const currentDuration = getDurationFromPlayer();
const videoData = extractVideoData(currentDuration);
const playlists = await StorageManager.getPlaylists();
const names = Object.keys(playlists);
const currentCols = StorageManager.getModalCols();
const overlay = document.createElement('div');
overlay.className = 'tm-modal-overlay';
overlay.innerHTML = `
<div class="tm-modal-content">
<div class="tm-modal-header">
<div style="display:flex; align-items:center; gap:10px;">
<span>${t('modal_title')}</span>
<select id="tm-col-selector" class="tm-col-select" title="Cols">
<option value="1">1${t('stg_col_unit')}</option>
<option value="2">2${t('stg_col_unit')}</option>
<option value="3">3${t('stg_col_unit')}</option>
<option value="4">4${t('stg_col_unit')}</option>
<option value="5">5${t('stg_col_unit')}</option>
</select>
</div>
<button type="button" class="tm-panel-close" aria-label="${t('btn_close')}" title="${t('btn_close')}">${UI_ICONS.close}</button>
</div>
<div class="tm-playlist-list" style="grid-template-columns: repeat(${currentCols}, 1fr);">
${names.map(name => {
const checked = playlists[name].some(v => v.id === videoId) ? 'checked' : '';
return `<label class="tm-playlist-item"><input type="checkbox" data-name="${name}" ${checked}> ${name}</label>`;
}).join('')}
</div>
<div class="tm-modal-footer">
<div class="tm-new-playlist-form">
<input type="text" class="tm-input" id="tm-modal-new-name" placeholder="${t('placeholder_new_playlist')}" style="margin:0;">
<button class="tm-btn-primary" id="tm-modal-create">${t('btn_create')}</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
const updateModalSize = (cols) => {
const content = overlay.querySelector('.tm-modal-content');
const list = overlay.querySelector('.tm-playlist-list');
list.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
let width = 320;
if (cols === 2) width = 450; if (cols === 3) width = 600; if (cols === 4) width = 750; if (cols === 5) width = 900;
content.style.width = `${width}px`;
};
updateModalSize(currentCols);
overlay.querySelector('#tm-col-selector').value = currentCols;
overlay.querySelector('.tm-panel-close').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('#tm-col-selector').addEventListener('change', (e) => {
const val = parseInt(e.target.value); StorageManager.setModalCols(val); updateModalSize(val);
});
overlay.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', async (e) => {
const name = e.target.dataset.name;
if (e.target.checked) { await StorageManager.addToPlaylist(name, videoData); showToast(t('msg_added_to', { name })); }
else { await StorageManager.removeFromPlaylist(name, videoId); showToast(t('msg_removed_from', { name })); }
});
});
overlay.querySelector('#tm-modal-create').addEventListener('click', async () => {
const name = overlay.querySelector('#tm-modal-new-name').value.trim();
if (name && await StorageManager.createPlaylist(name)) {
await StorageManager.addToPlaylist(name, videoData); showToast(t('msg_created_added', { name })); overlay.remove();
}
});
}
function attemptAutoLogin() {
if (!StorageManager.isAutoLoginEnabled()) return;
const loginLink = document.querySelector('a[href="#login-modal"]'); if (!loginLink) return;
const modal = document.getElementById('login-modal');
if (!(modal && (modal.style.display === 'block' || modal.classList.contains('in')))) loginLink.click();
let attempts = 0;
const interval = setInterval(() => {
const user = document.getElementById('login_username'); const pass = document.getElementById('login_password'); const btn = document.getElementById('login_submit');
if (user && pass && btn) {
if (!user.value && document.activeElement !== user) { user.focus(); user.click(); }
else if (user.value && !pass.value && document.activeElement !== pass) { pass.focus(); pass.click(); }
if (user.value && pass.value) { clearInterval(interval); showToast(t('msg_auto_login')); btn.click(); }
}
if (++attempts > 50) clearInterval(interval);
}, 100);
}
function exportData() {
const data = {
liked: GM_getValue('likedVideos', []),
history: GM_getValue('history', []),
playlists: GM_getValue('playlists', {}),
playlistOrder: GM_getValue('playlistOrder', []),
settings: {
defaultTab: GM_getValue('defaultTab', 'liked'),
autoLogin: GM_getValue('autoLoginEnabled', false),
uiMode: StorageManager.getUIMode(),
tabOrder: GM_getValue('tabOrder', DEFAULT_TAB_ORDER),
tabVisibility: StorageManager.getTabVisibility(),
panelState: GM_getValue('panelState', null),
btnPosition: GM_getValue('btnPosition', null),
mobilePanelState: GM_getValue('mobilePanelState', null),
mobileBtnPosition: GM_getValue('mobileBtnPosition', null),
contentFilterEnabled: GM_getValue('contentFilterEnabled', true),
blockedUploaders: GM_getValue('blockedUploaders', []),
mutedTitleTerms: GM_getValue('mutedTitleTerms', []),
feedFetchModes: GM_getValue('feedFetchModes', StorageManager.getDefaultFetchModes()),
friendsFetchModes: GM_getValue('friendsFetchModes', StorageManager.getDefaultFetchModes()),
feedMaxDays: GM_getValue('feedMaxDays', 3),
friendsMaxDays: GM_getValue('friendsMaxDays', 3),
feedMaxPages: GM_getValue('feedMaxPages', 1),
friendsMaxPages: GM_getValue('friendsMaxPages', 1)
}
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `tokyomotion_backup_${new Date().toISOString().slice(0, 10)}.json`;
a.click(); URL.revokeObjectURL(url);
}
function importData() {
const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json';
input.onchange = (e) => {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
if (confirm(t('confirm_overwrite'))) {
if (data.liked) GM_setValue('likedVideos', data.liked);
if (data.history) GM_setValue('history', data.history);
if (data.playlists) GM_setValue('playlists', data.playlists);
if (data.playlistOrder) GM_setValue('playlistOrder', data.playlistOrder);
if (data.settings) {
GM_setValue('defaultTab', data.settings.defaultTab);
GM_setValue('autoLoginEnabled', data.settings.autoLogin);
if (data.settings.uiMode) StorageManager.setUIMode(data.settings.uiMode);
if (data.settings.tabOrder) GM_setValue('tabOrder', data.settings.tabOrder);
if (data.settings.tabVisibility) StorageManager.setTabVisibility(data.settings.tabVisibility);
if (data.settings.panelState) GM_setValue('panelState', data.settings.panelState);
if (data.settings.btnPosition) GM_setValue('btnPosition', data.settings.btnPosition);
if (data.settings.mobilePanelState) GM_setValue('mobilePanelState', data.settings.mobilePanelState);
if (data.settings.mobileBtnPosition) GM_setValue('mobileBtnPosition', data.settings.mobileBtnPosition);
if (typeof data.settings.contentFilterEnabled === 'boolean') GM_setValue('contentFilterEnabled', data.settings.contentFilterEnabled);
if (Array.isArray(data.settings.blockedUploaders)) GM_setValue('blockedUploaders', data.settings.blockedUploaders);
if (Array.isArray(data.settings.mutedTitleTerms)) GM_setValue('mutedTitleTerms', data.settings.mutedTitleTerms);
if (data.settings.feedFetchModes) GM_setValue('feedFetchModes', StorageManager.normalizeFetchModes(data.settings.feedFetchModes));
if (data.settings.friendsFetchModes) GM_setValue('friendsFetchModes', StorageManager.normalizeFetchModes(data.settings.friendsFetchModes));
if (data.settings.feedMaxDays) GM_setValue('feedMaxDays', data.settings.feedMaxDays);
if (data.settings.friendsMaxDays) GM_setValue('friendsMaxDays', data.settings.friendsMaxDays);
if (data.settings.feedMaxPages) GM_setValue('feedMaxPages', data.settings.feedMaxPages);
if (data.settings.friendsMaxPages) GM_setValue('friendsMaxPages', data.settings.friendsMaxPages);
}
alert(t('msg_import_done')); location.reload();
}
} catch (err) { alert(t('msg_import_error', { msg: err })); }
};
reader.readAsText(file);
};
input.click();
}
function clearAllData() {
if (confirm(t('confirm_clear_all'))) {
GM_setValue('likedVideos', []); GM_setValue('history', []); GM_setValue('playlists', {}); GM_setValue('feedData', []); GM_setValue('friendsFeedData', []);
alert(t('msg_data_cleared')); location.reload();
}
}
function applyVideoGridCols() {
const configuredCols = Math.max(1, parseInt(StorageManager.getVideoGridCols(), 10) || 1);
const cols = isMobileUIMode() ? Math.min(configuredCols, 2) : configuredCols;
document.documentElement.style.setProperty('--tm-video-cols', cols);
}
function init() {
if (window.self !== window.top) return;
// 終了時間の記録
window.addEventListener('beforeunload', () => {
StorageManager.setLastClosedTime(Date.now());
});
// 起動時のリセットチェック
const lastClosed = StorageManager.getLastClosedTime();
if (lastClosed > 0) {
const diff = Date.now() - lastClosed;
const threshold = getScrollResetMs();
if (diff > threshold) {
// リセット対象のデータをクリア
const tabsToReset = ['liked', 'history', 'playlists', 'feed', 'friends'];
tabsToReset.forEach(tab => {
StorageManager.setTabScroll(tab, 0);
});
StorageManager.setActivePlaylist(null);
// 次回起動時にリセット通知を出すためのフラグを立てる(オプション)
// 今回は単純にすべてのタブスクロールを0にするため、UI生成時にそれが反映されるはず
// ただし、パネルがまだ生成されていないので、パネル生成後に適用される必要がある。
// restoreScrollPosition はパネル生成後に呼ばれるので、ここで値を0にしておけば0が復元される。
// 通知はDOMがないので出せないが、コンソールに出しておく
console.log(`[TokyoMotion Enhancer] Startup scroll reset triggered. (Closed for ${diff}ms > ${threshold}ms)`);
// DOMが準備できているはずなので通知を試みる
if (document.body) {
setTimeout(() => showToast(t('msg_scroll_reset')), 500); // UI描画と被らないよう少し遅延
} else {
document.addEventListener('DOMContentLoaded', () => setTimeout(() => showToast(t('msg_scroll_reset')), 500));
}
}
}
createMainUI();
if (location.pathname.startsWith('/video/')) setupVideoPage();
if (StorageManager.isAutoLoginEnabled()) {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', attemptAutoLogin);
else attemptAutoLogin();
}
PrivateScanner.startObserver();
ContentFilter.start();
}
init();
})();