Files
bettafish-company/UIexample/Web/script.js
T

1168 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
let mapping = {};
const dbStorage = {
dbName: "vvSearchCache",
dbVersion: 1,
async init() {
if (this._dbPromise) return this._dbPromise;
this._dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = (event) => {
reject(event.target.error);
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains("indices"))
db.createObjectStore("indices", { keyPath: "key" });
if (!db.objectStoreNames.contains("mappings"))
db.createObjectStore("mappings", { keyPath: "key" });
if (!db.objectStoreNames.contains("databases"))
db.createObjectStore("databases", { keyPath: "key" });
};
});
return this._dbPromise;
},
async getItem(storeName, key) {
try {
await this.init();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, "readonly");
const store = transaction.objectStore(storeName);
const request = store.get(key);
request.onsuccess = () => {
resolve(request.result ? request.result.value : null);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
} catch (error) {
return null;
}
},
async setItem(storeName, key, value) {
try {
await this.init();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
const request = store.put({ key, value });
request.onsuccess = () => {
resolve();
};
request.onerror = (event) => {
reject(event.target.error);
};
});
} catch (error) {
throw error;
}
},
async removeItem(storeName, key) {
try {
await this.init();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
const request = store.delete(key);
request.onsuccess = () => {
resolve();
};
request.onerror = (event) => {
reject(event.target.error);
};
});
} catch (error) {
throw error;
}
},
async getAllKeys(storeName) {
try {
await this.init();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, "readonly");
const store = transaction.objectStore(storeName);
const request = store.getAllKeys();
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
} catch (error) {
return [];
}
},
};
const RequestController = {
queue: new Map(),
maxConcurrent: 4,
async enqueue(key, requestFn) {
if (this.queue.has(key)) {
return this.queue.get(key);
}
while (this.queue.size >= this.maxConcurrent) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
const promise = requestFn().finally(() => {
this.queue.delete(key);
});
this.queue.set(key, promise);
return promise;
},
};
const indexCache = {
data: new Map(),
preloadQueue: new Set(),
preloadPromises: new Map(),
async get(groupIndex, baseDir) {
const cacheKey = `${baseDir}/${groupIndex}`;
if (this.data.has(cacheKey)) {
return this.data.get(cacheKey);
}
if (this.preloadPromises.has(cacheKey)) {
return this.preloadPromises.get(cacheKey);
}
return RequestController.enqueue(cacheKey, async () => {
try {
if (this.data.has(cacheKey)) {
return this.data.get(cacheKey);
}
const cachedData = await dbStorage.getItem("indices", cacheKey);
if (cachedData) {
const arrayBuffer = this._base64ToArrayBuffer(cachedData);
this.data.set(cacheKey, arrayBuffer);
return arrayBuffer;
}
const indexData = await this._fetchIndex(groupIndex, baseDir);
this.data.set(cacheKey, indexData);
this._saveToCache(cacheKey, indexData).catch(() => {});
return indexData;
} catch (error) {
console.error(`Failed to load index ${cacheKey}:`, error);
throw error;
}
});
},
async _saveToCache(cacheKey, data) {
const base64Data = this._arrayBufferToBase64(data);
await dbStorage.setItem("indices", cacheKey, base64Data);
},
async preload(groupIndex, baseDir) {
const cacheKey = `${baseDir}/${groupIndex}`;
if (this.data.has(cacheKey) || this.preloadQueue.has(cacheKey)) {
return;
}
this.preloadQueue.add(cacheKey);
const promise = this.get(groupIndex, baseDir)
.catch(() => {})
.finally(() => {
this.preloadQueue.delete(cacheKey);
this.preloadPromises.delete(cacheKey);
});
this.preloadPromises.set(cacheKey, promise);
},
async _fetchIndex(groupIndex, baseDir) {
const cacheKey = `${baseDir}/${groupIndex}`;
const indexUrl = `${baseDir}/${groupIndex}.index`;
try {
const indexResponse = await fetch(indexUrl, {
method: "GET",
mode: "cors",
credentials: "omit",
cache: "no-cache",
headers: {
Accept: "application/octet-stream",
},
referrerPolicy: "no-referrer",
});
if (!indexResponse.ok) {
throw new Error(
`Failed to fetch index: ${indexResponse.status} ${indexResponse.statusText}`,
);
}
const headers = Object.fromEntries(indexResponse.headers.entries());
const contentType = headers["content-type"];
const compressedData = await indexResponse.arrayBuffer();
if (compressedData.byteLength === 0) {
throw new Error("Received empty response");
}
const header = new Uint8Array(compressedData.slice(0, 2));
let decompressedData;
if (header[0] === 0x1f && header[1] === 0x8b) {
try {
const ds = new DecompressionStream("gzip");
const decompressedStream = new Response(
compressedData,
).body.pipeThrough(ds);
decompressedData = await new Response(
decompressedStream,
).arrayBuffer();
} catch (error) {
console.error("Decompression failed:", error);
throw error;
}
} else {
decompressedData = compressedData;
}
if (decompressedData.byteLength < 16) {
throw new Error("Data too small");
}
const view = new DataView(decompressedData);
const gridW = view.getUint32(0, true);
const gridH = view.getUint32(4, true);
const folderCount = view.getUint32(8, true);
if (gridW === 0 || gridH === 0 || folderCount === 0) {
throw new Error("Invalid index format");
}
return decompressedData;
} catch (error) {
console.error(`Failed to fetch or process index ${indexUrl}:`, error);
throw error;
}
},
_arrayBufferToBase64(buffer) {
const binary = [];
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++)
binary.push(String.fromCharCode(bytes[i]));
return btoa(binary.join(""));
},
_base64ToArrayBuffer(base64) {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binaryString.charCodeAt(i);
return bytes.buffer;
},
_cleanupLocalStorage() {
try {
const cacheKeys = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith("indexCache_")) cacheKeys.push(key);
}
if (cacheKeys.length > 20)
for (let i = 0; i < 5; i++) localStorage.removeItem(cacheKeys[i]);
} catch (e) {}
},
};
const watermarkImage = new Image();
watermarkImage.src = "watermark.png";
let watermarkLoaded = false;
watermarkImage.onload = () => {
watermarkLoaded = true;
};
async function extractFrame(folderId, frameNum, baseDir = "") {
const groupIndex = Math.floor((folderId - 1) / 10);
const requestKey = `${baseDir}/${groupIndex}/${folderId}/${frameNum}`;
return RequestController.enqueue(requestKey, async () => {
try {
if (!baseDir) {
throw new Error("Base directory is required");
}
const indexData = await indexCache.get(groupIndex, baseDir);
const dataView = new DataView(indexData);
let offset = 0;
const gridW = dataView.getUint32(offset, true);
offset += 4;
const gridH = dataView.getUint32(offset, true);
offset += 4;
const folderCount = dataView.getUint32(offset, true);
offset += 4;
offset += folderCount * 4;
const fileCount = dataView.getUint32(offset, true);
offset += 4;
let left = 0;
let right = fileCount - 1;
let startOffset = null;
let endOffset = null;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const recordOffset = offset + mid * 16;
const currFolder = dataView.getUint32(recordOffset, true);
const currFrame = dataView.getUint32(recordOffset + 4, true);
const currFileOffset = Number(
dataView.getBigUint64(recordOffset + 8, true),
);
if (currFolder === folderId && currFrame === frameNum) {
startOffset = currFileOffset;
if (mid < fileCount - 1) {
endOffset = Number(dataView.getBigUint64(recordOffset + 24, true));
}
break;
} else if (
currFolder < folderId ||
(currFolder === folderId && currFrame < frameNum)
) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (startOffset === null) {
throw new Error(`Frame ${frameNum} not found in folder ${folderId}`);
}
const imageUrl = `${baseDir}/${groupIndex}.webp`;
const response = await fetch(imageUrl, {
method: "GET",
headers: {
Range: `bytes=${startOffset}-${endOffset ? endOffset - 1 : ""}`,
},
mode: "cors",
credentials: "omit",
referrerPolicy: "no-referrer",
});
if (response.status === 416 || !response.ok) {
const fullResponse = await fetch(imageUrl, {
method: "GET",
mode: "cors",
credentials: "omit",
referrerPolicy: "no-referrer",
});
if (!fullResponse.ok) {
throw new Error(
`HTTP error: ${fullResponse.status} ${fullResponse.statusText}`,
);
}
return new Blob([await fullResponse.blob()], { type: "image/webp" });
}
const data = await response.blob();
if (!data || data.size === 0) {
throw new Error("Empty response");
}
return new Blob([data], { type: "image/webp" });
} catch (error) {
console.error(
`Error extracting frame ${frameNum} from folder ${folderId}:`,
error,
);
throw new Error(`Failed to load preview image: ${error.message}`);
}
});
}
async function loadMapping() {
try {
const cachedMapping = await dbStorage.getItem("mappings", "mapping");
if (cachedMapping) return cachedMapping;
try {
const localMapping = localStorage.getItem("mapping");
if (localMapping) {
const mappingData = JSON.parse(localMapping);
try {
await dbStorage.setItem("mappings", "mapping", mappingData);
localStorage.removeItem("mapping");
} catch (e) {}
return mappingData;
}
} catch (e) {}
const response = await fetch("./mapping.json", {
referrerPolicy: "no-referrer",
mode: "cors",
credentials: "omit",
});
if (!response.ok) throw new Error("Failed to load mapping.json");
const mappingData = await response.json();
try {
await dbStorage.setItem("mappings", "mapping", mappingData);
} catch (e) {
try {
localStorage.setItem("mapping", JSON.stringify(mappingData));
} catch (e) {}
}
return mappingData;
} catch (error) {
return {};
}
}
const AppState = {
isSearching: false,
randomStringDisplayed: false,
searchResults: [],
currentPage: 1,
itemsPerPage: 20,
hasMoreResults: true,
cachedResults: [],
displayedCount: 0,
showWatermark: true,
dbLoaded: false,
dbLoading: false,
};
const CONFIG = {
randomStrings: [
"\u63a2\u7d22VV\u7684\u5f00\u6e90\u4e16\u754c",
"\u4e3a\u4e1c\u5927\u52a9\u529b",
"\u641c\u7d22\u4f60\u60f3\u8981\u7684\u5185\u5bb9",
],
apiBaseUrl: "https://vvapi.cicada000.work",
semanticApiUrl: "https://vvapi.cicada000.work",
imageBaseUrl: "https://vv.noxylva.org",
watermarkPath: "watermark.png",
indexPreloadCount: 26
};
class UIController {
static updateSearchFormPosition(isSearching) {
const searchForm = document.getElementById("searchForm");
const randomStringDisplay = document.getElementById("randomStringDisplay");
if (isSearching) {
searchForm.classList.add("searching");
if (!AppState.randomStringDisplayed) this.showRandomString();
} else {
searchForm.classList.remove("searching");
this.clearRandomString();
}
}
static showRandomString() {
if (!AppState.randomStringDisplayed) {
const randomStringDisplay = document.getElementById(
"randomStringDisplay",
);
const randomIndex = Math.floor(
Math.random() * CONFIG.randomStrings.length,
);
randomStringDisplay.textContent = CONFIG.randomStrings[randomIndex];
AppState.randomStringDisplayed = true;
randomStringDisplay.classList.remove("fade-out");
randomStringDisplay.classList.add("fade-in");
}
}
static clearRandomString() {
const randomStringDisplay = document.getElementById("randomStringDisplay");
randomStringDisplay.classList.remove("fade-in");
randomStringDisplay.classList.add("fade-out");
setTimeout(() => {
randomStringDisplay.textContent = "";
AppState.randomStringDisplayed = false;
}, 300);
}
}
class SearchController {
static validateSearchInput(query) {
return query && query.trim().length > 0;
}
static async performSearch(query, minRatio, minSimilarity) {
const isSemanticSearch = document
.getElementById("semanticToggle")
.classList.contains("active");
if (!isSemanticSearch) {
if (window.subtitleDB && window.subtitleDB.isLoaded) {
try {
const localResults = await window.subtitleDB.search(
query,
minRatio,
minSimilarity,
);
if (localResults && Array.isArray(localResults)) {
return {
status: "success",
data: localResults,
count: localResults.length,
};
} else if (
localResults &&
localResults.status === "success" &&
Array.isArray(localResults.data)
) {
return localResults;
}
} catch (error) {
console.log("本地搜索失败,使用vvapi", error);
}
}
const vvapiUrl = `${CONFIG.apiBaseUrl}/search?query=${encodeURIComponent(query)}&min_ratio=${minRatio}&min_similarity=${minSimilarity}`;
try {
console.log("使用普通搜索:", vvapiUrl);
const response = await fetch(vvapiUrl);
if (!response.ok)
throw new Error(
`API 请求失败: ${response.status} ${response.statusText}`,
);
const text = await response.text();
const lines = text.trim().split("\n");
const results = [];
for (const line of lines) {
try {
if (line.trim()) {
const item = JSON.parse(line);
results.push(item);
}
} catch (e) {}
}
return {
status: "success",
data: results,
count: results.length,
};
} catch (error) {
throw error;
}
}
const emuUrl = `${CONFIG.semanticApiUrl}/search?query=${encodeURIComponent(query)}&min_ratio=${minRatio}&min_similarity=${minSimilarity}&rag=true`;
try {
console.log("使用语义搜索:", emuUrl);
const response = await fetch(emuUrl);
if (!response.ok)
throw new Error(
`API 请求失败: ${response.status} ${response.statusText}`,
);
const text = await response.text();
const lines = text.trim().split("\n");
const results = [];
for (const line of lines) {
try {
if (line.trim()) {
const item = JSON.parse(line);
if (item.filename && !item.filename.endsWith('.json')) {
item.filename = item.filename + '.json';
}
results.push(item);
}
} catch (e) {}
}
return {
status: "success",
data: results,
count: results.length,
};
} catch (error) {
throw error;
}
}
}
async function handleSearch(event) {
event.preventDefault();
const query = document.getElementById("query").value.trim();
if (!query) return;
const minRatio = parseInt(document.getElementById("minRatio").value) || 50;
const minSimilarity =
parseFloat(document.getElementById("minSimilarity").value) || 0;
const searchForm = document.getElementById("searchForm");
searchForm.classList.add("searching");
startNaturalLoadingBar();
try {
const results = await SearchController.performSearch(
query,
minRatio,
minSimilarity,
);
if (results && results.status === "success") {
AppState.cachedResults = results.data;
AppState.hasMoreResults = results.data.length > AppState.itemsPerPage;
AppState.displayedCount = 0;
displayResults(results);
completeLoadingBar();
} else {
throw new Error("Invalid search results format");
}
} catch (error) {
console.error("Search error:", error);
document.getElementById("errorDisplay").textContent =
`搜索失败: ${error.message}`;
document.getElementById("errorDisplay").style.display = "block";
completeLoadingBar();
} finally {
enableKeywordTags();
searchForm.classList.remove("searching");
}
}
async function initializeApp() {
try {
await dbStorage.init().catch((error) => {});
mapping = await loadMapping();
initializeScrollListener();
for (let i = 0; i <= CONFIG.indexPreloadCount; i++) {
indexCache.preload(i, CONFIG.imageBaseUrl).catch((error) => {});
}
if (
window.subtitleDB &&
!window.subtitleDB.isLoaded &&
!window.subtitleDB.isLoading
) {
window.subtitleDB
.load()
.then(() => {
AppState.dbLoaded = true;
})
.catch((error) => {
setTimeout(() => {
window.subtitleDB.load().catch((err) => {});
}, 3000);
});
}
document
.getElementById("searchForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
if (AppState.isSearching) return;
AppState.isSearching = true;
try {
await handleSearch(e);
} finally {
AppState.isSearching = false;
}
});
document.getElementById("query").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
if (AppState.isSearching) return;
document
.getElementById("searchForm")
.dispatchEvent(new Event("submit"));
}
});
document
.getElementById("refreshDiv")
.addEventListener("click", function () {
location.reload();
});
document
.getElementById("semanticToggle")
.addEventListener("click", function () {
this.classList.toggle("active");
});
} catch (error) {}
}
document.addEventListener("DOMContentLoaded", () => {
const loadingBar = document.getElementById("loadingBar");
if (loadingBar) {
loadingBar.style.width = "0%";
loadingBar.style.display = "none";
}
initializeApp();
const toggleButton = document.getElementById("toggleAdvancedOptions");
const advancedOptions = document.getElementById("advancedOptions");
toggleButton.addEventListener("click", () => {
const isExpanded = advancedOptions.classList.contains("show");
if (!isExpanded) {
advancedOptions.style.transition = "none";
advancedOptions.classList.add("show");
const height = advancedOptions.scrollHeight;
advancedOptions.classList.remove("show");
void advancedOptions.offsetHeight;
advancedOptions.style.transition = "";
advancedOptions.style.maxHeight = height + "px";
advancedOptions.classList.add("show");
} else {
advancedOptions.style.maxHeight = "0";
advancedOptions.classList.remove("show");
}
toggleButton.classList.toggle("active");
toggleButton.setAttribute("aria-expanded", !isExpanded);
});
const semanticToggle = document.getElementById("semanticToggle");
const semanticTooltip = document.getElementById("semanticTooltip");
semanticToggle.addEventListener("mouseenter", () => {
semanticTooltip.classList.add("visible");
});
semanticToggle.addEventListener("mouseleave", () => {
semanticTooltip.classList.remove("visible");
});
const watermarkToggle = document.getElementById("watermarkToggle");
watermarkToggle.addEventListener("change", () => {
AppState.showWatermark = watermarkToggle.checked;
if (window.canvasRenderQueue)
window.canvasRenderQueue.forEach((canvas) => {
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(canvas.originalCanvas, 0, 0);
if (AppState.showWatermark && watermarkLoaded) {
const watermarkScale = (canvas.width * 0.25) / watermarkImage.width;
const watermarkWidth = watermarkImage.width * watermarkScale;
const watermarkHeight = watermarkImage.height * watermarkScale;
ctx.drawImage(
watermarkImage,
canvas.width - watermarkWidth - 5,
canvas.height - watermarkHeight - 5,
watermarkWidth,
watermarkHeight,
);
}
});
});
});
function displayResults(data, append = false) {
const resultsDiv = document.getElementById("results");
const keywordsContainer = document.getElementById("keywordsContainer");
document.getElementById("errorDisplay").style.display = "none";
if (!append) {
resultsDiv.innerHTML = "";
AppState.displayedCount = 0;
keywordsContainer.innerHTML = "";
keywordsContainer.classList.remove("show");
}
if (!append && data.data.length > 0 && data.data[0].type === "keywords") {
const keywords = data.data[0].keywords;
if (keywords && keywords.length > 0) {
keywordsContainer.innerHTML = `
<div class="keywords-tags">
${keywords.map(keyword => `<span class="keyword-tag">${keyword}</span>`).join("")}
</div>
`;
keywordsContainer.classList.add("show");
keywordsContainer.querySelectorAll('.keyword-tag').forEach(tag => {
tag.addEventListener('click', () => {
if (AppState.isSearching) return;
const keyword = tag.textContent;
document.getElementById('query').value = keyword;
keywordsContainer.querySelectorAll('.keyword-tag').forEach(t => {
t.classList.add('disabled');
});
document.getElementById('searchForm').dispatchEvent(new Event('submit'));
});
});
}
data.data = data.data.slice(1);
}
if (data.data && data.data.length === 1 && data.data[0].count === 0) {
const noResultData = data.data[0];
console.log("No results case:", {
message: noResultData.message,
suggestions: noResultData.suggestions,
});
if (!append) {
const message =
noResultData.message ||
`未找到与 "${document.getElementById("query").value.trim()}" 匹配的结果`;
const suggestions = noResultData.suggestions || [
"检查输入是否正确",
`尝试降低最小匹配率(当前:${document.getElementById("minRatio").value}%`,
`尝试降低最小相似度(当前:${document.getElementById("minSimilarity").value}`,
"尝试使用更简短的关键词",
];
resultsDiv.innerHTML = `
<div class="error-message">
<h3>${message}</h3>
<p>建议:</p>
<ul>
${suggestions.map((suggestion) => `<li>${suggestion}</li>`).join("")}
</ul>
</div>`;
}
AppState.hasMoreResults = false;
return;
}
const fragment = document.createDocumentFragment();
const startIndex = AppState.displayedCount;
const endIndex = Math.min(
startIndex + AppState.itemsPerPage,
data.data.length,
);
const newResults = data.data.slice(startIndex, endIndex);
AppState.hasMoreResults = endIndex < data.data.length;
const cards = newResults
.map((result) => {
if (!result || typeof result !== "object") return null;
const card = document.createElement("div");
card.className = "result-card";
card.addEventListener("click", () => handleCardClick(result));
card.style.cursor = "pointer";
const episodeMatch = result.filename
? result.filename.match(/\[P(\d+)\]/)
: null;
const timeMatch = result.timestamp
? result.timestamp.match(/^(\d+)m(\d+)s$/)
: null;
const cleanFilename = result.filename
? result.filename
.replace(/\[P(\d+)\].*?\s+/, "P$1 ")
.replace(/\.json$/, "")
.trim()
: "";
const cardContent = `
<div class="result-content">
<h3>${episodeMatch ? `<span class="tag">${episodeMatch[1]}</span>${cleanFilename.replace(/P\d+/, "").trim()}` : cleanFilename}</h3>
<p class="result-text">${result.text || ""}</p>
${
result.timestamp
? `
<p class="result-meta">
${result.timestamp} \u00b7
\u5339\u914d\u5ea6 ${result.match_ratio ? parseFloat(result.match_ratio).toFixed(1) : 0}% \u00b7
\u76f8\u4f3c\u5ea6 ${result.similarity ? (result.similarity * 100).toFixed(1) : 0}%
</p>`
: ""
}
</div>
`;
card.innerHTML = cardContent;
return card;
})
.filter(Boolean);
cards.forEach((card) => fragment.appendChild(card));
resultsDiv.appendChild(fragment);
requestAnimationFrame(() => {
cards.forEach((card, index) => {
const result = newResults[index];
loadPreviewImage(card, result);
});
});
AppState.displayedCount = endIndex;
if (AppState.hasMoreResults) {
let trigger = document.getElementById("scroll-trigger");
if (!trigger) {
trigger = document.createElement("div");
trigger.id = "scroll-trigger";
trigger.style.cssText = "height: 20px; margin: 20px 0;";
if (window.currentObserver) {
window.currentObserver.observe(trigger);
}
}
resultsDiv.appendChild(trigger);
}
}
async function loadPreviewImage(card, result) {
const episodeMatch = result.filename?.match(/\[P(\d+)\]/);
const timeMatch = result.timestamp?.match(/^(\d+)m(\d+)s$/);
if (!episodeMatch || !timeMatch) return;
const episodeNum = parseInt(episodeMatch[1], 10);
const minutes = parseInt(timeMatch[1]);
const seconds = parseInt(timeMatch[2]);
const totalSeconds = minutes * 60 + seconds;
const imgContainer = document.createElement("div");
imgContainer.className = "preview-frame-container";
const placeholder = document.createElement("div");
placeholder.className = "preview-frame-placeholder";
imgContainer.appendChild(placeholder);
card.insertBefore(imgContainer, card.firstChild);
try {
const imageBlob = await extractFrame(
episodeNum,
totalSeconds,
CONFIG.imageBaseUrl,
);
const imageUrl = URL.createObjectURL(imageBlob);
const img = new Image();
img.src = imageUrl;
img.className = "preview-frame";
img.decoding = "async";
img.onerror = () => {
console.error("Failed to load preview image");
imgContainer.remove();
URL.revokeObjectURL(imageUrl);
};
img.onload = () => {
const originalCanvas = document.createElement("canvas");
originalCanvas.width = img.width;
originalCanvas.height = img.height;
try {
const originalCtx = originalCanvas.getContext("2d");
if (!originalCtx) {
throw new Error("Failed to get canvas context");
}
originalCtx.drawImage(img, 0, 0);
const displayCanvas = document.createElement("canvas");
displayCanvas.width = img.width;
displayCanvas.height = img.height;
displayCanvas.className = "preview-frame";
displayCanvas.originalCanvas = originalCanvas;
const renderCanvas = () => {
const ctx = displayCanvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get display canvas context");
}
ctx.clearRect(0, 0, displayCanvas.width, displayCanvas.height);
ctx.drawImage(originalCanvas, 0, 0);
if (watermarkLoaded && AppState.showWatermark) {
const watermarkScale =
(displayCanvas.width * 0.25) / watermarkImage.width;
const watermarkWidth = watermarkImage.width * watermarkScale;
const watermarkHeight = watermarkImage.height * watermarkScale;
ctx.drawImage(
watermarkImage,
displayCanvas.width - watermarkWidth - 5,
displayCanvas.height - watermarkHeight - 5,
watermarkWidth,
watermarkHeight,
);
}
};
renderCanvas();
if (!window.canvasRenderQueue) {
window.canvasRenderQueue = new Set();
}
window.canvasRenderQueue.add(displayCanvas);
displayCanvas.addEventListener("click", (e) => {
e.stopPropagation();
displayCanvas.toBlob((blob) => {
if (!blob) {
console.error("Failed to create image blob");
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `VV_${result.filename.replace(/[^\w\s-]/g, "")}_${result.timestamp}.png`;
a.click();
URL.revokeObjectURL(url);
}, "image/png");
});
// 添加复制按钮
const copyButton = document.createElement("button");
copyButton.className = "copy-button";
copyButton.title = "复制图片";
copyButton.innerHTML = `<img src="copy.svg" alt="复制" class="copy-icon">`;
copyButton.addEventListener("click", (e) => {
e.stopPropagation();
displayCanvas.toBlob(async (blob) => {
if (!blob) {
console.error("Failed to create image blob");
return;
}
try {
// 尝试使用Clipboard API复制图片
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
]);
// 显示成功提示
const toast = document.createElement("div");
toast.className = "copy-toast";
toast.textContent = "已复制到剪贴板";
document.body.appendChild(toast);
// 2秒后移除提示
setTimeout(() => {
toast.classList.add("fade-out");
setTimeout(() => toast.remove(), 300);
}, 2000);
} catch (err) {
console.error("复制失败:", err);
alert("复制失败,请使用更新的浏览器或手动保存图片");
}
}, "image/png");
});
imgContainer.appendChild(copyButton);
imgContainer.appendChild(displayCanvas);
setTimeout(() => {
displayCanvas.classList.add("loaded");
placeholder.style.opacity = "0";
setTimeout(() => placeholder.remove(), 300);
}, 50);
} catch (error) {
console.error("Canvas error:", error);
imgContainer.remove();
}
URL.revokeObjectURL(imageUrl);
};
} catch (error) {
console.error("加载预览图失败:", error);
imgContainer.remove();
}
}
function getEpisodeUrl(filename) {
for (let key in mapping) if (mapping[key] === filename) return key;
return null;
}
function startNaturalLoadingBar() {
const loadingBar = document.getElementById("loadingBar");
loadingBar.style.transition = "";
loadingBar.style.width = "0%";
loadingBar.style.display = "block";
if (loadingBar.interval) clearInterval(loadingBar.interval);
let progress = 0;
const targetProgress = 95;
let speed = 0.5;
loadingBar.interval = setInterval(() => {
if (progress < 30) speed = 0.8;
else if (progress < 60) speed = 0.4;
else if (progress < 80) speed = 0.2;
else speed = 0.1;
progress += speed;
if (progress >= targetProgress) {
clearInterval(loadingBar.interval);
progress = targetProgress;
}
loadingBar.style.width = `${progress}%`;
}, 50);
}
function completeLoadingBar() {
const loadingBar = document.getElementById("loadingBar");
clearInterval(loadingBar.interval);
loadingBar.style.transition = "width 0.3s ease-out";
loadingBar.style.width = "100%";
}
function initializeScrollListener() {
if (window.currentObserver) window.currentObserver.disconnect();
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (
entry.isIntersecting &&
AppState.hasMoreResults &&
!AppState.isSearching
) {
if (AppState.cachedResults.length > AppState.displayedCount) {
displayResults(
{
status: "success",
data: AppState.cachedResults,
count: AppState.cachedResults.length,
},
true,
);
}
}
});
},
{ root: null, rootMargin: "200px", threshold: 0.1 },
);
window.currentObserver = observer;
const oldTrigger = document.getElementById("scroll-trigger");
if (oldTrigger) oldTrigger.remove();
const trigger = document.createElement("div");
trigger.id = "scroll-trigger";
trigger.style.cssText = "height: 20px; margin: 20px 0;";
document.getElementById("results").appendChild(trigger);
observer.observe(trigger);
}
function handleCardClick(result) {
const episodeMatch = result.filename.match(/\[P(\d+)\]/);
const timeMatch = result.timestamp.match(/^(\d+)m(\d+)s$/);
if (episodeMatch && timeMatch) {
const episodeNum = parseInt(episodeMatch[1], 10);
const minutes = parseInt(timeMatch[1]);
const seconds = parseInt(timeMatch[2]);
const totalSeconds = minutes * 60 + seconds;
for (const [url, filename] of Object.entries(mapping))
if (filename === result.filename) {
const videoUrl = `https://www.bilibili.com${url}?t=${totalSeconds}`;
window.open(videoUrl, "_blank");
break;
}
}
}
function enableKeywordTags() {
const keywordsContainer = document.getElementById("keywordsContainer");
keywordsContainer.querySelectorAll('.keyword-tag').forEach(tag => {
tag.classList.remove('disabled');
});
}