初始提交:短剧库源码(含转码优化 ultrafast)
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
window.JukuRankings = {init};
|
||||
|
||||
function init({api, post, getSource, getDrama = () => null, sourceLabel, onLibraryChanged, onDownloadsChanged, getDownloadQuality = () => 0, canDownload = () => true, play}) {
|
||||
const $ = id => document.getElementById(id);
|
||||
const dialog = $('rankingPanel');
|
||||
const list = $('rankingList');
|
||||
const tabs = $('rankingTabs');
|
||||
let boards = [], board = null, entries = [], page = 0, hasMore = false;
|
||||
let busy = false, controller = null, sequence = 0, changed = false, fetchedAt = '', updatedText = '';
|
||||
const lastBoards = new Map(), submitted = new Set(), submitting = new Set();
|
||||
|
||||
function node(tag, className, text) {
|
||||
const result = document.createElement(tag);
|
||||
if (className) result.className = className;
|
||||
if (text !== undefined) result.textContent = text;
|
||||
return result;
|
||||
}
|
||||
function action(text, handler) {
|
||||
const result = node('button', 'secondary', text);
|
||||
result.type = 'button';
|
||||
result.addEventListener('click', handler);
|
||||
return result;
|
||||
}
|
||||
function cancel() {
|
||||
sequence++;
|
||||
if (controller) controller.abort();
|
||||
controller = null;
|
||||
busy = false;
|
||||
}
|
||||
function controls() {
|
||||
$('refreshRankingBtn').disabled = busy;
|
||||
$('rankingMoreBtn').disabled = busy;
|
||||
$('rankingMoreBtn').hidden = !hasMore;
|
||||
$('rankingMoreBtn').textContent = busy ? '加载中…' : '加载更多';
|
||||
$('rankingContent').setAttribute('aria-busy', String(busy));
|
||||
}
|
||||
function state(text) {
|
||||
list.replaceChildren(node('li', 'ranking-state', text));
|
||||
}
|
||||
function setError(text) { $('rankingError').textContent = text || ''; }
|
||||
function renderEntries() {
|
||||
const visible = entries.filter(item => window.JukuVIP.visible(dramaFor(item)));
|
||||
list.replaceChildren(...visible.map(renderEntry));
|
||||
if (!visible.length) state(entries.length ? '本页 VIP 内容已隐藏,可将 VIP 开关切换为“显示”或继续加载。' : '此榜单暂时没有条目');
|
||||
}
|
||||
window.addEventListener('jukuvipfilterchange', event => {if (dialog.open) {renderEntries(); updateSummary(false); if (event.detail?.interactive) event.detail.priority = entries.map(item => item.drama.id);}});
|
||||
function updateSummary(stale) {
|
||||
$('rankingSummary').textContent = (updatedText || board?.description || '按站点名次展示') + (entries.length ? ' · 显示 ' + entries.filter(item => window.JukuVIP.visible(dramaFor(item))).length + ' / ' + entries.length + ' 部' : '') + (board?.source === 'huangdou' ? ' · ' + window.JukuVIP.summary(entries.map(dramaFor)) : '');
|
||||
$('rankingFetchedAt').textContent = fetchedAt ? (stale ? '上次获取:' : '获取于 ') + new Date(fetchedAt).toLocaleString() : '';
|
||||
}
|
||||
|
||||
function dramaFor(item) {
|
||||
const known = getDrama(item.drama.id) || {};
|
||||
if ((Date.parse(known.sortMetadata?.checkedAt) || 0) > (Date.parse(fetchedAt) || 0)) return {...item.drama, ...known};
|
||||
const drama = {...known, ...item.drama};
|
||||
for (const [key, value] of Object.entries(known)) {if (drama[key] == null || drama[key] === '') drama[key] = value;}
|
||||
return drama;
|
||||
}
|
||||
window.addEventListener('jukulibrarychange', () => {if (dialog.open) {renderEntries(); updateSummary(false);}});
|
||||
|
||||
function renderEntry(item) {
|
||||
const drama = dramaFor(item), title = drama.title || drama.name || '短剧';
|
||||
const row = node('li', 'ranking-row');
|
||||
row.value = item.rank;
|
||||
const rank = node('span', 'ranking-number' + (item.rank <= 3 ? ' top' : ''), String(item.rank).padStart(2, '0'));
|
||||
rank.setAttribute('aria-label', '第 ' + item.rank + ' 名');
|
||||
const content = node('div');
|
||||
const heading = node('div', 'ranking-title', title);
|
||||
heading.title = title;
|
||||
content.appendChild(heading);
|
||||
const count = drama.totalEpisode || drama.episodeCount;
|
||||
const details = [window.JukuVIP.isVIP(drama) ? 'VIP · 仅试看' : '', item.metric, drama.remark || (count ? count + ' 集' : ''), drama.categoryName].filter(Boolean);
|
||||
if (details.length) content.appendChild(node('div', 'ranking-meta', details.join(' · ')));
|
||||
const actions = node('div', 'ranking-actions');
|
||||
const watchLabel = window.JukuHistory?.get(drama.id) ? '继续观看' : '播放';
|
||||
const watch = action(watchLabel, () => { dialog.close(); play(drama.id, title); });
|
||||
watch.setAttribute('aria-label', watchLabel + ' ' + title);
|
||||
const download = action(submitted.has(drama.id) ? '已提交' : '下载', async () => {
|
||||
if (!canDownload() || submitting.has(drama.id) || submitted.has(drama.id)) return;
|
||||
submitting.add(drama.id);
|
||||
download.disabled = true;
|
||||
download.textContent = '提交中';
|
||||
try {
|
||||
await post('/api/ui/download', {ids: [drama.id], quality: getDownloadQuality()});
|
||||
submitted.add(drama.id);
|
||||
download.textContent = '已提交';
|
||||
$('rankingActionStatus').textContent = '已将《' + title + '》加入下载队列';
|
||||
onDownloadsChanged();
|
||||
} catch (error) {
|
||||
download.textContent = '下载';
|
||||
download.disabled = false;
|
||||
$('rankingActionStatus').textContent = '加入下载失败:' + error.message;
|
||||
} finally {
|
||||
submitting.delete(drama.id);
|
||||
}
|
||||
});
|
||||
download.disabled = submitted.has(drama.id) || submitting.has(drama.id);
|
||||
download.setAttribute('aria-label', '下载 ' + title);
|
||||
actions.appendChild(watch);
|
||||
if (canDownload()) actions.appendChild(download);
|
||||
row.append(rank, content, actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function loadPage(nextPage, refresh = false) {
|
||||
if (!board) return;
|
||||
cancel();
|
||||
const ticket = sequence, activeBoard = board;
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
busy = true;
|
||||
setError('');
|
||||
$('rankingActionStatus').textContent = '';
|
||||
if (!entries.length) state('正在获取站点榜单…');
|
||||
controls();
|
||||
try {
|
||||
const result = await api('/api/ui/rankings?board=' + encodeURIComponent(activeBoard.id) + '&page=' + nextPage + (refresh ? '&refresh=1' : ''), {signal});
|
||||
if (ticket !== sequence || !dialog.open) return;
|
||||
if (result.boardId !== activeBoard.id || result.page !== nextPage || !Array.isArray(result.items)) throw new Error('榜单响应不完整,请刷新重试');
|
||||
if (nextPage > 1 && result.stale) throw new Error('此页暂时只能取得旧榜单,请刷新后继续');
|
||||
const incoming = result.items;
|
||||
let previousRank = nextPage > 1 && entries.length ? entries[entries.length - 1].rank : 0;
|
||||
const ids = new Set(nextPage > 1 ? entries.map(item => item.drama.id) : []);
|
||||
for (const item of incoming) {
|
||||
if (!item.drama?.id || !Number.isInteger(item.rank) || item.rank <= previousRank || ids.has(item.drama.id)) throw new Error('榜单顺序已变化,请刷新后继续');
|
||||
previousRank = item.rank;
|
||||
ids.add(item.drama.id);
|
||||
}
|
||||
if (nextPage > 1 && updatedText && result.updatedText && result.updatedText !== updatedText) throw new Error('站点榜单已更新,请刷新后继续');
|
||||
if (nextPage === 1) {
|
||||
entries = [];
|
||||
list.replaceChildren();
|
||||
$('rankingContent').scrollTop = 0;
|
||||
fetchedAt = result.fetchedAt || '';
|
||||
updatedText = result.updatedText || '';
|
||||
}
|
||||
entries.push(...incoming);
|
||||
renderEntries();
|
||||
page = nextPage;
|
||||
hasMore = Boolean(result.hasMore) && incoming.length > 0 && !result.stale;
|
||||
changed = changed || incoming.length > 0;
|
||||
if (!entries.length) state('此榜单暂时没有条目');
|
||||
updateSummary(result.stale);
|
||||
setError([result.warning, result.saved === false && incoming.length ? '剧库缓存未保存,本次仍可播放和下载。' : ''].filter(Boolean).join(' '));
|
||||
} catch (error) {
|
||||
if (ticket !== sequence || signal.aborted) return;
|
||||
setError(error.message + (entries.length ? ';已保留当前列表。' : ''));
|
||||
if (!entries.length) state('暂时无法取得榜单,点击“刷新”重试');
|
||||
} finally {
|
||||
if (ticket === sequence) { busy = false; controller = null; controls(); }
|
||||
}
|
||||
}
|
||||
|
||||
function selectBoard(id) {
|
||||
board = boards.find(item => item.id === id);
|
||||
if (!board) return;
|
||||
$('rankingVipFilterBtn').hidden = board.source !== 'huangdou';
|
||||
lastBoards.set(board.source, board.id);
|
||||
entries = []; page = 0; hasMore = false; fetchedAt = ''; updatedText = '';
|
||||
for (const tab of tabs.children) {
|
||||
const active = tab.dataset.board === id;
|
||||
tab.setAttribute('aria-selected', String(active));
|
||||
tab.tabIndex = active ? 0 : -1;
|
||||
}
|
||||
$('rankingContent').setAttribute('aria-labelledby', 'ranking-tab-' + id);
|
||||
updateSummary(false);
|
||||
loadPage(1);
|
||||
}
|
||||
|
||||
function selectSource() {
|
||||
const source = $('rankingSource').value;
|
||||
const available = boards.filter(item => item.source === source);
|
||||
tabs.replaceChildren();
|
||||
for (const item of available) {
|
||||
const tab = action(item.name, () => selectBoard(item.id));
|
||||
tab.id = 'ranking-tab-' + item.id;
|
||||
tab.dataset.board = item.id;
|
||||
tab.setAttribute('role', 'tab');
|
||||
tab.setAttribute('aria-controls', 'rankingContent');
|
||||
tabs.appendChild(tab);
|
||||
}
|
||||
selectBoard(available.find(item => item.id === lastBoards.get(source))?.id || available[0]?.id);
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
cancel();
|
||||
const ticket = sequence;
|
||||
busy = true;
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
controls();
|
||||
setError('');
|
||||
state('正在读取可用榜单…');
|
||||
try {
|
||||
if (!boards.length) {
|
||||
const result = await api('/api/ui/rankings', {signal});
|
||||
if (ticket !== sequence || !dialog.open) return;
|
||||
boards = Array.isArray(result.boards) ? result.boards : [];
|
||||
}
|
||||
if (!boards.length) throw new Error('暂未提供可用榜单');
|
||||
const sources = [...new Set(boards.map(item => item.source))];
|
||||
const wanted = getSource() || $('rankingSource').value;
|
||||
$('rankingSource').replaceChildren(...sources.map(source => {
|
||||
const option = node('option', '', sourceLabel(source));
|
||||
option.value = source;
|
||||
return option;
|
||||
}));
|
||||
$('rankingSource').value = sources.includes(wanted) ? wanted : sources[0];
|
||||
selectSource();
|
||||
} catch (error) {
|
||||
if (ticket !== sequence || signal.aborted) return;
|
||||
state('点击“刷新”重试');
|
||||
setError(error.message);
|
||||
} finally {
|
||||
if (ticket === sequence) { busy = false; controller = null; controls(); }
|
||||
}
|
||||
}
|
||||
|
||||
$('openRankingsBtn').addEventListener('click', () => {
|
||||
dialog.showModal();
|
||||
$('closeRankingsBtn').focus();
|
||||
$('rankingActionStatus').textContent = '';
|
||||
loadCatalog();
|
||||
});
|
||||
$('closeRankingsBtn').addEventListener('click', () => dialog.close());
|
||||
$('refreshRankingBtn').addEventListener('click', () => board ? loadPage(1, true) : loadCatalog());
|
||||
$('rankingMoreBtn').addEventListener('click', () => { if (!busy && hasMore) loadPage(page + 1); });
|
||||
$('rankingSource').addEventListener('change', selectSource);
|
||||
tabs.addEventListener('keydown', event => {
|
||||
const buttons = [...tabs.children], index = buttons.indexOf(event.target);
|
||||
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
|
||||
buttons[next].focus();
|
||||
selectBoard(buttons[next].dataset.board);
|
||||
});
|
||||
dialog.addEventListener('click', event => {
|
||||
if (event.target !== dialog) return;
|
||||
const box = dialog.getBoundingClientRect();
|
||||
if (event.clientX < box.left || event.clientX > box.right || event.clientY < box.top || event.clientY > box.bottom) dialog.close();
|
||||
});
|
||||
dialog.addEventListener('close', () => {
|
||||
|
||||
if (dialog.open) return;
|
||||
cancel();
|
||||
controls();
|
||||
if (changed) { changed = false; onLibraryChanged(); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user