Greasy Fork is available in English.
为每条推文添加 🎞️ 媒体按钮和 🔗 链接按钮。媒体按钮可复制图片/视频链接或以结构化文件名下载;长按可附加自定义前缀(Markdown 链接格式)。链接按钮复制推文网址;长按切换为 vxtwitter、fixupx 等可嵌入域名。可通过脚本管理器菜单配置。
当前为
// ==UserScript==
// @name Twitter / X — Media Copy & Download
// @name:zh-TW Twitter / X — 媒體複製與下載
// @name:zh-CN Twitter / X — 媒体复制与下载
// @name:ja Twitter / X — メディアコピー & ダウンロード
// @name:ko Twitter / X — 미디어 복사 & 다운로드
// @name:es Twitter / X — Copiar y Descargar Medios
// @name:pt-BR Twitter / X — Copiar e Baixar Mídia
// @name:fr Twitter / X — Copier & Télécharger les Médias
// @name:ru Twitter / X — Копирование и загрузка медиа
// @namespace http://greasyfork.icu/en/users/1575945-star-tanuki07?locale_override=1
// @version 1.6.0
// @license MIT
// @author Star_tanuki07
// @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com
// @match https://twitter.com/*
// @match https://x.com/*
// @grant GM_setClipboard
// @grant unsafeWindow
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @connect twitter.com
// @connect x.com
// @connect twimg.com
// @run-at document-idle
// @description Adds a 🎞️ media button and a 🔗 link button to every tweet. The media button copies image/video URLs or downloads files with structured filenames; long-press attaches a custom prefix in Markdown link format. The link button copies the tweet URL; long-press switches to an embed-friendly domain (e.g. vxtwitter, fixupx). Configurable via the userscript manager menu.
// @description:zh-TW 在每則推文注入 🎞️ 媒體按鈕與 🔗 連結按鈕。媒體按鈕可複製圖片/影片連結或以結構化檔名下載;長按可附加自訂前綴(Markdown 連結格式)。連結按鈕複製推文網址;長按切換為 vxtwitter、fixupx 等可嵌入域名。可透過腳本管理器選單設定。
// @description:zh-CN 为每条推文添加 🎞️ 媒体按钮和 🔗 链接按钮。媒体按钮可复制图片/视频链接或以结构化文件名下载;长按可附加自定义前缀(Markdown 链接格式)。链接按钮复制推文网址;长按切换为 vxtwitter、fixupx 等可嵌入域名。可通过脚本管理器菜单配置。
// @description:ja 各ツイートに 🎞️ メディアボタンと 🔗 リンクボタンを追加。メディアボタンは画像/動画URLのコピーや構造化ファイル名でのダウンロードに対応し、長押しでMarkdownリンク形式のカスタムプレフィックスを付加。リンクボタンはツイートURLをコピーし、長押しでvxtwitter・fixupxなど埋め込み対応ドメインに切替。スクリプトマネージャーのメニューから設定可能。
// @description:ko 모든 트윗에 🎞️ 미디어 버튼과 🔗 링크 버튼을 추가합니다. 미디어 버튼은 이미지/동영상 URL 복사 및 구조화 파일명으로 다운로드를 지원하며, 길게 누르면 Markdown 링크 형식의 커스텀 접두사를 첨부합니다. 링크 버튼은 트윗 URL을 복사하고, 길게 누르면 vxtwitter·fixupx 등 임베드 도메인으로 전환합니다. 스크립트 관리자 메뉴에서 설정 가능.
// @description:es Agrega un botón 🎞️ de medios y un botón 🔗 de enlace a cada tweet. El botón de medios copia URLs de imágenes/videos o descarga archivos con nombres estructurados; mantenga presionado para adjuntar un prefijo personalizado en formato Markdown.
// @description:pt-BR Adiciona um botão 🎞️ de mídia e um botão 🔗 de link a cada tweet. O botão de mídia copia URLs de imagens/vídeos ou baixa arquivos com nomes estruturados; pressione longo para anexar um prefixo personalizado no formato Markdown.
// @description:fr Ajoute un bouton 🎞️ média et un bouton 🔗 lien à chaque tweet. Le bouton média copie les URLs d'images/vidéos ou télécharge les fichiers avec des noms structurés ; appui long pour joindre un préfixe personnalisé en format Markdown.
// @description:ru Добавляет кнопку 🎞️ медиа и кнопку 🔗 ссылки к каждому твиту. Кнопка медиа копирует URL изображений/видео или скачивает файлы со структурированными именами; долгое нажатие добавляет префикс в формате Markdown. Кнопка ссылки копирует URL твита; долгое нажатие переключает домен на vxtwitter, fixupx и др.
// ==/UserScript==
(function () {
'use strict';
const KEY_PREFIX_TEXT = 'discord_prefix_text';
const KEY_LANG = 'app_language';
const KEY_LINK_DOMAIN_LONG = 'app_link_domain_long';
const KEY_LINK_DOMAIN_CLICK = 'app_link_domain_click';
const KEY_CLICK_MODE_CUSTOM = 'app_link_click_mode_custom';
const KEY_DATE_FORMAT = 'app_date_format';
const KEY_CUSTOM_LANG = 'app_custom_lang_json';
const KEY_VIDEO_VOLUME = 'app_video_volume';
const KEY_ONBOARDING_DONE = 'app_onboarding_done';
const KEY_FEEDBACK_STYLE = 'app_feedback_style';
const KEY_SEEN_FEATURES = 'app_seen_features';
const KEY_HISTORY_RECORDS = 'app_history_records';
const KEY_HISTORY_PANEL_POS = 'app_history_panel_pos';
const KEY_HISTORY_VIEW_MODE = 'app_history_view_mode';
const HISTORY_MAX_RECORDS = 300;
const NEW_FEATURE_IDS = [
'feedback_style',
'history_panel',
];
const DOMAIN_LIST = [
"vxtwitter.com",
"fixupx.com",
"fxtwitter.com",
"cunnyx.com",
"fixvx.com",
"twitter.com",
"x.com"
];
const TR = {
'en': {
langName: 'English',
menu_domain_click: '🔗 Set "Single-Click" Behavior',
menu_domain_long: '🔗 Set "Long-Press" Domain',
menu_prefix: '⚙️ Set Custom Prefix (Discord)',
menu_lang: '🌐 Change Language',
menu_help: '📖 Help / Manual',
prompt_prefix: 'Enter custom prefix (e.g., [text]):',
prompt_lang: 'Select language (enter number):\n1. English\n2. 繁體中文\n3. 简体中文\n4. 日本語\n5. 한국어\n6. Español\n7. Português (BR)\n8. Français\n9. Русский\n10. ✏️ Custom Language',
prompt_domain: 'Select domain (Enter Number):\n',
status_default: 'Default (x.com)',
status_custom: 'Custom',
btn_tooltip: 'Left Click: Copy Media Links\nMiddle Click: Preview Video / Image Lightbox\nRight Click: Download Files',
link_tooltip: 'Click: Copy ',
link_tooltip_long: '\nLong Press: Copy prefix + ',
msg_prefix_copied: 'Prefix Copied',
msg_copied: 'Copied',
msg_downloaded: 'Downloaded',
msg_no_media: '❌ No Media',
play_btn_tooltip: 'Click: Preview Video in Floating Player',
msg_no_video: '❌ No Video',
reload_msg: 'Settings Saved',
toast_domain_click: '🔗 Single-Click Domain → ',
toast_domain_long: '🔗 Long-Press Domain → ',
toast_prefix: '⚙️ Discord Prefix → ',
toast_date_fmt: '📅 Date Format → ',
toast_lang_pending: '🌐 Language change will apply after reload.',
confirm_lang_reload: 'Language changed to {lang}.\nReload page now to apply?',
menu_date_format: '📅 Date Format',
status_date_asian: 'Asian (YYYY.MM.DD)',
status_date_western: 'Western (DD.MM.YYYY)',
help_title: 'Twitter Media Copy Button - Manual',
help_content: `
<p><b>🖱️ Media Button (🎞️):</b><br>
• <b>Left Click:</b> Copy media links (images / video URLs).<br>
• <b>Long Press (0.5s):</b> Copy links with custom prefix (Markdown format, for Discord).<br>
• <b>Middle Click:</b> Preview — floating video player or image lightbox.<br>
• <b>Right Click:</b> Force download all media with structured filenames.<br>
(Format: <code>[twitter] Name(@ID)_Date_Text_ID.ext</code>)</p>
<hr>
<p><b>🔗 Link Button (🔗):</b><br>
• <b>Click:</b> Copy tweet link (default: x.com, or custom click domain).<br>
• <b>Long Press:</b> Copy with custom prefix + long-press embed domain (e.g. fixupx).</p>
<hr>
<p><b>📋 Download History:</b><br>
• Right-click downloads are automatically logged (up to 300 entries).<br>
• Downloaded tweets show a 🟢 badge on the 🎞️ button.<br>
• Click 📋 (top-right) to browse history: list / thumbnail view, search, export CSV / JSON.</p>
<hr>
<p><b>⚙️ Settings Panel:</b><br>
• Hover the top-right corner → 📋 history / ⚙️ gear button appears.<br>
• Configure: click domain, long-press domain, Discord prefix, date format, language, feedback style.</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ Disclaimer:</b><br>
Embed domains (e.g. fixupx, vxtwitter) are third-party services unaffiliated with this script.</p>
`,
onboard_title: '⚙ Settings Panel',
onboard_body: 'Hover the top-right corner to reveal the settings button. Click it to quickly manage domains, prefix, language and more — no script manager menu needed.',
onboard_got_it: 'Got it!',
menu_feedback_style: '🔔 Feedback Style',
status_feedback_toast: 'Toast',
status_feedback_silent: 'Silent',
toast_feedback_style: '🔔 Feedback Style → ',
},
'zh-TW': {
langName: '繁體中文',
menu_domain_click: '🔗 設定「單擊」行為模式',
menu_domain_long: '🔗 設定「長按」網址域名',
menu_prefix: '⚙️ 設定 Discord 前綴文字',
menu_lang: '🌐 切換語言 (Change Language)',
menu_help: '📖 使用說明書',
prompt_prefix: '請輸入自定義前綴(例如 [text]):',
prompt_lang: '請輸入數字選擇語言:\n1. English\n2. 繁體中文\n3. 简体中文\n4. 日本語\n5. 한국어\n6. Español\n7. Português (BR)\n8. Français\n9. Русский\n10. ✏️ Custom Language',
prompt_domain: '請輸入數字選擇域名:\n',
status_default: '預設 (x.com)',
status_custom: '自定義',
btn_tooltip: '左鍵:複製媒體連結\n中鍵:預覽影片 / 圖片燈箱\n右鍵:強制下載檔案',
link_tooltip: '點擊:複製 ',
link_tooltip_long: '\n長按:複製前綴 + ',
msg_prefix_copied: '前綴已複製',
msg_copied: '已複製',
msg_downloaded: '已下載',
msg_no_media: '❌ 無媒體',
play_btn_tooltip: '點擊:在浮動播放器中預覽影片',
msg_no_video: '❌ 無影片',
reload_msg: '設定已儲存',
toast_domain_click: '🔗 單擊域名 → ',
toast_domain_long: '🔗 長按域名 → ',
toast_prefix: '⚙️ Discord 前綴 → ',
toast_date_fmt: '📅 日期格式 → ',
toast_lang_pending: '🌐 語言已變更,重新載入後生效。',
confirm_lang_reload: '語言已切換為 {lang}。\n立即重新載入頁面以套用?',
menu_date_format: '📅 日期格式',
status_date_asian: '亞洲慣用 (YYYY.MM.DD)',
status_date_western: '歐美慣用 (DD.MM.YYYY)',
help_title: '推特媒體腳本 — 說明書',
help_content: `
<p><b>🖱️ 媒體按鈕 (🎞️):</b><br>
• <b>左鍵單擊:</b> 複製推文中所有圖片/影片連結。<br>
• <b>長按 (0.5秒):</b> 複製含自定義前綴的連結,例如 <code>[text](url)</code>(方便 Discord 嵌入)。<br>
• <b>中鍵點擊:</b> 開啟浮動影片播放器或圖片燈箱。<br>
• <b>右鍵點擊:</b> 下載全部媒體,自動生成結構化檔名。<br>
(格式:<code>[twitter] 暱稱(@ID)_日期_內文_ID.副檔名</code>)</p>
<hr>
<p><b>🔗 連結按鈕 (🔗):</b><br>
• <b>單擊:</b> 複製推文網址(x.com 或自定義單擊域名)。<br>
• <b>長按:</b> 複製前綴 + 長按域名網址(如 fixupx.com)。</p>
<hr>
<p><b>📋 下載履歷:</b><br>
• 右鍵下載後自動記錄(最多 300 筆)。<br>
• 滑鼠移至右上角 → 點擊 🕐 開啟履歷面板。<br>
• 支援列表/縮圖切換、搜尋、Shift 區間選取、批次刪除、CSV/JSON 匯出。</p>
<hr>
<p><b>⚙️ 設定面板:</b><br>
• 將滑鼠移至右上角,顯示齒輪 ⚙️ 與履歷 🕐 按鈕。<br>
• 點擊 ⚙️ 可設定:單擊域名、長按域名、Discord 前綴、提示風格、日期格式、語言。</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ 免責聲明:</b><br>
fixupx / vxtwitter 等域名皆為第三方服務,與本腳本無關,請僅使用您信任的域名。</p>
`,
onboard_title: '⚙ 設定面板',
onboard_body: '將滑鼠移至右上角即可叫出設定按鈕,點擊後可快速管理域名、前綴、語言等設定,無需開啟腳本管理器選單。',
onboard_got_it: '知道了!',
menu_feedback_style: '🔔 提示風格',
status_feedback_toast: 'Toast 提示',
status_feedback_silent: '靜默(僅圖示)',
toast_feedback_style: '🔔 提示風格 → ',
},
'zh-CN': {
langName: '简体中文',
menu_domain_click: '🔗 设置“单击”行为模式',
menu_domain_long: '🔗 设置“长按”网址域名',
menu_prefix: '⚙️ 设置 Discord 前缀文字',
menu_lang: '🌐 切换语言 (Change Language)',
menu_help: '📖 使用说明书',
prompt_prefix: '请输入自定义前缀(例如 [text]):',
prompt_lang: '请输入数字选择语言:\n1. English\n2. 繁體中文\n3. 简体中文\n4. 日本語\n5. 한국어\n6. Español\n7. Português (BR)\n8. Français\n9. Русский\n10. ✏️ Custom Language',
prompt_domain: '请输入数字选择域名:\n',
status_default: '默认 (x.com)',
status_custom: '自定义',
btn_tooltip: '左键:复制媒体链接\n中键:预览视频 / 图片灯箱\n右键:强制下载文件',
link_tooltip: '点击:复制 ',
link_tooltip_long: '\n长按:复制前缀 + ',
msg_prefix_copied: '前缀已复制',
msg_copied: '已复制',
msg_downloaded: '已下载',
msg_no_media: '❌ 无媒体',
play_btn_tooltip: '点击:在浮动播放器中预览视频',
msg_no_video: '❌ 无视频',
reload_msg: '设置已保存',
toast_domain_click: '🔗 单击域名 → ',
toast_domain_long: '🔗 长按域名 → ',
toast_prefix: '⚙️ Discord 前缀 → ',
toast_date_fmt: '📅 日期格式 → ',
toast_lang_pending: '🌐 语言已变更,重新载入后生效。',
confirm_lang_reload: '语言已切换为 {lang}。\n立即重新载入页面以应用?',
menu_date_format: '📅 日期格式',
status_date_asian: '亚洲惯用 (YYYY.MM.DD)',
status_date_western: '欧美惯用 (DD.MM.YYYY)',
help_title: '推特媒体脚本 — 说明书',
help_content: `
<p><b>🖱️ 媒体按钮 (🎞️):</b><br>
• <b>左键单击:</b> 复制推文中所有图片/视频链接。<br>
• <b>长按 (0.5秒):</b> 复制含自定义前缀的链接,例如 <code>[text](url)</code>(方便 Discord 嵌入)。<br>
• <b>中键单击:</b> 打开浮动视频播放器或图片灯箱。<br>
• <b>右键单击:</b> 下载全部媒体,自动生成结构化文件名。<br>
(格式:<code>[twitter] 昵称(@ID)_日期_内文_ID.扩展名</code>)</p>
<hr>
<p><b>🔗 链接按钮 (🔗):</b><br>
• <b>单击:</b> 复制推文链接(x.com 或自定义单击域名)。<br>
• <b>长按:</b> 复制前缀 + 长按域名链接(如 fixupx.com)。</p>
<hr>
<p><b>📋 下载历史:</b><br>
• 右键下载后自动记录(最多 300 条)。<br>
• 鼠标移至右上角 → 点击 🕐 打开历史面板。<br>
• 支持列表/缩略图切换、搜索、Shift 区间选择、批量删除、CSV/JSON 导出。</p>
<hr>
<p><b>⚙️ 设置面板:</b><br>
• 将鼠标移至右上角,显示齿轮 ⚙️ 与历史 🕐 按钮。<br>
• 点击 ⚙️ 可设置:单击域名、长按域名、Discord 前缀、提示风格、日期格式、语言。</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ 免责声明:</b><br>
fixupx / vxtwitter 等域名均为第三方服务,与本脚本无关,请仅使用您信任的域名。</p>
`,
onboard_title: '⚙ 设置面板',
onboard_body: '将鼠标移到右上角即可呼出设置按钮,点击后可快速管理域名、前缀、语言等设置,无需打开脚本管理器菜单。',
onboard_got_it: '知道了!',
menu_feedback_style: '🔔 提示风格',
status_feedback_toast: 'Toast 提示',
status_feedback_silent: '静默(仅图标)',
toast_feedback_style: '🔔 提示风格 → ',
},
'ja': {
langName: '日本語',
menu_domain_click: '🔗 クリック動作設定',
menu_domain_long: '🔗 長押しURLドメイン設定',
menu_prefix: '⚙️ プレフィックス設定 (Discord)',
menu_lang: '🌐 言語変更 (Change Language)',
menu_help: '📖 ヘルプ / 説明書',
prompt_prefix: 'カスタムプレフィックスを入力(例: [text]):',
prompt_lang: '番号を入力して言語を選択:\n1. English\n2. 繁體中文\n3. 简体中文\n4. 日本語\n5. 한국어\n6. Español\n7. Português (BR)\n8. Français\n9. Русский\n10. ✏️ Custom Language',
prompt_domain: 'ドメインの番号を入力:\n',
status_default: 'デフォルト (x.com)',
status_custom: 'カスタム',
btn_tooltip: '左:メディアリンクをコピー\n中:動画プレビュー / 画像ライトボックス\n右:ファイルをダウンロード',
link_tooltip: 'クリック:コピー ',
link_tooltip_long: '\n長押し:プレフィックス付きコピー ',
msg_prefix_copied: 'プレフィックス付',
msg_copied: 'コピー完了',
msg_downloaded: 'ダウンロード完了',
msg_no_media: '❌ メディアなし',
play_btn_tooltip: 'クリック:フローティングプレーヤーで動画を再生',
msg_no_video: '❌ 動画なし',
reload_msg: '設定が保存されました',
toast_domain_click: '🔗 クリックドメイン → ',
toast_domain_long: '🔗 長押しドメイン → ',
toast_prefix: '⚙️ Discordプレフィックス → ',
toast_date_fmt: '📅 日付フォーマット → ',
toast_lang_pending: '🌐 言語を変更しました。再読み込み後に反映されます。',
confirm_lang_reload: '言語を {lang} に変更しました。\n今すぐページを再読み込みしますか?',
menu_date_format: '📅 日付フォーマット',
status_date_asian: 'アジア式 (YYYY.MM.DD)',
status_date_western: '欧米式 (DD.MM.YYYY)',
help_title: 'Twitter メディアスクリプト — マニュアル',
help_content: `
<p><b>🖱️ メディアボタン (🎞️):</b><br>
• <b>左クリック:</b> ツイート内の画像/動画URLをすべてコピー。<br>
• <b>長押し (0.5秒):</b> カスタムプレフィックス付きでコピー(例:<code>[text](url)</code>、Discord向け)。<br>
• <b>中クリック:</b> フローティング動画プレーヤーまたは画像ライトボックスを開く。<br>
• <b>右クリック:</b> 全メディアをダウンロード(構造化ファイル名)。<br>
(形式:<code>[twitter] 名前(@ID)_日付_本文_ID.拡張子</code>)</p>
<hr>
<p><b>🔗 リンクボタン (🔗):</b><br>
• <b>クリック:</b> ツイートURLをコピー(x.com またはカスタムドメイン)。<br>
• <b>長押し:</b> プレフィックス + 長押しドメインURLをコピー(例:fixupx.com)。</p>
<hr>
<p><b>📋 ダウンロード履歴:</b><br>
• 右クリックダウンロードは自動記録(最大300件)。<br>
• 右上にカーソルを合わせ → 🕐 をクリックして履歴パネルを開く。<br>
• リスト/サムネイル表示、検索、Shift選択一括削除、CSV/JSONエクスポート対応。</p>
<hr>
<p><b>⚙️ 設定パネル:</b><br>
• 右上隅にカーソルを合わせると ⚙️ と 🕐 ボタンが表示される。<br>
• ⚙️ をクリックして設定:クリックドメイン、長押しドメイン、プレフィックス、通知スタイル、日付形式、言語。</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ 免責事項:</b><br>
fixupx / vxtwitter 等のドメインは第三者サービスであり、このスクリプトとは無関係です。信頼できるドメインのみご使用ください。</p>
`,
onboard_title: '⚙ 設定パネル',
onboard_body: '右上隅にカーソルを合わせると設定ボタンが現れます。クリックすればスクリプト管理器を開かずにドメイン・プレフィックス・言語などをすばやく管理できます。',
onboard_got_it: 'わかった!',
menu_feedback_style: '🔔 フィードバックスタイル',
status_feedback_toast: 'トースト通知',
status_feedback_silent: 'サイレント(アイコンのみ)',
toast_feedback_style: '🔔 フィードバックスタイル → ',
},
'ko': {
langName: '한국어',
menu_domain_click: '🔗 클릭 동작 설정',
menu_domain_long: '🔗 길게 누르기 도메인 설정',
menu_prefix: '⚙️ 접두사 설정 (Discord)',
menu_lang: '🌐 언어 변경 (Change Language)',
menu_help: '📖 도움말 / 설명서',
prompt_prefix: '사용자 지정 접두사 입력 (예: [text]):',
prompt_lang: '숫자를 입력하여 언어 선택:\n1. English\n2. 繁體中文\n3. 简体中文\n4. 日本語\n5. 한국어\n6. Español\n7. Português (BR)\n8. Français\n9. Русский\n10. ✏️ Custom Language',
prompt_domain: '도메인 번호를 선택하세요:\n',
status_default: '기본 (x.com)',
status_custom: '사용자 지정',
btn_tooltip: '왼쪽: 미디어 링크 복사\n가운데: 동영상 미리보기 / 이미지 라이트박스\n오른쪽: 파일 다운로드',
link_tooltip: '클릭: 복사 ',
link_tooltip_long: '\n길게 누르기: 접두사 포함 복사 ',
msg_prefix_copied: '접두사 복사됨',
msg_copied: '복사 완료',
msg_downloaded: '다운로드 완료',
msg_no_media: '❌ 미디어 없음',
play_btn_tooltip: '클릭: 플로팅 플레이어에서 동영상 재생',
msg_no_video: '❌ 동영상 없음',
reload_msg: '설정이 저장되었습니다',
toast_domain_click: '🔗 클릭 도메인 → ',
toast_domain_long: '🔗 길게 누르기 도메인 → ',
toast_prefix: '⚙️ Discord 접두사 → ',
toast_date_fmt: '📅 날짜 형식 → ',
toast_lang_pending: '🌐 언어가 변경되었습니다. 새로고침 후 적용됩니다.',
confirm_lang_reload: '언어가 {lang}(으)로 변경되었습니다.\n지금 페이지를 새로고침하시겠습니까?',
menu_date_format: '📅 날짜 형식',
status_date_asian: '아시아식 (YYYY.MM.DD)',
status_date_western: '서양식 (DD.MM.YYYY)',
help_title: '트위터 미디어 스크립트 — 설명서',
help_content: `
<p><b>🖱️ 미디어 버튼 (🎞️):</b><br>
• <b>좌클릭:</b> 트윗 내 모든 이미지/동영상 URL 복사。<br>
• <b>길게 누르기 (0.5초):</b> 커스텀 접두사 포함 복사(예:<code>[text](url)</code>, Discord용)。<br>
• <b>중간 클릭:</b> 동영상 플레이어 또는 이미지 라이트박스 열기。<br>
• <b>우클릭:</b> 모든 미디어 다운로드(구조화된 파일명 자동 생성)。<br>
(형식:<code>[twitter] 이름(@ID)_날짜_본문_ID.확장자</code>)</p>
<hr>
<p><b>🔗 링크 버튼 (🔗):</b><br>
• <b>클릭:</b> 트윗 URL 복사(x.com 또는 커스텀 도메인)。<br>
• <b>길게 누르기:</b> 접두사 + 길게 누르기 도메인 URL 복사(예:fixupx.com)。</p>
<hr>
<p><b>📋 다운로드 기록:</b><br>
• 우클릭 다운로드 후 자동 기록(최대 300건)。<br>
• 오른쪽 상단에 마우스를 올려 → 🕐 클릭으로 기록 패널 열기。<br>
• 목록/썸네일 보기, 검색, Shift 범위 선택, 일괄 삭제, CSV/JSON 내보내기 지원。</p>
<hr>
<p><b>⚙️ 설정 패널:</b><br>
• 오른쪽 상단에 마우스를 올리면 ⚙️ 와 🕐 버튼이 나타납니다。<br>
• ⚙️ 클릭으로 설정:클릭 도메인, 길게 누르기 도메인, 접두사, 알림 스타일, 날짜 형식, 언어。</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ 면책 조항:</b><br>
fixupx / vxtwitter 등은 본 스크립트와 무관한 제3자 서비스입니다. 신뢰할 수 있는 도메인만 사용하세요。</p>
`,
onboard_title: '⚙ 설정 패널',
onboard_body: '오른쪽 상단 모서리에 마우스를 올리면 설정 버튼이 나타납니다. 클릭하면 스크립트 관리자 없이 도메인, 접두사, 언어 등을 빠르게 관리할 수 있습니다.',
onboard_got_it: '알겠어요!',
menu_feedback_style: '🔔 피드백 스타일',
status_feedback_toast: '토스트',
status_feedback_silent: '조용히 (아이콘만)',
toast_feedback_style: '🔔 피드백 스타일 → ',
},
'es': {
langName: 'Español',
menu_domain_click: '🔗 Configurar comportamiento de "clic"',
menu_domain_long: '🔗 Configurar dominio de "pulsación larga"',
menu_prefix: '⚙️ Configurar prefijo personalizado (Discord)',
menu_lang: '🌐 Cambiar idioma (Change Language)',
menu_help: '📖 Ayuda / Manual',
prompt_prefix: 'Ingrese el prefijo personalizado (ej. [texto]):',
prompt_lang: 'Ingrese un número para seleccionar el idioma:\n1. English\n2. 繁體中文\n3. 简体中文\n4. 日本語\n5. 한국어\n6. Español\n7. Português (BR)\n8. Français\n9. Русский\n10. ✏️ Custom Language',
prompt_domain: 'Seleccione el número del dominio:\n',
status_default: 'Predeterminado (x.com)',
status_custom: 'Personalizado',
btn_tooltip: 'Clic izq: Copiar enlaces de medios\nClic central: Ver video / Galería de imágenes\nClic der: Descargar archivos',
link_tooltip: 'Clic: Copiar ',
link_tooltip_long: '\nPulsación larga: Copiar prefijo + ',
msg_prefix_copied: 'Prefijo copiado',
msg_copied: 'Copiado',
msg_downloaded: 'Descargado',
msg_no_media: '❌ Sin medios',
play_btn_tooltip: 'Clic: Ver video en reproductor flotante',
msg_no_video: '❌ Sin video',
reload_msg: 'Configuración guardada',
toast_domain_click: '🔗 Dominio de clic → ',
toast_domain_long: '🔗 Dominio de pulsación larga → ',
toast_prefix: '⚙️ Prefijo de Discord → ',
toast_date_fmt: '📅 Formato de fecha → ',
toast_lang_pending: '🌐 Idioma cambiado. Se aplicará al recargar.',
confirm_lang_reload: 'Idioma cambiado a {lang}.\n¿Recargar la página ahora?',
menu_date_format: '📅 Formato de fecha',
status_date_asian: 'Asiático (YYYY.MM.DD)',
status_date_western: 'Occidental (DD.MM.YYYY)',
help_title: 'Botón de copia de medios de Twitter - Manual',
help_content: `
<p><b>🖱️ Botón de medios (🎞️):</b><br>
• <b>Clic izquierdo:</b> Copiar enlaces de imágenes/videos.<br>
• <b>Pulsación larga (0.5s):</b> Copiar con prefijo personalizado (formato Markdown, para Discord).<br>
• <b>Clic central:</b> Vista previa——reproductor de video flotante o galería de imágenes.<br>
• <b>Clic derecho:</b> Descargar todos los medios con nombres de archivo estructurados.<br>
(Formato: <code>[twitter] Nombre(@ID)_Fecha_Texto_ID.ext</code>)</p>
<hr>
<p><b>🔗 Botón de enlace (🔗):</b><br>
• <b>Clic:</b> Copiar enlace del tweet (predeterminado x.com, o dominio personalizado de clic).<br>
• <b>Pulsación larga:</b> Copiar con prefijo + dominio de pulsación larga (ej. fixupx).</p>
<hr>
<p><b>📋 Historial de descargas:</b><br>
• Se registra automáticamente tras descargar con clic derecho (máx. 300 entradas).<br>
• Los tweets descargados muestran un 🟢 badge en el botón 🎞️.<br>
• Clic en 📋 (esquina superior derecha): vista lista/miniaturas, búsqueda, exportar CSV/JSON.</p>
<hr>
<p><b>⚙️ Panel de configuración:</b><br>
• Pasa el ratón por la esquina superior derecha → aparecen 📋 y ⚙️.<br>
• Configura: dominio de clic, dominio de pulsación larga, prefijo Discord, formato de fecha, idioma, estilo de notificación.</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ Aviso legal:</b><br>
Los dominios de conversión (ej. fixupx, vxtwitter) son servicios de terceros sin relación con este script.</p>
`,
onboard_title: '⚙ Panel de Configuración',
onboard_body: 'Mueve el cursor a la esquina superior derecha para revelar el botón de configuración. Haz clic para gestionar dominios, prefijo, idioma y más sin abrir el administrador de scripts.',
onboard_got_it: '¡Entendido!',
menu_feedback_style: '🔔 Estilo de Aviso',
status_feedback_toast: 'Toast',
status_feedback_silent: 'Silencioso (solo icono)',
toast_feedback_style: '🔔 Estilo de Aviso → ',
},
'pt-BR': {
langName: 'Português (BR)',
menu_domain_click: '🔗 Configurar comportamento de "clique"',
menu_domain_long: '🔗 Configurar domínio de "pressão longa"',
menu_prefix: '⚙️ Configurar prefixo personalizado (Discord)',
menu_lang: '🌐 Mudar idioma (Change Language)',
menu_help: '📖 Ajuda / Manual',
prompt_prefix: 'Digite o prefixo personalizado (ex. [texto]):',
prompt_lang: 'Digite um número para selecionar o idioma:\n1. English\n2. 繁體中文\n3. 简体中文\n4. 日本語\n5. 한국어\n6. Español\n7. Português (BR)\n8. Français\n9. Русский\n10. ✏️ Custom Language',
prompt_domain: 'Selecione o número do domínio:\n',
status_default: 'Padrão (x.com)',
status_custom: 'Personalizado',
btn_tooltip: 'Clique esq: Copiar links de mídia\nClique do meio: Visualizar vídeo / Galeria de imagens\nClique dir: Baixar arquivos',
link_tooltip: 'Clique: Copiar ',
link_tooltip_long: '\nPressão longa: Copiar prefixo + ',
msg_prefix_copied: 'Prefixo copiado',
msg_copied: 'Copiado',
msg_downloaded: 'Baixado',
msg_no_media: '❌ Sem mídia',
play_btn_tooltip: 'Clique: Reproduzir vídeo no player flutuante',
msg_no_video: '❌ Sem vídeo',
reload_msg: 'Configurações salvas',
toast_domain_click: '🔗 Domínio de clique → ',
toast_domain_long: '🔗 Domínio de pressão longa → ',
toast_prefix: '⚙️ Prefixo do Discord → ',
toast_date_fmt: '📅 Formato de data → ',
toast_lang_pending: '🌐 Idioma alterado. Será aplicado ao recarregar.',
confirm_lang_reload: 'Idioma alterado para {lang}.\nRecarregar a página agora?',
menu_date_format: '📅 Formato de data',
status_date_asian: 'Asiático (YYYY.MM.DD)',
status_date_western: 'Ocidental (DD.MM.YYYY)',
help_title: 'Botão de cópia de mídia do Twitter - Manual',
help_content: `
<p><b>🖱️ Botão de mídia (🎞️):</b><br>
• <b>Clique esquerdo:</b> Copiar links de imagens/vídeos.<br>
• <b>Pressão longa (0.5s):</b> Copiar com prefixo personalizado (formato Markdown, para Discord).<br>
• <b>Clique do meio:</b> Visualizar——player de vídeo flutuante ou galeria de imagens.<br>
• <b>Clique direito:</b> Baixar todas as mídias com nomes de arquivo estruturados.<br>
(Formato: <code>[twitter] Nome(@ID)_Data_Texto_ID.ext</code>)</p>
<hr>
<p><b>🔗 Botão de link (🔗):</b><br>
• <b>Clique:</b> Copiar link do tweet (padrão x.com, ou domínio de clique personalizado).<br>
• <b>Pressão longa:</b> Copiar com prefixo + domínio de pressão longa (ex. fixupx).</p>
<hr>
<p><b>📋 Histórico de downloads:</b><br>
• Registrado automaticamente após download com clique direito (máx. 300 entradas).<br>
• Tweets baixados mostram 🟢 badge no botão 🎞️.<br>
• Clique em 📋 (canto superior direito): lista/miniaturas, pesquisa, exportar CSV/JSON.</p>
<hr>
<p><b>⚙️ Painel de configurações:</b><br>
• Passe o mouse pelo canto superior direito → 📋 e ⚙️ aparecem.<br>
• Configure: domínio de clique, domínio de pressão longa, prefixo Discord, formato de data, idioma, estilo de aviso.</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ Aviso legal:</b><br>
Os domínios de conversão (ex. fixupx, vxtwitter) são serviços de terceiros sem relação com este script.</p>
`,
onboard_title: '⚙ Painel de Configurações',
onboard_body: 'Passe o mouse no canto superior direito para revelar o botão de configurações. Clique para gerenciar domínios, prefixo, idioma e mais sem abrir o gerenciador de scripts.',
onboard_got_it: 'Entendi!',
menu_feedback_style: '🔔 Estilo de Aviso',
status_feedback_toast: 'Toast',
status_feedback_silent: 'Silencioso (só ícone)',
toast_feedback_style: '🔔 Estilo de Aviso → ',
},
'fr': {
langName: 'Français',
menu_domain_click: '🔗 Configurer le comportement "clic"',
menu_domain_long: '🔗 Configurer le domaine "appui long"',
menu_prefix: '⚙️ Configurer le préfixe personnalisé (Discord)',
menu_lang: '🌐 Changer de langue (Change Language)',
menu_help: '📖 Aide / Manuel',
prompt_prefix: 'Entrez le préfixe personnalisé (ex. [texte]) :',
prompt_lang: 'Entrez un numéro pour sélectionner la langue :\n1. English\n2. 繁體中文\n3. 简体中文\n4. 日本語\n5. 한국어\n6. Español\n7. Português (BR)\n8. Français\n9. Русский\n10. ✏️ Custom Language',
prompt_domain: 'Sélectionnez le numéro du domaine :\n',
status_default: 'Par défaut (x.com)',
status_custom: 'Personnalisé',
btn_tooltip: 'Clic gauche : Copier les liens médias\nClic milieu : Aperçu vidéo / Galerie images\nClic droit : Télécharger les fichiers',
link_tooltip: 'Clic : Copier ',
link_tooltip_long: '\nAppui long : Copier préfixe + ',
msg_prefix_copied: 'Préfixe copié',
msg_copied: 'Copié',
msg_downloaded: 'Téléchargé',
msg_no_media: '❌ Aucun média',
play_btn_tooltip: 'Clic : Lire la vidéo dans le lecteur flottant',
msg_no_video: '❌ Aucune vidéo',
reload_msg: 'Paramètres enregistrés',
toast_domain_click: '🔗 Domaine clic → ',
toast_domain_long: '🔗 Domaine appui long → ',
toast_prefix: '⚙️ Préfixe Discord → ',
toast_date_fmt: '📅 Format de date → ',
toast_lang_pending: '🌐 Langue modifiée. Sera appliqué au rechargement.',
confirm_lang_reload: 'Langue changée en {lang}.\nRecharger la page maintenant ?',
menu_date_format: '📅 Format de date',
status_date_asian: 'Asiatique (YYYY.MM.DD)',
status_date_western: 'Occidental (DD.MM.YYYY)',
help_title: 'Bouton de copie de médias Twitter - Manuel',
help_content: `
<p><b>🖱️ Bouton média (🎞️) :</b><br>
• <b>Clic gauche :</b> Copier les liens des images/vidéos.<br>
• <b>Appui long (0.5s) :</b> Copier avec préfixe personnalisé (format Markdown, pour Discord).<br>
• <b>Clic central :</b> Aperçu——lecteur vidéo flottant ou galerie d'images.<br>
• <b>Clic droit :</b> Télécharger tous les médias avec noms de fichiers structurés.<br>
(Format : <code>[twitter] Nom(@ID)_Date_Texte_ID.ext</code>)</p>
<hr>
<p><b>🔗 Bouton lien (🔗) :</b><br>
• <b>Clic :</b> Copier le lien du tweet (x.com par défaut, ou domaine de clic personnalisé).<br>
• <b>Appui long :</b> Copier avec préfixe + domaine appui long (ex. fixupx).</p>
<hr>
<p><b>📋 Historique des téléchargements :</b><br>
• Enregistré automatiquement après téléchargement par clic droit (max. 300 entrées).<br>
• Les tweets téléchargés affichent un 🟢 badge sur le bouton 🎞️.<br>
• Cliquez sur 📋 (coin supérieur droit) : liste/miniatures, recherche, export CSV/JSON.</p>
<hr>
<p><b>⚙️ Panneau de paramètres :</b><br>
• Survolez le coin supérieur droit → 📋 et ⚙️ apparaissent.<br>
• Configurez : domaine de clic, domaine d'appui long, préfixe Discord, format de date, langue, style de retour.</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ Avertissement :</b><br>
Les domaines de conversion (ex. fixupx, vxtwitter) sont des services tiers sans lien avec ce script.</p>
`,
onboard_title: '⚙ Panneau de Paramètres',
onboard_body: 'Survolez le coin supérieur droit pour afficher le bouton de paramètres. Cliquez pour gérer les domaines, le préfixe, la langue et plus en utilisant le panneau de paramètres intégré.',
onboard_got_it: "Compris !",
menu_feedback_style: '🔔 Style de Retour',
status_feedback_toast: 'Toast',
status_feedback_silent: 'Silencieux (icône seul)',
toast_feedback_style: '🔔 Style de Retour → ',
},
'ru': {
langName: 'Русский',
menu_domain_click: '🔗 Настроить поведение «клика»',
menu_domain_long: '🔗 Настроить домен «долгого нажатия»',
menu_prefix: '⚙️ Настроить префикс (Discord)',
menu_lang: '🌐 Изменить язык (Change Language)',
menu_help: '📖 Справка / Руководство',
prompt_prefix: 'Введите префикс (например [текст]):',
prompt_lang: 'Введите номер для выбора языка:\n1. English\n2. 繁體中文\n3. 简体中文\n4. 日本語\n5. 한국어\n6. Español\n7. Português (BR)\n8. Français\n9. Русский\n10. ✏️ Custom Language',
prompt_domain: 'Выберите номер домена:\n',
status_default: 'По умолчанию (x.com)',
status_custom: 'Пользовательский',
btn_tooltip: 'Левый клик: Копировать медиа-ссылки\nСредний клик: Просмотр видео / Галерея изображений\nПравый клик: Скачать файлы',
link_tooltip: 'Клик: Копировать ',
link_tooltip_long: '\nДолгое нажатие: Копировать с префиксом ',
msg_prefix_copied: 'Префикс скопирован',
msg_copied: 'Скопировано',
msg_downloaded: 'Загружено',
msg_no_media: '❌ Нет медиа',
play_btn_tooltip: 'Клик: Воспроизвести видео во всплывающем плеере',
msg_no_video: '❌ Нет видео',
reload_msg: 'Настройки сохранены',
toast_domain_click: '🔗 Домен клика → ',
toast_domain_long: '🔗 Домен долгого нажатия → ',
toast_prefix: '⚙️ Префикс Discord → ',
toast_date_fmt: '📅 Формат даты → ',
toast_lang_pending: '🌐 Язык изменён. Применится после перезагрузки.',
confirm_lang_reload: 'Язык изменён на {lang}.\nПерезагрузить страницу сейчас?',
menu_date_format: '📅 Формат даты',
status_date_asian: 'Азиатский (YYYY.MM.DD)',
status_date_western: 'Западный (DD.MM.YYYY)',
help_title: 'Кнопка копирования медиа Twitter - Руководство',
help_content: `
<p><b>🖱️ Кнопка медиа (🎞️):</b><br>
• <b>Левый клик:</b> Копировать ссылки на изображения/видео.<br>
• <b>Долгое нажатие (0.5с):</b> Копировать с префиксом (формат Markdown, для Discord).<br>
• <b>Средний клик:</b> Предпросмотр——всплывающий видеоплеер или галерея изображений.<br>
• <b>Правый клик:</b> Принудительно скачать все медиафайлы со структурированными именами.<br>
(Формат: <code>[twitter] Имя(@ID)_Дата_Текст_ID.ext</code>)</p>
<hr>
<p><b>🔗 Кнопка ссылки (🔗):</b><br>
• <b>Клик:</b> Копировать ссылку на твит (по умолчанию x.com, или пользовательский домен).<br>
• <b>Долгое нажатие:</b> Копировать с префиксом + домен долгого нажатия (напр. fixupx).</p>
<hr>
<p><b>📋 История загрузок:</b><br>
• Автоматически записывается после правого клика (макс. 300 записей).<br>
• Скачанные твиты показывают 🟢 значок на кнопке 🎞️.<br>
• Нажмите 📋 (верхний правый угол): список/миниатюры, поиск, экспорт CSV/JSON.</p>
<hr>
<p><b>⚙️ Панель настроек:</b><br>
• Наведите курсор в правый верхний угол → появятся 📋 и ⚙️.<br>
• Настройте: домен клика, домен долгого нажатия, префикс Discord, формат даты, язык, стиль уведомлений.</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ Отказ от ответственности:</b><br>
Домены конвертации (fixupx, vxtwitter и др.) — сторонние сервисы, не связанные с этим скриптом. Используйте только те домены, которым доверяете.</p>
`,
onboard_title: '⚙ Панель настроек',
onboard_body: 'Наведите курсор в правый верхний угол, чтобы показать кнопку настроек. Нажмите для быстрого управления доменами, префиксом, языком и другими параметрами.',
onboard_got_it: 'Понятно!',
menu_feedback_style: '🔔 Стиль уведомлений',
status_feedback_toast: 'Тост',
status_feedback_silent: 'Тихий (только иконка)',
toast_feedback_style: '🔔 Стиль уведомлений → ',
}
};
function getAppLanguage() {
let lang = GM_getValue(KEY_LANG, null);
if (!lang) {
const navLang = navigator.language || 'en';
if (navLang.toLowerCase().startsWith('zh-cn')) lang = 'zh-CN';
else if (navLang.toLowerCase().startsWith('zh')) lang = 'zh-TW';
else if (navLang.toLowerCase().startsWith('ja')) lang = 'ja';
else if (navLang.toLowerCase().startsWith('ko')) lang = 'ko';
else if (navLang.toLowerCase().startsWith('pt')) lang = 'pt-BR';
else if (navLang.toLowerCase().startsWith('fr')) lang = 'fr';
else if (navLang.toLowerCase().startsWith('ru')) lang = 'ru';
else if (navLang.toLowerCase().startsWith('es')) lang = 'es';
else lang = 'en';
GM_setValue(KEY_LANG, lang);
}
if (lang === 'custom') {
try {
const raw = GM_getValue(KEY_CUSTOM_LANG, null);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && parsed.langName) {
TR['custom'] = parsed;
return 'custom';
}
}
} catch(e) {}
return 'en';
}
return TR[lang] ? lang : 'en';
}
const CURRENT_LANG = getAppLanguage();
const T = TR[CURRENT_LANG];
let _cachedDateFormat = GM_getValue(KEY_DATE_FORMAT, 'asian');
function _refreshDateFormatCache() {
_cachedDateFormat = GM_getValue(KEY_DATE_FORMAT, 'asian');
}
function _readSettings() {
return {
clickCustom: GM_getValue(KEY_CLICK_MODE_CUSTOM, false),
clickDomain: GM_getValue(KEY_LINK_DOMAIN_CLICK, 'x.com'),
prefix: GM_getValue(KEY_PREFIX_TEXT, '[text]'),
fmt: GM_getValue(KEY_DATE_FORMAT, 'asian'),
fbStyle: GM_getValue(KEY_FEEDBACK_STYLE, 'toast'),
};
}
function showToast(message) {
const existing = document.getElementById('tm-reload-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'tm-reload-toast';
toast.style.cssText = `
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: #1d9bf0; color: white; padding: 10px 20px;
border-radius: 9999px; box-shadow: 0 8px 16px rgba(0,0,0,0.2);
font-family: system-ui, -apple-system, sans-serif; font-size: 14px; font-weight: bold;
z-index: 999999; display: flex; align-items: center; gap: 8px;
transition: opacity 0.3s ease-in-out; opacity: 0; pointer-events: none;
white-space: nowrap; max-width: 90vw; overflow: hidden; text-overflow: ellipsis;
`;
const toastSpan = document.createElement('span');
toastSpan.textContent = message;
toast.appendChild(toastSpan);
document.body.appendChild(toast);
requestAnimationFrame(() => { toast.style.opacity = '1'; });
setTimeout(() => {
if (toast) {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}
}, 2500);
}
function sanitizeHelpHtml(htmlString, container) {
const ALLOWED_TAGS = new Set(['P','B','BR','HR','CODE','UL','LI','A','SPAN']);
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
function walk(srcNode, destParent) {
srcNode.childNodes.forEach(child => {
if (child.nodeType === Node.TEXT_NODE) {
destParent.appendChild(document.createTextNode(child.textContent));
return;
}
if (child.nodeType !== Node.ELEMENT_NODE) return;
const tag = child.tagName.toUpperCase();
if (!ALLOWED_TAGS.has(tag)) {
destParent.appendChild(document.createTextNode(child.textContent));
return;
}
const el = document.createElement(tag === 'A' ? 'a' : tag);
if (tag === 'A') {
const href = child.getAttribute('href');
if (href && /^https?:\/\//i.test(href)) {
el.href = href;
el.rel = 'noopener noreferrer';
el.target = '_blank';
}
}
if ((tag === 'P' || tag === 'SPAN') && child.hasAttribute('style')) {
const raw = child.getAttribute('style');
const safe = raw.split(';')
.filter(rule => /^\s*(color|font-size)\s*:/i.test(rule))
.join(';');
if (safe) el.setAttribute('style', safe);
}
walk(child, el);
destParent.appendChild(el);
});
}
walk(doc.body, container);
}
function showHelpModal() {
const old = document.getElementById('tm-copy-help-modal');
if (old) old.remove();
const curLang = GM_getValue(KEY_LANG, 'en');
const curT = TR[curLang] || TR['en'];
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const C = dark ? {
overlay: 'rgba(0,0,0,0.82)',
panel: '#16202b',
text: '#e7e9ea',
border: '#2f3336',
sub: '#8b98a5',
} : {
overlay: 'rgba(0,0,0,0.7)',
panel: '#ffffff',
text: '#333333',
border: '#eeeeee',
sub: '#536471',
};
const modal = document.createElement('div');
modal.id = 'tm-copy-help-modal';
modal.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: ${C.overlay}; z-index: 99999;
display: flex; align-items: center; justify-content: center;
`;
const content = document.createElement('div');
content.style.cssText = `
background: ${C.panel}; color: ${C.text}; padding: 25px; border-radius: 12px;
width: 90%; max-width: 500px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);
font-family: sans-serif; line-height: 1.6; position: relative;
max-height: 90vh; overflow-y: auto;
`;
const title = document.createElement('h2');
title.textContent = curT.help_title;
title.style.cssText = `margin-top: 0; border-bottom: 2px solid ${C.border}; padding-bottom: 10px; font-size: 1.2rem; color: ${C.text};`;
const body = document.createElement('div');
body.style.cssText = `font-size: 14px; color: ${C.text};`;
sanitizeHelpHtml(curT.help_content, body);
if (dark) {
const style = document.createElement('style');
style.textContent = `#tm-copy-help-modal code { background: #1e2732; border-radius: 4px; padding: 1px 5px; color: #8b98a5; }`;
content.appendChild(style);
}
const closeBtn = document.createElement('button');
closeBtn.innerHTML = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>`;
closeBtn.style.cssText = `
position: absolute; top: 10px; right: 12px; border: none; background: none;
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
cursor: pointer; color: ${C.sub}; border-radius: 5px;
`;
closeBtn.onclick = () => modal.remove();
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
content.appendChild(closeBtn);
content.appendChild(title);
content.appendChild(body);
modal.appendChild(content);
document.body.appendChild(modal);
}
function showLangPickerModal() {
const old = document.getElementById('tm-lang-picker-modal');
if (old) old.remove();
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const C = dark ? {
overlay: 'rgba(0,0,0,0.82)',
panel: '#16202b',
text: '#e7e9ea',
sub: '#8b98a5',
border: '#2f3336',
rowBg: '#1e2732',
rowHover: '#2d3741',
activeBg: '#1e3a4f',
activeBorder:'#1d9bf0',
activeText: '#1d9bf0',
customBg: '#2b2510',
customBorder:'#7a5c00',
customText: '#f4c430',
customHover: '#332c10',
} : {
overlay: 'rgba(0,0,0,0.72)',
panel: '#ffffff',
text: '#0f1419',
sub: '#536471',
border: '#eff3f4',
rowBg: '#ffffff',
rowHover: '#f7f9f9',
activeBg: '#e8f5fe',
activeBorder:'#1d9bf0',
activeText: '#1d9bf0',
customBg: '#fffbea',
customBorder:'#e0a800',
customText: '#7a5700',
customHover: '#fff8d6',
};
const LANG_MAP = [
{ code: 'en', label: 'English' },
{ code: 'zh-TW', label: '繁體中文' },
{ code: 'zh-CN', label: '简体中文' },
{ code: 'ja', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'es', label: 'Español' },
{ code: 'pt-BR', label: 'Português (BR)' },
{ code: 'fr', label: 'Français' },
{ code: 'ru', label: 'Русский' },
];
const currentCode = GM_getValue(KEY_LANG, 'en');
const modal = document.createElement('div');
modal.id = 'tm-lang-picker-modal';
modal.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: ${C.overlay}; z-index: 999999;
display: flex; align-items: center; justify-content: center;
font-family: system-ui, -apple-system, sans-serif;
`;
const panel = document.createElement('div');
panel.style.cssText = `
background: ${C.panel}; color: ${C.text}; padding: 24px 20px 20px;
border-radius: 16px; width: 92%; max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.35); position: relative;
`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>`;
closeBtn.style.cssText = `
position: absolute; top: 12px; right: 14px; border: none; background: none;
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
cursor: pointer; color: ${C.sub}; border-radius: 5px;
`;
closeBtn.onclick = () => modal.remove();
modal.onclick = e => { if (e.target === modal) modal.remove(); };
const title = document.createElement('h3');
title.textContent = '🌐 ' + T.menu_lang.replace(/^🌐\s*/, '');
title.style.cssText = `margin: 0 0 16px; font-size: 1rem; color: ${C.text}; padding-right: 24px;`;
const list = document.createElement('div');
list.style.cssText = 'display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;';
const applyLang = (code) => {
modal.remove();
GM_setValue(KEY_LANG, code);
GM_deleteValue(KEY_ONBOARDING_DONE);
const newT = TR[code] || TR['en'];
const confirmMsg = T.confirm_lang_reload.replace('{lang}', newT.langName);
if (confirm(confirmMsg)) {
location.reload();
} else {
showToast(newT.toast_lang_pending);
}
};
LANG_MAP.forEach(({ code, label }) => {
const btn = document.createElement('button');
const isActive = (code === currentCode);
btn.textContent = (isActive ? '★ ' : '') + label;
btn.style.cssText = `
width: 100%; padding: 9px 14px; border-radius: 9999px; text-align: left;
border: 2px solid ${isActive ? C.activeBorder : C.border};
background: ${isActive ? C.activeBg : C.rowBg};
color: ${isActive ? C.activeText : C.text};
font-size: 14px; font-weight: ${isActive ? '700' : '400'};
cursor: pointer; transition: border-color 0.15s, background 0.15s;
`;
btn.onmouseenter = () => {
if (!isActive) { btn.style.borderColor = C.sub; btn.style.background = C.rowHover; }
};
btn.onmouseleave = () => {
if (!isActive) { btn.style.borderColor = C.border; btn.style.background = C.rowBg; }
};
btn.onclick = () => applyLang(code);
list.appendChild(btn);
});
const hr = document.createElement('hr');
hr.style.cssText = `border: none; border-top: 1px solid ${C.border}; margin: 4px 0 10px;`;
const customBtn = document.createElement('button');
const hasCustom = !!GM_getValue(KEY_CUSTOM_LANG, null);
const isCustomActive = (currentCode === 'custom');
customBtn.textContent = (isCustomActive ? '★ ' : '') + '✏️ Custom Language' + (hasCustom && !isCustomActive ? ' (loaded)' : '');
customBtn.style.cssText = `
width: 100%; padding: 9px 14px; border-radius: 9999px; text-align: left;
border: 2px solid ${isCustomActive ? C.activeBorder : C.customBorder};
background: ${isCustomActive ? C.activeBg : C.customBg};
color: ${isCustomActive ? C.activeText : C.customText};
font-size: 14px; font-weight: ${isCustomActive ? '700' : '500'};
cursor: pointer; transition: border-color 0.15s, background 0.15s;
`;
customBtn.onmouseenter = () => {
if (!isCustomActive) customBtn.style.background = C.customHover;
};
customBtn.onmouseleave = () => {
if (!isCustomActive) customBtn.style.background = C.customBg;
};
customBtn.onclick = () => {
modal.remove();
showCustomLangPanel();
};
panel.appendChild(closeBtn);
panel.appendChild(title);
panel.appendChild(list);
panel.appendChild(hr);
panel.appendChild(customBtn);
modal.appendChild(panel);
document.body.appendChild(modal);
}
const CUSTOM_LANG_HOW_TO = [
"English: Export → translate the values → Import",
"Deutsch: Exportieren → Werte übersetzen → Importieren",
"Français: Exporter → traduire les valeurs → Importer",
"Español: Exportar → traducir los valores → Importar",
"Italiano: Esporta → traduci i valori → Importa",
"Português: Exportar → traduzir os valores → Importar",
"Русский: Экспорт → перевести значения → Импорт",
"Українська: Експорт → перекласти значення → Імпорт",
"ภาษาไทย: ส่งออก → แปลค่า → นำเข้า",
"Türkçe: Dışa aktar → değerleri çevir → İçe aktar",
"Polski: Eksportuj → przetłumacz wartości → Importuj",
"Čeština: Exportovat → přeložit hodnoty → Importovat",
"Română: Exportați → traduceți valorile → Importați",
"Magyar: Exportálás → értékek fordítása → Importálás",
"Ελληνικά: Εξαγωγή → μετάφραση τιμών → Εισαγωγή",
"العربية: تصدير ← ترجمة القيم ← استيراد",
"עברית: ייצוא ← תרגום הערכים ← ייבוא",
"فارسی: صادر کردن ← ترجمه مقادیر ← وارد کردن",
"हिन्दी: निर्यात → मान अनुवाद करें → आयात",
"বাংলা: রপ্তানি → মান অনুবাদ করুন → আমদানি",
"Indonesia: Ekspor → terjemahkan nilai → Impor",
"Bahasa Melayu: Eksport → terjemah nilai → Import",
"Filipino: I-export → isalin ang mga halaga → I-import",
"Tiếng Việt: Xuất → dịch các giá trị → Nhập",
"Nederlands: Exporteren → waarden vertalen → Importeren",
"Svenska: Exportera → översätt värdena → Importera",
"Kiswahili: Hamisha → tafsiri maadili → Ingiza",
"한국어: 내보내기 → 값 번역 → 가져오기",
"日本語: エクスポート → 値を翻訳 → インポート",
"繁體中文: 匯出 → 翻譯內容 → 匯入",
"简体中文: 导出 → 翻译内容 → 导入",
];
function buildExportTemplate() {
const base = Object.assign({}, TR['en']);
const template = {
_note: "Translate the VALUES only. Do NOT change the KEYS. Keep {placeholders} like {lang} untouched. Preserve HTML tags and emoji as-is. Set \"langName\" to your language's native name.",
langName: "My Custom Language",
menu_domain_click: base.menu_domain_click,
menu_domain_long: base.menu_domain_long,
menu_prefix: base.menu_prefix,
menu_lang: base.menu_lang,
menu_help: base.menu_help,
prompt_prefix: base.prompt_prefix,
prompt_domain: base.prompt_domain,
status_default: base.status_default,
status_custom: base.status_custom,
btn_tooltip: base.btn_tooltip,
link_tooltip: base.link_tooltip,
link_tooltip_long: base.link_tooltip_long,
msg_prefix_copied: base.msg_prefix_copied,
msg_copied: base.msg_copied,
msg_downloaded: base.msg_downloaded,
msg_no_media: base.msg_no_media,
play_btn_tooltip: base.play_btn_tooltip,
msg_no_video: base.msg_no_video,
reload_msg: base.reload_msg,
toast_domain_click: base.toast_domain_click,
toast_domain_long: base.toast_domain_long,
toast_prefix: base.toast_prefix,
toast_date_fmt: base.toast_date_fmt,
toast_lang_pending: base.toast_lang_pending,
confirm_lang_reload: base.confirm_lang_reload,
menu_date_format: base.menu_date_format,
status_date_asian: base.status_date_asian,
status_date_western: base.status_date_western,
help_title: base.help_title,
help_content: base.help_content.trim(),
onboard_title: base.onboard_title,
onboard_body: base.onboard_body,
onboard_got_it: base.onboard_got_it,
};
return template;
}
function showCustomLangPanel() {
const old = document.getElementById('tm-custom-lang-modal');
if (old) old.remove();
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const C = dark ? {
overlay: 'rgba(0,0,0,0.82)',
panel: '#16202b',
text: '#e7e9ea',
sub: '#8b98a5',
border: '#2f3336',
guideBg: '#1e2732',
guideText: '#8b98a5',
exportBorder: '#1d9bf0',
exportText: '#1d9bf0',
exportBg: '#16202b',
exportHover: '#1e2f3f',
importBg: '#1d9bf0',
importHover: '#1a8cd8',
clearBorder: '#e0245e',
clearText: '#e0245e',
clearBg: '#16202b',
clearHover: '#2a1520',
} : {
overlay: 'rgba(0,0,0,0.75)',
panel: '#ffffff',
text: '#0f1419',
sub: '#536471',
border: '#eff3f4',
guideBg: '#f7f9f9',
guideText: '#536471',
exportBorder: '#1d9bf0',
exportText: '#1d9bf0',
exportBg: '#ffffff',
exportHover: '#e8f5fe',
importBg: '#1d9bf0',
importHover: '#1a8cd8',
clearBorder: '#e0245e',
clearText: '#e0245e',
clearBg: '#ffffff',
clearHover: '#fdf0f2',
};
const existingJson = GM_getValue(KEY_CUSTOM_LANG, null);
const hasCustom = !!existingJson;
const modal = document.createElement('div');
modal.id = 'tm-custom-lang-modal';
modal.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: ${C.overlay}; z-index: 999999;
display: flex; align-items: center; justify-content: center;
font-family: system-ui, -apple-system, sans-serif;
`;
const panel = document.createElement('div');
panel.style.cssText = `
background: ${C.panel}; color: ${C.text}; padding: 28px 28px 24px;
border-radius: 16px; width: 95%; max-width: 640px;
box-shadow: 0 8px 32px rgba(0,0,0,0.35); position: relative;
max-height: 90vh; overflow-y: auto;
`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>`;
closeBtn.style.cssText = `
position: absolute; top: 12px; right: 14px; border: none; background: none;
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
cursor: pointer; color: ${C.sub}; border-radius: 5px;
`;
closeBtn.onclick = () => modal.remove();
modal.onclick = e => { if (e.target === modal) modal.remove(); };
const title = document.createElement('h3');
title.textContent = '✏️ Custom Language';
title.style.cssText = `margin: 0 0 6px; font-size: 1.1rem; color: ${C.text};`;
const statusLine = document.createElement('p');
statusLine.style.cssText = `margin: 0 0 16px; font-size: 13px; color: ${C.sub};`;
if (hasCustom) {
try {
const parsed = JSON.parse(existingJson);
statusLine.textContent = '';
const starText = document.createTextNode('★ Active: ');
const boldEl = document.createElement('b');
boldEl.textContent = parsed.langName || 'Custom';
statusLine.appendChild(starText);
statusLine.appendChild(boldEl);
statusLine.style.color = '#1d9bf0';
} catch(e) {
statusLine.textContent = '⚠️ Saved data is corrupted.';
statusLine.style.color = '#e0245e';
}
} else {
statusLine.textContent = 'No custom language loaded.';
}
const hr = document.createElement('hr');
hr.style.cssText = `border: none; border-top: 1px solid ${C.border}; margin: 0 0 12px;`;
const guideBox = document.createElement('div');
guideBox.style.cssText = `
background: ${C.guideBg}; border: 1px solid ${C.border}; border-radius: 8px;
padding: 10px 14px; margin-bottom: 16px;
font-size: 12px; font-family: monospace; line-height: 1.8;
color: ${C.guideText}; white-space: pre;
`;
guideBox.textContent = CUSTOM_LANG_HOW_TO.join('\n');
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display: flex; gap: 10px; flex-wrap: wrap;';
const exportBtn = document.createElement('button');
exportBtn.textContent = '📤 Export Template';
exportBtn.style.cssText = `
flex: 1; min-width: 140px; padding: 10px 16px; border-radius: 9999px;
border: 2px solid ${C.exportBorder}; background: ${C.exportBg}; color: ${C.exportText};
font-size: 14px; font-weight: 700; cursor: pointer;
transition: background 0.15s;
`;
exportBtn.onmouseenter = () => { exportBtn.style.background = C.exportHover; };
exportBtn.onmouseleave = () => { exportBtn.style.background = C.exportBg; };
exportBtn.onclick = () => {
const template = buildExportTemplate();
const jsonStr = JSON.stringify(template, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'twitter-media-copy-custom-lang.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 5000);
showToast('📤 Template exported!');
};
const importBtn = document.createElement('button');
importBtn.textContent = '📥 Import Translation';
importBtn.style.cssText = `
flex: 1; min-width: 140px; padding: 10px 16px; border-radius: 9999px;
border: none; background: ${C.importBg}; color: #fff;
font-size: 14px; font-weight: 700; cursor: pointer;
transition: background 0.15s;
`;
importBtn.onmouseenter = () => { importBtn.style.background = C.importHover; };
importBtn.onmouseleave = () => { importBtn.style.background = C.importBg; };
importBtn.onclick = () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json,application/json';
fileInput.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const parsed = JSON.parse(ev.target.result);
if (!parsed.langName) throw new Error('Missing "langName" field.');
const merged = Object.assign({}, TR['en'], parsed);
delete merged._note;
GM_setValue(KEY_CUSTOM_LANG, JSON.stringify(merged));
GM_setValue(KEY_LANG, 'custom');
GM_deleteValue(KEY_ONBOARDING_DONE);
TR['custom'] = merged;
modal.remove();
showToast(`✅ Loaded: ${merged.langName}`);
if (confirm(`Custom language "${merged.langName}" loaded.\nReload page now to apply?`)) {
location.reload();
}
} catch(err) {
alert(`❌ Import failed: ${err.message}\n\nMake sure the file is valid JSON and contains a "langName" field.`);
}
};
reader.readAsText(file, 'UTF-8');
};
document.body.appendChild(fileInput);
fileInput.click();
document.body.removeChild(fileInput);
};
btnRow.appendChild(exportBtn);
btnRow.appendChild(importBtn);
panel.appendChild(closeBtn);
panel.appendChild(title);
panel.appendChild(statusLine);
panel.appendChild(hr);
panel.appendChild(guideBox);
panel.appendChild(btnRow);
if (hasCustom) {
const clearBtn = document.createElement('button');
clearBtn.textContent = '🗑️ Clear Custom';
clearBtn.style.cssText = `
width: 100%; margin-top: 10px; padding: 9px 16px; border-radius: 9999px;
border: 2px solid ${C.clearBorder}; background: ${C.clearBg}; color: ${C.clearText};
font-size: 13px; font-weight: 600; cursor: pointer;
transition: background 0.15s;
`;
clearBtn.onmouseenter = () => { clearBtn.style.background = C.clearHover; };
clearBtn.onmouseleave = () => { clearBtn.style.background = C.clearBg; };
clearBtn.onclick = () => {
if (!confirm('Remove custom language and revert to English?')) return;
GM_deleteValue(KEY_CUSTOM_LANG);
GM_setValue(KEY_LANG, 'en');
delete TR['custom'];
modal.remove();
showToast('🗑️ Custom language cleared.');
if (confirm('Reverted to English.\nReload page now?')) location.reload();
};
panel.appendChild(clearBtn);
}
modal.appendChild(panel);
document.body.appendChild(modal);
}
function selectDomain(key) {
let msg = T.prompt_domain;
DOMAIN_LIST.forEach((d, index) => {
msg += `${index + 1}. ${d}\n`;
});
const input = prompt(msg, "");
if (input !== null) {
const index = parseInt(input.trim()) - 1;
if (!isNaN(index) && DOMAIN_LIST[index]) {
GM_setValue(key, DOMAIN_LIST[index]);
return true;
} else if (input.trim() !== "") {
const cleanDomain = input.trim();
if (/^[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}(\.[a-zA-Z0-9\-]{1,63})*\.[a-zA-Z]{2,}$/.test(cleanDomain)) {
GM_setValue(key, cleanDomain);
return true;
} else {
alert(`Invalid domain: "${cleanDomain}"\nPlease enter a plain domain (e.g. fixupx.com), without http:// or paths.`);
}
}
}
return false;
}
let menuIds = [];
function registerMenus() {
menuIds.forEach(id => GM_unregisterMenuCommand(id));
menuIds = [];
const currentPrefix = GM_getValue(KEY_PREFIX_TEXT, '[text]');
const clickCustom = GM_getValue(KEY_CLICK_MODE_CUSTOM, false);
const clickDomain = GM_getValue(KEY_LINK_DOMAIN_CLICK, 'x.com');
const clickStatusText = clickCustom ? `${T.status_custom} (${clickDomain})` : T.status_default;
menuIds.push(GM_registerMenuCommand(T.menu_domain_click + ` [${clickStatusText}]`, () => {
if (!clickCustom) {
if(selectDomain(KEY_LINK_DOMAIN_CLICK)) {
GM_setValue(KEY_CLICK_MODE_CUSTOM, true);
const newDomain = GM_getValue(KEY_LINK_DOMAIN_CLICK, 'x.com');
showToast(T.toast_domain_click + newDomain);
registerMenus();
}
} else {
GM_setValue(KEY_CLICK_MODE_CUSTOM, false);
showToast(T.toast_domain_click + 'x.com');
registerMenus();
}
}));
const longDomain = GM_getValue(KEY_LINK_DOMAIN_LONG, 'fixupx.com');
menuIds.push(GM_registerMenuCommand(T.menu_domain_long + ` [${longDomain}]`, () => {
if (selectDomain(KEY_LINK_DOMAIN_LONG)) {
const newDomain = GM_getValue(KEY_LINK_DOMAIN_LONG, 'fixupx.com');
showToast(T.toast_domain_long + newDomain);
registerMenus();
}
}));
menuIds.push(GM_registerMenuCommand(T.menu_prefix + ` (${currentPrefix})`, () => {
const newPrefix = prompt(T.prompt_prefix, currentPrefix);
if (newPrefix !== null) {
GM_setValue(KEY_PREFIX_TEXT, newPrefix);
showToast(T.toast_prefix + (newPrefix || '(empty)'));
registerMenus();
}
}));
menuIds.push(GM_registerMenuCommand(T.menu_lang + ` [${T.langName}]`, () => {
showLangPickerModal();
}));
const currentFmt = GM_getValue(KEY_DATE_FORMAT, 'asian');
const fmtStatusText = currentFmt === 'western' ? T.status_date_western : T.status_date_asian;
menuIds.push(GM_registerMenuCommand(T.menu_date_format + ` [${fmtStatusText}]`, () => {
const newFmt = currentFmt === 'western' ? 'asian' : 'western';
GM_setValue(KEY_DATE_FORMAT, newFmt);
_refreshDateFormatCache();
const newLabel = newFmt === 'western' ? T.status_date_western : T.status_date_asian;
showToast(T.toast_date_fmt + newLabel);
registerMenus();
}));
menuIds.push(GM_registerMenuCommand(T.menu_help, showHelpModal));
}
registerMenus();
function _initSettingsPanel() {
if (document.body) { createSettingsPanel(); }
else { document.addEventListener('DOMContentLoaded', createSettingsPanel, { once: true }); }
}
function showOnboardingOverlay() {
if (GM_getValue(KEY_ONBOARDING_DONE, false)) return;
const gearEl = document.getElementById('tm-settings-gear-btn');
if (!gearEl) { setTimeout(showOnboardingOverlay, 400); return; }
const rect = gearEl.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const r1 = 26;
const r2 = 42;
const wrapperEl = document.getElementById('tm-settings-wrapper');
if (wrapperEl) {
wrapperEl.style.setProperty('opacity', '1', 'important');
wrapperEl.style.setProperty('transition', 'none', 'important');
}
gearEl.style.setProperty('opacity', '1', 'important');
gearEl.style.setProperty('transition', 'none', 'important');
const obStyle = document.createElement('style');
obStyle.id = 'tm-ob-style';
obStyle.textContent = `
@keyframes tm-ob-pulse-ring {
0% { transform:translate(-50%,-50%) scale(1); opacity:0.9; }
70% { transform:translate(-50%,-50%) scale(1.55); opacity:0; }
100% { transform:translate(-50%,-50%) scale(1.55); opacity:0; }
}
@keyframes tm-ob-fadein { from { opacity:0; } to { opacity:1; } }
@keyframes tm-ob-card-in {
from { opacity:0; transform:translateY(10px) scale(0.96); }
to { opacity:1; transform:translateY(0) scale(1); }
}
#tm-ob-overlay {
position:fixed; inset:0; z-index:999985;
pointer-events:all;
animation: tm-ob-fadein 0.45s ease forwards;
}
#tm-ob-ring {
position:fixed; border-radius:50%; pointer-events:none;
border: 2px solid rgba(29,155,240,0.85);
animation: tm-ob-pulse-ring 1.7s cubic-bezier(0.215,0.61,0.355,1) infinite;
z-index:999988;
}
#tm-ob-card {
position:fixed; z-index:999988;
animation: tm-ob-card-in 0.4s 0.2s cubic-bezier(0.34,1.56,0.64,1) both;
pointer-events:all;
}
#tm-ob-got-it {
width:100%; padding:9px; border-radius:9999px;
border:none; background:#1d9bf0; color:#fff;
font-size:14px; font-weight:700; cursor:pointer;
text-align:center; display:block;
transition:background 0.15s;
}
#tm-ob-got-it:hover { background:#1a8cd8; }
`;
document.head.appendChild(obStyle);
const overlay = document.createElement('div');
overlay.id = 'tm-ob-overlay';
overlay.style.background =
`radial-gradient(circle at ${cx}px ${cy}px, transparent ${r1}px, rgba(0,0,0,0.80) ${r2}px)`;
const ring = document.createElement('div');
ring.id = 'tm-ob-ring';
ring.style.cssText = `width:${r1 * 2}px; height:${r1 * 2}px; left:${cx}px; top:${cy}px; transform:translate(-50%,-50%);`;
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const cardBg = dark ? '#16202b' : '#ffffff';
const cardText = dark ? '#e7e9ea' : '#0f1419';
const cardSub = dark ? '#8b98a5' : '#536471';
const arrowClr = dark ? '#16202b' : '#ffffff';
const cardW = 270;
const cardLeft = Math.max(8, Math.min(cx - cardW / 2, window.innerWidth - cardW - 8));
const cardTop = cy + r2 + 10;
const card = document.createElement('div');
card.id = 'tm-ob-card';
card.style.cssText = `
width:${cardW}px; left:${cardLeft}px; top:${cardTop}px;
background:${cardBg}; border-radius:14px;
box-shadow:0 8px 32px rgba(0,0,0,0.32);
padding:18px 18px 14px;
z-index:999989;
`;
const arrow = document.createElement('div');
arrow.style.cssText = `
position:absolute; top:-10px; right:16px; width:0; height:0;
border-left:10px solid transparent; border-right:10px solid transparent;
border-bottom:10px solid ${arrowClr};
`;
const titleEl = document.createElement('div');
titleEl.style.cssText = `font-size:15px;font-weight:700;color:${cardText};margin-bottom:8px;`;
titleEl.textContent = T.onboard_title || '⚙ Settings Panel';
const bodyEl = document.createElement('div');
bodyEl.style.cssText = `font-size:13px;color:${cardSub};line-height:1.55;margin-bottom:14px;`;
bodyEl.textContent = T.onboard_body || 'Hover the top-right corner to reveal the settings button.';
const gotItBtn = document.createElement('button');
gotItBtn.id = 'tm-ob-got-it';
gotItBtn.textContent = T.onboard_got_it || 'Got it!';
const dismiss = () => {
GM_setValue(KEY_ONBOARDING_DONE, true);
[overlay, card, ring].forEach(el => {
el.style.transition = 'opacity 0.3s ease';
el.style.opacity = '0';
});
setTimeout(() => {
[overlay, card, ring, obStyle].forEach(el => el.remove());
gearEl.style.removeProperty('opacity');
gearEl.style.removeProperty('transition');
if (wrapperEl) {
wrapperEl.style.removeProperty('opacity');
wrapperEl.style.removeProperty('transition');
}
}, 320);
};
gotItBtn.addEventListener('click', e => { e.stopPropagation(); dismiss(); });
overlay.addEventListener('click', dismiss);
card.appendChild(arrow);
card.appendChild(titleEl);
card.appendChild(bodyEl);
card.appendChild(gotItBtn);
document.body.appendChild(overlay);
document.body.appendChild(ring);
document.body.appendChild(card);
}
setTimeout(showOnboardingOverlay, 1200);
_initSettingsPanel();
function showDomainPickerModal(key, onSuccess) {
const old = document.getElementById('tm-domain-picker-modal');
if (old) old.remove();
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const C = dark ? {
overlay: 'rgba(0,0,0,0.82)', panel: '#16202b', text: '#e7e9ea',
sub: '#8b98a5', border: '#2f3336', rowBg: '#1e2732',
rowHover: '#2d3741', activeBg: '#1e3a4f',
activeBorder: '#1d9bf0', activeText: '#1d9bf0',
} : {
overlay: 'rgba(0,0,0,0.72)', panel: '#ffffff', text: '#0f1419',
sub: '#536471', border: '#eff3f4', rowBg: '#ffffff',
rowHover: '#f7f9f9', activeBg: '#e8f5fe',
activeBorder: '#1d9bf0', activeText: '#1d9bf0',
};
const currentVal = GM_getValue(key, key === KEY_LINK_DOMAIN_LONG ? 'fixupx.com' : 'x.com');
const modal = document.createElement('div');
modal.id = 'tm-domain-picker-modal';
modal.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
background:${C.overlay};z-index:9999999;
display:flex;align-items:center;justify-content:center;
font-family:system-ui,-apple-system,sans-serif;
`;
const panel = document.createElement('div');
panel.style.cssText = `
background:${C.panel};color:${C.text};padding:22px 18px 18px;
border-radius:16px;width:92%;max-width:360px;
box-shadow:0 8px 32px rgba(0,0,0,0.35);position:relative;
`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>`;
closeBtn.style.cssText = `position:absolute;top:12px;right:14px;border:none;
background:none;width:26px;height:26px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:${C.sub};border-radius:5px;`;
closeBtn.onclick = () => modal.remove();
modal.onclick = e => { if (e.target === modal) modal.remove(); };
const title = document.createElement('h3');
title.textContent = key === KEY_LINK_DOMAIN_LONG
? T.menu_domain_long.replace(/^🔗\s*/, '')
: T.menu_domain_click.replace(/^🔗\s*/, '');
title.style.cssText = `margin:0 0 14px;font-size:0.95rem;color:${C.text};padding-right:24px;`;
const list = document.createElement('div');
list.style.cssText = 'display:flex;flex-direction:column;gap:6px;';
DOMAIN_LIST.forEach(domain => {
const isActive = domain === currentVal;
const btn = document.createElement('button');
btn.textContent = (isActive ? '★ ' : '') + domain;
btn.style.cssText = `
width:100%;padding:8px 14px;border-radius:9999px;text-align:left;
border:2px solid ${isActive ? C.activeBorder : C.border};
background:${isActive ? C.activeBg : C.rowBg};
color:${isActive ? C.activeText : C.text};
font-size:13px;font-weight:${isActive ? '700' : '400'};
cursor:pointer;transition:border-color 0.15s,background 0.15s;
`;
btn.onmouseenter = () => { if (!isActive) { btn.style.borderColor = C.sub; btn.style.background = C.rowHover; } };
btn.onmouseleave = () => { if (!isActive) { btn.style.borderColor = C.border; btn.style.background = C.rowBg; } };
btn.onclick = () => {
GM_setValue(key, domain);
modal.remove();
if (onSuccess) onSuccess(domain);
};
list.appendChild(btn);
});
panel.appendChild(closeBtn);
panel.appendChild(title);
panel.appendChild(list);
modal.appendChild(panel);
document.body.appendChild(modal);
}
function isFeatureNew(id) {
if (!NEW_FEATURE_IDS.includes(id)) return false;
try {
const seen = JSON.parse(GM_getValue(KEY_SEEN_FEATURES, '[]'));
return !seen.includes(id);
} catch(_) { return true; }
}
function markFeatureSeen(id) {
try {
const seen = JSON.parse(GM_getValue(KEY_SEEN_FEATURES, '[]'));
if (!seen.includes(id)) {
seen.push(id);
GM_setValue(KEY_SEEN_FEATURES, JSON.stringify(seen));
}
} catch(_) {}
}
function createSettingsPanel() {
const existingWrapper = document.getElementById('tm-settings-wrapper');
if (existingWrapper) existingWrapper.remove();
const existingStyle = document.getElementById('tm-settings-panel-style');
if (existingStyle) existingStyle.remove();
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const C = dark ? {
panel: '#16202b',
header: '#1e2732',
text: '#e7e9ea',
sub: '#8b98a5',
border: '#2f3336',
rowHover: '#1e2732',
badge: '#1d9bf0',
gearFg: '#e7e9ea',
gearBg: 'rgba(255,255,255,0.08)',
} : {
panel: '#ffffff',
header: '#f7f9f9',
text: '#0f1419',
sub: '#536471',
border: '#eff3f4',
rowHover: '#f7f9f9',
badge: '#1d9bf0',
gearFg: '#536471',
gearBg: 'rgba(0,0,0,0.06)',
};
const panelStyle = document.createElement('style');
panelStyle.id = 'tm-settings-panel-style';
panelStyle.textContent = `
#tm-settings-wrapper {
position: fixed; top: 12px; right: 12px; z-index: 999990;
width: 90px; height: 50px;
opacity: 0;
transition: opacity 0.3s ease;
}
#tm-settings-wrapper:hover, #tm-settings-wrapper[data-open="true"] {
opacity: 1;
}
#tm-history-btn {
position: absolute; right: 28px; top: 2px;
width: 44px; height: 44px;
border-radius: 50%; border: none; background: transparent;
cursor: pointer; padding: 0;
display: flex; align-items: center; justify-content: center;
color: ${C.gearFg};
z-index: 3; opacity: 1;
transform: scale(1) translateX(0);
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#tm-history-btn svg { width: 24px; height: 24px; display: block; }
#tm-settings-gear-btn {
position: absolute; right: 4px; top: 6px;
width: 36px; height: 36px;
border-radius: 50%; border: none; background: transparent;
cursor: pointer; padding: 0;
display: flex; align-items: center; justify-content: center;
color: ${C.gearFg};
z-index: 1; opacity: 0.5;
transform: scale(0.9) translateX(0);
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#tm-settings-gear-btn svg { width: 20px; height: 20px; display: block; transition: transform 0.3s ease; }
/* =========================================
狀態切換 (由 Wrapper 的 JS data-focus 控制)
========================================= */
#tm-settings-wrapper[data-focus="hist"] #tm-history-btn {
transform: scale(1.15);
background: ${C.gearBg};
}
#tm-settings-wrapper[data-focus="gear"] #tm-settings-gear-btn,
#tm-settings-wrapper[data-open="true"] #tm-settings-gear-btn {
z-index: 4;
opacity: 1;
transform: scale(1.2) translateX(-22px);
background: ${C.gearBg};
}
#tm-settings-wrapper[data-focus="gear"] #tm-history-btn,
#tm-settings-wrapper[data-open="true"] #tm-history-btn {
z-index: 1;
opacity: 0.35;
transform: scale(0.75) translateX(26px);
background: transparent;
}
#tm-settings-wrapper[data-open="true"] #tm-settings-gear-btn svg {
transform: rotate(90deg);
}
#tm-settings-panel {
position: absolute; top: calc(100% + 4px); right: 4px;
width: 300px; background: ${C.panel};
border-radius: 14px;
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.10);
border: 1px solid ${C.border};
font-family: system-ui, -apple-system, sans-serif;
overflow: hidden;
transform-origin: top right;
transform: scale(0.88) translateY(-8px); opacity: 0;
transition: transform 0.22s cubic-bezier(0.34,1.56,0.64,1), opacity 0.18s ease;
pointer-events: none;
}
#tm-settings-wrapper[data-open="true"] #tm-settings-panel {
transform: scale(1) translateY(0); opacity: 1;
pointer-events: all;
}
.tm-sp-header { display: flex; align-items: center; padding: 11px 14px 10px; background: ${C.header}; border-bottom: 1px solid ${C.border}; font-size: 12px; font-weight: 700; color: ${C.sub}; letter-spacing: 0.04em; text-transform: uppercase; }
.tm-sp-row { display: flex; align-items: center; justify-content: space-between; padding: 9px 14px; gap: 8px; border-bottom: 1px solid ${C.border}; cursor: pointer; transition: background 0.1s; }
.tm-sp-row:last-child { border-bottom: none; }
.tm-sp-row:hover { background: ${C.rowHover}; }
.tm-sp-row-left { display:flex; flex-direction:column; gap:1px; min-width:0; }
.tm-sp-row-label { font-size: 12px; color: ${C.sub}; white-space: nowrap; }
.tm-sp-row-value { font-size: 13px; color: ${C.text}; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tm-sp-arrow { font-size: 11px; color: ${C.sub}; flex-shrink: 0; margin-left: 4px; opacity: 0.5; }
@keyframes tm-sp-new-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(29,155,240,0.55); }
50% { box-shadow: 0 0 0 4px rgba(29,155,240,0); }
}
.tm-sp-new-badge { font-size: 9px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; padding: 2px 6px; border-radius: 9999px; flex-shrink: 0; background: #1d9bf0; color: #fff; animation: tm-sp-new-pulse 1.8s ease-in-out infinite; margin-right: 2px; }
@keyframes tm-float-new-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(249, 24, 128, 0.6); }
50% { box-shadow: 0 0 0 4px rgba(249, 24, 128, 0); }
}
.tm-float-new-badge {
position: absolute; top: -2px; right: -4px;
font-size: 8px; font-weight: 800; letter-spacing: 0.04em;
text-transform: uppercase; padding: 2px 4px;
border-radius: 4px; background: #f91880; color: #fff;
animation: tm-float-new-pulse 1.8s ease-in-out infinite;
pointer-events: none; z-index: 5;
}
#tm-settings-wrapper[data-absorb="true"] {
opacity: 1 !important;
}
/* ⚠️ 注意:histBtn 的 transform 不能加 !important,
否則 CSS Animation (層級低於 !important) 會被完全鎖死,
bounce keyframe 的 transform 永遠不會生效。
只管 opacity / z-index,transform 交給 animation 處理。 */
#tm-settings-wrapper[data-absorb="true"] #tm-history-btn {
z-index: 3 !important;
opacity: 1 !important;
}
#tm-settings-wrapper[data-absorb="true"] #tm-settings-gear-btn {
opacity: 0.35 !important;
transform: scale(0.88) translateX(0) !important;
z-index: 1 !important;
}
@keyframes tm-hist-absorb-bounce {
0% { transform: scale(1) translateX(0); filter: none; }
18% { transform: scale(1.42) translateX(0); filter: drop-shadow(0 0 10px rgba(29,155,240,0.95)); }
38% { transform: scale(0.88) translateX(0); filter: drop-shadow(0 0 5px rgba(29,155,240,0.5)); }
58% { transform: scale(1.18) translateX(0); filter: drop-shadow(0 0 7px rgba(29,155,240,0.7)); }
75% { transform: scale(0.95) translateX(0); filter: none; }
100% { transform: scale(1) translateX(0); filter: none; }
}
#tm-history-btn.tm-absorbing {
animation: tm-hist-absorb-bounce 0.75s cubic-bezier(0.36,0.07,0.19,0.97) forwards;
transition: none !important;
}
`;
document.head.appendChild(panelStyle);
const wrapper = document.createElement('div');
wrapper.id = 'tm-settings-wrapper';
wrapper.setAttribute('data-focus', 'hist');
wrapper.setAttribute('data-open', 'false');
let focusTimer = null;
let currentFocus = 'hist';
wrapper.addEventListener('mousemove', (e) => {
if (wrapper.getAttribute('data-open') === 'true') return;
const rect = wrapper.getBoundingClientRect();
const x = e.clientX - rect.left;
const targetFocus = x > 45 ? 'gear' : 'hist';
if (targetFocus !== currentFocus) {
if (!focusTimer) {
focusTimer = setTimeout(() => {
currentFocus = targetFocus;
wrapper.setAttribute('data-focus', currentFocus);
focusTimer = null;
}, 150);
}
} else {
if (focusTimer) {
clearTimeout(focusTimer);
focusTimer = null;
}
}
});
wrapper.addEventListener('mouseleave', () => {
if (wrapper.getAttribute('data-open') === 'true') return;
if (focusTimer) { clearTimeout(focusTimer); focusTimer = null; }
currentFocus = 'hist';
wrapper.setAttribute('data-focus', 'hist');
});
const SVG_GEAR = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
const gearBtn = document.createElement('button');
gearBtn.id = 'tm-settings-gear-btn';
gearBtn.innerHTML = SVG_GEAR;
gearBtn.title = '⚙️ Twitter Media Script Settings';
const SVG_HISTORY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 15.5"/><path d="M5 5l2.5 2.5" opacity="0.5"/></svg>`;
const histBtn = document.createElement('button');
histBtn.id = 'tm-history-btn';
histBtn.innerHTML = SVG_HISTORY;
histBtn.title = '📋 Download History';
if (isFeatureNew('history_panel')) {
const floatBadge = document.createElement('span');
floatBadge.className = 'tm-float-new-badge';
floatBadge.textContent = 'NEW';
histBtn.appendChild(floatBadge);
}
histBtn.addEventListener('click', e => {
e.stopPropagation();
if (isFeatureNew('history_panel')) {
markFeatureSeen('history_panel');
const badge = histBtn.querySelector('.tm-float-new-badge');
if (badge) badge.remove();
}
wrapper.setAttribute('data-open', 'false');
showHistoryPanel();
});
const panel = document.createElement('div');
panel.id = 'tm-settings-panel';
function buildContent() {
panel.innerHTML = '';
const { clickCustom, clickDomain, prefix, fmt, fbStyle: _fbStyle } = _readSettings();
const header = document.createElement('div');
header.className = 'tm-sp-header';
header.textContent = '⚙ Media Script Settings';
panel.appendChild(header);
const makeRow = (label, value, onClick, featureId = null) => {
const row = document.createElement('div');
row.className = 'tm-sp-row';
const left = document.createElement('div');
left.className = 'tm-sp-row-left';
const lbl = document.createElement('span');
lbl.className = 'tm-sp-row-label';
lbl.textContent = label;
const val = document.createElement('span');
val.className = 'tm-sp-row-value';
val.textContent = value;
left.appendChild(lbl);
left.appendChild(val);
row.appendChild(left);
if (featureId && isFeatureNew(featureId)) {
const badge = document.createElement('span');
badge.className = 'tm-sp-new-badge';
badge.textContent = 'NEW';
row.appendChild(badge);
}
const arrow = document.createElement('span');
arrow.className = 'tm-sp-arrow';
arrow.textContent = '›';
row.appendChild(arrow);
row.addEventListener('click', () => {
if (featureId) markFeatureSeen(featureId);
onClick();
});
panel.appendChild(row);
};
const clickLabel = T.menu_domain_click.replace(/^🔗\s*/, '');
const clickVal = clickCustom ? clickDomain : 'x.com (default)';
makeRow('🔗 ' + clickLabel, clickVal, () => {
if (!clickCustom) {
showDomainPickerModal(KEY_LINK_DOMAIN_CLICK, dom => {
GM_setValue(KEY_CLICK_MODE_CUSTOM, true);
showToast(T.toast_domain_click + dom);
registerMenus(); buildContent();
});
} else {
GM_setValue(KEY_CLICK_MODE_CUSTOM, false);
showToast(T.toast_domain_click + 'x.com');
registerMenus(); buildContent();
}
});
makeRow('⚙️ Discord Prefix', prefix || '(empty)', () => {
const newPrefix = prompt(T.prompt_prefix, prefix);
if (newPrefix !== null) {
GM_setValue(KEY_PREFIX_TEXT, newPrefix);
showToast(T.toast_prefix + (newPrefix || '(empty)'));
registerMenus(); buildContent();
}
});
const fbLabel = (T.menu_feedback_style || '🔔 Feedback Style').replace(/^🔔\s*/, '');
let fbVal = T.status_feedback_toast || 'Toast';
if (_fbStyle === 'silent') fbVal = T.status_feedback_silent || 'Silent (Text)';
if (_fbStyle === 'icon') fbVal = T.status_feedback_icon || 'Icon Only';
makeRow('🔔 ' + fbLabel, fbVal, () => {
const nextMap = { 'toast': 'icon', 'icon': 'toast' };
const newFb = nextMap[_fbStyle] || 'toast';
GM_setValue(KEY_FEEDBACK_STYLE, newFb);
let fbToastLabel = T.status_feedback_toast || 'Toast';
if (newFb === 'silent') fbToastLabel = T.status_feedback_silent || 'Silent (Text)';
if (newFb === 'icon') fbToastLabel = T.status_feedback_icon || 'Icon Only';
showToast((T.toast_feedback_style || '🔔 Feedback Style → ') + fbToastLabel);
buildContent();
}, 'feedback_style');
const fmtLabel = T.menu_date_format.replace(/^📅\s*/, '');
const fmtVal = fmt === 'western' ? T.status_date_western : T.status_date_asian;
makeRow('📅 ' + fmtLabel, fmtVal, () => {
const newFmt = fmt === 'western' ? 'asian' : 'western';
GM_setValue(KEY_DATE_FORMAT, newFmt);
_refreshDateFormatCache();
const newLabel = newFmt === 'western' ? T.status_date_western : T.status_date_asian;
showToast(T.toast_date_fmt + newLabel);
registerMenus(); buildContent();
});
const langLabel = T.menu_lang.replace(/^🌐\s*/, '').replace(/\s*\(Change Language\)/i, '').trim();
makeRow('🌐 ' + langLabel, T.langName, () => {
showLangPickerModal();
});
const helpLabel = T.menu_help.replace(/^📖\s*/, '');
makeRow('📖 ' + helpLabel, '', () => {
showHelpModal();
});
}
buildContent();
gearBtn.addEventListener('click', e => {
e.stopPropagation();
const isOpen = wrapper.getAttribute('data-open') === 'true';
wrapper.setAttribute('data-open', String(!isOpen));
});
document.addEventListener('click', e => {
if (!wrapper.contains(e.target)) {
wrapper.setAttribute('data-open', 'false');
}
});
wrapper.appendChild(histBtn);
wrapper.appendChild(gearBtn);
wrapper.appendChild(panel);
document.body.appendChild(wrapper);
}
const _downloadedIds = (() => {
try {
const arr = JSON.parse(GM_getValue(KEY_HISTORY_RECORDS, '[]'));
return new Set(arr.map(r => r.tweetId));
} catch (_) { return new Set(); }
})();
let _historyUndoBuffer = null;
let _historyUndoTimer = null;
function _getTweetIdFromArticle(article) {
for (const lk of article.querySelectorAll('a[href*="/status/"]')) {
const m = lk.getAttribute('href')?.match(/\/status\/(\d+)/);
if (m) return m[1];
}
return null;
}
function _applyHistoryBadge(btn) {
if (!btn || btn.querySelector('.tm-hist-badge')) return;
const badge = document.createElement('span');
badge.className = 'tm-hist-badge';
badge.style.cssText = `
position: absolute; top: 6px; right: 6px;
width: 8px; height: 8px; border-radius: 50%;
background: #00ba7c; pointer-events: none;
box-shadow: 0 0 0 2px rgba(0,0,0,0.65);
animation: tm-pop-bounce 0.35s cubic-bezier(0.175,0.885,0.32,1.275) both;
z-index: 10;
`;
btn.appendChild(badge);
}
function recordHistory(info, urls) {
try {
const thumbUrls = urls.filter(u => !u.includes('.mp4'));
const hasVideo = urls.some(u => u.includes('.mp4'));
if (thumbUrls.length === 0 && info.videoThumb) {
thumbUrls.push(info.videoThumb);
}
const raw = info.date || '';
let yyyymm = '0000.00';
if (_cachedDateFormat === 'western') {
const p = raw.split('.');
if (p.length === 3) yyyymm = `${p[2]}.${p[1]}`;
} else {
yyyymm = raw.slice(0, 7);
}
const record = {
id: Date.now(),
ts: Date.now(),
yyyymm,
tweetId: info.id,
tweetUrl: `https://x.com/${info.screenName}/status/${info.id}`,
tweetDate: info.date,
screenName: info.screenName,
displayName: info.displayName,
text: (info.text || '').slice(0, 80),
thumbUrls,
hasVideo,
count: urls.length,
};
let records = [];
try { records = JSON.parse(GM_getValue(KEY_HISTORY_RECORDS, '[]')); } catch (_) {}
const _oldRecord = records.find(r => r.tweetId === info.id);
if (_oldRecord?.favorited) record.favorited = true;
records = records.filter(r => r.tweetId !== info.id);
records.unshift(record);
if (records.length > HISTORY_MAX_RECORDS) {
const _overflow = records.slice(HISTORY_MAX_RECORDS).filter(r => r.favorited);
records = [...records.slice(0, HISTORY_MAX_RECORDS), ..._overflow];
}
GM_setValue(KEY_HISTORY_RECORDS, JSON.stringify(records));
_downloadedIds.add(info.id);
document.querySelectorAll(`article a[href*="/status/${info.id}"]`).forEach(a => {
const art = a.closest('article');
if (art) {
const targetBtn = art.querySelector('.force-media-copy-btn');
if (targetBtn) _applyHistoryBadge(targetBtn);
}
});
const existPanel = document.getElementById('tm-hist-panel');
if (existPanel) existPanel.dispatchEvent(new CustomEvent('tm-hist-refresh'));
} catch (e) { console.error('[TMHist] recordHistory error:', e); }
}
function showHistoryPanel() {
const existing = document.getElementById('tm-hist-panel');
if (existing) {
if (typeof existing._tmCleanup === 'function') existing._tmCleanup();
existing.remove();
_cleanZoom();
return;
}
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const C = dark ? {
bg: '#16202b', header: '#1e2732', text: '#e7e9ea', sub: '#8b98a5',
border: '#2f3336', rowHover: '#1e2732', inputBg: '#1e2732',
groupHdr: '#2f3336', groupTxt: '#8b98a5',
thumbBg: '#1e2732', danger: '#e0245e', dangerHover: '#c01e4e',
badgeNew: '#1d9bf0', scrollbar: '#2f3336',
} : {
bg: '#ffffff', header: '#f7f9f9', text: '#0f1419', sub: '#536471',
border: '#eff3f4', rowHover: '#f7f9f9', inputBg: '#f7f9f9',
groupHdr: '#f7f9f9', groupTxt: '#536471',
thumbBg: '#f7f9f9', danger: '#e0245e', dangerHover: '#c01e4e',
badgeNew: '#1d9bf0', scrollbar: '#eff3f4',
};
let pos = {
x: Math.max(8, window.innerWidth - 408),
y: 60, w: 390, h: 540,
};
try {
const saved = JSON.parse(GM_getValue(KEY_HISTORY_PANEL_POS, 'null'));
if (saved && typeof saved.x === 'number') {
pos = {
x: Math.min(saved.x, window.innerWidth - 300),
y: Math.min(saved.y, window.innerHeight - 200),
w: Math.max(300, Math.min(saved.w || 390, 680)),
h: Math.max(280, Math.min(saved.h || 540, window.innerHeight - 80)),
};
}
} catch (_) {}
let viewMode = GM_getValue(KEY_HISTORY_VIEW_MODE, 'list');
let editMode = false;
let query = '';
const selectedIds = new Set();
const collapsedGroups = new Set();
let anchorIdx = -1;
let histStyleEl = document.getElementById('tm-hist-style');
if (!histStyleEl) {
histStyleEl = document.createElement('style');
histStyleEl.id = 'tm-hist-style';
document.head.appendChild(histStyleEl);
}
histStyleEl.textContent = `
#tm-hist-panel {
position: fixed; z-index: 999980;
font-family: system-ui, -apple-system, sans-serif;
display: flex; flex-direction: column;
background: ${C.bg}; border: 1px solid ${C.border};
border-radius: 14px;
box-shadow: 0 12px 40px rgba(0,0,0,0.22), 0 2px 8px rgba(0,0,0,0.10);
overflow: hidden;
min-width: 300px; min-height: 280px;
}
#tm-hist-titlebar {
display: flex; align-items: center; gap: 6px;
padding: 9px 12px; cursor: grab;
background: ${C.header}; border-bottom: 1px solid ${C.border};
user-select: none; flex-shrink: 0;
}
#tm-hist-titlebar:active { cursor: grabbing; }
.tm-hist-title {
font-size: 13px; font-weight: 700; color: ${C.text};
flex: 1; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tm-hist-count-badge {
font-size: 10px; padding: 2px 7px; border-radius: 99px;
background: ${C.groupHdr}; color: ${C.sub};
white-space: nowrap; flex-shrink: 0;
}
.tm-hist-icon-btn {
width: 26px; height: 26px; border-radius: 6px; border: none;
background: transparent; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: ${C.sub}; transition: background 0.1s, color 0.1s;
flex-shrink: 0;
}
.tm-hist-icon-btn:hover { background: ${C.rowHover}; color: ${C.text}; }
.tm-hist-icon-btn svg { width: 14px; height: 14px; pointer-events: none; }
.tm-hist-icon-btn.active { color: #1d9bf0; }
.tm-hist-searchbar {
padding: 7px 12px; border-bottom: 1px solid ${C.border}; flex-shrink: 0;
}
#tm-hist-search {
width: 100%; padding: 5px 10px; border-radius: 99px;
border: 1px solid ${C.border}; background: ${C.inputBg};
color: ${C.text}; font-size: 12px;
outline: none; box-sizing: border-box;
}
#tm-hist-search::placeholder { color: ${C.sub}; }
#tm-hist-body {
flex: 1; overflow-y: auto; overflow-x: hidden;
scrollbar-width: thin; scrollbar-color: ${C.scrollbar} transparent;
}
.tm-hist-group-header {
position: sticky; top: 0; z-index: 2;
padding: 5px 12px 4px;
background: ${C.groupHdr}; border-bottom: 1px solid ${C.border};
font-size: 11px; font-weight: 700; color: ${C.groupTxt};
letter-spacing: 0.03em;
cursor: pointer; user-select: none;
display: flex; align-items: center; gap: 5px;
}
.tm-hist-group-header:hover { background: ${C.rowHover}; }
.tm-hist-group-chevron {
opacity: 0.55; flex-shrink: 0;
display: inline-flex; align-items: center;
transition: transform 0.18s ease;
}
.tm-hist-group-header.tm-collapsed .tm-hist-group-chevron {
transform: rotate(-90deg);
}
.tm-hist-group-count {
margin-left: auto; font-size: 10px; font-weight: 400;
opacity: 0.5; padding-right: 2px;
}
.tm-hist-sel-all-btn {
padding: 4px 10px; border-radius: 99px;
border: 1px solid ${C.border}; background: transparent;
color: ${C.sub}; font-size: 11px; cursor: pointer;
transition: background 0.1s, color 0.1s;
white-space: nowrap; flex-shrink: 0;
}
.tm-hist-sel-all-btn:hover { background: ${C.rowHover}; color: ${C.text}; }
#tm-hist-gh-footer {
flex-shrink: 0; box-sizing: border-box;
height: 26px;
border-top: 1px solid ${C.border};
display: flex; align-items: center; justify-content: center;
background: ${C.header};
}
#tm-hist-gh-footer a {
font-size: 10px; color: ${C.sub}; text-decoration: none;
opacity: 0.38; letter-spacing: 0.02em;
font-family: system-ui, -apple-system, sans-serif;
transition: opacity 0.18s;
pointer-events: all;
}
#tm-hist-gh-footer a:hover { opacity: 0.85; text-decoration: underline; }
.tm-hist-row {
display: flex; align-items: flex-start; gap: 10px;
padding: 8px 12px; border-bottom: 1px solid ${C.border};
transition: background 0.08s;
position: relative;
}
.tm-hist-row:hover { background: ${C.rowHover}; }
.tm-hist-row.selected { background: rgba(29,155,240,0.08); }
.tm-hist-row.selected::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: #1d9bf0; border-radius: 0 2px 2px 0;
}
.tm-hist-cb { flex-shrink: 0; margin-top: 3px; cursor: pointer; }
.tm-hist-thumb-wrap {
width: 44px; height: 44px; border-radius: 6px;
overflow: hidden; flex-shrink: 0;
background: ${C.thumbBg}; border: 1px solid ${C.border};
display: flex; align-items: center; justify-content: center;
position: relative; cursor: pointer;
}
.tm-hist-thumb-wrap img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.tm-hist-thumb-wrap .tm-hist-video-icon { color: ${C.sub}; }
.tm-hist-thumb-wrap .tm-hist-video-icon svg { width: 20px; height: 20px; }
.tm-hist-info { flex: 1; min-width: 0; }
.tm-hist-author {
font-size: 12px; font-weight: 600; color: ${C.text};
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tm-hist-handle {
font-size: 11px; color: ${C.sub}; margin-left: 4px; font-weight: 400;
}
.tm-hist-text {
font-size: 11px; color: ${C.sub}; margin: 2px 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
line-height: 1.4;
}
.tm-hist-url {
font-size: 10px; color: #1d9bf0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: pointer;
}
.tm-hist-url:hover { text-decoration: underline; }
.tm-hist-actions {
display: flex; flex-direction: row; gap: 1px;
flex-shrink: 0; align-items: center;
}
.tm-hist-act-btn {
width: 24px; height: 24px; border-radius: 5px; border: none;
background: transparent; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: ${C.sub}; transition: background 0.1s, color 0.1s;
}
.tm-hist-act-btn:hover { background: ${C.rowHover}; color: ${C.text}; }
.tm-hist-act-btn.danger:hover { color: ${C.danger}; }
.tm-hist-act-btn svg { width: 13px; height: 13px; pointer-events: none; }
.tm-hist-act-btn.tm-fav-active { color: #e0245e; }
.tm-hist-act-btn.tm-fav-btn:hover { color: #e0245e; }
.tm-hist-act-btn.tm-fav-btn svg { width: 17px; height: 17px; }
#tm-hist-thumb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 3px; padding: 8px;
}
.tm-hist-grid-cell {
aspect-ratio: 1; border-radius: 6px; overflow: hidden;
position: relative; cursor: pointer;
background: ${C.thumbBg};
}
.tm-hist-grid-cell img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.tm-hist-grid-cell .tm-hist-grid-overlay {
position: absolute; inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0) 55%);
opacity: 0; transition: opacity 0.18s;
display: flex; flex-direction: column; justify-content: flex-end;
padding: 6px;
}
.tm-hist-grid-cell:hover .tm-hist-grid-overlay { opacity: 1; }
.tm-hist-grid-overlay .gov-author {
font-size: 11px; font-weight: 700; color: #fff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tm-hist-grid-overlay .gov-text {
font-size: 10px; color: rgba(255,255,255,0.82);
overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
line-height: 1.3; margin-top: 2px;
}
.tm-hist-grid-cell .tm-hist-grid-nothumb {
width: 100%; height: 100%; display: flex; align-items: center;
justify-content: center; color: ${C.sub};
}
.tm-hist-grid-cell .tm-hist-grid-nothumb svg { width: 28px; height: 28px; }
#tm-thumb-lb-backdrop {
position: fixed; inset: 0; z-index: 10000010;
background: rgba(0,0,0,0); backdrop-filter: blur(0px);
display: flex; align-items: center; justify-content: center;
transition: background 0.22s ease, backdrop-filter 0.22s ease;
cursor: zoom-out;
}
#tm-thumb-lb-backdrop.tm-lb-visible {
background: rgba(0,0,0,0.88);
backdrop-filter: blur(6px);
}
#tm-thumb-lb-img {
max-width: 92vw; max-height: 88vh;
border-radius: 10px;
box-shadow: 0 24px 80px rgba(0,0,0,0.7);
object-fit: contain; display: block;
transform-origin: var(--lb-ox, 50%) var(--lb-oy, 50%);
transform: scale(var(--lb-scale-from, 0.18)) translate(var(--lb-tx, 0px), var(--lb-ty, 0px));
opacity: 0;
transition: transform 0.32s cubic-bezier(0.22,1,0.36,1),
opacity 0.22s ease;
cursor: default;
}
#tm-thumb-lb-img.tm-lb-visible {
transform: scale(1) translate(0,0);
opacity: 1;
}
#tm-thumb-lb-nav {
position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 12px;
z-index: 10000011; pointer-events: none;
opacity: 0; transition: opacity 0.2s;
}
#tm-thumb-lb-nav.tm-lb-visible { opacity: 1; pointer-events: auto; }
.tm-lb-nav-btn {
width: 36px; height: 36px; border-radius: 50%; border: none;
background: rgba(255,255,255,0.15); backdrop-filter: blur(8px);
color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s; font-size: 18px; line-height: 1;
}
.tm-lb-nav-btn:hover { background: rgba(255,255,255,0.28); }
.tm-lb-nav-btn:disabled { opacity: 0.3; cursor: default; }
#tm-lb-counter {
font-size: 12px; color: rgba(255,255,255,0.7);
font-family: system-ui, -apple-system, sans-serif;
min-width: 40px; text-align: center;
}
#tm-thumb-lb-close {
position: fixed; top: 18px; right: 22px; z-index: 10000012;
width: 36px; height: 36px; border-radius: 50%; border: none;
background: rgba(255,255,255,0.15); backdrop-filter: blur(8px);
color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.2s, background 0.15s;
pointer-events: none;
}
#tm-thumb-lb-close.tm-lb-visible { opacity: 1; pointer-events: auto; }
#tm-thumb-lb-close:hover { background: rgba(255,255,255,0.28); }
.tm-hist-thumb-wrap.tm-has-thumb { cursor: zoom-in; }
#tm-hist-footer {
border-top: 1px solid ${C.border}; padding: 7px 12px;
display: flex; align-items: center; gap: 8px;
background: ${C.header}; flex-shrink: 0;
}
#tm-hist-footer.hidden { display: none; }
.tm-hist-del-sel-btn {
padding: 5px 12px; border-radius: 99px; border: none;
background: ${C.danger}; color: #fff; font-size: 12px;
font-weight: 600; cursor: pointer; transition: background 0.1s;
}
.tm-hist-del-sel-btn:hover { background: ${C.dangerHover}; }
.tm-hist-cancel-edit {
padding: 5px 12px; border-radius: 99px;
border: 1px solid ${C.border}; background: transparent;
color: ${C.text}; font-size: 12px; cursor: pointer;
}
#tm-hist-resize {
position: absolute; bottom: 0; right: 0;
width: 14px; height: 14px; cursor: se-resize;
opacity: 0.4;
}
#tm-hist-resize:hover { opacity: 0.8; }
.tm-hist-empty {
display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 40px 20px;
color: ${C.sub}; font-size: 13px; gap: 10px; text-align: center;
}
.tm-hist-empty svg { width: 36px; height: 36px; opacity: 0.4; }
#tm-hist-zoom {
position: fixed; z-index: 999999;
width: 200px; height: 200px; border-radius: 8px;
overflow: hidden; pointer-events: none;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
border: 2px solid rgba(255,255,255,0.2);
}
#tm-hist-zoom img { width: 100%; height: 100%; object-fit: cover; }
`;
const panel = document.createElement('div');
panel.id = 'tm-hist-panel';
panel.style.cssText = `left:${pos.x}px; top:${pos.y}px; width:${pos.w}px; height:${pos.h}px;`;
const titlebar = document.createElement('div');
titlebar.id = 'tm-hist-titlebar';
const titleIcon = document.createElement('span');
titleIcon.style.cssText = 'display:inline-flex;align-items:center;flex-shrink:0;opacity:0.55;margin-right:2px;';
titleIcon.innerHTML = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="1" width="10" height="14" rx="1.5"/><line x1="6" y1="5" x2="10" y2="5"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="6" y1="11" x2="8.5" y2="11"/></svg>`;
const titleEl = document.createElement('span');
titleEl.className = 'tm-hist-title';
titleEl.textContent = 'Download History';
titleEl.title = 'Download History';
const countBadge = document.createElement('span');
countBadge.className = 'tm-hist-count-badge';
const SVG_LIST = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="1" y1="4" x2="15" y2="4"/><line x1="1" y1="8" x2="15" y2="8"/><line x1="1" y1="12" x2="15" y2="12"/></svg>`;
const SVG_GRID = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>`;
const SVG_EDIT = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M11 2l3 3-8 8H3v-3L11 2z"/></svg>`;
const SVG_EXP = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 2v8M5 7l3 4 3-4"/><line x1="2" y1="13" x2="14" y2="13"/></svg>`;
const SVG_CLOSE = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>`;
const btnList = _mkIconBtn(SVG_LIST, 'List mode');
const btnThumb = _mkIconBtn(SVG_GRID, 'Thumbnail mode');
const btnEdit = _mkIconBtn(SVG_EDIT, 'Edit mode');
const btnExp = _mkIconBtn(SVG_EXP, 'Export');
const btnClose = _mkIconBtn(SVG_CLOSE, 'Close');
titlebar.appendChild(titleIcon);
titlebar.appendChild(titleEl);
titlebar.appendChild(countBadge);
titlebar.appendChild(btnList);
titlebar.appendChild(btnThumb);
titlebar.appendChild(btnEdit);
titlebar.appendChild(btnExp);
titlebar.appendChild(btnClose);
panel.appendChild(titlebar);
const searchBar = document.createElement('div');
searchBar.className = 'tm-hist-searchbar';
const searchInput = document.createElement('input');
searchInput.id = 'tm-hist-search';
searchInput.type = 'search';
searchInput.placeholder = '🔍 Search author / content…';
searchBar.appendChild(searchInput);
panel.appendChild(searchBar);
const body = document.createElement('div');
body.id = 'tm-hist-body';
panel.appendChild(body);
const footer = document.createElement('div');
footer.id = 'tm-hist-footer';
footer.className = 'hidden';
const selAllBtn = document.createElement('button');
selAllBtn.className = 'tm-hist-sel-all-btn';
selAllBtn.textContent = 'Select All';
const delSelBtn = document.createElement('button');
delSelBtn.className = 'tm-hist-del-sel-btn';
const cancelEditBtn = document.createElement('button');
cancelEditBtn.className = 'tm-hist-cancel-edit';
cancelEditBtn.textContent = 'Cancel';
footer.appendChild(selAllBtn);
footer.appendChild(delSelBtn);
footer.appendChild(cancelEditBtn);
panel.appendChild(footer);
const ghFooterEl = document.createElement('div');
ghFooterEl.id = 'tm-hist-gh-footer';
const ghLink = document.createElement('a');
ghLink.href = 'https://github.com/Startanuki07/Twitter-X-Media-Copy-Download';
ghLink.target = '_blank';
ghLink.rel = 'noopener noreferrer';
ghLink.textContent = '★ Star on GitHub';
ghFooterEl.appendChild(ghLink);
panel.appendChild(ghFooterEl);
const resizeHandle = document.createElement('div');
resizeHandle.id = 'tm-hist-resize';
resizeHandle.innerHTML = `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="2" y1="12" x2="12" y2="12"/><line x1="7" y1="12" x2="12" y2="7"/><line x1="12" y1="2" x2="12" y2="12"/></svg>`;
panel.appendChild(resizeHandle);
document.body.appendChild(panel);
function getRecords() {
try { return JSON.parse(GM_getValue(KEY_HISTORY_RECORDS, '[]')); }
catch (_) { return []; }
}
function getFiltered(records) {
if (!query) return records;
const q = query.toLowerCase();
return records.filter(r =>
r.displayName?.toLowerCase().includes(q) ||
r.screenName?.toLowerCase().includes(q) ||
r.text?.toLowerCase().includes(q)
);
}
function _fmtGroupLabel(yyyymm) {
const p = yyyymm.split('.');
if (p.length !== 2) return yyyymm;
const y = p[0];
const m = parseInt(p[1], 10);
if (_cachedDateFormat === 'western') {
const names = ['','January','February','March','April','May','June',
'July','August','September','October','November','December'];
return `${names[m] || p[1]} ${y}`;
}
return `${y}年 ${m}月`;
}
function render() {
const records = getRecords();
const filtered = getFiltered(records);
countBadge.textContent = `${records.length} / ${HISTORY_MAX_RECORDS}`;
delSelBtn.textContent = `Delete selected (${selectedIds.size})`;
const visibleIds = filtered
.filter(r => !collapsedGroups.has(r.yyyymm))
.map(r => r.id);
const allSelected = visibleIds.length > 0 && visibleIds.every(id => selectedIds.has(id));
selAllBtn.textContent = allSelected ? 'Deselect All' : 'Select All';
body.innerHTML = '';
if (viewMode === 'list') renderList(filtered);
else renderThumb(filtered);
btnList.classList.toggle('active', viewMode === 'list');
btnThumb.classList.toggle('active', viewMode === 'thumb');
}
function renderList(records) {
if (!records.length) { _renderEmpty(); return; }
const groupCounts = {};
records.forEach(r => { groupCounts[r.yyyymm] = (groupCounts[r.yyyymm] || 0) + 1; });
let lastGroup = null;
let _cbShiftDown = false;
records.forEach((rec, idx) => {
if (rec.yyyymm !== lastGroup) {
lastGroup = rec.yyyymm;
const isCollapsed = collapsedGroups.has(rec.yyyymm);
const gh = document.createElement('div');
gh.className = 'tm-hist-group-header' + (isCollapsed ? ' tm-collapsed' : '');
gh.dataset.yyyymm = rec.yyyymm;
const chevron = document.createElement('span');
chevron.className = 'tm-hist-group-chevron';
chevron.innerHTML = `<svg viewBox="0 0 10 10" width="8" height="8" fill="currentColor"><path d="M1 3l4 4 4-4z"/></svg>`;
const label = document.createElement('span');
label.textContent = _fmtGroupLabel(rec.yyyymm);
const countEl = document.createElement('span');
countEl.className = 'tm-hist-group-count';
countEl.textContent = `${groupCounts[rec.yyyymm]}`;
gh.appendChild(chevron);
gh.appendChild(label);
gh.appendChild(countEl);
gh.addEventListener('click', () => {
if (collapsedGroups.has(rec.yyyymm)) collapsedGroups.delete(rec.yyyymm);
else collapsedGroups.add(rec.yyyymm);
render();
});
body.appendChild(gh);
}
if (collapsedGroups.has(rec.yyyymm)) return;
const row = document.createElement('div');
row.className = 'tm-hist-row' + (selectedIds.has(rec.id) ? ' selected' : '');
row.dataset.id = rec.id;
row.dataset.idx = idx;
if (editMode) {
if (!rec.favorited) {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'tm-hist-cb';
cb.checked = selectedIds.has(rec.id);
cb.addEventListener('mousedown', e => { _cbShiftDown = e.shiftKey; });
cb.addEventListener('change', e => {
e.stopPropagation();
_handleCheckbox(rec.id, idx, _cbShiftDown);
_cbShiftDown = false;
});
row.appendChild(cb);
} else {
const lock = document.createElement('span');
lock.className = 'tm-hist-cb';
lock.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;opacity:0.35;font-size:10px;';
lock.textContent = '♥';
row.appendChild(lock);
}
}
const thumbWrap = document.createElement('div');
thumbWrap.className = 'tm-hist-thumb-wrap';
if (rec.thumbUrls && rec.thumbUrls.length > 0) {
const img = document.createElement('img');
img.src = _thumbUrl(rec.thumbUrls[0]);
img.loading = 'lazy';
img.alt = '';
thumbWrap.appendChild(img);
thumbWrap.classList.add('tm-has-thumb');
thumbWrap.addEventListener('mouseenter', (e) => _showZoom(rec.thumbUrls[0], e));
thumbWrap.addEventListener('mousemove', (e) => _moveZoom(e));
thumbWrap.addEventListener('mouseleave', _hideZoom);
thumbWrap.addEventListener('click', (e) => {
e.stopPropagation();
_hideZoom();
_openThumbLightbox(rec.thumbUrls, 0, thumbWrap.querySelector('img'));
});
} else {
const vi = document.createElement('div');
vi.className = 'tm-hist-video-icon';
vi.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M15 10l4.55-2.28A1 1 0 0 1 21 8.65v6.7a1 1 0 0 1-1.45.93L15 14"/><rect x="2" y="7" width="13" height="10" rx="2"/></svg>`;
thumbWrap.appendChild(vi);
}
row.appendChild(thumbWrap);
const info = document.createElement('div');
info.className = 'tm-hist-info';
const author = document.createElement('div');
author.className = 'tm-hist-author';
author.textContent = rec.displayName || rec.screenName;
const handle = document.createElement('span');
handle.className = 'tm-hist-handle';
handle.textContent = `@${rec.screenName}`;
author.appendChild(handle);
const textEl = document.createElement('div');
textEl.className = 'tm-hist-text';
textEl.textContent = rec.text || '(no caption)';
const urlEl = document.createElement('div');
urlEl.className = 'tm-hist-url';
urlEl.textContent = rec.tweetUrl;
urlEl.title = rec.tweetUrl + '\n(點擊前往推文)';
urlEl.addEventListener('click', (e) => {
e.stopPropagation();
const path = new URL(rec.tweetUrl).pathname;
history.pushState({ tmNav: true }, '', path);
window.dispatchEvent(new Event('popstate'));
});
info.appendChild(author);
info.appendChild(textEl);
info.appendChild(urlEl);
row.appendChild(info);
const acts = document.createElement('div');
acts.className = 'tm-hist-actions';
const SVG_JUMP = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M10 2h4v4"/><path d="M7 9L14 2"/><path d="M12 10v4H2V4h4"/></svg>`;
const SVG_DEL = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><polyline points="2,4 4,4 14,4"/><path d="M13 4l-.9 9H3.9L3 4"/><path d="M6.5 7v4M9.5 7v4"/><path d="M5.5 4V2.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5V4"/></svg>`;
const SVG_HEART_EMPTY = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8 13.5S1.5 9.5 1.5 5.5A3.5 3.5 0 0 1 8 3.207 3.5 3.5 0 0 1 14.5 5.5C14.5 9.5 8 13.5 8 13.5z"/></svg>`;
const SVG_HEART_FULL = `<svg viewBox="0 0 16 16" fill="currentColor" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 13.5S1.5 9.5 1.5 5.5A3.5 3.5 0 0 1 8 3.207 3.5 3.5 0 0 1 14.5 5.5C14.5 9.5 8 13.5 8 13.5z"/></svg>`;
const jmpBtn = document.createElement('button');
jmpBtn.className = 'tm-hist-act-btn';
jmpBtn.innerHTML = SVG_JUMP;
jmpBtn.title = 'Open tweet';
jmpBtn.addEventListener('click', (e) => { e.stopPropagation(); window.open(rec.tweetUrl, '_blank'); });
const favBtn = document.createElement('button');
const isFav = !!rec.favorited;
favBtn.className = 'tm-hist-act-btn tm-fav-btn' + (isFav ? ' tm-fav-active' : '');
favBtn.innerHTML = isFav ? SVG_HEART_FULL : SVG_HEART_EMPTY;
favBtn.title = isFav ? 'Unfavorite' : 'Favorite';
if (editMode) {
favBtn.style.opacity = '0.3';
favBtn.style.pointerEvents = 'none';
}
favBtn.addEventListener('click', (e) => {
e.stopPropagation();
let records = getRecords();
const target = records.find(r => r.id === rec.id);
if (!target) return;
target.favorited = !target.favorited;
GM_setValue(KEY_HISTORY_RECORDS, JSON.stringify(records));
const nowFav = target.favorited;
favBtn.innerHTML = nowFav ? SVG_HEART_FULL : SVG_HEART_EMPTY;
favBtn.title = nowFav ? 'Unfavorite' : 'Favorite';
favBtn.classList.toggle('tm-fav-active', nowFav);
rec.favorited = nowFav;
});
const delBtn = document.createElement('button');
delBtn.className = 'tm-hist-act-btn danger';
delBtn.innerHTML = SVG_DEL;
delBtn.title = 'Delete';
delBtn.addEventListener('click', (e) => { e.stopPropagation(); _deleteOne(rec.id, idx); });
acts.appendChild(favBtn);
acts.appendChild(jmpBtn);
acts.appendChild(delBtn);
row.appendChild(acts);
if (editMode && !rec.favorited) {
row.style.cursor = 'pointer';
row.addEventListener('click', (e) => {
if (e.target.classList.contains('tm-hist-cb')) return;
_handleCheckbox(rec.id, idx, e.shiftKey);
});
}
body.appendChild(row);
});
}
function renderThumb(records) {
if (!records.length) { _renderEmpty(); return; }
const grid = document.createElement('div');
grid.id = 'tm-hist-thumb-grid';
records.forEach(rec => {
const cell = document.createElement('div');
cell.className = 'tm-hist-grid-cell';
cell.title = `${rec.displayName} @${rec.screenName}`;
if (rec.thumbUrls && rec.thumbUrls.length > 0) {
const img = document.createElement('img');
img.src = _thumbUrl(rec.thumbUrls[0]);
img.loading = 'lazy';
img.alt = '';
cell.appendChild(img);
} else {
const ni = document.createElement('div');
ni.className = 'tm-hist-grid-nothumb';
ni.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M15 10l4.55-2.28A1 1 0 0 1 21 8.65v6.7a1 1 0 0 1-1.45.93L15 14"/><rect x="2" y="7" width="13" height="10" rx="2"/></svg>`;
cell.appendChild(ni);
}
const overlay = document.createElement('div');
overlay.className = 'tm-hist-grid-overlay';
const govAuthor = document.createElement('div');
govAuthor.className = 'gov-author';
govAuthor.textContent = rec.displayName || rec.screenName;
const govText = document.createElement('div');
govText.className = 'gov-text';
govText.textContent = rec.text || '';
overlay.appendChild(govAuthor);
overlay.appendChild(govText);
cell.appendChild(overlay);
cell.addEventListener('click', (e) => {
e.stopPropagation();
const path = new URL(rec.tweetUrl).pathname;
history.pushState({ tmNav: true }, '', path);
window.dispatchEvent(new Event('popstate'));
});
grid.appendChild(cell);
});
body.appendChild(grid);
}
function _renderEmpty() {
const em = document.createElement('div');
em.className = 'tm-hist-empty';
em.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`;
const msg = document.createElement('div');
msg.textContent = query ? 'No results matching search.' : 'No download history yet.\nRight-click 🎞️ to download & record.';
msg.style.whiteSpace = 'pre-line';
em.appendChild(msg);
body.appendChild(em);
}
function _handleCheckbox(id, idx, shiftKey) {
const allRecords = getFiltered(getRecords());
const target = allRecords.find(r => r.id === id);
if (target && target.favorited) return;
if (shiftKey && anchorIdx >= 0) {
const lo = Math.min(anchorIdx, idx);
const hi = Math.max(anchorIdx, idx);
for (let i = lo; i <= hi; i++) {
if (allRecords[i] && !allRecords[i].favorited) selectedIds.add(allRecords[i].id);
}
} else {
if (selectedIds.has(id)) selectedIds.delete(id);
else { selectedIds.add(id); anchorIdx = idx; }
}
render();
}
function _deleteOne(id, idx) {
let records = getRecords();
const record = records.find(r => r.id === id);
if (!record) return;
if (record.favorited) return;
records = records.filter(r => r.id !== id);
GM_setValue(KEY_HISTORY_RECORDS, JSON.stringify(records));
_downloadedIds.delete(record.tweetId);
if (_historyUndoTimer) clearTimeout(_historyUndoTimer);
_historyUndoBuffer = { record, index: idx };
render();
const ut = document.createElement('div');
ut.style.cssText = `
position:fixed; bottom:24px; left:50%; transform:translateX(-50%);
background:rgba(15,20,25,0.92); color:#fff; padding:8px 16px;
border-radius:99px; font-size:12px; font-family:system-ui;
display:flex; align-items:center; gap:10px;
z-index:9999999; box-shadow:0 4px 16px rgba(0,0,0,0.3);
animation:tm-toast-rise 5s forwards;
`;
ut.id = 'tm-hist-undo-toast';
const msg = document.createElement('span');
msg.textContent = 'Record deleted';
const undoBtn = document.createElement('button');
undoBtn.textContent = 'Undo';
undoBtn.style.cssText = `background:none;border:none;color:#1d9bf0;cursor:pointer;font-weight:700;font-size:12px;padding:0;`;
undoBtn.addEventListener('click', () => {
if (_historyUndoBuffer) {
let recs = getRecords();
recs.splice(_historyUndoBuffer.index, 0, _historyUndoBuffer.record);
GM_setValue(KEY_HISTORY_RECORDS, JSON.stringify(recs));
_downloadedIds.add(_historyUndoBuffer.record.tweetId);
_historyUndoBuffer = null;
ut.remove();
clearTimeout(_historyUndoTimer);
render();
}
});
ut.appendChild(msg);
ut.appendChild(undoBtn);
document.getElementById('tm-hist-undo-toast')?.remove();
document.body.appendChild(ut);
_historyUndoTimer = setTimeout(() => { ut.remove(); _historyUndoBuffer = null; }, 5000);
}
function _thumbUrl(url) {
try {
if (url.includes('pbs.twimg.com') && url.includes('/media/')) {
const u = new URL(url);
u.searchParams.set('name', 'small');
return u.toString();
}
} catch (_) {}
return url;
}
function _showZoom(url, e) {
let z = document.getElementById('tm-hist-zoom');
if (!z) { z = document.createElement('div'); z.id = 'tm-hist-zoom'; document.body.appendChild(z); }
z.innerHTML = '';
const img = document.createElement('img');
img.src = _thumbUrl(url).replace('name=small', 'name=medium');
img.alt = '';
z.appendChild(img);
_moveZoom(e);
}
function _moveZoom(e) {
const z = document.getElementById('tm-hist-zoom');
if (!z) return;
const w = 200, h = 200, margin = 14;
let left = e.clientX - w - margin;
let top = e.clientY - h / 2;
if (left < 4) left = e.clientX + margin;
if (top < 4) top = 4;
if (top + h > window.innerHeight - 4) top = window.innerHeight - h - 4;
z.style.cssText = `position:fixed;z-index:9999999;width:${w}px;height:${h}px;left:${left}px;top:${top}px;border-radius:8px;overflow:hidden;pointer-events:none;box-shadow:0 8px 24px rgba(0,0,0,0.4);border:2px solid rgba(255,255,255,0.2);`;
}
function _hideZoom() { document.getElementById('tm-hist-zoom')?.remove(); }
function _openThumbLightbox(urls, startIdx, originEl) {
document.getElementById('tm-thumb-lb-backdrop')?.remove();
const allUrls = (urls || []).map(u => {
try {
if (u.includes('pbs.twimg.com') && u.includes('/media/')) {
const obj = new URL(u);
obj.searchParams.set('name', 'large');
return obj.toString();
}
} catch (_) {}
return u;
});
if (!allUrls.length) return;
let idx = Math.max(0, Math.min(startIdx, allUrls.length - 1));
const originRect = originEl ? originEl.getBoundingClientRect() : null;
const vpW = window.innerWidth, vpH = window.innerHeight;
function _calcOrigin(rect) {
if (!rect) return { ox: '50%', oy: '50%', tx: '0px', ty: '0px', scale: 0.18 };
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const finalW = Math.min(vpW * 0.92, vpH * 0.88 * 1.5);
const finalH = finalW / 1.5;
const imgCx = vpW / 2, imgCy = vpH / 2;
const scaleFrom = Math.max(rect.width / finalW, 0.06);
const ox = ((cx - imgCx) / finalW * 100 + 50).toFixed(2) + '%';
const oy = ((cy - imgCy) / finalH * 100 + 50).toFixed(2) + '%';
return { ox, oy, tx: '0px', ty: '0px', scale: scaleFrom };
}
const backdrop = document.createElement('div');
backdrop.id = 'tm-thumb-lb-backdrop';
const imgEl = document.createElement('img');
imgEl.id = 'tm-thumb-lb-img';
imgEl.alt = '';
const closeBtn = document.createElement('button');
closeBtn.id = 'tm-thumb-lb-close';
closeBtn.innerHTML = `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>`;
closeBtn.title = 'Close';
const nav = document.createElement('div');
nav.id = 'tm-thumb-lb-nav';
const prevBtn = document.createElement('button');
prevBtn.className = 'tm-lb-nav-btn';
prevBtn.innerHTML = `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><polyline points="10,3 5,8 10,13"/></svg>`;
prevBtn.title = 'Previous';
const counter = document.createElement('span');
counter.id = 'tm-lb-counter';
const nextBtn = document.createElement('button');
nextBtn.className = 'tm-lb-nav-btn';
nextBtn.innerHTML = `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><polyline points="6,3 11,8 6,13"/></svg>`;
nextBtn.title = 'Next';
nav.appendChild(prevBtn);
nav.appendChild(counter);
nav.appendChild(nextBtn);
backdrop.appendChild(imgEl);
document.body.appendChild(backdrop);
document.body.appendChild(closeBtn);
document.body.appendChild(nav);
function _showIdx(newIdx, fromEl) {
idx = Math.max(0, Math.min(newIdx, allUrls.length - 1));
counter.textContent = allUrls.length > 1 ? `${idx + 1} / ${allUrls.length}` : '';
prevBtn.disabled = (idx === 0);
nextBtn.disabled = (idx === allUrls.length - 1);
nav.style.display = allUrls.length > 1 ? '' : 'none';
const o = _calcOrigin(fromEl ? fromEl.getBoundingClientRect() : null);
imgEl.style.setProperty('--lb-ox', o.ox);
imgEl.style.setProperty('--lb-oy', o.oy);
imgEl.style.setProperty('--lb-scale-from', o.scale);
imgEl.classList.remove('tm-lb-visible');
imgEl.src = allUrls[idx];
imgEl.onload = () => {
requestAnimationFrame(() => {
imgEl.classList.add('tm-lb-visible');
});
};
if (imgEl.complete && imgEl.naturalWidth) {
requestAnimationFrame(() => imgEl.classList.add('tm-lb-visible'));
}
}
function _close() {
backdrop.classList.remove('tm-lb-visible');
closeBtn.classList.remove('tm-lb-visible');
nav.classList.remove('tm-lb-visible');
imgEl.classList.remove('tm-lb-visible');
setTimeout(() => {
backdrop.remove();
closeBtn.remove();
nav.remove();
}, 320);
document.removeEventListener('keydown', _onKey);
}
function _onKey(e) {
if (e.key === 'Escape') _close();
if (e.key === 'ArrowLeft' && idx > 0) _showIdx(idx - 1, null);
if (e.key === 'ArrowRight' && idx < allUrls.length - 1) _showIdx(idx + 1, null);
}
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) _close(); });
closeBtn.addEventListener('click', _close);
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); _showIdx(idx - 1, null); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); _showIdx(idx + 1, null); });
imgEl.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('keydown', _onKey);
_showIdx(startIdx, originEl);
requestAnimationFrame(() => {
backdrop.classList.add('tm-lb-visible');
closeBtn.classList.add('tm-lb-visible');
if (allUrls.length > 1) nav.classList.add('tm-lb-visible');
});
}
function _exportCSV() {
const records = getRecords();
const header = 'tweetId,tweetUrl,date,screenName,displayName\n';
const rows = records.map(r =>
[r.tweetId, r.tweetUrl, r.tweetDate, r.screenName, `"${(r.displayName||'').replace(/"/g,'""')}"`].join(',')
).join('\n');
_download('history.csv', header + rows, 'text/csv');
}
function _exportJSON() {
const records = getRecords();
_download('history.json', JSON.stringify(records, null, 2), 'application/json');
}
function _download(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
function _mkIconBtn(svg, title) {
const b = document.createElement('button');
b.className = 'tm-hist-icon-btn';
b.innerHTML = svg;
b.title = title;
return b;
}
btnList.addEventListener('click', () => { viewMode = 'list'; GM_setValue(KEY_HISTORY_VIEW_MODE, 'list'); render(); });
btnThumb.addEventListener('click', () => { viewMode = 'thumb'; GM_setValue(KEY_HISTORY_VIEW_MODE, 'thumb'); render(); });
btnClose.addEventListener('click', () => {
_panelAC.abort();
if (_historyUndoTimer) { clearTimeout(_historyUndoTimer); _historyUndoTimer = null; _historyUndoBuffer = null; }
panel.remove();
_cleanZoom();
});
btnEdit.addEventListener('click', () => {
editMode = !editMode;
selectedIds.clear(); anchorIdx = -1;
footer.classList.toggle('hidden', !editMode);
btnEdit.classList.toggle('active', editMode);
render();
});
selAllBtn.addEventListener('click', () => {
const filtered = getFiltered(getRecords());
const visibleIds = filtered
.filter(r => !collapsedGroups.has(r.yyyymm) && !r.favorited)
.map(r => r.id);
const allSelected = visibleIds.length > 0 && visibleIds.every(id => selectedIds.has(id));
if (allSelected) {
visibleIds.forEach(id => selectedIds.delete(id));
} else {
visibleIds.forEach(id => selectedIds.add(id));
const firstVisible = filtered.find(r => !collapsedGroups.has(r.yyyymm) && !r.favorited);
if (firstVisible) {
anchorIdx = filtered.indexOf(firstVisible);
}
}
render();
});
delSelBtn.addEventListener('click', () => {
if (!selectedIds.size) return;
let records = getRecords();
records.filter(r => selectedIds.has(r.id) && !r.favorited).forEach(r => _downloadedIds.delete(r.tweetId));
records = records.filter(r => !selectedIds.has(r.id) || r.favorited);
GM_setValue(KEY_HISTORY_RECORDS, JSON.stringify(records));
selectedIds.clear(); anchorIdx = -1;
render();
});
cancelEditBtn.addEventListener('click', () => {
editMode = false; selectedIds.clear(); anchorIdx = -1;
footer.classList.add('hidden'); btnEdit.classList.remove('active');
render();
});
let _expState = 0;
btnExp.addEventListener('click', () => {
_expState = (_expState + 1) % 3;
if (_expState === 1) _exportCSV();
else if (_expState === 2) _exportJSON();
else _expState = 0;
});
btnExp.title = 'Export CSV (click once) / JSON (click twice)';
searchInput.addEventListener('input', () => { query = searchInput.value.trim(); render(); });
panel.addEventListener('tm-hist-refresh', render);
const _panelAC = new AbortController();
const _acSignal = { signal: _panelAC.signal };
panel._tmCleanup = () => { _panelAC.abort(); };
let _dragging = false, _dx = 0, _dy = 0;
titlebar.addEventListener('mousedown', (e) => {
if (e.button !== 0 || e.target.classList.contains('tm-hist-icon-btn')) return;
_dragging = true;
_dx = e.clientX - panel.getBoundingClientRect().left;
_dy = e.clientY - panel.getBoundingClientRect().top;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!_dragging) return;
let nx = e.clientX - _dx;
let ny = e.clientY - _dy;
nx = Math.max(0, Math.min(nx, window.innerWidth - panel.offsetWidth));
ny = Math.max(0, Math.min(ny, window.innerHeight - 60));
panel.style.left = nx + 'px';
panel.style.top = ny + 'px';
}, _acSignal);
document.addEventListener('mouseup', () => {
if (!_dragging) return;
_dragging = false;
_savePos();
}, _acSignal);
let _resizing = false, _rsx = 0, _rsy = 0, _rsw = 0, _rsh = 0;
resizeHandle.addEventListener('mousedown', (e) => {
_resizing = true;
_rsx = e.clientX; _rsy = e.clientY;
_rsw = panel.offsetWidth; _rsh = panel.offsetHeight;
e.preventDefault(); e.stopPropagation();
});
document.addEventListener('mousemove', (e) => {
if (!_resizing) return;
const nw = Math.max(300, Math.min(_rsw + (e.clientX - _rsx), 680));
const nh = Math.max(280, Math.min(_rsh + (e.clientY - _rsy), window.innerHeight - 80));
panel.style.width = nw + 'px';
panel.style.height = nh + 'px';
}, _acSignal);
document.addEventListener('mouseup', () => {
if (!_resizing) return;
_resizing = false;
_savePos();
}, _acSignal);
function _savePos() {
const r = panel.getBoundingClientRect();
GM_setValue(KEY_HISTORY_PANEL_POS, JSON.stringify({ x: r.left, y: r.top, w: r.width, h: r.height }));
}
panel.addEventListener('click', e => e.stopPropagation());
render();
}
function _cleanZoom() { document.getElementById('tm-hist-zoom')?.remove(); }
function fireMeteor(fromEl) {
const histBtn = document.getElementById('tm-history-btn');
if (!histBtn || !fromEl) return;
const fromRect = fromEl.getBoundingClientRect();
const toRect = histBtn.getBoundingClientRect();
const fromX = fromRect.left + fromRect.width / 2;
const fromY = fromRect.top + fromRect.height / 2;
const toX = toRect.left + toRect.width / 2;
const toY = toRect.top + toRect.height / 2;
const dx = toX - fromX;
const dy = toY - fromY;
const dist = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
const tailLen = Math.min(Math.max(dist * 0.24, 16), 40);
const container = document.createElement('div');
container.style.cssText = `
position: fixed;
left: ${fromX}px; top: ${fromY}px;
width: 0; height: 0;
pointer-events: none;
z-index: 99999999;
`;
const tail = document.createElement('div');
tail.style.cssText = `
position: absolute;
width: ${tailLen * 1.2}px; height: 6px;
border-radius: 9999px;
background: linear-gradient(90deg,
rgba(255,255,255,0) 0%,
rgba(255,220,100,0.15) 30%,
rgba(255,200,80,0.3) 70%,
rgba(255,240,150,0.25)100%);
box-shadow: 0 0 4px 1px rgba(255,200,100,0.3);
filter: drop-shadow(0 0 3px rgba(255,200,100,0.2));
transform: translateX(-100%) translateY(-50%) rotate(${angle}deg);
transform-origin: 100% 50%;
`;
const tailGlow = document.createElement('div');
tailGlow.style.cssText = `
position: absolute;
width: ${tailLen}px; height: 10px;
border-radius: 9999px;
background: linear-gradient(90deg,
rgba(255,240,200,0) 0%,
rgba(255,220,150,0.15) 50%,
rgba(255,240,200,0) 100%);
filter: blur(1.5px);
transform: translateX(-100%) translateY(-50%) rotate(${angle}deg);
transform-origin: 100% 50%;
opacity: 0.4;
`;
const dot = document.createElement('div');
dot.style.cssText = `
position: absolute;
width: 9px; height: 9px; border-radius: 50%;
background: radial-gradient(circle at 40% 40%, #ffffdd, #ffeed4);
box-shadow: 0 0 5px 2px rgba(255,220,150,0.4),
0 0 10px 4px rgba(255,200,100,0.15);
transform: translate(-50%, -50%);
`;
const particles = [];
for (let i = 0; i < 4; i++) {
const particle = document.createElement('div');
const offsetX = (Math.random() - 0.5) * tailLen * 0.4;
const offsetY = (Math.random() - 0.5) * 8;
const size = 1.5 + Math.random() * 2;
particle.style.cssText = `
position: absolute;
width: ${size}px; height: ${size}px; border-radius: 50%;
background: rgba(255, 240, 180, ${0.3 + Math.random() * 0.3});
left: ${offsetX}px; top: ${offsetY}px;
filter: blur(0.5px);
box-shadow: 0 0 ${size + 1}px rgba(255,220,150,0.3);
`;
particles.push(particle);
}
container.appendChild(tailGlow);
container.appendChild(tail);
particles.forEach(p => container.appendChild(p));
container.appendChild(dot);
document.body.appendChild(container);
const DURATION = 680;
const anim = container.animate([
{ transform: 'translate(0px, 0px)', opacity: 0, offset: 0 },
{ transform: 'translate(0px, 0px)', opacity: 1, offset: 0.04 },
{ transform: `translate(${dx}px, ${dy}px)`, opacity: 1, offset: 0.88 },
{ transform: `translate(${dx}px, ${dy}px)`, opacity: 0, offset: 1 },
], {
duration: DURATION,
easing: 'cubic-bezier(0.28, 0.0, 0.72, 1.0)',
fill: 'forwards',
});
anim.onfinish = () => {
container.remove();
_triggerHistAbsorb();
};
}
let _absorbTimer = null;
function _triggerHistAbsorb() {
const wrapper = document.getElementById('tm-settings-wrapper');
const histBtn = document.getElementById('tm-history-btn');
if (!wrapper || !histBtn) return;
if (_absorbTimer) clearTimeout(_absorbTimer);
wrapper.setAttribute('data-focus', 'hist');
wrapper.setAttribute('data-absorb', 'true');
histBtn.classList.remove('tm-absorbing');
void histBtn.offsetWidth;
histBtn.classList.add('tm-absorbing');
_absorbTimer = setTimeout(() => {
wrapper.removeAttribute('data-absorb');
histBtn.classList.remove('tm-absorbing');
_absorbTimer = null;
}, 2500);
}
const BUTTON_CLASS = 'force-media-copy-btn';
const style = document.createElement('style');
style.textContent = `
.${BUTTON_CLASS}, .custom-copy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
font-size: 11px;
min-width: 36px;
width: 36px;
height: 36px;
padding: 8px;
box-sizing: border-box;
opacity: 0.75;
cursor: pointer;
margin-left: 4px;
transition: opacity 0.2s, filter 0.2s;
color: #536471;
flex-shrink: 0;
}
@media (prefers-color-scheme: dark) {
.${BUTTON_CLASS}, .custom-copy-icon { color: #71767b; }
}
.${BUTTON_CLASS} svg, .custom-copy-icon svg {
width: 20px;
height: 20px;
display: block;
overflow: visible;
flex-shrink: 0;
}
.${BUTTON_CLASS}:hover { opacity: 1.0; }
.custom-copy-icon:hover {
opacity: 1.0;
filter: drop-shadow(0 0 4px currentColor);
}
`;
document.head.appendChild(style);
const _toastStyle = document.createElement('style');
_toastStyle.textContent = `
@keyframes tm-toast-rise {
0% { opacity: 0; transform: translate(-50%, -100%) scale(0.85); }
15% { opacity: 1; transform: translate(-50%, -118%) scale(1); }
70% { opacity: 1; transform: translate(-50%, -135%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -152%) scale(0.95); }
}
.tm-action-toast {
position: fixed; white-space: nowrap; pointer-events: none;
transform: translate(-50%, -100%);
background: rgba(29,155,240,0.92); color: #fff;
font: 600 11px/1 system-ui,-apple-system,sans-serif;
padding: 4px 9px; border-radius: 9999px;
box-shadow: 0 2px 8px rgba(0,0,0,0.25); z-index: 9999999;
animation: tm-toast-rise 1.6s cubic-bezier(0.22,1,0.36,1) forwards;
}
.tm-action-toast.warn { background: rgba(255,140,0,0.92); }
.tm-action-toast.error { background: rgba(224,36,94,0.92); }
@keyframes tm-spin { to { transform: rotate(360deg); } }
.tm-dl-ring { display:inline-flex; align-items:center; justify-content:center;
width:20px; height:20px; flex-shrink:0; }
.tm-dl-ring svg { overflow:visible; }
.tm-dl-ring .tm-bg { stroke: rgba(128,128,128,0.28); }
.tm-dl-ring .tm-fg { stroke: currentColor;
transition: stroke-dashoffset 0.15s linear;
transform-origin: 10px 10px; }
.tm-dl-ring.indeterminate .tm-fg {
animation: tm-spin 0.85s linear infinite; }
`;
document.head.appendChild(_toastStyle);
function showActionToast(anchorEl, message, type = 'ok') {
if (GM_getValue(KEY_FEEDBACK_STYLE, 'toast') === 'silent') return;
const rect = anchorEl.getBoundingClientRect();
const viewW = window.innerWidth;
const cx = Math.max(48, Math.min(rect.left + rect.width / 2, viewW - 48));
const toast = document.createElement('span');
toast.className = 'tm-action-toast' + (type !== 'ok' ? ` ${type}` : '');
toast.textContent = message;
toast.style.left = cx + 'px';
toast.style.top = rect.top + 'px';
document.body.appendChild(toast);
toast.addEventListener('animationend', () => toast.remove(), { once: true });
}
function createProgressRing() {
const R = 8, C = +(2 * Math.PI * R).toFixed(4);
const el = document.createElement('span');
el.className = 'tm-dl-ring indeterminate';
el.innerHTML = `<svg viewBox="0 0 20 20" width="20" height="20">
<circle class="tm-bg" cx="10" cy="10" r="${R}" fill="none" stroke-width="2.5"/>
<circle class="tm-fg" cx="10" cy="10" r="${R}" fill="none" stroke-width="2.5"
stroke-dasharray="${C}" stroke-dashoffset="${C}"
transform="rotate(-90 10 10)"/></svg>`;
const fg = el.querySelector('.tm-fg');
const update = (pct) => {
if (pct === null) {
el.classList.add('indeterminate');
fg.style.strokeDashoffset = C;
} else {
el.classList.remove('indeterminate');
fg.style.strokeDashoffset = C * (1 - Math.max(0, Math.min(1, pct / 100)));
}
};
return { el, update, remove: () => el.remove() };
}
async function forceDownloadBlob(url, filename, onProgress) {
try {
const resp = await unsafeWindow.fetch(url, { credentials: 'omit' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const contentLength = parseInt(resp.headers.get('content-length') || '0', 10);
const reader = resp.body.getReader();
const chunks = [];
let received = 0;
if (onProgress) onProgress(contentLength > 0 ? 0 : null);
while (true) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (onProgress && contentLength > 0) {
onProgress(Math.round(received / contentLength * 100));
}
}
if (onProgress) onProgress(100);
const blob = new Blob(chunks, { type: resp.headers.get('content-type') || 'application/octet-stream' });
const blobUrl = (window.URL || window.webkitURL).createObjectURL(blob);
try {
const tag = document.createElement('a');
tag.href = blobUrl; tag.download = filename;
document.body.appendChild(tag); tag.click(); document.body.removeChild(tag);
} finally {
setTimeout(() => (window.URL || window.webkitURL).revokeObjectURL(blobUrl), 8000);
}
return;
} catch (fetchErr) {
console.warn('[MediaDL] fetch stream failed, falling back to GM_xmlhttpRequest:', fetchErr);
}
await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET", url: url, responseType: "blob",
onload: function(response) {
if (response.status === 200) {
const blob = response.response;
const urlCreator = window.URL || window.webkitURL;
const blobUrl = urlCreator.createObjectURL(blob);
try {
const tag = document.createElement('a');
tag.href = blobUrl;
tag.download = filename;
document.body.appendChild(tag);
tag.click();
document.body.removeChild(tag);
} finally {
setTimeout(() => urlCreator.revokeObjectURL(blobUrl), 8000);
}
resolve();
} else {
reject(new Error(`GM fallback HTTP ${response.status}`));
}
},
onerror: function(err) {
console.error('[MediaDL] GM fallback also failed:', err);
const tag = document.createElement('a');
tag.href = url; tag.download = filename; tag.target = '_blank';
document.body.appendChild(tag); tag.click(); document.body.removeChild(tag);
resolve();
}
});
});
}
function showFloatingVideoPlayer(videoUrls, startIndex = 0, imageUrls = null) {
document.querySelectorAll('video, audio').forEach(media => {
if (!media.paused) media.pause();
});
const oldModal = document.getElementById('tm-floating-video-modal');
if (oldModal) oldModal.remove();
const total = videoUrls.length;
let currentIndex = Math.max(0, Math.min(startIndex, total - 1));
if (!document.getElementById('tm-vp-style')) {
const s = document.createElement('style');
s.id = 'tm-vp-style';
s.textContent = `
#tm-floating-video-modal {
opacity: 0;
transition: opacity 0.22s ease;
}
#tm-floating-video-modal.tm-vp-visible {
opacity: 1;
}
#tm-floating-video-modal video {
opacity: 0;
transform: scale(0.88) translateZ(0);
transition: opacity 0.30s ease 0.10s,
transform 0.38s cubic-bezier(0.22,1,0.36,1) 0.08s;
will-change: transform, opacity;
}
#tm-floating-video-modal.tm-vp-visible video {
opacity: 1;
transform: scale(1) translateZ(0);
}
`;
document.head.appendChild(s);
}
const modal = document.createElement('div');
modal.id = 'tm-floating-video-modal';
modal.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.95); z-index: 9999999;
display: flex; align-items: center; justify-content: center;
overscroll-behavior: contain;
`;
modal.addEventListener('wheel', e => e.preventDefault(), { passive: false });
modal.addEventListener('touchmove', e => e.preventDefault(), { passive: false });
const video = document.createElement('video');
video.controls = true;
video.autoplay = true;
video.volume = Math.max(0, Math.min(1, parseFloat(GM_getValue(KEY_VIDEO_VOLUME, '1')) || 1));
video.addEventListener('volumechange', () => {
GM_setValue(KEY_VIDEO_VOLUME, String(video.volume));
});
video.style.cssText = `
max-width: 88%; max-height: 88%;
border-radius: 8px; box-shadow: 0 10px 50px rgba(0,0,0,0.8);
background: #000; outline: none;
`;
const counter = document.createElement('div');
counter.style.cssText = `
position: absolute; top: 20px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.55); backdrop-filter: blur(6px);
color: rgba(255,255,255,0.85); padding: 4px 14px;
border-radius: 9999px; font: 13px/1.5 system-ui, sans-serif;
z-index: 3; pointer-events: none; white-space: nowrap;
display: ${total > 1 ? 'block' : 'none'};
`;
const NAV_BASE = `
position: absolute; top: 50%; transform: translateY(-50%);
background: rgba(0,0,0,0.55); backdrop-filter: blur(4px);
color: white; border: none;
width: 46px; height: 46px; border-radius: 50%;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.2s, opacity 0.2s; z-index: 3;
`;
const SVG_PREV = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15,18 9,12 15,6"/></svg>`;
const SVG_NEXT = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9,18 15,12 9,6"/></svg>`;
const prevBtn = document.createElement('button');
prevBtn.innerHTML = SVG_PREV;
prevBtn.style.cssText = NAV_BASE + 'left: 20px;';
prevBtn.onmouseenter = () => prevBtn.style.background = 'rgba(255,255,255,0.25)';
prevBtn.onmouseleave = () => prevBtn.style.background = 'rgba(0,0,0,0.55)';
const nextBtn = document.createElement('button');
nextBtn.innerHTML = SVG_NEXT;
nextBtn.style.cssText = NAV_BASE + 'right: 20px;';
nextBtn.onmouseenter = () => nextBtn.style.background = 'rgba(255,255,255,0.25)';
nextBtn.onmouseleave = () => nextBtn.style.background = 'rgba(0,0,0,0.55)';
const closeBtn = document.createElement('button');
closeBtn.innerHTML = `<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>`;
closeBtn.style.cssText = `
position: absolute; top: 20px; right: 25px;
background: rgba(0,0,0,0.6); color: white; border: none;
width: 40px; height: 40px; border-radius: 50%;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.2s; z-index: 4;
`;
closeBtn.onmouseenter = () => closeBtn.style.background = 'rgba(255,255,255,0.3)';
closeBtn.onmouseleave = () => closeBtn.style.background = 'rgba(0,0,0,0.6)';
const viewImgBtn = imageUrls && imageUrls.length ? document.createElement('button') : null;
if (viewImgBtn) {
viewImgBtn.innerHTML = '🖼️ Images';
viewImgBtn.style.cssText = `
position: absolute; top: 20px; right: 75px;
background: rgba(29,155,240,0.8); color: white; border: none;
padding: 6px 16px; border-radius: 9999px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.2s; z-index: 4; font: 13px/1.5 system-ui, sans-serif;
`;
viewImgBtn.onmouseenter = () => viewImgBtn.style.background = 'rgba(29,155,240,1)';
viewImgBtn.onmouseleave = () => viewImgBtn.style.background = 'rgba(29,155,240,0.8)';
viewImgBtn.onclick = (e) => {
e.stopPropagation();
closeModal();
showImageLightbox(imageUrls, videoUrls);
};
}
function updatePlayer() {
video.src = videoUrls[currentIndex];
video.play().catch(() => {});
if (total > 1) {
counter.textContent = `${currentIndex + 1} / ${total}`;
prevBtn.style.opacity = currentIndex === 0 ? '0.3' : '1';
nextBtn.style.opacity = currentIndex === total - 1 ? '0.3' : '1';
prevBtn.style.pointerEvents = currentIndex === 0 ? 'none' : 'auto';
nextBtn.style.pointerEvents = currentIndex === total - 1 ? 'none' : 'auto';
}
}
prevBtn.onclick = e => { e.stopPropagation(); if (currentIndex > 0) { currentIndex--; updatePlayer(); } };
nextBtn.onclick = e => { e.stopPropagation(); if (currentIndex < total - 1) { currentIndex++; updatePlayer(); } };
const closeModal = () => {
modal.classList.remove('tm-vp-visible');
video.pause();
setTimeout(() => {
modal.remove();
}, 220);
document.removeEventListener('keydown', keyHandler);
};
closeBtn.onclick = closeModal;
modal.onclick = (e) => { if (e.target === modal) closeModal(); };
const keyHandler = (e) => {
if (e.key === 'Escape') { closeModal(); return; }
if ((e.key === 'ArrowRight' || e.key === 'ArrowDown') && currentIndex < total - 1) { currentIndex++; updatePlayer(); }
if ((e.key === 'ArrowLeft' || e.key === 'ArrowUp') && currentIndex > 0) { currentIndex--; updatePlayer(); }
};
document.addEventListener('keydown', keyHandler);
modal.appendChild(video);
modal.appendChild(closeBtn);
if (viewImgBtn) modal.appendChild(viewImgBtn);
modal.appendChild(counter);
if (total > 1) { modal.appendChild(prevBtn); modal.appendChild(nextBtn); }
document.body.appendChild(modal);
updatePlayer();
requestAnimationFrame(() => requestAnimationFrame(() => {
modal.classList.add('tm-vp-visible');
}));
}
function showImageLightbox(urls, videoUrls = null) {
if (!urls.length) return;
const old = document.getElementById('tm-image-lightbox');
if (old) old.remove();
if (!document.getElementById('tm-lb-style')) {
const s = document.createElement('style');
s.id = 'tm-lb-style';
s.textContent = `
#tm-image-lightbox {
opacity: 0;
transition: opacity 0.22s ease;
}
#tm-image-lightbox.tm-lb-in { opacity: 1; }
#tm-image-lightbox .tm-lb-single-img {
opacity: 0;
transform: scale(0.88) translateZ(0);
transition: opacity 0.32s ease 0.08s,
transform 0.40s cubic-bezier(0.22,1,0.36,1) 0.06s;
will-change: transform, opacity;
}
#tm-image-lightbox.tm-lb-in .tm-lb-single-img {
opacity: 1;
transform: scale(1) translateZ(0);
}
#tm-image-lightbox .tm-lb-card.tm-lb-animated {
transition:
transform 0.38s cubic-bezier(0.34,1.18,0.64,1),
opacity 0.26s ease;
will-change: transform, opacity;
}
#tm-image-lightbox .tm-lb-card { box-shadow: 0 12px 36px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.07); }
#tm-image-lightbox .tm-lb-card.tm-lb-focused { box-shadow: 0 28px 80px rgba(0,0,0,0.9), 0 0 0 1px rgba(255,255,255,0.18); }
`;
document.head.appendChild(s);
}
const total = urls.length;
const isSingleImage = total === 1;
let focused = 0;
let _rafId = null;
const VW = window.innerWidth;
const VH = window.innerHeight;
const CARD_W = Math.min(VW * 0.50, 580);
const CARD_H = Math.min(VH * 0.88, 1000);
const SPREAD = Math.min(CARD_W * 0.40, 200);
function calcTransform(pos) {
const abs = Math.abs(pos);
return {
dx: pos * SPREAD,
rot: pos * 9,
scale: Math.max(0.68, 1 - abs * 0.12),
zIndex: 20 - abs * 2,
opacity: abs >= 3 ? 0.5 : 1,
focused: pos === 0,
};
}
const modal = document.createElement('div');
modal.id = 'tm-image-lightbox';
modal.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.95); z-index: 9999999;
display: flex; align-items: center; justify-content: center;
overflow: hidden; overscroll-behavior: contain;
`;
modal.addEventListener('wheel', e => e.preventDefault(), { passive: false });
modal.addEventListener('touchmove', e => e.preventDefault(), { passive: false });
function closeLightbox() {
modal.classList.remove('tm-lb-in');
setTimeout(() => { modal.remove(); }, 220);
document.removeEventListener('keydown', keyHandler);
}
if (isSingleImage) {
const container = document.createElement('div');
container.style.cssText = `
position: relative; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
`;
const img = document.createElement('img');
img.src = urls[0];
img.draggable = false;
img.className = 'tm-lb-single-img';
img.style.cssText = `
max-width: 95vw; max-height: 95vh;
object-fit: contain; display: block;
background: transparent; pointer-events: none;
user-select: none; -webkit-user-drag: none;
`;
container.appendChild(img);
modal.appendChild(container);
const closeBtn = document.createElement('button');
closeBtn.innerHTML = `<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>`;
closeBtn.style.cssText = `
position: absolute; top: 20px; right: 25px;
background: rgba(0,0,0,0.6); color: white; border: none;
width: 40px; height: 40px; border-radius: 50%;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.2s; z-index: 30;
`;
closeBtn.onmouseenter = () => closeBtn.style.background = 'rgba(255,255,255,0.25)';
closeBtn.onmouseleave = () => closeBtn.style.background = 'rgba(0,0,0,0.6)';
closeBtn.onclick = closeLightbox;
modal.onclick = e => { if (e.target === modal || e.target === container) closeLightbox(); };
modal.appendChild(closeBtn);
if (videoUrls && videoUrls.length) {
const viewVidBtn = document.createElement('button');
viewVidBtn.innerHTML = '▶️ Videos';
viewVidBtn.style.cssText = `
position: absolute; top: 20px; right: 75px;
background: rgba(29,155,240,0.8); color: white; border: none;
padding: 6px 16px; border-radius: 9999px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.2s; z-index: 30; font: 13px/1.5 system-ui, sans-serif;
`;
viewVidBtn.onmouseenter = () => viewVidBtn.style.background = 'rgba(29,155,240,1)';
viewVidBtn.onmouseleave = () => viewVidBtn.style.background = 'rgba(29,155,240,0.8)';
viewVidBtn.onclick = e => { e.stopPropagation(); closeLightbox(); showFloatingVideoPlayer(videoUrls, 0, urls); };
modal.appendChild(viewVidBtn);
}
const keyHandler = e => { if (e.key === 'Escape') closeLightbox(); };
document.addEventListener('keydown', keyHandler);
document.body.appendChild(modal);
requestAnimationFrame(() => requestAnimationFrame(() => {
modal.classList.add('tm-lb-in');
}));
return;
}
const stage = document.createElement('div');
stage.style.cssText = `
position: relative;
width: ${CARD_W}px; height: ${CARD_H}px;
flex-shrink: 0; overflow: visible;
`;
const cards = urls.map((url, i) => {
const card = document.createElement('div');
card.className = 'tm-lb-card';
card.style.cssText = `
position: absolute; left: 0; top: 0;
width: ${CARD_W}px; height: ${CARD_H}px;
border-radius: 14px; overflow: hidden;
background: radial-gradient(ellipse at 50% 38%, #1e1e1e 0%, #0a0a0a 100%);
cursor: pointer;
transform: translateY(${VH * 0.6}px) scale(0.72) translateZ(0);
opacity: 0;
z-index: ${i};
`;
const img = document.createElement('img');
img.src = url;
img.loading = i === 0 ? 'eager' : 'lazy';
img.draggable = false;
img.style.cssText = `
width: 100%; height: 100%;
object-fit: contain; display: block;
background: transparent; pointer-events: none;
user-select: none; -webkit-user-drag: none;
`;
card.appendChild(img);
card.addEventListener('click', e => {
e.stopPropagation();
if (i === focused) return;
focused = i;
scheduleUpdate();
});
stage.appendChild(card);
return card;
});
const dotsWrap = document.createElement('div');
dotsWrap.style.cssText = `
position: absolute; bottom: 22px; left: 50%;
transform: translateX(-50%);
display: flex; gap: 8px; z-index: 30;
`;
const dots = urls.map((_, i) => {
const dot = document.createElement('div');
dot.style.cssText = `
width: 7px; height: 7px; border-radius: 50%;
background: rgba(255,255,255,0.35); cursor: pointer;
transition: background 0.22s, transform 0.22s;
`;
dot.addEventListener('click', e => { e.stopPropagation(); focused = i; scheduleUpdate(); });
dotsWrap.appendChild(dot);
return dot;
});
const counter = document.createElement('div');
counter.style.cssText = `
position: absolute; top: 20px; left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.55); backdrop-filter: blur(6px);
color: rgba(255,255,255,0.85);
padding: 4px 14px; border-radius: 9999px;
font: 13px/1.5 system-ui, sans-serif; z-index: 30;
pointer-events: none; white-space: nowrap;
`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = `<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>`;
closeBtn.style.cssText = `
position: absolute; top: 20px; right: 25px;
background: rgba(0,0,0,0.6); color: white; border: none;
width: 40px; height: 40px; border-radius: 50%;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.2s; z-index: 30;
`;
closeBtn.onmouseenter = () => closeBtn.style.background = 'rgba(255,255,255,0.25)';
closeBtn.onmouseleave = () => closeBtn.style.background = 'rgba(0,0,0,0.6)';
closeBtn.onclick = closeLightbox;
modal.onclick = e => { if (e.target === modal) closeLightbox(); };
const viewVidBtn = videoUrls && videoUrls.length ? document.createElement('button') : null;
if (viewVidBtn) {
viewVidBtn.innerHTML = '▶️ Videos';
viewVidBtn.style.cssText = `
position: absolute; top: 20px; right: 75px;
background: rgba(29,155,240,0.8); color: white; border: none;
padding: 6px 16px; border-radius: 9999px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.2s; z-index: 30; font: 13px/1.5 system-ui, sans-serif;
`;
viewVidBtn.onmouseenter = () => viewVidBtn.style.background = 'rgba(29,155,240,1)';
viewVidBtn.onmouseleave = () => viewVidBtn.style.background = 'rgba(29,155,240,0.8)';
viewVidBtn.onclick = e => { e.stopPropagation(); closeLightbox(); showFloatingVideoPlayer(videoUrls, 0, urls); };
}
const keyHandler = e => {
if (e.key === 'Escape') { closeLightbox(); return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { focused = Math.min(focused + 1, total - 1); scheduleUpdate(); }
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { focused = Math.max(focused - 1, 0); scheduleUpdate(); }
};
document.addEventListener('keydown', keyHandler);
function scheduleUpdate() {
if (_rafId) return;
_rafId = requestAnimationFrame(() => {
_rafId = null;
_flushUpdate();
});
}
function _flushUpdate() {
cards.forEach((card, i) => {
const { dx, rot, scale, zIndex, opacity, focused: isFocused } = calcTransform(i - focused);
card.style.transform = `translateX(${dx}px) rotate(${rot}deg) scale(${scale}) translateZ(0)`;
card.style.zIndex = zIndex;
card.style.opacity = opacity;
card.style.cursor = isFocused ? 'default' : 'pointer';
card.classList.toggle('tm-lb-focused', isFocused);
});
dots.forEach((dot, i) => {
dot.style.background = i === focused ? 'rgba(255,255,255,0.95)' : 'rgba(255,255,255,0.35)';
dot.style.transform = i === focused ? 'scale(1.4)' : 'scale(1)';
});
counter.textContent = `${focused + 1} / ${total}`;
}
modal.appendChild(stage);
modal.appendChild(closeBtn);
if (viewVidBtn) modal.appendChild(viewVidBtn);
if (total > 1) { modal.appendChild(dotsWrap); modal.appendChild(counter); }
document.body.appendChild(modal);
requestAnimationFrame(() => requestAnimationFrame(() => {
modal.classList.add('tm-lb-in');
cards.forEach((card, i) => {
const delay = i * 55;
setTimeout(() => {
card.style.transition = `
transform 0.42s cubic-bezier(0.22,1,0.36,1) ${delay}ms,
opacity 0.28s ease ${delay}ms
`;
card.style.transform = `translateX(0) rotate(0deg) scale(1) translateZ(0)`;
card.style.opacity = '1';
card.style.zIndex = 20 - Math.abs(i);
}, 0);
});
const totalStagger = (total - 1) * 55 + 420;
setTimeout(() => {
cards.forEach(card => card.classList.add('tm-lb-animated'));
_flushUpdate();
}, totalStagger);
}));
}
function formatDate(dateInput) {
try {
if (!dateInput) return '0000.00.00';
const date = new Date(dateInput);
if (isNaN(date.getTime())) return '0000.00.00';
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return _cachedDateFormat === 'western' ? `${d}.${m}.${y}` : `${y}.${m}.${d}`;
} catch (e) { return '0000.00.00'; }
}
function sanitizeForFilename(text, maxLength = 50) {
if (!text) return "";
let clean = text.replace(/[\r\n]+/g, ' ');
clean = clean.replace(/[\\/:*?"<>|#%&]/g, '');
clean = clean.replace(/[\u0000-\u001f\u007f\uff0f\uff3c\uff1a\uff0a\uff1f\uff02\uff1c\uff1e\uff5c\u200b\u200c\u200d\uFEFF]/g, '');
clean = clean.replace(/\.+$/, '').replace(/^\.+/, '');
clean = clean.trim();
if (clean.length > maxLength) clean = clean.substring(0, maxLength).trimEnd().replace(/\.+$/, '');
if (!clean) return '_';
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(clean)) clean = '_' + clean;
return clean;
}
function getTweetInfo(article) {
let date = '0000.00.00';
let id = '0000';
let screenName = 'unknown';
let displayName = 'User';
let tweetText = '';
const timeEl = article.querySelector('time');
if (timeEl) date = formatDate(timeEl.getAttribute('datetime'));
else date = formatDate(new Date());
const allLinks = article.querySelectorAll('a[href*="/status/"]');
for (const link of allLinks) {
const href = link.getAttribute('href');
const match = href.match(/\/([a-zA-Z0-9_]+)\/status\/(\d+)/);
if (match) {
screenName = match[1];
id = match[2];
break;
}
}
const textNode = article.querySelector('[data-testid="tweetText"]');
if (textNode) tweetText = textNode.innerText || "";
try {
const userBlock = article.querySelector('[data-testid="User-Name"]');
if (userBlock) {
const lines = userBlock.innerText.split('\n');
if (lines.length >= 1) displayName = lines[0].trim();
if (screenName === 'unknown' && lines.length >= 2) {
const handle = lines.find(l => l.startsWith('@'));
if (handle) screenName = handle.replace('@', '');
}
}
} catch(e) { console.warn('[MediaDL] Failed to extract displayName:', e); }
if (id === '0000' && screenName === 'unknown') {
id = "Ad_" + Date.now().toString().slice(-6);
screenName = 'Promoted';
}
let videoThumb = null;
const posterVid = article.querySelector('video[poster]');
if (posterVid) {
videoThumb = posterVid.getAttribute('poster');
} else {
const thumbImg = article.querySelector('img[src*="video_thumb"], img[src*="ext_tw_video_thumb"], img[src*="amplify_video_thumb"]');
if (thumbImg) videoThumb = thumbImg.src;
}
return {
screenName: screenName,
displayName: displayName,
id: id,
date: date,
text: sanitizeForFilename(tweetText),
videoThumb: videoThumb
};
}
function extractFiberNode(node) {
const key = Object.keys(node).find(k => k.startsWith("__reactFiber"));
return key ? node[key] : null;
}
const _apiVideoCache = new Map();
const _API_CACHE_TTL = 300_000;
function _parseVideosFromTweetResult(tweetResult) {
try {
const core = tweetResult?.result ?? tweetResult;
const tweetId = core?.legacy?.id_str ?? core?.tweet?.legacy?.id_str;
if (!tweetId) return null;
let mp4Urls = [];
const searchVariants = (obj, depth = 0) => {
if (depth > 12 || !obj || typeof obj !== 'object') return;
if (Array.isArray(obj.variants)) {
const mp4s = obj.variants.filter(v => v.content_type === 'video/mp4');
if (mp4s.length > 0) {
const best = mp4s.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0];
if (best?.url) mp4Urls.push(best.url.split('?')[0]);
}
}
if (typeof obj.value === 'string' && obj.value.startsWith('{') && obj.value.includes('"video/mp4"')) {
try {
const parsedCard = JSON.parse(obj.value);
searchVariants(parsedCard, depth + 1);
} catch(e) {}
}
for (const key in obj) {
if (obj[key] && typeof obj[key] === 'object') {
searchVariants(obj[key], depth + 1);
}
}
};
searchVariants(core);
if (mp4Urls.length > 0) {
return { id: tweetId, urls: [...new Set(mp4Urls)] };
}
} catch (_) {}
return null;
}
function _processApiPayload(text) {
try {
const json = JSON.parse(text);
const ts = Date.now();
const walk = (obj, depth) => {
if (!obj || typeof obj !== 'object' || depth > 40) return;
if (obj.tweet_results) {
const parsed = _parseVideosFromTweetResult(obj.tweet_results);
if (parsed) _apiVideoCache.set(parsed.id, { urls: parsed.urls, ts });
}
if (obj.tweetResult) {
const parsed = _parseVideosFromTweetResult(obj.tweetResult);
if (parsed) _apiVideoCache.set(parsed.id, { urls: parsed.urls, ts });
}
if (obj.retweeted_status_result) {
const parsed = _parseVideosFromTweetResult(obj.retweeted_status_result);
if (parsed) _apiVideoCache.set(parsed.id, { urls: parsed.urls, ts });
}
if (obj.quoted_status_result) {
const parsed = _parseVideosFromTweetResult(obj.quoted_status_result);
if (parsed) _apiVideoCache.set(parsed.id, { urls: parsed.urls, ts });
}
for (const v of Object.values(obj)) {
if (v && typeof v === 'object') walk(v, depth + 1);
}
};
walk(json, 0);
if (_apiVideoCache.size > 200) {
const now = Date.now();
for (const [id, entry] of _apiVideoCache) {
if (now - entry.ts >= _API_CACHE_TTL) _apiVideoCache.delete(id);
}
}
} catch (_) {}
}
(function _interceptFetch() {
const _origFetch = unsafeWindow.fetch;
unsafeWindow.fetch = function(...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
const isGraphQL = url.includes('/graphql/') && (
url.includes('HomeTimeline') || url.includes('TweetDetail') ||
url.includes('UserTweets') || url.includes('SearchTimeline') ||
url.includes('ListTimeline') || url.includes('TweetResultByRestId')
);
const promise = _origFetch.apply(this, args);
if (!isGraphQL) return promise;
return promise.then(resp => {
const clone = resp.clone();
clone.text().then(_processApiPayload).catch(() => {});
return resp;
});
};
})();
const _fiberVideoCache = new WeakMap();
const _FIBER_CACHE_TTL = 60_000;
const _fiberImageCache = new WeakMap();
function _collectCandidateIds(article) {
const ids = new Set();
article.querySelectorAll('a[href*="/status/"]').forEach(a => {
const m = a.getAttribute('href')?.match(/\/status\/(\d+)/);
if (m) ids.add(m[1]);
});
article.querySelectorAll('video[poster]').forEach(v => {
const m = v.getAttribute('poster')?.match(/(?:amplify_video_thumb|ext_tw_video_thumb|tweet_video_thumb)\/(\d+)/);
if (m) ids.add(m[1]);
});
article.querySelectorAll('img[src*="video_thumb"]').forEach(img => {
const m = img.getAttribute('src')?.match(/(?:amplify_video_thumb|ext_tw_video_thumb|tweet_video_thumb)\/(\d+)/);
if (m) ids.add(m[1]);
});
return ids;
}
function _lookupApiCache(ids) {
for (const id of ids) {
const entry = _apiVideoCache.get(id);
if (entry && (Date.now() - entry.ts < _API_CACHE_TTL)) return entry.urls;
}
return null;
}
async function fetchTweetMediaFromAPI(statusId) {
try {
let cookies = {};
document.cookie.split(';').forEach(c => {
let [k, v] = c.split('=');
if (k && v) cookies[k.trim()] = v.trim();
});
const AUTH_TOKEN = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const variables = {"tweetId":statusId,"with_rux_injections":false,"includePromotedContent":true,"withCommunity":true,"withQuickPromoteEligibilityTweetFields":true,"withBirdwatchNotes":true,"withVoice":true,"withV2Timeline":true};
const features = {"articles_preview_enabled":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"freedom_of_speech_not_reach_fetch_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"view_counts_everywhere_api_enabled":true};
let url = `https://${location.hostname}/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(features))}`;
let headers = { 'authorization': AUTH_TOKEN, 'x-twitter-active-user': 'yes' };
if (cookies.ct0) headers['x-csrf-token'] = cookies.ct0;
if (cookies.gt) headers['x-guest-token'] = cookies.gt;
let res = await fetch(url, { headers });
if (!res.ok) return null;
let json = await res.json();
let core = json.data?.tweetResult?.result?.tweet || json.data?.tweetResult?.result;
if (!core) return null;
let result = { videos: [], images: [] };
const walk = (obj) => {
if (!obj || typeof obj !== 'object') return;
if (obj.extended_entities?.media) {
obj.extended_entities.media.forEach(m => {
if (m.type === 'photo') {
result.images.push(m.media_url_https + '?name=orig');
} else if (m.type === 'video' || m.type === 'animated_gif') {
let mp4s = m.video_info?.variants?.filter(v => v.content_type === 'video/mp4') || [];
if (mp4s.length) {
let best = mp4s.sort((a,b)=>(b.bitrate||0)-(a.bitrate||0))[0];
result.videos.push(best.url.split('?')[0]);
}
}
});
}
['tweet', 'quoted_status_result', 'retweeted_status_result', 'result', 'legacy'].forEach(k => {
if (obj[k]) walk(obj[k]);
});
};
walk(core);
return result;
} catch(e) {
return null;
}
}
async function extractVideoUrl(article) {
const cached = _fiberVideoCache.get(article);
if (cached && (Date.now() - cached.ts < _FIBER_CACHE_TTL)) return cached.urls;
let statusId = null;
const links = article.querySelectorAll('a[href*="/status/"]');
for(let a of links) {
const match = a.href.match(/\/status\/(\d+)/);
if(match) { statusId = match[1]; break; }
}
if (statusId) {
const apiData = await fetchTweetMediaFromAPI(statusId);
if (apiData && apiData.videos.length > 0) {
_fiberVideoCache.set(article, { urls: apiData.videos, ts: Date.now() });
return apiData.videos;
}
}
let result = Array.from(article.querySelectorAll('video'))
.map(v => v.src || v.querySelector('source')?.src)
.filter(src => src && src.includes('mp4') && !src.startsWith('blob:'))
.map(src => src.split('?')[0]);
return result;
}
async function extractMediaUrls(article) {
const uniqueMedias = new Map();
function addImageUrl(src) {
if (!src) return;
if (src.includes('/card_img/')) { uniqueMedias.set(src, src); return; }
const idMatch = src.match(/\/(?:media|ext_tw_video_thumb|amplify_video_thumb|tweet_video_thumb)\/([A-Za-z0-9_-]+)/);
const mediaId = idMatch ? idMatch[1] : src.split('?')[0];
if (uniqueMedias.has(mediaId)) return;
try {
const url = new URL(src);
url.searchParams.set('name', 'orig');
uniqueMedias.set(mediaId, url.toString());
} catch (e) {
uniqueMedias.set(mediaId, src);
}
}
let statusId = null;
const links = article.querySelectorAll('a[href*="/status/"]');
for(let a of links) {
const match = a.href.match(/\/status\/(\d+)/);
if(match) { statusId = match[1]; break; }
}
let apiSuccess = false;
if (statusId) {
const apiData = await fetchTweetMediaFromAPI(statusId);
if (apiData && (apiData.videos.length > 0 || apiData.images.length > 0)) {
apiData.videos.forEach(v => uniqueMedias.set(v, v));
apiData.images.forEach(addImageUrl);
apiSuccess = true;
}
}
if (!apiSuccess) {
Array.from(article.querySelectorAll('img[src*="twimg.com"]'))
.map(img => img.src)
.filter(src => src.includes('pbs.twimg.com') && !src.includes('profile_images') && !src.includes('/emoji/') && !src.includes('twemoji'))
.forEach(addImageUrl);
const videos = await extractVideoUrl(article);
videos.forEach(v => uniqueMedias.set(v, v));
article.querySelectorAll('[data-testid="tweet"] img[src*="pbs.twimg.com"]').forEach(img => {
if (!img.src.includes('profile_images') && !img.src.includes('/emoji/')) addImageUrl(img.src);
});
}
return Array.from(uniqueMedias.values());
}
function extractTweetUrl(article, baseUrl) {
const timeLink = article.querySelector('a[href*="/status/"] > time')?.parentElement;
if (timeLink) return baseUrl + timeLink.getAttribute('href');
const link = article.querySelector('a[href*="/status/"]');
if(link) return baseUrl + link.getAttribute('href');
return null;
}
function insertCopyButton(article) {
if (article.querySelector(`.${BUTTON_CLASS}`)) return;
const actions = Array.from(article.querySelectorAll('[role="group"]')).pop();
if (!actions) return;
if (!document.getElementById('tm-icon-anim-style')) {
const s = document.createElement('style');
s.id = 'tm-icon-anim-style';
s.textContent = `
@keyframes tm-pop-bounce {
0% { transform: scale(0.5); opacity: 0; }
60% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes tm-pop-bounce-text {
0% { transform: translateY(-50%) scale(0.5); opacity: 0; }
60% { transform: translateY(-50%) scale(1.1); opacity: 1; }
100% { transform: translateY(-50%) scale(1); opacity: 1; }
}
.tm-anim-pop {
animation: tm-pop-bounce 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) both;
}
.tm-anim-pop-text {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
transform-origin: right center;
white-space: nowrap;
font-weight: 700;
font-size: 13px;
font-family: system-ui, -apple-system, sans-serif;
background: rgba(128, 128, 128, 0.2);
backdrop-filter: blur(4px);
padding: 5px 12px;
border-radius: 9999px;
color: currentColor;
z-index: 9999;
pointer-events: none;
animation: tm-pop-bounce-text 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) both;
}
`;
document.head.appendChild(s);
}
const SVG_FILM = `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="16" height="12" rx="2"/><line x1="2" y1="7" x2="18" y2="7"/><line x1="2" y1="13" x2="18" y2="13"/><line x1="6" y1="4" x2="6" y2="7"/><line x1="10" y1="4" x2="10" y2="7"/><line x1="14" y1="4" x2="14" y2="7"/><line x1="6" y1="13" x2="6" y2="16"/><line x1="10" y1="13" x2="10" y2="16"/><line x1="14" y1="13" x2="14" y2="16"/></svg>`;
const SVG_CHECK_SM = `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4,10 8,14 16,6"/></svg>`;
const SVG_PREFIX_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
const SVG_DL = `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3v10M6 9l4 4 4-4"/><line x1="3" y1="17" x2="17" y2="17"/></svg>`;
const btn = document.createElement('button');
btn.className = BUTTON_CLASS;
btn.title = T.btn_tooltip;
btn.style.position = 'relative';
const setMediaIcon = (state, extra, silentText, actionType = 'copy') => {
const fbStyle = GM_getValue(KEY_FEEDBACK_STYLE, 'toast');
btn.classList.remove('tm-anim-pop');
const setTextMode = (text, customColor) => {
const safeColor = (customColor && /^#[0-9a-fA-F]{3,8}$/.test(customColor)) ? customColor : null;
const span = document.createElement('span');
span.className = 'tm-anim-pop-text';
span.textContent = text;
if (safeColor) span.style.cssText = `color: ${safeColor} !important;`;
btn.innerHTML = '';
btn.appendChild(span);
};
const setIconMode = (svg, customColor) => {
const safeColor = (customColor && /^#[0-9a-fA-F]{3,8}$/.test(customColor)) ? customColor : null;
btn.innerHTML = svg;
btn.classList.add('tm-anim-pop');
if (safeColor) {
const svgEl = btn.querySelector('svg');
if (svgEl) {
svgEl.style.color = safeColor;
svgEl.style.filter = `drop-shadow(0 0 4px ${safeColor}66)`;
}
}
};
const getSilentText = () => silentText || extra;
if (state === 'default') {
btn.innerHTML = SVG_FILM;
} else if (state === 'dl') {
btn.innerHTML = SVG_DL;
} else if (state === 'ok') {
if (fbStyle === 'silent') {
setTextMode(getSilentText() || 'Copied');
} else if (fbStyle === 'icon') {
if (actionType === 'prefix') setIconMode(SVG_PREFIX_COPY);
else if (actionType === 'download') setIconMode(SVG_CHECK_SM);
else setIconMode(SVG_CHECK_SM);
} else {
btn.innerHTML = SVG_CHECK_SM;
btn.classList.add('tm-anim-pop');
showActionToast(btn, extra || T.msg_copied, 'ok');
}
} else if (state === 'warn') {
if (fbStyle === 'silent') {
setTextMode(getSilentText(), '#ff8c00');
} else if (fbStyle === 'icon') {
setIconMode(SVG_FILM, '#ff8c00');
} else {
btn.innerHTML = SVG_FILM;
showActionToast(btn, extra, 'warn');
}
} else {
if (fbStyle === 'silent' && getSilentText()) {
setTextMode(getSilentText(), state === 'error' ? '#e0245e' : null);
} else if (fbStyle === 'icon') {
setIconMode(SVG_FILM, state === 'error' ? '#e0245e' : null);
} else {
btn.innerHTML = SVG_FILM;
if (extra) showActionToast(btn, extra, state === 'error' ? 'error' : 'ok');
}
}
const tweetId = _getTweetIdFromArticle(article);
if (tweetId && _downloadedIds.has(tweetId)) {
_applyHistoryBadge(btn);
}
};
setMediaIcon('default');
let timer = null;
btn.addEventListener('mousedown', async (e) => {
e.preventDefault(); e.stopPropagation();
if (e.button === 0) {
timer = setTimeout(async () => {
const urls = await extractMediaUrls(article);
if (!urls.length) return;
const prefix = GM_getValue(KEY_PREFIX_TEXT, '[text]');
const txt = urls.map(u => `${prefix}(${u})`).join('\n');
GM_setClipboard(txt);
setMediaIcon('ok', T.msg_prefix_copied, 'Prefix Copied', 'prefix');
setTimeout(() => setMediaIcon('default'), 1500);
timer = null;
}, 500);
} else if (e.button === 1) {
const videos = await extractVideoUrl(article);
const allUrls = await extractMediaUrls(article);
const imgUrls = allUrls.filter(u => !u.includes('.mp4'));
if (videos.length && imgUrls.length) {
showFloatingVideoPlayer(videos, 0, imgUrls);
} else if (videos.length) {
showFloatingVideoPlayer(videos);
} else if (imgUrls.length) {
showImageLightbox(imgUrls);
} else {
setMediaIcon('msg', T.msg_no_media, 'No Media');
setTimeout(() => setMediaIcon('default'), 1500);
}
}
});
btn.addEventListener('mouseup', async (e) => {
if (e.button !== 0) return;
if (timer) {
clearTimeout(timer); timer = null;
const urls = await extractMediaUrls(article);
if (!urls.length) {
setMediaIcon('msg', T.msg_no_media, 'No Media');
setTimeout(() => setMediaIcon('default'), 1500);
return;
}
GM_setClipboard(urls.join('\n'));
setMediaIcon('ok', T.msg_copied, 'Copied', 'copy');
setTimeout(() => setMediaIcon('default'), 1500);
}
});
btn.addEventListener('mouseleave', () => { if (timer) { clearTimeout(timer); timer = null; } });
btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); });
btn.addEventListener('contextmenu', async (e) => {
e.preventDefault(); e.stopPropagation();
const urls = await extractMediaUrls(article);
if (urls.length === 0) return;
const info = getTweetInfo(article);
setMediaIcon('dl');
const ring = createProgressRing();
ring.el.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none;';
btn.appendChild(ring.el);
let index = 1;
let failCount = 0;
const total = urls.length;
for (const url of urls) {
let ext = '.jpg';
if (url.includes('.mp4')) ext = '.mp4';
else if (url.includes('format=png')) ext = '.png';
else {
const parts = url.split('/').pop().split('?')[0].split('.');
if (parts.length > 1) ext = '.' + parts.pop();
}
const textPart = info.text ? `_${info.text}` : "";
const safeDisplay = sanitizeForFilename(info.displayName);
const safeScreen = sanitizeForFilename(info.screenName);
const filename = `[twitter] ${safeDisplay}(@${safeScreen})_${info.date}${textPart}_${info.id}_${index}${ext}`;
const fileOffset = (index - 1) / total;
const fileShare = 1 / total;
try {
await forceDownloadBlob(url, filename, (pct) => {
if (pct === null) {
ring.update(null);
} else {
ring.update(Math.round((fileOffset + fileShare * pct / 100) * 100));
}
});
} catch(_) {
failCount++;
}
await new Promise(r => setTimeout(r, 250));
index++;
}
ring.remove();
const successCount = total - failCount;
if (failCount > 0) {
setMediaIcon('warn', `⚠️ ${successCount}/${total}`);
} else {
setMediaIcon('ok', T.msg_downloaded, 'Downloaded', 'download');
recordHistory(info, urls);
fireMeteor(btn);
}
setTimeout(() => setMediaIcon('default'), 2000);
});
const LINK_BTN_CLASS = 'custom-copy-icon';
if (!article.querySelector(`.${LINK_BTN_CLASS}`)) {
const icon = document.createElement('div');
icon.className = LINK_BTN_CLASS;
icon.style.position = 'relative';
const SVG_LINK = `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12a4 4 0 0 0 5.66 0l2-2a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M12 8a4 4 0 0 0-5.66 0l-2 2a4 4 0 0 0 5.66 5.66l1-1"/></svg>`;
const SVG_CHECK = `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4,10 8,14 16,6"/></svg>`;
const setLinkIcon = (state, extra, silentText, actionType = 'copy') => {
const fbStyle = GM_getValue(KEY_FEEDBACK_STYLE, 'toast');
icon.classList.remove('tm-anim-pop');
const setTextMode = (text) => {
const span = document.createElement('span');
span.className = 'tm-anim-pop-text';
span.textContent = text;
icon.innerHTML = '';
icon.appendChild(span);
};
const setIconMode = (svg) => {
icon.innerHTML = svg;
icon.classList.add('tm-anim-pop');
};
if (state === 'ok') {
if (fbStyle === 'silent') {
setTextMode(silentText || extra || 'Copied');
} else if (fbStyle === 'icon') {
const useSvg = actionType === 'prefix' ? SVG_PREFIX_COPY : SVG_CHECK;
setIconMode(useSvg);
} else {
icon.innerHTML = SVG_CHECK;
icon.classList.add('tm-anim-pop');
showActionToast(icon, extra || T.msg_copied, 'ok');
}
} else {
icon.innerHTML = SVG_LINK;
}
};
setLinkIcon('default');
icon.addEventListener('mouseenter', () => {
const long = GM_getValue(KEY_LINK_DOMAIN_LONG, 'fixupx.com');
const custom = GM_getValue(KEY_CLICK_MODE_CUSTOM, false);
const click = custom ? GM_getValue(KEY_LINK_DOMAIN_CLICK, 'x.com') : 'x.com';
icon.title = T.link_tooltip + click + T.link_tooltip_long + long;
});
let lTimer = null;
icon.addEventListener('mousedown', e => {
if (e.button !== 0) return;
e.preventDefault(); e.stopPropagation();
lTimer = setTimeout(() => {
const targetDomain = GM_getValue(KEY_LINK_DOMAIN_LONG, 'fixupx.com');
const url = extractTweetUrl(article, 'https://' + targetDomain);
if (url) {
const prefix = GM_getValue(KEY_PREFIX_TEXT, '[text]');
GM_setClipboard(`${prefix}(${url})`);
setLinkIcon('ok', T.msg_prefix_copied, 'Prefix Copied', 'prefix');
setTimeout(() => setLinkIcon('default'), 1500);
}
lTimer = null;
}, 500);
});
icon.addEventListener('mouseup', () => {
if(lTimer) {
clearTimeout(lTimer);
lTimer = null;
const useCustom = GM_getValue(KEY_CLICK_MODE_CUSTOM, false);
const targetDomain = useCustom ? GM_getValue(KEY_LINK_DOMAIN_CLICK, 'x.com') : 'x.com';
const url = extractTweetUrl(article, 'https://' + targetDomain);
if(url) {
GM_setClipboard(url);
setLinkIcon('ok', T.msg_copied, 'Copied', 'copy');
setTimeout(() => setLinkIcon('default'), 1500);
}
}
});
icon.addEventListener('mouseleave', () => { if(lTimer) { clearTimeout(lTimer); lTimer = null; } });
icon.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); });
actions.appendChild(btn);
actions.insertBefore(icon, btn);
if (_downloadedIds.size > 0) {
requestAnimationFrame(() => {
const tweetId = _getTweetIdFromArticle(article);
if (tweetId && _downloadedIds.has(tweetId)) _applyHistoryBadge(btn);
});
}
}
}
let _tmdDebounceTimer = null;
const _processedArticles = new WeakSet();
function scanAndInsert() {
document.querySelectorAll('article').forEach(article => {
if (_processedArticles.has(article) && article.querySelector(`.${BUTTON_CLASS}`)) return;
insertCopyButton(article);
if (article.querySelector(`.${BUTTON_CLASS}`)) _processedArticles.add(article);
});
}
const observer = new MutationObserver(mutations => {
let shouldCheck = false;
for (let m of mutations) {
if (m.addedNodes.length > 0 || m.removedNodes.length > 0) {
shouldCheck = true;
break;
}
}
if (shouldCheck) {
clearTimeout(_tmdDebounceTimer);
_tmdDebounceTimer = setTimeout(scanAndInsert, 250);
}
});
observer.observe(document.body, { childList: true, subtree: true, attributes: false, characterData: false });
setInterval(scanAndInsert, 1500);
setTimeout(scanAndInsert, 1000);
})();