useVideoPlayer.js 11.2 KB
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { wxInfo } from "@/utils/tools";
import Hls from 'hls.js';
import videojs from "video.js";

/**
 * 视频播放核心逻辑 Hook
 * 处理不同环境下的播放器选择、HLS支持、自动播放策略等
 */
export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
  // 播放器实例
  const player = ref(null);
  const state = ref(null);
  const hlsInstance = ref(null);

  // 错误处理相关
  const showErrorOverlay = ref(false);
  const errorMessage = ref('');
  const retryCount = ref(0);
  const maxRetries = 3;

  // 原生播放器状态
  const nativeReady = ref(false);
  let nativeListeners = null;

  // 资源探测信息
  const probeInfo = ref({
    ok: null,
    status: null,
    content_type: "",
    content_length: null,
    accept_ranges: "",
  });
  const probeLoading = ref(false);

  // 1. 环境判断与播放器选择
  const useNativePlayer = computed(() => {
    // iOS 微信环境下强制使用原生播放器,因为 video.js 在此环境有兼容性问题
    return wxInfo().isIOSWeChat;
  });

  // 2. 视频源处理
  const videoUrlValue = computed(() => {
    return (props.videoUrl || "").trim();
  });

  // 3. HLS 支持判断
  const isM3U8 = computed(() => {
    const url = videoUrlValue.value.toLowerCase();
    return url.includes('.m3u8');
  });

  // 4. 视频类型判断
  const getVideoMimeType = (url) => {
    const urlText = (url || "").toLowerCase();
    if (urlText.includes(".m3u8")) return "application/x-mpegURL";
    if (urlText.includes(".mp4")) return "video/mp4";
    if (urlText.includes(".mov")) return "video/quicktime";
    return "";
  };

  // 5. 视频源配置
  const videoSources = computed(() => {
    const type = getVideoMimeType(videoUrlValue.value);
    if (type) {
      return [{ src: videoUrlValue.value, type }];
    }
    return [{ src: videoUrlValue.value }];
  });

  // 6. 错误处理逻辑
  const formatBytes = (bytes) => {
    const size = Number(bytes) || 0;
    if (!size) return "";
    const kb = 1024;
    const mb = kb * 1024;
    const gb = mb * 1024;
    if (size >= gb) return (size / gb).toFixed(2) + "GB";
    if (size >= mb) return (size / mb).toFixed(2) + "MB";
    if (size >= kb) return (size / kb).toFixed(2) + "KB";
    return String(size) + "B";
  };

  const getErrorHint = () => {
    if (probeInfo.value.status === 403) return "(403:无权限或已过期)";
    if (probeInfo.value.status === 404) return "(404:资源不存在)";
    if (probeInfo.value.status && probeInfo.value.status >= 500) return `(${probeInfo.value.status}:服务器异常)`;

    const len = probeInfo.value.content_length;
    if (len && len >= 1024 * 1024 * 1024) {
      const text = formatBytes(len);
      return text ? `(文件约${text},建议 WiFi)` : "(文件较大,建议 WiFi)";
    }
    return "";
  };

  // 资源探测
  const probeVideo = async () => {
    const url = videoUrlValue.value;
    if (!url || typeof fetch === "undefined") return;
    if (probeLoading.value) return;

    probeLoading.value = true;
    const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
    const timeoutId = setTimeout(() => controller?.abort?.(), 8000);

    try {
      const headRes = await fetch(url, {
        method: "HEAD",
        mode: "cors",
        cache: "no-store",
        signal: controller?.signal,
      });

      const contentLength = headRes.headers.get("content-length");
      probeInfo.value = {
        ok: headRes.ok,
        status: headRes.status,
        content_type: headRes.headers.get("content-type") || "",
        content_length: contentLength ? Number(contentLength) || null : null,
        accept_ranges: headRes.headers.get("accept-ranges") || "",
      };

      if (headRes.ok && probeInfo.value.content_length) return;
    } catch (e) {
      // 忽略 HEAD 请求失败
    } finally {
      clearTimeout(timeoutId);
      probeLoading.value = false;
    }

    // 如果 HEAD 失败,尝试 GET Range 0-1
    const controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
    const timeoutId2 = setTimeout(() => controller2?.abort?.(), 8000);
    try {
      const rangeRes = await fetch(url, {
        method: "GET",
        mode: "cors",
        cache: "no-store",
        headers: { Range: "bytes=0-1" },
        signal: controller2?.signal,
      });
      const contentRange = rangeRes.headers.get("content-range") || "";
      const match = contentRange.match(/\/(\d+)\s*$/);
      const total = match ? Number(match[1]) || null : null;
      const contentLength = rangeRes.headers.get("content-length");

      probeInfo.value = {
        ok: rangeRes.ok,
        status: rangeRes.status,
        content_type: rangeRes.headers.get("content-type") || "",
        content_length: total || (contentLength ? Number(contentLength) || null : null),
        accept_ranges: rangeRes.headers.get("accept-ranges") || "",
      };
    } catch (e) {
      // 忽略错误
    } finally {
      clearTimeout(timeoutId2);
    }
  };

  // 7. 错误处理逻辑
  const handleError = (code, message = '') => {
    showErrorOverlay.value = true;
    switch (code) {
      case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
        errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
        if (retryCount.value < maxRetries) {
          setTimeout(retryLoad, 1000);
        }
        break;
      case 3: // MEDIA_ERR_DECODE
        errorMessage.value = '视频解码失败,可能是文件损坏';
        break;
      case 2: // MEDIA_ERR_NETWORK
        errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint();
        if (retryCount.value < maxRetries) {
          setTimeout(retryLoad, 2000);
        }
        break;
      case 1: // MEDIA_ERR_ABORTED
        errorMessage.value = '视频加载被中止';
        break;
      default:
        errorMessage.value = message || '视频播放出现未知错误';
    }
  };

  // 4. 原生播放器逻辑 (iOS微信)
  const initNativePlayer = () => {
    const videoEl = nativeVideoRef.value;
    if (!videoEl) return;

    // HLS 处理
    if (isM3U8.value && Hls.isSupported()) {
      // 如果原生支持 HLS (iOS Safari),直接用 src 即可,不需要 hls.js
      // 但如果是安卓微信等不支持原生 HLS 的环境,才需要 hls.js
      // 由于 useNativePlayer 仅针对 iOS 微信,而 iOS 原生支持 HLS,所以这里直接赋值 src 即可
      // 无需额外操作
    }

    const onLoadStart = () => {
      showErrorOverlay.value = false;
      nativeReady.value = false;
    };

    const onCanPlay = () => {
      showErrorOverlay.value = false;
      retryCount.value = 0;
      nativeReady.value = true;
    };

    const onError = () => {
      handleError(videoEl.error?.code);
    };

    videoEl.addEventListener("loadstart", onLoadStart);
    videoEl.addEventListener("canplay", onCanPlay);
    videoEl.addEventListener("error", onError);
    nativeListeners = { videoEl, onLoadStart, onCanPlay, onError };

    if (props.autoplay) {
      tryNativePlay();
      if (typeof document !== "undefined") {
        document.addEventListener(
          "WeixinJSBridgeReady",
          () => tryNativePlay(),
          { once: true }
        );
      }
    }
  };

  const tryNativePlay = () => {
    const videoEl = nativeVideoRef.value;
    if (!videoEl) return;

    const playPromise = videoEl.play();
    if (playPromise && typeof playPromise.catch === "function") {
      playPromise.catch(() => {
        if (typeof window !== "undefined" && window.WeixinJSBridge) {
          window.WeixinJSBridge.invoke("getNetworkType", {}, () => {
            videoEl.play().catch(() => {});
          });
        }
      });
    }
  };

  // 5. Video.js 播放器逻辑 (PC/Android)
  const videoOptions = computed(() => ({
    controls: true,
    preload: "metadata",
    responsive: true,
    autoplay: props.autoplay,
    playsinline: true,
    playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
    sources: videoSources.value,
    html5: {
      vhs: {
        overrideNative: !videojs.browser.IS_SAFARI, // 非 Safari 下使用 VHS 解析 HLS
      },
      nativeVideoTracks: false,
      nativeAudioTracks: false,
      nativeTextTracks: false,
      hls: {
        withCredentials: false
      }
    },
    errorDisplay: true,
    techOrder: ['html5'],
    userActions: {
      hotkeys: true,
      doubleClick: true,
    },
    controlBar: {
      progressControl: {
        seekBar: {
          mouseTimeDisplay: {
            keepTooltipsInside: true,
          },
        },
      },
    },
    ...props.options,
  }));

  // 8. Video.js 挂载处理
  const handleVideoJsMounted = (payload) => {
    state.value = payload.state;
    player.value = payload.player;

    if (player.value) {
      player.value.on('error', () => {
        const err = player.value.error();
        handleError(err?.code, err?.message);
      });

      player.value.on('loadstart', () => {
        showErrorOverlay.value = false;
      });

      player.value.on('canplay', () => {
        showErrorOverlay.value = false;
        retryCount.value = 0;
      });

      if (props.autoplay) {
        player.value.play().catch(console.warn);
      }
    }
  };

  // 6. 重试逻辑
  const retryLoad = () => {
    if (retryCount.value >= maxRetries) {
      errorMessage.value = '重试次数已达上限,请稍后再试';
      return;
    }

    retryCount.value++;
    showErrorOverlay.value = false;

    if (useNativePlayer.value) {
      const videoEl = nativeVideoRef.value;
      if (videoEl) {
        nativeReady.value = false;
        const currentSrc = videoEl.currentSrc || videoEl.src;
        videoEl.pause();
        videoEl.removeAttribute("src");
        videoEl.load();
        videoEl.src = currentSrc || videoUrlValue.value;
        videoEl.load();
        tryNativePlay();
      }
    } else {
      if (player.value && !player.value.isDisposed()) {
        player.value.load();
      }
    }
  };

  // 7. 生命周期与监听
  watch(() => videoUrlValue.value, () => {
    retryCount.value = 0;
    showErrorOverlay.value = false;
    void probeVideo();

    // 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境)
    if (useNativePlayer.value && isM3U8.value) {
       // iOS 原生支持,不需要额外操作
       // 如果未来支持 Android 原生播放器且不支持 HLS,需在此处初始化 hls.js
    }
  });

  onMounted(() => {
    void probeVideo();
    if (useNativePlayer.value) {
      initNativePlayer();
    }
  });

  onBeforeUnmount(() => {
    if (nativeListeners?.videoEl) {
      nativeListeners.videoEl.removeEventListener("loadstart", nativeListeners.onLoadStart);
      nativeListeners.videoEl.removeEventListener("canplay", nativeListeners.onCanPlay);
      nativeListeners.videoEl.removeEventListener("error", nativeListeners.onError);
    }

    if (hlsInstance.value) {
      hlsInstance.value.destroy();
    }

    if (videoRef.value?.$player) {
      videoRef.value.$player.dispose();
    }
  });

  return {
    player,
    state,
    useNativePlayer,
    videoUrlValue,
    videoOptions,
    showErrorOverlay,
    errorMessage,
    retryLoad,
    handleVideoJsMounted,
    tryNativePlay
  };
}