抖音下载

为web版抖音增加下载按钮

// ==UserScript==
// @name            抖音下载
// @namespace       https://github.com/zhzLuke96/douyin-dl-user-js
// @version         1.2.4
// @description     为web版抖音增加下载按钮
// @author          zhzluke96
// @match           https://*.douyin.com/*
// @icon            https://www.google.com/s2/favicons?sz=64&domain=douyin.com
// @grant           none
// @license         MIT
// @supportURL      https://github.com/zhzLuke96/douyin-dl-user-js/issues
// ==/UserScript==

(function () {
  "use strict";

  /**
   * 模板字符串函数
   * 用于占位标记用来触发编辑器高亮和格式化,没有实际作用
   *
   * @type {function(strings: TemplateStringsArray, ...values: any[]): string}}
   */
  const html = (strings, ...values) =>
    strings.reduce((acc, str, i) => acc + str + (values[i] || ""), "");

  class Config {
    static global = new Config();

    features = {
      convert_webp_to_png: true,
    };

    _key = "__douyin-dl-user-js__";

    constructor() {
      try {
        this.load();
      } catch (error) {
        console.error(error);
      }
    }

    toJSON() {
      return {
        features: this.features,
      };
    }

    load() {
      if (localStorage.getItem(this._key)) {
        const data = JSON.parse(localStorage.getItem(this._key));
        this.features = {
          ...this.features,
          ...data.features,
        };
      }
    }

    save() {
      localStorage.setItem(this._key, JSON.stringify(this.toJSON()));
    }
  }

  class Downloader {
    constructor() {}

    /**
     * @param {Blob} blob
     */
    async convertWebPToPNG(blob) {
      // 创建一个图像对象来加载WebP
      const img = new Image();
      img.src = URL.createObjectURL(blob);

      await new Promise((resolve, reject) => {
        img.onload = resolve;
        img.onerror = reject;
      });

      // 创建canvas来转换图像
      const canvas = document.createElement("canvas");
      canvas.width = img.width;
      canvas.height = img.height;

      // 将图像绘制到canvas
      const ctx = canvas.getContext("2d");
      ctx.drawImage(img, 0, 0);

      // 释放原始Blob URL
      URL.revokeObjectURL(img.src);

      return new Promise((resolve) => {
        // 将canvas转换为PNG Blob
        canvas.toBlob((pngBlob) => {
          resolve(pngBlob);
        }, "image/png");
        canvas.onerror = (e) => {
          console.error("WebP转PNG失败,回退到原格式:", e);
          resolve(blob); // Fallback to original blob
        };
      });
    }

    /**
     * 预下载文件
     *
     * PS: 这一步其实没有下载,而是通过浏览器的缓存读取了
     * PSS: 并且如果浏览器没有缓存,似乎会报错,因为server那边会校验cookie,我们没带上(现在不知道要带上什么...在js里也没法重放请求...)
     *
     * @param imgSrc {string}
     * @param filename_input {string}
     * @returns {Promise<{ok: boolean, blob?: Blob, filename?: string, isImage?: boolean, isWebP?: boolean, pngBlob?: Blob | null, fileExt?: string, error?: string}>}
     */
    async prepare_download_file(imgSrc, filename_input = "") {
      if (imgSrc.startsWith("//")) {
        const protocol = window.location.protocol;
        imgSrc = `${protocol}${imgSrc}`;
      }
      const url = new URL(imgSrc);
      const response = await fetch(imgSrc);
      if (!response.ok) {
        // Original script had: alert("Failed to fetch the file");
        // We now return an error status for the caller to decide.
        return {
          ok: false,
          error: `Failed to fetch the file: ${response.status} ${response.statusText}`,
        };
      }
      const contentType = response.headers.get("content-type");
      if (!contentType) {
        return { ok: false, error: "Content-Type header missing" };
      }
      const isImage = contentType.startsWith("image/");
      const isWebP = contentType.includes("webp");

      let fileExtGuess = contentType.split("/")[1]?.toLowerCase();
      if (!fileExtGuess && isImage)
        fileExtGuess = "jpg"; // fallback for image/*
      else if (!fileExtGuess) fileExtGuess = "bin"; // fallback for unknown

      const determinedFileExt = isImage
        ? isWebP
          ? "png" // Target extension for WebP after conversion
          : fileExtGuess
        : fileExtGuess;

      let filename =
        filename_input || url.pathname.split("/").pop() || "download";
      if (filename.endsWith(".image")) {
        filename = filename.slice(0, -".image".length);
      }
      // Ensure filename ends with the determined extension
      const currentExtPattern = new RegExp(`\\.${determinedFileExt}$`, "i");
      if (!currentExtPattern.test(filename)) {
        // Remove any existing extension before appending the new one
        filename = filename.replace(/\.[^/.]+$/, "");
        filename += `.${determinedFileExt}`;
      }

      const blob = await response.blob();
      let pngBlob = null;

      if (isImage && isWebP && Config.global.features.convert_webp_to_png) {
        try {
          pngBlob = await this.convertWebPToPNG(blob);
        } catch (error) {
          console.error("[dy-dl]WebP转PNG失败", error);
          // If conversion fails, pngBlob remains null, original blob will be used
        }
      }

      return {
        blob,
        filename,
        isImage,
        isWebP,
        pngBlob,
        fileExt: determinedFileExt,
        ok: true,
      };
    }

    /**
     * @param {Blob} blob
     * @param {string} filename
     */
    async download_blob(blob, filename) {
      const link = document.createElement("a");
      link.style.display = "none";
      link.download = filename;
      link.href = URL.createObjectURL(blob);
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      URL.revokeObjectURL(link.href);
    }

    /**
     * 下载文件流程:
     *
     * 1. 预下载为 blob ,读取元信息
     * 2. 如果是 webp 图片,尝试转为 png 图片
     * 3. 下载 blob
     *
     * @param source {string}
     * @param filename_input {string}
     * @param fallback_src {string[]} 比如其他分辨率
     */
    async download_file(source, filename_input = "", fallback_src = []) {
      let url_sources = [source, ...fallback_src].filter(
        (x) => typeof x === "string" && x.length > 0
      );
      url_sources = Array.from(new Set(url_sources));

      let firstAttemptFailedMessage = "";

      for (const [index, url] of url_sources.entries()) {
        let blob, pngBlob, filename;
        try {
          const result = await this.prepare_download_file(url, filename_input);
          if (!result.ok) {
            const errorMessage = `[dy-dl]预下载失败 (${
              result.error || "Unknown error"
            }),将重试其他地址: ${url}`;
            console.error(errorMessage);
            if (index === 0) {
              // Store message from first attempt
              firstAttemptFailedMessage = result.error?.includes(
                "Failed to fetch"
              )
                ? "Failed to fetch the file"
                : "";
            }
            continue;
          }
          blob = result.blob;
          pngBlob = result.pngBlob; // This will be the converted PNG if successful, or null
          filename = result.filename;
        } catch (error) {
          console.error(`[dy-dl]预下载异常,将重试其他地址: ${url}`, error);
          if (index === 0) {
            // Store message from first attempt
            firstAttemptFailedMessage =
              "Failed to fetch the file due to an exception";
          }
          continue;
        }

        // Prefer PNG blob if available (i.e., WebP was converted)
        if (pngBlob) {
          try {
            await this.download_blob(pngBlob, filename);
            return;
          } catch (error) {
            console.error(
              `[dy-dl]下载转换后的PNG失败,回退原始版本: ${filename}`,
              error
            );
            // Fall through to try downloading the original blob
          }
        }

        // Download original blob (or if PNG download failed)
        if (blob) {
          try {
            await this.download_blob(blob, filename);
            return;
          } catch (error) {
            console.error(
              `[dy-dl]下载blob失败,尝试其他版本: ${filename}`,
              error
            );
            continue;
          }
        }
      }

      // If all downloads failed, show an alert.
      // If the first attempt failed with a "Failed to fetch" style error, replicate original alert.
      if (firstAttemptFailedMessage && url_sources.length === 1) {
        alert(firstAttemptFailedMessage);
      } else {
        alert(`[dy-dl]所有尝试下载都失败,请刷新重试`);
      }
    }
  }
  class Modal {
    /**
     * callback会在创建element之后调用
     * @param {(root: HTMLElement, overlay: Element) => any} callback
     */
    constructor(callback) {
      this.overlay = document.createElement("div");
      Object.assign(this.overlay.style, {
        position: "fixed",
        top: 0,
        left: 0,
        width: "100vw",
        height: "100vh",
        backgroundColor: "rgba(0, 0, 0, 0.5)",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        zIndex: 1000,
      });

      this.root = document.createElement("div");
      Object.assign(this.root.style, {
        backgroundColor: "#fff",
        padding: "20px",
        borderRadius: "8px",
        minWidth: "300px",
        minHeight: "150px",
        boxShadow: "0 4px 20px rgba(0,0,0,0.3)",
      });

      // 阻止事件冒泡,防止点击 root 也触发关闭
      this.root.addEventListener("click", (e) => e.stopPropagation());

      this.overlay.addEventListener("click", () => this.close());

      this.overlay.appendChild(this.root);
      document.body.appendChild(this.overlay);

      if (typeof callback === "function") {
        callback(this.root, this.overlay);
      }
    }

    close() {
      this.overlay.remove();
    }
  }

  class MediaHandler {
    /** @type {import("./types").DouyinPlayer.PlayerInstance | null} */
    player = null;
    /** @type {import("./types").DouyinMedia.MediaRoot | null} */
    current_media = null;
    downloading = false;
    /** @type {Downloader} */
    downloader;
    /** @type {HTMLElement | null} */
    $btn = null; // Corresponds to downloader_status.$btn from original, not actively used for UI updates by original logic

    /**
     * @param {Downloader} downloader
     */
    constructor(downloader) {
      this.downloader = downloader;
      this.download_current_media = this._lock_download(
        this._download_current_media_logic.bind(this)
      );
    }

    /**
     * @param {string} bigintStr
     */
    static toShortId(bigintStr) {
      try {
        return BigInt(bigintStr).toString(36);
      } catch (error) {
        return bigintStr;
      }
    }

    /**
     * 文件名
     *
     * [nickname] + [short_id] + [tags] + [desc]
     * max length: 64
     *
     * @param {import("./types").DouyinMedia.MediaRoot} media
     */
    _build_filename(media) {
      const {
        authorInfo: { nickname },
        awemeId,
        desc,
        textExtra,
      } = media;
      const short_id = MediaHandler.toShortId(awemeId);
      const tag_list =
        textExtra?.map((x) => x.hashtagName).filter(Boolean) || [];
      const tags = tag_list.map((x) => "#" + x).join("_");
      let rawDesc = desc || "";
      tag_list.forEach((t) => {
        rawDesc = rawDesc.replace(new RegExp(`#${t}\\s*`, "g"), "");
      });
      rawDesc = rawDesc.trim().replace(/[#/\?<>\\:\*\|":]/g, ""); // Sanitize illegal characters

      const baseName = `${nickname}_${short_id}_${tags}_${rawDesc}`;
      return baseName.length > 64 ? baseName.slice(0, 64) : baseName;
    }

    _bind_player_events() {
      if (!this.player) return;
      const update = () => {
        if (this.player?.config?.awemeInfo) {
          this.current_media = this.player.config.awemeInfo;
        }
      };
      update(); // Initial update
      this.player.on("play", update);
      this.player.on("seeked", update);
      // Potentially listen to other events like 'pause' or 'videochange' if available and needed
    }

    async _start_detect_player_change() {
      while (1) {
        // @ts-ignore // window.player is not typed here
        const currentPlayer = window.player;
        if (this.player !== currentPlayer) {
          this.player = currentPlayer;
          if (this.player) {
            this._bind_player_events();
          }
          // console.log(`[dy-dl] player changed: ${this.player}`);
        }
        await new Promise((r) => setTimeout(r, 1000));
      }
    }

    _flag_start_download() {
      this.downloading = true;
      // const { $btn } = this; // Original script had $btn in status but didn't use it for UI updates.
      // if ($btn) {
      //   // TODO: progress
      // }
      return () => {
        this.downloading = false;
        // if ($btn) {
        //   // TODO: progress
        // }
      };
    }

    _lock_download(download_fn) {
      return async (...args) => {
        if (this.downloading) {
          alert("[dy-dl]正在下载中...请稍等或刷新页面");
          return;
        }
        const releaseLock = this._flag_start_download();
        try {
          await download_fn(...args);
        } finally {
          // Small delay before releasing lock, as in original script
          await new Promise((r) => setTimeout(r, 300));
          releaseLock();
        }
      };
    }

    /**
     * 从 video 对象上取得所有 url
     *
     * TODO: 这里其实还有编码 256 没有取
     * TODO: 不同 url 代表不同分辨率,现在我们也还没区分
     *
     * @param {import("./types").DouyinMedia.DouyinPlayerVideo | null | undefined} video_obj
     */
    _get_video_urls(video_obj) {
      if (video_obj === null || video_obj === undefined) {
        return [];
      }
      const sources = [];
      if (video_obj.playApi) {
        sources.push(video_obj.playApi);
      }
      if (Array.isArray(video_obj.playAddr)) {
        sources.push(...video_obj.playAddr.map((x) => x.src));
      }
      if (video_obj.bitRateList) {
        video_obj.bitRateList.forEach((x) => {
          if (x.playApi) sources.push(x.playApi);
        });
      }
      return Array.from(new Set(sources.filter(Boolean)));
    }

    /**
     * 抖音作品有两种形式:
     * 1. 单图、单视频
     * 2. 图集
     *
     * 如果是图集形式,必须从 images 这个数组里面取字段,其他字段都有可能是 fallback 值
     */
    async _download_current_media_logic() {
      if (!this.current_media) {
        alert("[dy-dl]无当前媒体信息,请尝试播放视频或等待加载。");
        return;
      }
      const { video, images } = this.current_media;
      const filename_base = this._build_filename(this.current_media);

      if (Array.isArray(images) && images.length !== 0) {
        // 下载图集
        // TODO 要是能支持 zip 打包会更好一点
        let downloadedCount = 0;
        for (let idx = 0; idx < images.length; idx++) {
          const imageItem = images[idx];
          const item_filename = `${filename_base}_${idx + 1}`; // 1-based index for files

          const image_video = imageItem?.video;
          if (image_video) {
            // 包含视频的图集项
            const video_urls = this._get_video_urls(image_video);
            if (video_urls.length > 0) {
              await this.downloader.download_file(
                video_urls[0],
                item_filename,
                video_urls
              );
              downloadedCount++;
            } else {
              console.warn("[dy-dl]图集内视频无有效URL,跳过下载", image_video);
            }
            continue;
          }

          // 单纯的图片图集项
          const img_urls = imageItem?.urlList?.filter(Boolean);
          if (img_urls && img_urls.length > 0) {
            await this.downloader.download_file(
              img_urls[0],
              item_filename,
              img_urls
            );
            downloadedCount++;
          } else {
            console.warn("[dy-dl]图集内图片无有效URL,跳过下载", imageItem);
          }
        }
        if (downloadedCount === 0 && images.length > 0) {
          alert("[dy-dl]图集下载失败,未找到有效媒体链接。");
        }
        return;
      } else {
        // 单视频或单图片(老版本可能直接在video字段放图片信息,但新版通常是images)
        const video_urls = this._get_video_urls(video);
        if (video_urls.length !== 0) {
          await this.downloader.download_file(
            video_urls[0],
            filename_base,
            video_urls
          );
          return;
        }
      }
      alert("[dy-dl]无法下载当前媒体,尝试刷新、暂停、播放等操作后重试。");
    }

    // 下载封面
    async download_thumb() {
      if (!this.current_media) {
        alert("[dy-dl]无当前媒体信息,请尝试播放视频或等待加载。");
        return;
      }
      const { video } = this.current_media;
      // 第一个是压缩的,所以用第二个
      const thumb = video.coverUrlList[1];
      const filename_base = this._build_filename(this.current_media);
      this.downloader.download_file(thumb, `thumb_${filename_base}`);
    }

    // 显示媒体详情
    async show_media_details() {
      if (!this.current_media) {
        alert("[dy-dl]无当前媒体信息,请尝试播放视频或等待加载。");
        return;
      }

      // 假设 current_media 的类型是 DouyinMedia.MediaRoot
      const current_media = this.current_media;

      // 点击后打开一个 modal 框,显示媒体详情,并提供下载链接
      const modal = new Modal((root, overlay) => {
        // issues #18 https://github.com/zhzLuke96/douyin-dl-user-js/issues/18
        overlay.style.zIndex = 999999;
        // 需要放在 slidelist 里,不然全屏之后看不见
        const $fullscreenElement = document.fullscreenElement;
        if ($fullscreenElement) {
          // FIXME: 这里有个问题,滚轮事件有可能被 parent 捕获了...没想到什么好办法解决...
          $fullscreenElement.appendChild(overlay);
        }
      });

      // --- 1. 辅助函数 (Helpers for rendering) ---
      const render_helpers = {
        formatTimestamp: (ts) =>
          ts ? new Date(ts * 1000).toLocaleString() : "N/A",
        formatCount: (num) => {
          if (num === undefined || num === null) return "N/A";
          if (num > 10000) return (num / 10000).toFixed(1) + " 万";
          return num.toString();
        },
        renderKeyValue: (label, value) => html`
          <div
            style="display: flex; padding: 4px 0; font-size: 14px; border-bottom: 1px solid #f0f0f0;"
          >
            <strong style="width: 100px; flex-shrink: 0; color: #555;"
              >${label}</strong
            >
            <span style="flex-grow: 1; color: #333; word-break: break-all;"
              >${value || "-"}</span
            >
          </div>
        `,
        renderCopyableValue: (label, value) => {
          if (!value) return render_helpers.renderKeyValue(label, value);
          return html`
            <div
              style="display: flex; padding: 4px 0; font-size: 14px; align-items: center; border-bottom: 1px solid #f0f0f0;"
            >
              <strong style="width: 100px; flex-shrink: 0; color: #555;"
                >${label}</strong
              >
              <span
                style="flex-grow: 1; color: #333; word-break: break-all; margin-right: 8px;"
                >${value}</span
              >
              <button
                class="dy-dl-copy-btn"
                data-copy-text="${value}"
                style="padding: 2px 6px; font-size: 12px; cursor: pointer;"
              >
                复制
              </button>
            </div>
          `;
        },
      };

      // --- 2. 准备每个 Tab 的内容 ---
      const {
        video,
        images,
        music,
        authorInfo,
        stats,
        desc,
        createTime,
        awemeId,
        shareInfo,
        awemeControl,
      } = current_media;
      const tabs = {};

      // Tab 1: 媒体资源
      const is_video = video && video.bitRateList.length > 0;
      const is_images = images && images.length > 0;

      const cover_details_html = (() => {
        if (!video || !video.coverUrlList || video.coverUrlList.length === 0) {
          return "";
        }
        // 优先使用索引为 1 的高清封面,如果不存在则回退到索引 0
        const cover_url = video.coverUrlList[1] || video.coverUrlList[0];
        const filename_base = this._build_filename(current_media);
        const cover_filename = `cover_${filename_base}.jpeg`;

        return html`
          <fieldset>
            <legend>视频封面</legend>
            <div style="display: flex; gap: 1rem; align-items: flex-start;">
              <img
                src="${cover_url}"
                alt="Video Cover"
                style="width: 120px; max-height: 180px; object-fit: cover; border-radius: 4px; border: 1px solid #ccc;"
              />
              <div
                style="font-size: 14px; display: flex; flex-direction: column; gap: 0.5rem;"
              >
                <p style="margin: 0;">
                  <strong>分辨率:</strong> ${video.width} × ${video.height}
                </p>
                <div style="display: flex; gap: 0.5rem; margin-top: 8px;">
                  <a
                    href="${cover_url}"
                    target="_blank"
                    style="padding: 4px 10px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px;"
                    >新标签打开</a
                  >
                  <a
                    href="${cover_url}"
                    download="${cover_filename}"
                    style="padding: 4px 10px; background: #007bff; color: white; text-decoration: none; border-radius: 4px;"
                    >下载封面</a
                  >
                </div>
              </div>
            </div>
          </fieldset>
        `;
      })();

      const video_details_html = is_video
        ? html` <fieldset>
            <legend>视频</legend>
            <table
              border="1"
              cellspacing="0"
              cellpadding="4"
              style="width: 100%; font-size: 12px;"
            >
              <thead>
                <tr>
                  <th>清晰度</th>
                  <th>分辨率</th>
                  <th>格式</th>
                  <th>FPS</th>
                  <th>Bitrate (kbps)</th>
                  <th>大小</th>
                  <th>播放链接</th>
                </tr>
              </thead>
              <tbody>
                ${video.bitRateList
                  .map(
                    (item) => html` <tr>
                      <td>${item.gearName}</td>
                      <td>${item.width}×${item.height}</td>
                      <td>${item.format}</td>
                      <td>${item.fps}</td>
                      <td>${(item.bitRate / 1000).toFixed(1)}</td>
                      <td>
                        ${item.dataSize
                          ? (item.dataSize / 1024 / 1024).toFixed(2) + " MB"
                          : "-"}
                      </td>
                      <td>
                        ${item.playApi
                          ? `<a href="${item.playApi}" target="_blank">播放</a>`
                          : "-"}
                      </td>
                    </tr>`
                  )
                  .join("")}
              </tbody>
            </table>
          </fieldset>`
        : "";

      const images_details_html = is_images
        ? html` <fieldset>
            <legend>图集</legend>
            <table
              border="1"
              cellspacing="0"
              cellpadding="4"
              style="width: 100%; font-size: 12px;"
            >
              <thead>
                <tr>
                  <th>序号</th>
                  <th>类型</th>
                  <th>分辨率</th>
                  <th>大小</th>
                  <th>预览</th>
                  <th>下载</th>
                </tr>
              </thead>
              <tbody>
                ${images
                  .map((img, idx) => {
                    const isVideo = !!img.video;
                    const thumbUrl = isVideo
                      ? img.video.coverUrlList?.[0] || ""
                      : img.urlList?.[0] || "";
                    const downloadUrl = isVideo
                      ? img.video.playAddr?.[0]?.src || ""
                      : img.downloadUrlList?.[0] || "";
                    const resolution = isVideo
                      ? `${img.video.width}×${img.video.height}`
                      : `${img.width}×${img.height}`;
                    const sizeMB =
                      isVideo && img.video.dataSize
                        ? (img.video.dataSize / 1024 / 1024).toFixed(2) + " MB"
                        : "-";
                    return html` <tr>
                      <td>${idx + 1}</td>
                      <td>${isVideo ? "视频" : "图片"}</td>
                      <td>${resolution}</td>
                      <td>${sizeMB}</td>
                      <td>
                        <img
                          src="${thumbUrl}"
                          style="max-width: 100px; max-height: 60px;"
                        />
                      </td>
                      <td>
                        ${downloadUrl
                          ? `<a href="${downloadUrl}" target="_blank">下载</a>`
                          : "-"}
                      </td>
                    </tr>`;
                  })
                  .join("")}
              </tbody>
            </table>
          </fieldset>`
        : "";

      const music_details_html = music
        ? html` <fieldset>
            <legend>音乐</legend>
            <div style="display: flex; align-items: center; gap: 1rem;">
              <img
                src="${music?.coverThumb?.urlList?.[0] || ""}"
                alt="cover"
                style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px;"
              />
              <div style="flex: 1; font-size: 14px;">
                <div><strong>标题:</strong>${music.title}</div>
                <div><strong>作者:</strong>${music.author}</div>
                <div>
                  <strong>时长:</strong>${(music.duration / 1000).toFixed(1)}
                  秒
                </div>
                <div>
                  <strong>播放:</strong>${music.playUrl?.urlList?.[0]
                    ? `<a href="${music.playUrl.urlList[0]}" target="_blank">试听</a>`
                    : "-"}
                </div>
              </div>
            </div>
          </fieldset>`
        : "";

      tabs.media = {
        title: "媒体资源",
        content: `<div style="display: flex; flex-direction: column; gap: 1rem;">${cover_details_html}${video_details_html}${images_details_html}${music_details_html}</div>`,
      };

      // Tab 2: 作者信息
      if (authorInfo) {
        tabs.author = {
          title: "作者信息",
          content: html`
            <div
              style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;"
            >
              <img
                src="${authorInfo.avatarThumb.urlList[0]}"
                style="width: 80px; height: 80px; border-radius: 50%;"
              />
              <div style="flex-grow: 1;">
                <h3 style="margin: 0 0 8px 0;">${authorInfo.nickname}</h3>
                <a
                  href="https://www.douyin.com/user/${authorInfo.secUid}"
                  target="_blank"
                  style="display: inline-block; padding: 4px 12px; background-color: #007bff; color: white; text-decoration: none; border-radius: 4px; font-size: 14px;"
                  >访问主页</a
                >
              </div>
            </div>
            <div>
              ${render_helpers.renderKeyValue(
                "认证信息",
                authorInfo.customVerify || authorInfo.enterpriseVerifyReason
              )}
              ${render_helpers.renderCopyableValue("UID", authorInfo.uid)}
              ${render_helpers.renderCopyableValue("SecUID", authorInfo.secUid)}
              ${render_helpers.renderKeyValue(
                "粉丝数",
                render_helpers.formatCount(authorInfo.followerCount)
              )}
              ${render_helpers.renderKeyValue(
                "获赞数",
                render_helpers.formatCount(authorInfo.totalFavorited)
              )}
            </div>
          `,
        };
      }

      // Tab 3: 作品信息
      tabs.post = {
        title: "作品信息",
        content: html`
          <fieldset>
            <legend>描述</legend>
            <p
              style="font-size: 14px; white-space: pre-wrap; line-height: 1.6;"
            >
              ${desc || "无"}
            </p>
          </fieldset>
          <fieldset>
            <legend>详情</legend>
            ${render_helpers.renderKeyValue(
              "发布时间",
              render_helpers.formatTimestamp(createTime)
            )}
            ${render_helpers.renderCopyableValue(
              "分享链接",
              shareInfo.shareUrl
            )}
            ${render_helpers.renderKeyValue(
              "点赞数",
              render_helpers.formatCount(stats.diggCount)
            )}
            ${render_helpers.renderKeyValue(
              "评论数",
              render_helpers.formatCount(stats.commentCount)
            )}
            ${render_helpers.renderKeyValue(
              "收藏数",
              render_helpers.formatCount(stats.collectCount)
            )}
            ${render_helpers.renderKeyValue(
              "分享数",
              render_helpers.formatCount(stats.shareCount)
            )}
          </fieldset>
        `,
      };

      // Tab 4: 高级信息
      tabs.advanced = {
        title: "高级信息",
        content: html`
          <fieldset>
            <legend>ID</legend>
            ${render_helpers.renderCopyableValue("Aweme ID", awemeId)}
            ${render_helpers.renderCopyableValue(
              "Group ID",
              current_media.groupId
            )}
          </fieldset>
          <fieldset>
            <legend>权限控制</legend>
            ${render_helpers.renderKeyValue(
              "允许评论",
              awemeControl?.canComment ? "是" : "否"
            )}
            ${render_helpers.renderKeyValue(
              "允许分享",
              awemeControl?.canShare ? "是" : "否"
            )}
            ${render_helpers.renderKeyValue(
              "允许下载",
              current_media.download?.allowDownload ? "是" : "否"
            )}
            ${render_helpers.renderKeyValue(
              "是否私密",
              current_media.isPrivate ? "是" : "否"
            )}
          </fieldset>
        `,
      };

      // Tab 5: 完整 JSON
      tabs.json = {
        title: "完整 JSON",
        content: html` <fieldset>
          <legend>
            JSON <button id="json_select">选中</button>
            <button id="json_console">console</button>
          </legend>
          <pre
            style="max-height: 20rem; overflow: auto; word-break: break-all; white-space: pre-wrap;"
          ><code>${JSON.stringify(current_media, null, 2)}</code></pre>
        </fieldset>`,
      };

      // --- 3. 构建最终的 UI ---
      const tabKeys = Object.keys(tabs);
      const details = DOMPatcher.render_html(html`
        <div class="dy-dl-modal-container">
          <style>
            .dy-dl-modal-container {
              display: flex;
              flex-direction: column;
              max-width: 90vw;
              max-height: 90vh;
              width: 800px;
              padding: 1rem;
              box-sizing: border-box;
              background: #fff;
            }
            .dy-dl-nav {
              display: flex;
              border-bottom: 1px solid #ccc;
              margin-bottom: 1rem;
              flex-shrink: 0;
            }
            .dy-dl-nav-button {
              padding: 0.5rem 1rem;
              border: none;
              background: transparent;
              cursor: pointer;
              font-size: 14px;
              border-bottom: 2px solid transparent;
              margin-bottom: -1px;
            }
            .dy-dl-nav-button.active {
              color: #007bff;
              border-bottom-color: #007bff;
              font-weight: bold;
            }
            .dy-dl-tab-content {
              flex-grow: 1;
              overflow-y: auto;
              padding-right: 10px;
            }
            .dy-dl-tab-panel {
              display: none;
            }
            .dy-dl-tab-panel.active {
              display: block;
            }
            .dy-dl-tab-panel fieldset {
              border: 1px solid #ddd;
              border-radius: 4px;
              margin-bottom: 1rem;
              padding: 0.5rem 1rem;
            }
            .dy-dl-tab-panel legend {
              font-weight: bold;
              color: #333;
            }
          </style>
          <nav class="dy-dl-nav">
            ${tabKeys
              .map(
                (key) =>
                  `<button class="dy-dl-nav-button" data-tab-id="${key}">${tabs[key].title}</button>`
              )
              .join("")}
          </nav>
          <div class="dy-dl-tab-content">
            ${tabKeys
              .map(
                (key) =>
                  `<div class="dy-dl-tab-panel" data-tab-id="${key}">${tabs[key].content}</div>`
              )
              .join("")}
          </div>
        </div>
      `);

      // --- 4. 添加交互逻辑 ---
      const navButtons = details.querySelectorAll(".dy-dl-nav-button");
      const tabPanels = details.querySelectorAll(".dy-dl-tab-panel");

      function switchTab(tabId) {
        navButtons.forEach((btn) =>
          btn.classList.toggle("active", btn.dataset.tabId === tabId)
        );
        tabPanels.forEach((panel) =>
          panel.classList.toggle("active", panel.dataset.tabId === tabId)
        );
      }

      navButtons.forEach((button) => {
        button.addEventListener("click", () => switchTab(button.dataset.tabId));
      });

      if (tabKeys.length > 0) {
        switchTab(tabKeys[0]);
      }

      const $json_select = details.querySelector("#json_select");
      if ($json_select) {
        $json_select.addEventListener("click", () => {
          const $code = details.querySelector("code");
          if (!$code) return;
          window.getSelection().selectAllChildren($code);
        });
      }

      const $json_console = details.querySelector("#json_console");
      if ($json_console) {
        $json_console.addEventListener("click", () => {
          console.log(JSON.parse(JSON.stringify(this.current_media)));
        });
      }

      details.querySelectorAll(".dy-dl-copy-btn").forEach((button) => {
        button.addEventListener("click", (e) => {
          const textToCopy = e.target.dataset.copyText;
          navigator.clipboard
            .writeText(textToCopy)
            .then(() => {
              e.target.textContent = "已复制!";
              setTimeout(() => {
                e.target.textContent = "复制";
              }, 2000);
            })
            .catch((err) => {
              console.error("复制失败: ", err);
              alert("复制失败!");
            });
        });
      });

      modal.root.appendChild(details);
    }

    init() {
      this._start_detect_player_change();
    }
  }

  class TooltipsButton {
    /**
     * 带有 hover 的按钮
     *
     * NOTE: dy-dl-video-btn 是用于标记是否注入用的
     *
     * @param {TooltipsButton} that
     * @returns {string}
     */
    static _html_base = (that) => html`
      <xg-icon
        class="xgplayer-playclarity-setting dy-dl-video-btn"
        data-state="normal"
        data-index="11"
      >
        <div class="gear isSmoothSwitchClarityLogin">
          <div class="virtual"></div>
          <div class="btn" tabindex="0">${that.label}</div>
        </div>
      </xg-icon>
    `;

    /**
     *
     * @param {string} label
     * @param {{label?: string, callback?: Function, html?: string, render?: Function}[]} items
     * @param {Function} onclick
     */
    constructor(label, items, onclick) {
      this.label = label;
      this.items = items;
      this.onclick = onclick;
    }

    render() {
      const root = DOMPatcher.render_html(TooltipsButton._html_base(this));
      /**
       * @type {HTMLElement}
       */
      const $gear = root.querySelector(".gear");
      const $items_list = root.querySelector(".virtual");
      const $btn = root.querySelector(".btn");

      // 绑定 hover
      $gear.addEventListener("mouseenter", () => $gear.classList.add("hover"));
      $gear.addEventListener("mouseleave", () =>
        $gear.classList.remove("hover")
      );

      // 渲染 items
      for (const item of this.items) {
        if (item.html) {
          $items_list.appendChild(DOMPatcher.render_html(item.html));
          continue;
        }
        if (item.render) {
          $items_list.appendChild(item.render());
          continue;
        }
        const $item = DOMPatcher.render_html(
          `<div class="item">${item.label}</div>`
        );
        $item.addEventListener("click", item.callback);
        $items_list.appendChild($item);
      }

      $btn.addEventListener("click", this.onclick);

      return root;
    }
  }

  class DOMPatcher {
    /** @type {Downloader} */
    downloader;
    /** @type {MediaHandler} */
    handler;
    /** @type {MutationObserver} */
    observer;

    /**
     * @param {Downloader} downloader
     * @param {MediaHandler} handler
     */
    constructor(downloader, handler) {
      this.downloader = downloader;
      this.handler = handler;
      this.observer = new MutationObserver(this._handleMutations.bind(this));
    }

    /**
     *
     * @param node {HTMLElement}
     * @returns {HTMLImageElement | null}
     */
    static findImage(node) {
      let img;
      let current = node;
      while (current) {
        img = current.querySelector("img");
        if (img) return img;
        current =
          current.parentNode instanceof HTMLElement ? current.parentNode : null;
      }
      return null;
    }

    /**
     *
     * @param html {string}
     * @returns {HTMLElement}
     */
    static render_html(html) {
      const div = document.createElement("div");
      div.innerHTML = html.trim();
      return /** @type {HTMLElement} */ (div.children[0]);
    }

    /**
     * @param {MutationRecord[]} mutations
     */
    _handleMutations(mutations) {
      mutations.forEach((mutation) => {
        mutation.addedNodes.forEach((/** @type {Node} */ node) => {
          if (node.nodeType !== Node.ELEMENT_NODE) {
            return;
          }
          const elementNode = /** @type {HTMLElement} */ (node);

          // Tooltip for emoticons
          if (elementNode.classList.contains("semi-portal")) {
            const tooltipNode = elementNode.querySelector(
              ".semi-tooltip-wrapper"
            );
            if (tooltipNode) {
              setTimeout(() => {
                // Delay to ensure content is populated
                this._handleTooltip(/** @type {HTMLElement} */ (tooltipNode));
              });
              return;
            }
          }
          // Fullscreen image modal
          // Heuristic: direct body child, no classes, contains an img
          if (
            elementNode.parentElement === document.body &&
            elementNode.classList.length === 0
          ) {
            setTimeout(() => {
              // Delay for modal rendering
              this._handleModal(elementNode);
            });
            return;
          }
          // Video player controls
          if (
            elementNode.localName === "xg-controls" ||
            elementNode.querySelector("xg-controls")
          ) {
            // FIXME: 这里有个问题,feed里面还有直播流,直播画面不应该有下载按钮,因为没用(不过有也没什么,不点就行了...)
            this._handleXgControl(/** @type {HTMLElement} */ (elementNode));
            return;
          }
        });
      });
    }

    /**
     * @param {HTMLElement} modalNode
     */
    _handleModal(modalNode) {
      const close_icon = modalNode.querySelector("#svg_icon_ic_close");
      const img = modalNode.querySelector("img");
      // Modals often have a specific container for the image, let's try to find it.
      // This might be fragile. The original used img.parentElement.
      const container =
        img?.closest('div[style*="transform: scale(1)"] > div') ||
        img?.parentElement;

      if (!close_icon || !img || !container) return;
      if (container.querySelector(".dy-dl-modal-btn")) return; // Button already exists

      const downloadButton = document.createElement("div");
      downloadButton.textContent = "下载图片";
      downloadButton.className = "LV01TNDE dy-dl-modal-btn"; // Added a specific class for checking
      downloadButton.addEventListener("click", (e) => {
        e.stopPropagation(); // Prevent modal from closing
        const imgSrc = img.src;
        this.downloader.download_file(imgSrc, "douyin_image");
      });
      // Styling from original script
      downloadButton.style.position = "absolute";
      downloadButton.style.bottom = "35px";
      downloadButton.style.right = "35px";
      downloadButton.style.color = "#fff";
      downloadButton.style.backgroundColor = "rgba(0,0,0,0.5)";
      downloadButton.style.padding = "5px 10px";
      downloadButton.style.borderRadius = "4px";
      downloadButton.style.fontSize = "16px";
      downloadButton.style.zIndex = "999999"; // Ensure it's on top of other modal elements
      downloadButton.style.cursor = "pointer";
      container.appendChild(downloadButton);
    }

    /**
     * @param {HTMLElement} tooltipNode
     */
    _handleTooltip(tooltipNode) {
      const tooltipContent = tooltipNode.querySelector(".semi-tooltip-content");
      if (!tooltipContent) return;

      if (!tooltipContent.textContent?.includes("添加到表情")) return;

      const imgNode = DOMPatcher.findImage(tooltipNode); // Search upwards from tooltip wrapper
      if (!imgNode?.src) return;

      if (tooltipContent.querySelector(".download-button")) return; // Button already exists

      const downloadButton = document.createElement("div");
      downloadButton.textContent = "下载表情包";
      downloadButton.className = "LV01TNDE download-button"; // Class from original

      downloadButton.style.cursor = "pointer"; // Make it look clickable
      downloadButton.style.paddingTop = "4px"; // Add some spacing

      downloadButton.addEventListener("click", (e) => {
        e.stopPropagation(); // Prevent other tooltip actions
        const imgSrc = imgNode.src;
        this.downloader.download_file(imgSrc, "douyin_emoticon");
      });

      tooltipContent.appendChild(downloadButton);
    }

    /**
     * @param {HTMLElement} xg_control_node
     */
    _handleXgControl(xg_control_node) {
      const right_grid = xg_control_node.querySelector(".xg-right-grid");
      if (!right_grid) return;
      if (right_grid.querySelector(".dy-dl-video-btn")) return; // Button already exists

      const btn = new TooltipsButton(
        "插件",
        [
          {
            html: `<div class="xgTips item"><span>快捷键:</span><span class="shortcutKey">M</span>`,
          },
          {
            label: "需求/反馈",
            callback: () => {
              window.open(
                "https://github.com/zhzLuke96/douyin-dl-user-js/issues",
                "_blank",
                "noopener,noreferrer"
              );
            },
          },
          {
            render: () => {
              const encode_to_png_switch = DOMPatcher.render_html(
                `<div class="item"><label><input type="checkbox"/> WebP转码PNG</label></item>`
              );
              const $input = encode_to_png_switch.querySelector("input");
              $input.checked = Config.global.features.convert_webp_to_png;
              $input.addEventListener("click", () => {
                Config.global.features.convert_webp_to_png = $input.checked;
                Config.global.save();
              });
              return encode_to_png_switch;
            },
          },
          {
            label: "媒体详情",
            callback: () => {
              this.handler.show_media_details();
            },
          },
          {
            label: "下载封面",
            callback: () => {
              this.handler.download_thumb();
            },
          },
          {
            label: "下载",
            callback: () => {
              this.handler.download_current_media();
            },
          },
        ],
        (e) => {
          // TODO: 没用... 会被劫持,所以移动到上面的 item 中去了
          // e.stopPropagation();
          // this.downloadCurrentMediaFn();
        }
      );
      const downloadButton = btn.render();

      // Try to insert before volume or settings for better placement
      const qualitySwitch = right_grid.querySelector(
        ".xgplayer-quality-setting"
      );
      const volumeControl = right_grid.querySelector(".xgplayer-volume");
      if (qualitySwitch) {
        right_grid.insertBefore(downloadButton, qualitySwitch);
      } else if (volumeControl) {
        right_grid.insertBefore(downloadButton, volumeControl);
      } else {
        right_grid.appendChild(downloadButton); // Fallback
      }
    }

    startObserving() {
      this.observer.observe(document.body, {
        childList: true,
        subtree: true,
      });
      // Initial scan for already existing elements
      document
        .querySelectorAll("xg-controls")
        .forEach((controls) =>
          this._handleXgControl(/** @type {HTMLElement} */ (controls))
        );
    }
  }

  class HotkeyManager {
    constructor() {}
    /**
     * @param {string} key
     * @param {Function} fn
     */
    addHotkey(key, fn) {
      document.addEventListener("keydown", (ev) => {
        if (ev.key.toLowerCase() !== key.toLowerCase()) return;

        const activeElement = /** @type {HTMLElement} */ (
          document.activeElement
        );
        if (activeElement) {
          // Check if activeElement is not null
          const tagName = activeElement.tagName;
          const isInputElement =
            tagName === "INPUT" ||
            tagName === "TEXTAREA" ||
            activeElement.isContentEditable;
          if (isInputElement) return;
        }

        ev.preventDefault();
        fn();
      });
    }
  }

  // ========== Main Script Logic =============

  const downloader = new Downloader();
  const mediaHandler = new MediaHandler(downloader);
  // Pass the already bound method from mediaHandler instance
  const domPatcher = new DOMPatcher(downloader, mediaHandler);
  const hotkeyManager = new HotkeyManager();

  mediaHandler.init(); // Starts player detection
  domPatcher.startObserving(); // Starts DOM observation and initial scan

  // Pass the already bound method from mediaHandler instance for the hotkey
  hotkeyManager.addHotkey("m", mediaHandler.download_current_media);

  console.log("[dy-dl]已启动");
})();