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.4.3
// @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 NEW_FEATURE_IDS = [
'feedback_style',
];
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.<br>
• <b>Long Press (0.5s):</b> Copy links with custom prefix (for Discord).<br>
• <b>Right Click:</b> Force download media files.<br>
(Name format: <code>[twitter] Name(@ID)_Date_Text_ID.ext</code>)</p>
<hr>
<p><b>🔗 Link Button (🔗):</b><br>
• <b>Click:</b> Copy link (Default: x.com, or Custom Domain).<br>
• <b>Long Press:</b> Copy link with custom prefix + Long-Press Domain.<br>
(Configure domains in the script manager menu).</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ Disclaimer:</b><br>
The custom link domains (e.g., fixupx, vxtwitter) are third-party services not affiliated with this script. Please ensure you trust them before use.</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> 複製包含「自定義前綴」的連結 (方便 Discord 使用)。<br>
• <b>右鍵點擊:</b> 強制下載推文內所有圖片/影片。<br>
(檔名格式:<code>[twitter] 暱稱(@ID)_日期_內文_ID.檔名</code>)</p>
<hr>
<p><b>🔗 連結按鈕 (🔗):</b><br>
• <b>單擊:</b> 複製連結 (可設定為 x.com 或 自定義域名)。<br>
• <b>長按:</b> 複製帶自定義前綴的「長按專用」域名連結。<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: '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> 复制包含“自定义前缀”的链接。<br>
• <b>右键点击:</b> 强制下载推文内所有图片/视频。<br>
(文件名格式:<code>[twitter] 昵称(@ID)_日期_内文_ID.ext</code>)</p>
<hr>
<p><b>🔗 链接按钮 (🔗):</b><br>
• <b>单击:</b> 复制链接 (默认:x.com,或自定义域名)。<br>
• <b>长按:</b> 复制带自定义前缀的<b>长按专用</b>域名链接 (如 fixupx)。<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: '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> メディアリンクをコピー。<br>
• <b>長押し (0.5秒):</b> 「カスタムプレフィックス」付きでコピー (Discord用)。<br>
• <b>右クリック:</b> 画像・動画を強制ダウンロード。<br>
(ファイル名:<code>[twitter] 名前(@ID)_日付_本文_ID.ext</code>)</p>
<hr>
<p><b>🔗 リンクボタン (🔗):</b><br>
• <b>クリック:</b> 元の <code>x.com</code> リンクをコピー。<br>
• <b>長押し:</b> <b>カスタムドメイン</b>で「カスタムプレフィックス」付きコピー (例: fixupx)。<br>
(スクリプト管理メニューからドメインを変更できます)。</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ 免責事項:</b><br>
リストにある変換URL(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> 미디어 링크 복사.<br>
• <b>길게 누르기 (0.5초):</b> "사용자 지정 접두사" 포함 복사.<br>
• <b>오른쪽 클릭:</b> 모든 미디어 강제 다운로드.<br>
(파일 이름: <code>[twitter] 이름(@ID)_날짜_내용_ID.ext</code>)</p>
<hr>
<p><b>🔗 링크 버튼 (🔗):</b><br>
• <b>클릭:</b> 원본 <code>x.com</code> 링크 복사.<br>
• <b>길게 누르기:</b> <b>사용자 지정 도메인</b>에서 「사용자 지정 접두사」 포함 복사 (예: fixupx).<br>
(스크립트 관리자 메뉴에서 설정을 변경할 수 있습니다).</p>
<hr>
<p style="color: #e0245e; font-size: 0.9em;"><b>⚠️ 면책 조항:</b><br>
목록의 변환 URL(예: fixupx, vxtwitter)은 본 스크립트와 무관한 타사 서비스입니다. 위험을 스스로 평가하고 신뢰할 수 있는 도메인만 사용하십시오.</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 medios.<br>
• <b>Pulsación larga (0.5s):</b> Copiar con prefijo personalizado (para Discord).<br>
• <b>Clic derecho:</b> Forzar descarga de medios.<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 (predeterminado: x.com o dominio personalizado).<br>
• <b>Pulsación larga:</b> Copiar con dominio personalizado e incluir prefijo personalizado (ej. fixupx).<br>
(Configura los dominios en el menú del administrador de scripts).</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 mídia.<br>
• <b>Pressão longa (0.5s):</b> Copiar com prefixo personalizado (para Discord).<br>
• <b>Clique direito:</b> Forçar download de mídia.<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 (padrão: x.com ou domínio personalizado).<br>
• <b>Pressão longa:</b> Copiar com domínio de pressão longa incluindo prefixo personalizado.<br>
(Configure os domínios no menu do gerenciador de scripts).</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 médias.<br>
• <b>Appui long (0.5s) :</b> Copier avec préfixe personnalisé (pour Discord).<br>
• <b>Clic droit :</b> Forcer le téléchargement des médias.<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 (par défaut : x.com ou domaine personnalisé).<br>
• <b>Appui long :</b> Copier avec domaine d'appui long en incluant le préfixe personnalisé.<br>
(Configurez les domaines dans le menu du gestionnaire de scripts).</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> Копировать с пользовательским префиксом (для Discord).<br>
• <b>Правый клик:</b> Принудительно скачать все медиафайлы.<br>
(Формат имени: <code>[twitter] Имя(@ID)_Дата_Текст_ID.ext</code>)</p>
<hr>
<p><b>🔗 Кнопка ссылки (🔗):</b><br>
• <b>Клик:</b> Копировать ссылку (по умолчанию: x.com или пользовательский домен).<br>
• <b>Долгое нажатие:</b> Копировать ссылку с доменом долгого нажатия, включая пользовательский префикс.<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: '🔔 Стиль уведомлений → ',
}
};
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];
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.textContent = '✕';
closeBtn.style.cssText = `
position: absolute; top: 10px; right: 12px; border: none; background: none;
font-size: 16px; cursor: pointer; color: ${C.sub}; line-height: 1;
`;
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.textContent = '✕';
closeBtn.style.cssText = `
position: absolute; top: 12px; right: 14px; border: none; background: none;
font-size: 15px; cursor: pointer; color: ${C.sub}; line-height: 1; padding: 2px 6px;
`;
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.textContent = '✕';
closeBtn.style.cssText = `
position: absolute; top: 12px; right: 14px; border: none; background: none;
font-size: 16px; cursor: pointer; color: ${C.sub}; line-height: 1;
`;
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() !== "") {
GM_setValue(key, input.trim());
return true;
}
}
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);
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;
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 cardRight = Math.max(8, window.innerWidth - cx - 18);
const cardTop = cy + r2 + 10;
const card = document.createElement('div');
card.id = 'tm-ob-card';
card.style.cssText = `
width:${cardW}px; right:${cardRight}px; top:${cardTop}px;
background:${cardBg}; border-radius:14px;
box-shadow:0 8px 32px rgba(0,0,0,0.32);
padding:18px 18px 14px;
`;
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');
}, 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.textContent = '✕';
closeBtn.style.cssText = `position:absolute;top:12px;right:14px;border:none;
background:none;font-size:15px;cursor:pointer;color:${C.sub};`;
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.07)',
} : {
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: 8px; right: 8px; z-index: 999990;
}
#tm-settings-gear-btn {
width: 36px; height: 36px; border-radius: 50%; border: none;
background: transparent; cursor: pointer; padding: 6px;
display: flex; align-items: center; justify-content: center;
opacity: 0;
transition: opacity 0.28s ease, background 0.15s ease;
color: ${C.gearFg};
}
#tm-settings-wrapper:hover #tm-settings-gear-btn { opacity: 0.7; }
#tm-settings-gear-btn:hover { opacity: 1 !important; background: ${C.gearBg} !important; }
#tm-settings-gear-btn svg {
width: 20px; height: 20px; display: block;
transition: transform 0.38s cubic-bezier(0.34,1.56,0.64,1);
}
#tm-settings-gear-btn[data-open="true"] svg { transform: rotate(90deg); }
#tm-settings-panel {
position: absolute; top: calc(100% + 4px); right: 0;
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-panel[data-open="true"] {
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;
}
`;
document.head.appendChild(panelStyle);
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 wrapper = document.createElement('div');
wrapper.id = 'tm-settings-wrapper';
const gearBtn = document.createElement('button');
gearBtn.id = 'tm-settings-gear-btn';
gearBtn.innerHTML = SVG_GEAR;
gearBtn.title = '⚙️ Twitter Media Script Settings';
gearBtn.setAttribute('data-open', 'false');
const panel = document.createElement('div');
panel.id = 'tm-settings-panel';
panel.setAttribute('data-open', 'false');
function buildContent() {
panel.innerHTML = '';
const clickCustom = GM_getValue(KEY_CLICK_MODE_CUSTOM, false);
const clickDomain = GM_getValue(KEY_LINK_DOMAIN_CLICK, 'x.com');
const prefix = GM_getValue(KEY_PREFIX_TEXT, '[text]');
const fmt = GM_getValue(KEY_DATE_FORMAT, 'asian');
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 fbStyle = GM_getValue(KEY_FEEDBACK_STYLE, 'toast');
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);
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 = panel.getAttribute('data-open') === 'true';
const next = String(!isOpen);
panel.setAttribute('data-open', next);
gearBtn.setAttribute('data-open', next);
});
document.addEventListener('click', e => {
if (!wrapper.contains(e.target)) {
panel.setAttribute('data-open', 'false');
gearBtn.setAttribute('data-open', 'false');
}
});
wrapper.appendChild(gearBtn);
wrapper.appendChild(panel);
document.body.appendChild(wrapper);
}
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));
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;
transform: translateZ(0); will-change: transform;
`;
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 = '✕';
closeBtn.style.cssText = `
position: absolute; top: 20px; right: 25px;
background: rgba(0,0,0,0.6); color: white; border: none;
font-size: 20px; 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 = () => {
video.pause();
modal.remove();
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();
}
function showImageLightbox(urls, videoUrls = null) {
if (!urls.length) return;
const old = document.getElementById('tm-image-lightbox');
if (old) old.remove();
const total = urls.length;
const isSingleImage = total === 1;
let focused = 0;
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,
};
}
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 });
const stage = document.createElement('div');
stage.style.cssText = `
position: relative;
width: ${CARD_W}px; height: ${CARD_H}px;
flex-shrink: 0; overflow: visible;
`;
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.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 = '✕';
closeBtn.style.cssText = `
position: absolute; top: 20px; right: 25px;
background: rgba(0,0,0,0.6); color: white; border: none;
font-size: 20px; 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)';
const closeLightbox = () => {
modal.remove();
document.removeEventListener('keydown', keyHandler);
};
closeBtn.onclick = closeLightbox;
modal.onclick = e => { if (e.target === modal || e.target === container) closeLightbox(); };
const keyHandler = e => {
if (e.key === 'Escape') closeLightbox();
};
document.addEventListener('keydown', keyHandler);
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);
}
document.body.appendChild(modal);
return;
}
const TRANSITION = `
transform 0.40s cubic-bezier(0.34, 1.18, 0.64, 1),
opacity 0.28s ease,
box-shadow 0.28s ease
`;
const SHADOW_FOCUS = '0 28px 80px rgba(0,0,0,0.9), 0 0 0 1px rgba(255,255,255,0.18)';
const SHADOW_IDLE = '0 12px 36px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.07)';
const cards = urls.map((url, i) => {
const card = document.createElement('div');
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; transition: none;
`;
const img = document.createElement('img');
img.src = url;
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;
updateAll();
});
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; updateAll();
});
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 = '✕';
closeBtn.style.cssText = `
position: absolute; top: 20px; right: 25px;
background: rgba(0,0,0,0.6); color: white; border: none;
font-size: 20px; 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)';
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 closeLightbox = () => {
modal.remove();
document.removeEventListener('keydown', keyHandler);
};
closeBtn.onclick = closeLightbox;
modal.onclick = e => { if (e.target === modal) closeLightbox(); };
const keyHandler = e => {
if (e.key === 'Escape') { closeLightbox(); return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
focused = Math.min(focused + 1, total - 1); updateAll();
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
focused = Math.max(focused - 1, 0); updateAll();
}
};
document.addEventListener('keydown', keyHandler);
function updateAll() {
cards.forEach((cardObj, i) => {
const pos = i - focused;
const { dx, rot, scale, zIndex, opacity } = calcTransform(pos);
const isFocused = i === focused;
const card = cardObj.card || cardObj;
card.style.transform = `translateX(${dx}px) rotate(${rot}deg) scale(${scale})`;
card.style.zIndex = zIndex;
card.style.opacity = opacity;
card.style.cursor = isFocused ? 'default' : 'pointer';
card.style.boxShadow = isFocused ? SHADOW_FOCUS : SHADOW_IDLE;
});
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)';
});
if (total > 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);
updateAll();
requestAnimationFrame(() => requestAnimationFrame(() => {
cards.forEach(c => { c.style.transition = TRANSITION; });
}));
}
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');
const fmt = GM_getValue(KEY_DATE_FORMAT, 'asian');
return fmt === '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';
}
return {
screenName: screenName,
displayName: displayName,
id: id,
date: date,
text: sanitizeForFilename(tweetText)
};
}
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);
} 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) => {
if (customColor) {
btn.innerHTML = `<span class="tm-anim-pop-text" style="color: ${customColor} !important;">${text}</span>`;
} else {
btn.innerHTML = `<span class="tm-anim-pop-text">${text}</span>`;
}
};
const setIconMode = (svg, customColor) => {
btn.innerHTML = svg;
btn.classList.add('tm-anim-pop');
if (customColor) {
const svgEl = btn.querySelector('svg');
if (svgEl) {
svgEl.style.color = customColor;
svgEl.style.filter = `drop-shadow(0 0 4px ${customColor}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');
}
}
};
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');
}
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) => {
icon.innerHTML = `<span class="tm-anim-pop-text">${text}</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);
}
}
let _tmdDebounceTimer = null;
function scanAndInsert() {
document.querySelectorAll('article').forEach(article => {
insertCopyButton(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 });
setInterval(scanAndInsert, 1500);
setTimeout(scanAndInsert, 1000);
})();