1.前沿

總結工作遇到的處理文件的一些情況,簡單入門文件

2.背景

某天陽光明媚的早上,app的同事問我:我這邊有個上傳圖片的接口調不通,後端説你們前端接口調通了,那個前端input type=file 讀取到的數據叫什麼?
一臉懵逼的我心裏想數據是叫什麼?二進制流?blob?buffer?file對象?
通過回憶以前寫過一篇關於二進制流、blob、buffer、File對象、Base64之間的轉化,知道了答案是File對象
我:喂,那個app同事,讀取到的數據格式叫File對象
app同事:好的,File對象??

3.擴展前端處理文件的場景

對於需要了解,基礎的同學傳送門,
這裏擴展一下實戰的api,FileReader對象允許 Web 應用程序異步讀取存儲在用户計算機上的文件(或原始數據緩衝區)的內容,使用 File 或 Blob 對象指定要讀取的文件或數據。

// 以原始二進制方式讀取,讀取結果可直接轉成整數數組
fileReader.readAsArrayBuffer(this.files[0]);

前端pdf文件預覽以及大文件處理_html


可以看到,它對前端開發人員也是透明的,不能夠直接讀取裏面的內容,但可以通過ArrayBuffer.length得到長度,還能轉成整型數組,就能知道文件的原始二進制內容了:

let buffer = this.result;
// 依次每字節8位讀取,放到一個整數數組
let view = new Uint8Array(buffer);
console.log(view);

小磐,在這裏提出兩個問題
1.二進制在前端怎麼使用?
2.二進制在前端怎麼接受?

基本使用

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>FileReader</title>
    <!-- <link rel="stylesheet" type="text/css" href="style.css"> -->
  </head>
  <body>
    <div class="container">
      <label for="select-input">選擇要上傳的關鍵詞txt文件</label>
      <input type="file" id="select-input" value="上傳關鍵詞2" />
      <div id="content"></div>
      <img id="picture" src="" />
    </div>

    <script>
      document.getElementById("select-input").addEventListener(
        "change",
        (e) => {
          //獲取選擇的文件對象
          let file = e.target.files[0];
          // 檢測瀏覽器對FileReader的支持
          if (window.FileReader) {
            // 創建FileReader對象(文件對象)
            const reader = new FileReader();

            /*----------    6種事件模型    ---------*/
            // 開始讀取時:
            reader.onloadstart = function (e) {
              console.log("開始讀取", e);
            };
            // 正在讀取:
            reader.onprogress = function (e) {
              console.log("正在讀取", e);
            };
            // 讀取出錯時:
            reader.onerror = function (e) {
              console.log("讀取出錯", e);
            };
            // 讀取中斷時:
            reader.onabort = function (e) {
              console.log("讀取中斷", e);
            };
            // 讀取成功時:
            reader.onload = function (e) {
              keyList = e.target.result.split(",");
              console.log("讀取成功", keyList);

              if (/image\/\w+/.test(file.type)) {
                document.getElementById("picture").src = e.target.result;
              } else {
                // 輸出文件
                document.getElementById("content").innerText = e.target.result;
              }
            };
            // 讀取完成,無論成功失敗:
            reader.onloadend = function (e) {
              console.log("讀取完成,無論成功失敗", e);
            };

            /*-------  4種文件讀功能(方法、函數)  ------*/
            /*	reader.readAsText(file,"utf-8");
          reader.readAsBinaryString(file);  	// 將文件讀取為二進制編碼,buffer就是二進制
        reader.readAsDataURL(file);  		// 將文件讀取為DataURL
        reader.readAsText(none);  			// 終端讀取操作 			*/

            if (/image\/\w+/.test(file.type)) {
              reader.readAsDataURL(file);
            } else {
              // 輸出文件
              reader.readAsBinaryString(file, "utf-8");
            }
          } else {
            alert("Not supported by your browser!");
          }
        },
        false
      );
    </script>
  </body>
</html>

3.1下載文件流

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件流下載示例</title>
    <!-- 引入axios庫 -->
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <style>
        body {
            font-family: Arial, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 2rem;
        }
        .download-btn {
            padding: 0.8rem 1.5rem;
            background-color: #42b983;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
            transition: background-color 0.3s;
        }
        .download-btn:hover {
            background-color: #359e6d;
        }
        .status {
            margin-top: 1rem;
            color: #666;
        }
    </style>
</head>
<body>
    <h2>文件下載示例</h2>
    <button class="download-btn" id="downloadBtn">下載Excel文件</button>
    <div class="status" id="status"></div>

    <script>
        // 獲取DOM元素
        const downloadBtn = document.getElementById('downloadBtn');
        const statusElement = document.getElementById('status');

        // 下載按鈕點擊事件
        downloadBtn.addEventListener('click', () => {
            downloadFile();
        });

        /**
         * 下載文件流的函數
         */
        function downloadFile() {
            // 顯示加載狀態
            statusElement.textContent = '正在下載文件...';
            downloadBtn.disabled = true;

            // 模擬請求參數
            const param = {
                // 這裏可以根據實際需求填寫請求參數
                startDate: '2023-01-01',
                endDate: '2023-12-31',
                type: 'report'
            };

            // 實際接口地址,請替換為你的後端接口
            const _urls = 'https://example.com/api/export-excel';

            axios({
                method: "post",
                data: param,
                responseType: "blob",  // 關鍵:指定響應類型為blob
                url: _urls,
                headers: {
                    // 可以根據需要添加請求頭,如認證信息等
                    // 'Authorization': 'Bearer your_token_here'
                }
            }).then((response) => {
                // 注意:axios返回的響應對象中,數據在response.data裏
                const blob = new Blob([response.data], { 
                    type: "application/vnd.ms-excel" 
                });
                
                // 創建下載鏈接
                const link = document.createElement("a");
                const objectUrl = window.URL.createObjectURL(blob);
                
                link.href = objectUrl;
                // 設置下載文件名
                link.download = `數據報表_${new Date().toLocaleDateString().replace(/\//g, '-')}.xlsx`;
                
                // 觸發下載
                document.body.appendChild(link);
                link.click();
                
                // 清理資源
                setTimeout(() => {
                    document.body.removeChild(link);
                    window.URL.revokeObjectURL(objectUrl);
                }, 100);

                // 更新狀態
                statusElement.textContent = '文件下載成功!';
            }).catch((error) => {
                console.error('下載失敗:', error);
                statusElement.textContent = '文件下載失敗,請重試!';
            }).finally(() => {
                // 恢復按鈕狀態
                downloadBtn.disabled = false;
            });
        }
    </script>
</body>
</html>

3.2下載canvas為圖片

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas下載為圖片示例</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 2rem;
            gap: 1.5rem;
        }
        .container {
            border: 2px dashed #ccc;
            border-radius: 8px;
            padding: 1rem;
        }
        canvas {
            border: 1px solid #666;
            background-color: #f9f9f9;
        }
        .controls {
            display: flex;
            gap: 1rem;
        }
        button {
            padding: 0.6rem 1.2rem;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            transition: all 0.3s;
        }
        .draw-btn {
            background-color: #4285f4;
            color: white;
        }
        .download-btn {
            background-color: #34a853;
            color: white;
        }
        button:hover {
            opacity: 0.9;
            transform: translateY(-2px);
        }
        .info {
            color: #666;
            font-size: 14px;
        }
    </style>
</head>
<body>
    <h2>Canvas下載為圖片示例</h2>
    
    <div class="container">
        <!-- Canvas元素 -->
        <canvas id="myCanvas" width="600" height="400"></canvas>
    </div>
    
    <div class="controls">
        <button class="draw-btn" id="drawBtn">在Canvas上繪製內容</button>
        <button class="download-btn" id="downloadBtn">下載為圖片</button>
    </div>
    
    <div class="info" id="info"></div>

    <script>
        // 獲取DOM元素
        const canvas = document.getElementById('myCanvas');
        const ctx = canvas.getContext('2d');
        const drawBtn = document.getElementById('drawBtn');
        const downloadBtn = document.getElementById('downloadBtn');
        const infoElement = document.getElementById('info');

        // 初始狀態提示
        infoElement.textContent = '點擊"在Canvas上繪製內容"按鈕生成示例圖形';

        // 繪製示例內容
        drawBtn.addEventListener('click', () => {
            // 清空Canvas
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            
            // 繪製背景
            ctx.fillStyle = '#f0f8ff';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            
            // 繪製矩形
            ctx.fillStyle = '#4285f4';
            ctx.fillRect(50, 50, 200, 150);
            
            // 繪製圓形
            ctx.fillStyle = '#ea4335';
            ctx.beginPath();
            ctx.arc(400, 125, 75, 0, Math.PI * 2);
            ctx.fill();
            
            // 繪製文本
            ctx.fillStyle = '#333';
            ctx.font = '24px Arial';
            ctx.textAlign = 'center';
            ctx.fillText('Canvas 示例', canvas.width / 2, 320);
            
            // 繪製線條
            ctx.strokeStyle = '#fbbc05';
            ctx.lineWidth = 5;
            ctx.beginPath();
            ctx.moveTo(100, 350);
            ctx.lineTo(500, 350);
            ctx.stroke();
            
            infoElement.textContent = '已繪製示例圖形,可點擊下載按鈕保存為圖片';
        });

        // 下載Canvas為圖片
        downloadBtn.addEventListener('click', () => {
            try {
                // 將Canvas內容轉換為圖片URL (支持png/jpeg/webp等格式)
                // 注意:toDataURL方法受同源策略限制,且Canvas如果被污染(包含跨域圖片)會報錯
                const imageUrl = canvas.toDataURL('image/png'); // 也可以使用 'image/jpeg'
                
                // 創建下載鏈接
                const link = document.createElement('a');
                link.href = imageUrl;
                
                // 設置下載文件名(包含時間戳避免重複)
                const timestamp = new Date().getTime();
                link.download = `canvas-export-${timestamp}.png`;
                
                // 觸發下載
                document.body.appendChild(link);
                link.click();
                
                // 清理
                document.body.removeChild(link);
                URL.revokeObjectURL(imageUrl);
                
                infoElement.textContent = '圖片下載成功!';
            } catch (error) {
                console.error('下載失敗:', error);
                infoElement.textContent = '下載失敗:' + error.message;
            }
        });
    </script>
</body>
</html>

3.3獲取視頻的第一幀,並且轉化為圖片上傳。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>視頻第一幀捕獲示例</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 2rem auto;
            padding: 0 1rem;
        }
        .container {
            display: flex;
            flex-direction: column;
            gap: 1.5rem;
        }
        .preview-area {
            display: flex;
            gap: 1rem;
            flex-wrap: wrap;
        }
        .preview-box {
            flex: 1;
            min-width: 300px;
        }
        video, img {
            width: 100%;
            border: 1px solid #ddd;
            border-radius: 4px;
            max-height: 300px;
            object-fit: contain;
        }
        button {
            padding: 0.8rem 1.5rem;
            background-color: #4285f4;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
        }
        button:hover {
            background-color: #3367d6;
        }
        .status {
            padding: 1rem;
            border-radius: 4px;
            margin-top: 1rem;
        }
        .success {
            background-color: #e6f4ea;
            color: #137333;
        }
        .error {
            background-color: #fce8e6;
            color: #c5221f;
        }
    </style>
</head>
<body>
    <div class="container">
        <h2>視頻第一幀捕獲與上傳</h2>
        <button id="captureBtn">獲取視頻第一幀</button>
        
        <div class="preview-area">
            <div class="preview-box">
                <h3>視頻預覽</h3>
                <div id="videoContainer"></div>
            </div>
            <div class="preview-box">
                <h3>第一幀圖片</h3>
                <div id="frameContainer"></div>
            </div>
        </div>
        
        <div id="status" class="status"></div>
    </div>

    <script>
        const captureBtn = document.getElementById('captureBtn');
        const videoContainer = document.getElementById('videoContainer');
        const frameContainer = document.getElementById('frameContainer');
        const statusElement = document.getElementById('status');
        
        // 使用提供的視頻地址
        const videoUrl = 'https://d7246990-e472-4105-aa90-f0d2a9f73dae.mdnplay.dev/shared-assets/videos/flower.webm';

        captureBtn.addEventListener('click', captureFirstFrame);

        function captureFirstFrame() {
            // 清空之前的內容
            videoContainer.innerHTML = '';
            frameContainer.innerHTML = '';
            statusElement.textContent = '';
            statusElement.className = 'status';

            try {
                // 創建視頻元素
                const video = document.createElement('video');
                video.crossOrigin = 'anonymous'; // 處理跨域
                video.controls = true;
                video.autoplay = true;
                video.muted = true; // 靜音以允許自動播放
                video.src = videoUrl;

                // 添加到頁面預覽
                videoContainer.appendChild(video);

                // 視頻加載完成後捕獲第一幀
                video.addEventListener('loadeddata', function() {
                    statusElement.textContent = '視頻加載完成,正在捕獲第一幀...';
                    statusElement.className = 'status';

                    // 創建canvas繪製第一幀
                    const canvas = document.createElement('canvas');
                    canvas.width = video.videoWidth;
                    canvas.height = video.videoHeight;
                    const ctx = canvas.getContext('2d');
                    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

                    // 顯示捕獲的幀
                    const frameImg = document.createElement('img');
                    frameImg.src = canvas.toDataURL('image/png');
                    frameContainer.appendChild(frameImg);

                    // 轉換為文件並模擬上傳
                    const fileName = `flower-frame-${new Date().getTime()}.png`;
                    dataURLtoFile(canvas.toDataURL('image/png'), fileName);
                });

                // 視頻加載錯誤處理
                video.addEventListener('error', function() {
                    statusElement.textContent = '視頻加載失敗,請檢查網絡或視頻地址';
                    statusElement.className = 'status error';
                });

            } catch (error) {
                console.error('捕獲失敗:', error);
                statusElement.textContent = `捕獲失敗: ${error.message}`;
                statusElement.className = 'status error';
            }
        }

        // 將dataURL轉換為File對象
        function dataURLtoFile(dataurl, filename) {
            try {
                const arr = dataurl.split(',');
                const mime = arr[0].match(/:(.*?);/)[1];
                const bstr = atob(arr[1]);
                const n = bstr.length;
                const u8arr = new Uint8Array(n);
                
                for (let i = 0; i < n; i++) {
                    u8arr[i] = bstr.charCodeAt(i);
                }
                
                const file = new File([u8arr], filename, { type: mime });
                statusElement.textContent = '第一幀捕獲成功,準備上傳...';
                
                // 模擬上傳過程
                simulateUpload(file);
                
            } catch (error) {
                console.error('轉換失敗:', error);
                statusElement.textContent = `轉換失敗: ${error.message}`;
                statusElement.className = 'status error';
            }
        }

        // 模擬文件上傳
        function simulateUpload(file) {
            // 實際項目中這裏會是真實的上傳接口調用
            setTimeout(() => {
                statusElement.textContent = `上傳成功!文件名: ${file.name},大小: ${(file.size/1024).toFixed(2)}KB`;
                statusElement.className = 'status success';
            }, 1500);
        }
        // 真實上傳示例
			function uploadFile(file) {
			    const formData = new FormData();
			    formData.append('frame', file);
			    
			    fetch('/your-upload-api', {
			        method: 'POST',
			        body: formData
			    })
			    .then(response => response.json())
			    .then(data => {
			        statusElement.textContent = '上傳成功,服務器已接收';
			        statusElement.className = 'status success';
			    })
			    .catch(error => {
			        statusElement.textContent = '上傳失敗: ' + error.message;
			        statusElement.className = 'status error';
			    });
			}
    </script>
</body>
</html>

解決獲取時候的報錯信息

3.3.1.toDataUrl方法畫布轉base64失敗

前端pdf文件預覽以及大文件處理_下載文件_02

視頻節點未設置允許跨域,導致畫布資源導出失敗
video.setAttribute(‘crossOrigin’, ‘anonymous’);

3.3.2.資源跨域問題

前端pdf文件預覽以及大文件處理_下載文件_03

這是資源問題,在要在資源那邊設置,我更改為自己公司的oss地址視頻就可以了

3.4vue項目後端接口返回文件流,接口報錯時前端獲取不到錯誤信息解決方法和文件流處理

exportFiles () { // 導出案卷包
			let params = {
			casesId: this.caseId,
			taskId: this.taskId	
			}
            downloadCases(params).then(
                res => {
                    let data = res.data
                    let _self = this
                    let fileReader = new FileReader();
                    fileReader.onload = function() {
                        try {
                            let jsonData = JSON.parse(this.result);  // 説明是普通對象數據,後台轉換失敗
                            if (jsonData.code === 400) { // 接口返回的錯誤信息
                           		 // alert(jsonData.msg)
                                _self.$alarm.showWarning(jsonData.msg) // 彈出的提示信息
                            }
                        } catch (err) {   // 解析成對象失敗,説明是正常的文件流
                            const blob = new Blob([res.data], { type: 'application/zip' }) // 如類型為excel,type為:'application/vnd.ms-excel'
                            const fileName = res.headers.filename
                            const url = window.URL.createObjectURL(blob)
                            const link = document.createElement('a')
                            link.style.display = 'none'
                            link.href = url
                            link.setAttribute('download', fileName)

                            document.body.appendChild(link)
                            link.click()
                            document.body.removeChild(link) // 點擊後移除,防止生成很多個隱藏a標籤
							URL.revokeObjectURL(link.href);
                        } 
                    };
                    fileReader.readAsText(data)  // 注意別落掉此代碼,可以將 Blob 或者 File 對象轉根據特殊的編碼格式轉化為內容(字符串形式)
                }
            ).catch(err => {
                console.log(err)
            })
        },

3.5ArrayBuffer操作二進制數組

參考:傳送門ArrayBuffer對象、TypedArray視圖和DataView視圖,它們都是以數組的語法處理二進制數據,所以統稱為二進制數組。

這個接口的原始設計目的,與 WebGL 項目有關。所謂 WebGL,就是指瀏覽器與顯卡之間的通信接口,為了滿足 JavaScript 與顯卡之間大量的、實時的數據交換,它們之間的數據通信必須是二進制的,而不能是傳統的文本格式。

允許開發者以數組下標的形式,直接操作內存,大大增強了 JavaScript 處理二進制數據的能力,使得開發者有可能通過 JavaScript 與操作系統的原生接口進行二進制通信。比如:瀏覽器與顯卡之間的通信接口

ArrayBuffer對象代表儲存二進制數據的一段內存,它不能直接讀寫,只能通過視圖(TypedArray視圖和DataView視圖)來讀寫,視圖的作用是以指定格式解讀二進制數據。

const buf = new ArrayBuffer(32);
const dataView = new DataView(buf);
dataView.getUint8(0) // 0

ArrayBuffer 與字符串的互相轉換

/**
 * Convert ArrayBuffer/TypedArray to String via TextDecoder
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder
 */
function ab2str(
  input: ArrayBuffer | Uint8Array | Int8Array | Uint16Array | Int16Array | Uint32Array | Int32Array,
  outputEncoding: string = 'utf8',
): string {
  const decoder = new TextDecoder(outputEncoding)
  return decoder.decode(input)
}

/**
 * Convert String to ArrayBuffer via TextEncoder
 *
 * @see https://developer.mozilla.org/zh-CN/docs/Web/API/TextEncoder
 */
function str2ab(input: string): ArrayBuffer {
  const view = str2Uint8Array(input)
  return view.buffer
}

/** Convert String to Uint8Array */
function str2Uint8Array(input: string): Uint8Array {
  const encoder = new TextEncoder()
  const view = encoder.encode(input)
  return view
}

3.6 base-64 轉blob

function dataURLtoBlob(base64Buf: string): Blob {
  const arr = base64Buf.split(',');
  const typeItem = arr[0];
  const mime = typeItem.match(/:(.*?);/)![1];
  const bstr = atob(arr[1]);
  let n = bstr.length;
  const u8arr = new Uint8Array(n);
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n);
  }
  return new Blob([u8arr], { type: mime });
}

大概的意思就是:

  1. 用atob對base-64 編碼的字符串進行解碼
  2. 新增一個8 位無符號整型類數組,設置長度為解碼後的字符長度
  3. charCodeAt獲取字符對應的UTF-16 碼元,並存進去Uint8Array

備註:Uint8Array 數組類型表示一個 8 位無符號整型數組,創建時內容被初始化為 0。
charCodeAt() 方法返回一個整數,表示給定索引處的 UTF-16 碼元,其值介於 0 和 65535 之間。

3.7大文件分片下載:

// script
function downloadRange(url, start, end, i) {
  return new Promise((resolve, reject) => {
     const req = new XMLHttpRequest();
     req.open('GET', url, true);
     req.setRequestHeader('range', `bytes=${start}-${end}`);
     req.responseType = 'blob';
     req.onload = function (oEvent) {
       req.response.arrayBuffer().then((res) => {
         resolve({
           i,
           buffer: res,
         });
       });
     };
     req.send();
   });
  }
  // 合併buffer
  function concatenate(resultConstructor, arrays) {
    let totalLength = 0;
    for (let arr of arrays) {
      totalLength += arr.length;
    }
    let result = new resultConstructor(totalLength);
    let offset = 0;
    for (let arr of arrays) {
      result.set(arr, offset);
      offset += arr.length;
    }
    return result;
  }
  download2.onclick = () => {
    axios({
      url,
      method: 'head',
    }).then((res) => {
      // 獲取長度來進行分割塊
      console.time('併發下載');
      const size = Number(res.headers['content-length']);
      const length = parseInt(size / m);
      const arr = [];
      for (let i = 0; i < length; i++) {
        let start = i * m;
        let end = i == length - 1 ? size - 1 : (i + 1) * m - 1;
        arr.push(downloadRange(url, start, end, i));
      }
      Promise.all(arr).then((res) => {
        const arrBufferList = res
          .sort((item) => item.i - item.i)
          .map((item) => new Uint8Array(item.buffer));
        const allBuffer = concatenate(Uint8Array, arrBufferList);
        const blob = new Blob([allBuffer], { type: 'image/jpeg' });
        const blobUrl = URL.createObjectURL(blob);
        const aTag = document.createElement('a');
        aTag.download = '360_0388.jpg';
        aTag.href = blobUrl;
        aTag.click();
        URL.revokeObjectURL(blobUrl);
        console.timeEnd('併發下載');
      });
    });
  };

3.8 如何拼接兩個音頻文件

由以上整理的轉換圖得出途徑
fetch請求音頻資源 -> ArrayBuffer -> TypedArray -> 拼接成一個 TypedArray -> ArrayBuffer -> Blob -> Object URL

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>音頻文件拼接示例</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 2rem auto;
            padding: 0 1rem;
        }
        .container {
            display: flex;
            flex-direction: column;
            gap: 1.5rem;
        }
        .input-group {
            margin-bottom: 1rem;
        }
        label {
            display: block;
            margin-bottom: 0.5rem;
            font-weight: bold;
        }
        input, button {
            width: 100%;
            padding: 0.8rem;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
        }
        button {
            background-color: #4285f4;
            color: white;
            border: none;
            cursor: pointer;
            font-size: 16px;
            transition: background-color 0.3s;
        }
        button:hover {
            background-color: #3367d6;
        }
        .audio-player {
            margin-top: 1rem;
            width: 100%;
        }
        .status {
            padding: 1rem;
            border-radius: 4px;
            margin-top: 1rem;
        }
        .success {
            background-color: #e6f4ea;
            color: #137333;
        }
        .error {
            background-color: #fce8e6;
            color: #c5221f;
        }
        .info {
            background-color: #e8f0fe;
            color: #1967d2;
        }
    </style>
</head>
<body>
    <div class="container">
        <h2>音頻文件拼接工具</h2>
        
        <div class="input-group">
            <label for="audioUrl1">第一個音頻URL</label>
            <input type="text" id="audioUrl1" 
                value="https://c9a115da-8327-437a-a080-470a5b029c4e.mdnplay.dev/shared-assets/audio/t-rex-roar.mp3" 
                placeholder="輸入第一個音頻文件的URL">
        </div>
        
        <div class="input-group">
            <label for="audioUrl2">第二個音頻URL</label>
            <input type="text" id="audioUrl2" 
                value="https://c9a115da-8327-437a-a080-470a5b029c4e.mdnplay.dev/shared-assets/audio/t-rex-roar.mp3" 
                placeholder="輸入第二個音頻文件的URL">
        </div>
        
        <button id="concatBtn">拼接音頻文件</button>
        
        <div id="status" class="status info">請點擊拼接按鈕,將兩個T-Rex roar音頻拼接在一起</div>
        
        <div id="originalAudios">
            <h3>原始音頻(兩個相同的音頻)</h3>
            <div>
                <p>音頻1:</p>
                <audio id="originalAudio1" class="audio-player" controls></audio>
            </div>
            <div style="margin-top: 1rem;">
                <p>音頻2:</p>
                <audio id="originalAudio2" class="audio-player" controls></audio>
            </div>
        </div>
        
        <div id="combinedAudioSection">
            <h3>拼接後的音頻(時長應為原始的2倍)</h3>
            <audio id="combinedAudio" class="audio-player" controls></audio>
            <button id="downloadBtn" style="margin-top: 1rem; display: none;">下載拼接後的音頻</button>
        </div>
    </div>

    <script>
        // 獲取DOM元素
        const audioUrl1Input = document.getElementById('audioUrl1');
        const audioUrl2Input = document.getElementById('audioUrl2');
        const concatBtn = document.getElementById('concatBtn');
        const statusElement = document.getElementById('status');
        const originalAudio1 = document.getElementById('originalAudio1');
        const originalAudio2 = document.getElementById('originalAudio2');
        const combinedAudio = document.getElementById('combinedAudio');
        const downloadBtn = document.getElementById('downloadBtn');
        
        let combinedBlobUrl = null;

        // 綁定拼接按鈕事件
        concatBtn.addEventListener('click', concatAudioFiles);
        
        // 綁定下載按鈕事件
        downloadBtn.addEventListener('click', downloadCombinedAudio);

        /**
         * 拼接兩個音頻文件
         */
        async function concatAudioFiles() {
            const url1 = audioUrl1Input.value.trim();
            const url2 = audioUrl2Input.value.trim();
            
            // 驗證輸入
            if (!url1 || !url2) {
                showStatus('請輸入兩個有效的音頻URL', 'error');
                return;
            }
            
            showStatus('正在加載音頻文件...', 'info');
            
            try {
                // 1. Fetch請求獲取音頻資源,轉換為ArrayBuffer
                // 對同一個URL使用兩次fetch確保獲取完整數據
                const [buffer1, buffer2] = await Promise.all([
                    fetchAudioAsArrayBuffer(url1),
                    fetchAudioAsArrayBuffer(url2)
                ]);
                
                // 顯示原始音頻
                originalAudio1.src = url1;
                originalAudio2.src = url2;
                
                // 2. 將ArrayBuffer轉換為TypedArray (Uint8Array)
                const audio1 = new Uint8Array(buffer1);
                const audio2 = new Uint8Array(buffer2);
                
                showStatus('正在拼接音頻文件...', 'info');
                
                // 3. 拼接兩個TypedArray
                const combined = concatenateTypedArrays(audio1, audio2);
                
                // 4. 將拼接後的TypedArray轉換回ArrayBuffer
                const combinedBuffer = combined.buffer;
                
                // 5. 創建Blob對象
                const mimeType = 'audio/mpeg'; // 此URL的音頻為MP3格式
                const combinedBlob = new Blob([combinedBuffer], { type: mimeType });
                
                // 6. 創建Object URL
                combinedBlobUrl = URL.createObjectURL(combinedBlob);
                
                // 設置拼接後音頻的源
                combinedAudio.src = combinedBlobUrl;
                
                showStatus('音頻文件拼接成功!拼接後的音頻時長應為原始的2倍', 'success');
                downloadBtn.style.display = 'block';
                
            } catch (error) {
                console.error('音頻拼接失敗:', error);
                showStatus(`拼接失敗: ${error.message}`, 'error');
            }
        }

        /**
         * 使用fetch獲取音頻並轉換為ArrayBuffer
         */
        async function fetchAudioAsArrayBuffer(url) {
            try {
                const response = await fetch(url);
                if (!response.ok) {
                    throw new Error(`HTTP錯誤: ${response.status} ${response.statusText}`);
                }
                return response.arrayBuffer();
            } catch (error) {
                throw new Error(`無法加載音頻: ${error.message}`);
            }
        }

        /**
         * 拼接兩個TypedArray (Uint8Array)
         */
        function concatenateTypedArrays(a, b) {
            const result = new Uint8Array(a.length + b.length);
            result.set(a, 0);         // 將第一個數組放在開始位置
            result.set(b, a.length);  // 將第二個數組放在第一個數組後面
            return result;
        }

        /**
         * 下載拼接後的音頻
         */
        function downloadCombinedAudio() {
            if (!combinedBlobUrl) return;
            
            const a = document.createElement('a');
            a.href = combinedBlobUrl;
            a.download = 'combined-t-rex-roar.mp3';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
        }

        /**
         * 顯示狀態信息
         */
        function showStatus(message, type = 'info') {
            statusElement.textContent = message;
            statusElement.className = 'status';
            statusElement.classList.add(type);
        }

        // 頁面卸載時清理資源
        window.addEventListener('beforeunload', () => {
            if (combinedBlobUrl) {
                URL.revokeObjectURL(combinedBlobUrl);
            }
        });
    </script>
</body>
</html>