471 lines
27 KiB
JavaScript
471 lines
27 KiB
JavaScript
import { createCardViewport } from './card-viewport.js';
|
|
import { createCoverRepair } from './cover-repair.js';
|
|
import { createDramaRefresh } from './drama-refresh.js';
|
|
import { createRecommendations } from './recommendations.js';
|
|
import { icon, initial, withFocus, readPreference, savePreference } from './ui-core.js';
|
|
import { $, element, empty, button, setMessage, api, post, valueText, firstNonEmpty, dramaTitle, watchLabel, historySuffix, normalizeSource, sourceKey, sourceLabel, categoryName, episodeCount, coverURL, tagsText, dramaSearchText, rebuildOptions, number, formatBytes, formatTime, statusText, releaseText, phaseText, progressText, progressBar, episodeLabel, groupStats } from './ui-core.js';
|
|
|
|
export function createLibrary(app) {
|
|
const dramas = [], selected = new Set(), byID = new Map(), cards = $('cards');
|
|
let visibleIDs = [], libraryRevision = 0, libraryRequest = false, libraryReload = false;
|
|
let libraryUpdateRequest = false, libraryQueuedUpdate = false;
|
|
let libraryTimer, libraryBusyText = '', libraryIsLoading = false, libraryMetadataRemaining = {};
|
|
let sortMessage = '', onlineSearchMessage = '', onlineSearchQuery = '', onlineSearchIDs = new Set();
|
|
let searchController, searchSequence = 0, batch = false, filterTimer;
|
|
let pendingCategory = null, sourceWarning = '', readError = false;
|
|
let vipMetadataRequest = false, vipMetadataMessage = '';
|
|
const recommendations = createRecommendations({allowed: () => app.viewer?.sources?.includes('hongguo') !== false, merge: mergeRecommendations, changed: renderDramas, switched: () => {resetOnlineSearch(); toggleBatch(false); app.shell.resetScroll();}});
|
|
const viewport = createCardViewport({container: cards, scroller: $('workspaceMain'), create: renderCard, key: dramaCardKey, rendered: refreshFollowing, update: (card, drama) => {
|
|
card.classList.toggle('selected', selected.has(drama.id));
|
|
card.querySelector('input[type=checkbox]').checked = selected.has(drama.id);
|
|
}});
|
|
const searchText = new WeakMap();
|
|
const coverRepair = createCoverRepair({get: id => byID.get(id), post, apply: (id, address, previous) => {
|
|
const drama = byID.get(id);
|
|
if (!drama) return;
|
|
drama.cover = address;
|
|
drama.coverUrl = address;
|
|
if (address === previous) {
|
|
for (const card of cards.querySelectorAll('.card')) {if (card.dataset.dramaId === id) card.retryCover?.();}
|
|
} else viewport.refresh();
|
|
app.details.refreshCover(id, address === previous);
|
|
app.following.refreshCover(id);
|
|
}});
|
|
const dramaRefresh = createDramaRefresh({post, apply: applyDramaRefresh, failure: (id) => {
|
|
app.details.metadataStatus(id, '资料暂未更新,已保留原有信息;稍后重新打开可重试。');
|
|
coverRepair.touch(id);
|
|
}});
|
|
function reconcileDrama(drama) {return coverRepair.reconcile(dramaRefresh.reconcile(drama));}
|
|
function applyDramaRefresh(drama, warning) {
|
|
const previous = byID.get(drama.id), index = dramas.findIndex(item => item.id === drama.id);
|
|
if (index >= 0) dramas[index] = drama; else dramas.push(drama);
|
|
byID.set(drama.id, drama);
|
|
libraryRevision = 0;
|
|
coverRepair.updated(drama.id);
|
|
app.details.metadataStatus(drama.id, warning);
|
|
rebuildChannels(false);
|
|
renderDramas();
|
|
app.following.render();
|
|
app.details.refresh();
|
|
if (previous && coverURL(previous) === coverURL(drama)) {
|
|
for (const card of cards.querySelectorAll('.card')) {if (card.dataset.dramaId === drama.id) card.retryCover?.();}
|
|
app.details.refreshCover(drama.id, true);
|
|
app.following.refreshCover(drama.id);
|
|
}
|
|
window.dispatchEvent(new Event('jukulibrarychange'));
|
|
}
|
|
function refreshDrama(id) {
|
|
if (!['hongguo', 'huangdou', 'huangguoai', 'huangguo-video'].includes(String(id).split(':')[0])) return;
|
|
void dramaRefresh.refresh(id);
|
|
}
|
|
const renderTasks = () => app.downloads.render();
|
|
const pollTasks = () => app.downloads.refresh();
|
|
function searchableText(drama) {if (!searchText.has(drama)) searchText.set(drama, dramaSearchText(drama)); return searchText.get(drama);}
|
|
function filteredDramas(){ const keyword=$('searchInput').value.trim().toLowerCase();const source=$('sourceSelect').value;const channel=$('channelSelect').value;return dramas.filter(dr=>{if(!window.JukuVIP.visible(dr))return false;if(source&&sourceKey(dr)!==source)return false;const cat=categoryName(dr);if(channel&&cat!==channel)return false;return !keyword||searchableText(dr).includes(keyword)||onlineSearchQuery===keyword&&onlineSearchIDs.has(dr.id);}); }
|
|
|
|
function rebuildSources(){ rebuildOptions($('sourceSelect'),app.viewer?.sources || ['huangguo','huangdou','hongguo'],'全部站源',sourceLabel,false); }
|
|
|
|
function rebuildChannels(reset){ const source=$('sourceSelect').value;const values=Array.from(new Set(dramas.filter(dr=>!source||sourceKey(dr)===source).map(categoryName))).sort((left,right)=>left.localeCompare(right,'zh-Hans-CN'));rebuildOptions($('channelSelect'),values,'全部分类',value=>value,reset);updateRefreshLabel(); }
|
|
|
|
function updateLibraryButton() {
|
|
const source = recommendations.enabled ? 'hongguo' : $('sourceSelect').value, hongguo = !source || source === 'hongguo';
|
|
const button = $('refreshBtn'), label = $('refreshBtnLabel');
|
|
const busy = libraryUpdateRequest || libraryIsLoading;
|
|
const text = busy ? '更新中…' : '更新剧库';
|
|
if (label.textContent !== text) label.textContent = text;
|
|
if (button.disabled !== busy) button.disabled = busy;
|
|
if (button.getAttribute('aria-busy') !== String(busy)) button.setAttribute('aria-busy', String(busy));
|
|
button.setAttribute('aria-label', (busy ? '正在更新' : '更新') + (source ? sourceLabel(source) : '全部站源') + '剧库');
|
|
button.title = '检查新内容' + (hongguo ? '并继续加载历史目录' : '') + ',后台分批补充缺失资料;优先当前筛选结果' + (libraryMetadataRemaining[source] ? ',待检查 ' + libraryMetadataRemaining[source] + ' 部' : '');
|
|
}
|
|
|
|
function metadataPriorityIDs(){const pending=new Set(dramas.filter(drama=>!drama.sortMetadata||drama.sortMetadata.version!==(['huangguo','huangguoai','huangguoai.com'].includes(String(drama.source||drama.id).split(':')[0])?2:1)||sourceKey(drama)==='huangdou'&&drama.vip==null&&!drama.sortMetadata.vipChecked||sourceKey(drama)==='hongguo'&&!coverURL(drama)&&!drama.sortMetadata.coverChecked).map(drama=>drama.id));return visibleIDs.filter(id=>pending.has(id)).slice(0,40);}
|
|
|
|
function updateRefreshLabel(){ const source=$('sourceSelect').value;$('vipFilterBtn').hidden=app.viewer?.sources?.includes('huangdou')===false||Boolean(source&&source!=='huangdou');const hongguo=app.viewer?.sources?.includes('hongguo')!==false&&(!source||source==='hongguo');updateLibraryButton();$('onlineSearchBtn').hidden=!hongguo;$('searchInput').placeholder='搜索剧名、简介或标签';$('onlineSearchBtn').title='联网搜索红果,也可按回车'; }
|
|
|
|
function placeholder(text){ return element('div','cover placeholder',text||'暂无封面'); }
|
|
|
|
function dramaMetaText(drama) {
|
|
const history = window.JukuHistory.progressText(drama.id);
|
|
const count = episodeCount(drama);
|
|
const status = drama.releaseStatus === 'finished' ? '已完结' : drama.releaseStatus === 'ongoing' ? '连载中' : '';
|
|
return [history || (count ? count + ' 集' : '集数未知'), history ? '' : status].filter(Boolean).join(' · ');
|
|
}
|
|
|
|
function dramaCardKey(drama) {
|
|
return JSON.stringify([dramaTitle(drama), sourceKey(drama), categoryName(drama), episodeCount(drama), coverURL(drama), drama.releaseStatus, drama.vip]);
|
|
}
|
|
|
|
function renderCard(drama) {
|
|
const title = dramaTitle(drama);
|
|
const card = element('article', 'card' + (selected.has(drama.id) ? ' selected' : ''));
|
|
card.dataset.dramaId = drama.id;
|
|
card.renderKey = dramaCardKey(drama);
|
|
const label = element('label', 'card-select');
|
|
label.dataset.downloadOnly = '';
|
|
const checkbox = element('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.checked = selected.has(drama.id);
|
|
checkbox.dataset.focusKey = 'select-' + drama.id;
|
|
checkbox.setAttribute('aria-label', '选择 ' + title);
|
|
checkbox.addEventListener('change', () => {
|
|
if (checkbox.checked) selected.add(drama.id); else selected.delete(drama.id);
|
|
card.classList.toggle('selected', checkbox.checked);
|
|
updateDramaSelection();
|
|
});
|
|
label.appendChild(checkbox);
|
|
const hasProgress = Boolean(window.JukuHistory.get(drama.id));
|
|
const poster = button('', () => app.play(drama.id, title), false, 'poster watch-button' + (hasProgress ? ' has-progress' : ''));
|
|
poster.setAttribute('aria-label', (hasProgress ? '继续观看 ' : '播放 ') + title);
|
|
poster.dataset.focusKey = 'play-' + drama.id;
|
|
const cover = coverURL(drama);
|
|
const fallback = element('span', 'cover placeholder');
|
|
fallback.setAttribute('aria-hidden', 'true');
|
|
fallback.append(element('span', '', initial(title)), element('small', '', cover ? '海报加载失败' : '暂无海报'));
|
|
if (cover) {
|
|
const image = element('img', 'cover');
|
|
image.alt = '';
|
|
image.loading = 'lazy';
|
|
image.decoding = 'async';
|
|
image.addEventListener('error', () => {image.replaceWith(fallback); coverRepair.failed(drama.id, cover);});
|
|
image.addEventListener('load', () => coverRepair.loaded(drama.id, cover));
|
|
image.src = cover;
|
|
card.retryCover = () => {if (fallback.isConnected) {image.src = cover; fallback.replaceWith(image);}};
|
|
poster.appendChild(image);
|
|
} else poster.appendChild(fallback);
|
|
poster.appendChild(element('span', 'poster-badge', sourceLabel(sourceKey(drama))));
|
|
if (window.JukuVIP.isVIP(drama)) poster.appendChild(element('span', 'vip-badge', 'VIP'));
|
|
const caption = element('span', 'poster-caption');
|
|
caption.setAttribute('aria-hidden', 'true');
|
|
caption.append(icon('play'), element('span', 'watch-label', hasProgress ? '续播' : '播放'), element('span', 'poster-category', categoryName(drama)));
|
|
const progress = element('span', 'poster-progress');
|
|
progress.setAttribute('aria-hidden', 'true');
|
|
progress.appendChild(element('span'));
|
|
progress.hidden = true;
|
|
poster.append(caption, progress);
|
|
const body = element('div', 'card-body');
|
|
const name = button(title, () => app.details.open(drama.id), false, 'card-title');
|
|
name.title = '查看详情 · ' + title;
|
|
name.setAttribute('aria-label', '查看 ' + title + ' 详情');
|
|
name.dataset.focusKey = 'title-' + drama.id;
|
|
const meta = element('div', 'meta', dramaMetaText(drama));
|
|
meta.title = dramaMetaText(drama);
|
|
const footer = element('div', 'card-footer');
|
|
const actions = element('div', 'card-actions');
|
|
const download = button('', async () => {
|
|
if (download.disabled) return;
|
|
download.disabled = true;
|
|
try {await app.downloads.enqueue([drama.id]);} finally {download.disabled = false;}
|
|
}, false, 'quiet icon-button card-download');
|
|
download.appendChild(icon('download'));
|
|
download.title = '加入下载';
|
|
download.setAttribute('aria-label', '下载 ' + title);
|
|
download.dataset.focusKey = 'download-' + drama.id;
|
|
actions.appendChild(app.following.saveButton(drama.id, title));
|
|
if (!app.viewer?.onlineOnly) actions.appendChild(download);
|
|
footer.append(meta, actions);
|
|
body.append(name, footer);
|
|
card.append(label, poster, body);
|
|
return card;
|
|
}
|
|
|
|
function updateDramaSelection() {
|
|
$('dramaSelection').textContent = '已选 ' + selected.size + ' 部';
|
|
$('enqueueBtn').disabled = !selected.size;
|
|
}
|
|
|
|
function renderDramas() {
|
|
clearTimeout(filterTimer);
|
|
for (const id of selected) {if (!byID.has(id) || !window.JukuVIP.visible(byID.get(id))) selected.delete(id);}
|
|
const filtered = filteredDramas();
|
|
const mode = $('sortSelect').value;
|
|
const list = recommendations.enabled ? recommendations.items().map(drama => byID.get(drama.id) || drama) : window.JukuLibrarySort.sortDramas(filtered, mode);
|
|
sortMessage = recommendations.enabled ? '' : window.JukuLibrarySort.summary(filtered, mode);
|
|
$('sortSelect').title = '缺少数据的剧集排在后面;不同站源的统计口径可能不同。' + sortMessage;
|
|
visibleIDs = list.map(drama => drama.id);
|
|
$('dramaCount').textContent = recommendations.enabled ? list.length + ' 部' : list.length === dramas.length ? list.length + ' 部' : list.length + ' / ' + dramas.length + ' 部';
|
|
updateDramaSelection();
|
|
updateLibraryStatus();
|
|
refreshFilterSummary();
|
|
let state = null;
|
|
if (!list.length && recommendations.enabled) {
|
|
state = element('div', 'empty', '红果分类推荐会显示在这里。');
|
|
} else if (!list.length) {
|
|
state = element('div', 'empty');
|
|
state.append(element('strong', '', dramas.length ? '没有找到匹配的剧集' : '剧库还没有内容'), element('p', '', dramas.length ? '换个关键词,或清除当前筛选。' : '更新剧库即可获取可用站源的内容。'));
|
|
state.appendChild(button(dramas.length ? '清除筛选' : '更新剧库', () => dramas.length ? resetFilters() : loadDramas(true), false, 'secondary'));
|
|
}
|
|
viewport.setItems(list, state);
|
|
}
|
|
|
|
async function loadDramas(update) {
|
|
if (libraryRequest) {
|
|
libraryReload = true;
|
|
if (update) {libraryQueuedUpdate = true; libraryUpdateRequest = true; updateLibraryButton();}
|
|
return;
|
|
}
|
|
libraryRequest = true;
|
|
libraryUpdateRequest = Boolean(update);
|
|
clearTimeout(libraryTimer);
|
|
updateLibraryButton();
|
|
const query = update ? '?update=1&source=' + encodeURIComponent(recommendations.enabled ? 'hongguo' : $('sourceSelect').value) + '&priority=' + encodeURIComponent(metadataPriorityIDs().join(',')) : '?revision=' + libraryRevision;
|
|
try {
|
|
const result = await api('/api/ui/dramas' + query);
|
|
readError = false;
|
|
libraryRevision = result.revision || 0;
|
|
libraryIsLoading = Boolean(result.loading || result.metadata?.running);
|
|
libraryMetadataRemaining = result.metadataRemaining || {};
|
|
app.shell.sourceStates(result.sources, result.loading);
|
|
sourceWarning = result.error ? '部分站源尚未完成,可在“更多”查看状态' : '';
|
|
if (Array.isArray(result.data)) {
|
|
dramas.length = 0;
|
|
byID.clear();
|
|
for (let drama of result.data) {drama = reconcileDrama(drama); dramas.push(drama); byID.set(drama.id, drama);}
|
|
for (const drama of recommendations.all()) {if (!byID.has(drama.id)) {dramas.push(drama); byID.set(drama.id, drama);}}
|
|
rebuildSources();
|
|
rebuildChannels(false);
|
|
if (pendingCategory !== null) {
|
|
const found = Array.from($('channelSelect').options).some(option => option.value === pendingCategory);
|
|
if (found) $('channelSelect').value = pendingCategory;
|
|
if (found || !result.loading) pendingCategory = null;
|
|
}
|
|
renderDramas();
|
|
app.following.render();
|
|
app.details.refresh();
|
|
window.dispatchEvent(new Event('jukulibrarychange'));
|
|
}
|
|
const loaded = result.loadedAt && !result.loadedAt.startsWith('0001') ? new Date(result.loadedAt).toLocaleString() : '';
|
|
$('libraryUpdatedAt').textContent = loaded ? '更新于 ' + loaded : '';
|
|
$('libraryUpdatedAt').title = $('libraryUpdatedAt').textContent;
|
|
$('libraryMenuUpdatedAt').textContent = $('libraryUpdatedAt').textContent;
|
|
const metadata = result.metadata || {};
|
|
if (!metadata.running && !vipMetadataRequest) vipMetadataMessage = '';
|
|
libraryBusyText = result.loading ? '正在更新剧库,已有内容可继续使用' : metadata.running ? '后台补充资料 ' + metadata.checked + ' / ' + metadata.total : '';
|
|
updateLibraryStatus();
|
|
if (result.loading || metadata.running) libraryTimer = setTimeout(() => loadDramas(false), result.loading ? 1200 : 5000);
|
|
} catch (error) {
|
|
libraryIsLoading = false;
|
|
readError = true;
|
|
libraryBusyText = '剧库读取失败:' + error.message;
|
|
if (!dramas.length) renderDramas();
|
|
updateLibraryStatus();
|
|
} finally {
|
|
libraryRequest = false;
|
|
if (libraryReload) {
|
|
const queuedUpdate = libraryQueuedUpdate;
|
|
libraryReload = false;
|
|
libraryQueuedUpdate = false;
|
|
loadDramas(queuedUpdate);
|
|
} else {
|
|
libraryUpdateRequest = false;
|
|
updateLibraryButton();
|
|
}
|
|
}
|
|
}
|
|
|
|
function mergeRecommendations(items) {
|
|
const positions = new Map(dramas.map((drama, index) => [drama.id, index]));
|
|
for (let drama of items) {
|
|
drama = reconcileDrama(drama);
|
|
const index = positions.get(drama.id);
|
|
if (index === undefined) {positions.set(drama.id, dramas.length); dramas.push(drama);} else dramas[index] = drama;
|
|
byID.set(drama.id, drama);
|
|
}
|
|
libraryRevision = 0;
|
|
rebuildSources();
|
|
rebuildChannels(false);
|
|
app.following.render();
|
|
app.details.refresh();
|
|
}
|
|
|
|
function updateLibraryStatus() {
|
|
const source = $('sourceSelect').value;
|
|
const vip = recommendations.enabled ? '' : window.JukuVIP.summary(dramas.filter(drama => (!source || sourceKey(drama) === source)));
|
|
const text = [libraryBusyText, onlineSearchMessage, sourceWarning, vip, vipMetadataMessage, sortMessage].filter(Boolean).join(' · ');
|
|
$('libraryStatus').textContent = text;
|
|
$('libraryFeedback').hidden = !text;
|
|
$('retryLibraryBtn').hidden = !readError;
|
|
}
|
|
|
|
async function refreshVIPMetadata(priority = visibleIDs) {
|
|
if (vipMetadataRequest || app.viewer?.sources?.includes('huangdou') === false) return;
|
|
vipMetadataRequest = true;
|
|
vipMetadataMessage = '正在检查 VIP 状态';
|
|
updateLibraryStatus();
|
|
try {
|
|
const ids = priority.filter(id => id.startsWith('huangdou:')).slice(0, 40);
|
|
const result = await post('/api/ui/vip/metadata', {priority: ids});
|
|
vipMetadataMessage = result.pending ? '后台分批识别 VIP,也可点开剧集立即补充资料' : result.remaining ? '尚未识别的内容暂保留,可稍后点击“更新剧库”重试' : '';
|
|
await loadDramas(false);
|
|
} catch (_) {vipMetadataMessage = 'VIP 状态暂未补齐,可点击“更新剧库”重试';}
|
|
finally {vipMetadataRequest = false; updateLibraryStatus();}
|
|
}
|
|
|
|
function resetOnlineSearch(){searchSequence++;if(searchController)searchController.abort();searchController=null;onlineSearchQuery='';onlineSearchIDs.clear();onlineSearchMessage='';$('onlineSearchBtn').disabled=false;$('onlineSearchBtn').textContent='联网搜索';updateLibraryStatus();}
|
|
|
|
async function searchOnline(){
|
|
rememberSearch($('searchInput').value);
|
|
if(app.viewer?.sources?.includes('hongguo')===false||$('sourceSelect').value&&$('sourceSelect').value!=='hongguo')return;
|
|
const keyword=$('searchInput').value.trim();if(!keyword){setMessage('请先输入搜索词',true);return;}if(searchController)return;
|
|
resetOnlineSearch();const sequence=searchSequence;const controller=new AbortController();searchController=controller;
|
|
$('onlineSearchBtn').disabled=true;$('onlineSearchBtn').textContent='搜索中';onlineSearchMessage='正在联网搜索红果';updateLibraryStatus();
|
|
try{
|
|
const result=await api('/api/ui/search?q='+encodeURIComponent(keyword),{signal:controller.signal});
|
|
if(sequence!==searchSequence||keyword!==$('searchInput').value.trim())return;
|
|
const matches=(Array.isArray(result.data)?result.data:[]).filter(drama=>sourceKey(drama)==='hongguo');
|
|
onlineSearchQuery=keyword.toLowerCase();onlineSearchIDs=new Set(matches.map(drama=>drama.id));
|
|
|
|
const positions=new Map(dramas.map((drama,index)=>[drama.id,index]));for(let drama of matches){drama=reconcileDrama(drama);const position=positions.get(drama.id);if(position===undefined){positions.set(drama.id,dramas.length);dramas.push(drama);}else dramas[position]=drama;}
|
|
onlineSearchMessage=matches.length?'联网返回 '+matches.length+' 部红果'+(result.total>matches.length?'(首批匹配)':''):'联网暂无匹配';
|
|
if(matches.length&&result.saved===false)onlineSearchMessage+=',缓存未保存';
|
|
for(const drama of dramas)byID.set(drama.id,drama);rebuildSources();rebuildChannels(false);renderDramas();app.following.render();updateLibraryStatus();libraryRevision=0;await loadDramas(false);
|
|
}catch(error){if(sequence===searchSequence&&!controller.signal.aborted){onlineSearchMessage='联网搜索暂不可用:'+error.message+';可重试,本地筛选仍可使用';updateLibraryStatus();}}
|
|
finally{if(sequence===searchSequence){searchController=null;$('onlineSearchBtn').disabled=false;$('onlineSearchBtn').textContent='联网搜索';}}
|
|
}
|
|
|
|
async function enqueueSelected() {
|
|
if (!selected.size) {setMessage('请先选择剧集', true); return;}
|
|
$('enqueueBtn').disabled = true;
|
|
const ok = await app.downloads.enqueue(Array.from(selected));
|
|
if (ok) toggleBatch(false);
|
|
updateDramaSelection();
|
|
}
|
|
|
|
|
|
|
|
function refreshFilterSummary() {
|
|
for (const id of ['sourceSelect', 'channelSelect']) $(id).title = $(id).selectedOptions[0]?.textContent || '';
|
|
$('resetFiltersBtn').hidden = recommendations.enabled || !$('searchInput').value && !$('channelSelect').value && $('sortSelect').value === 'default' && ['', 'hongguo'].includes($('sourceSelect').value);
|
|
$('clearSearchBtn').hidden = !$('searchInput').value;
|
|
}
|
|
|
|
function persistFilters() {
|
|
savePreference('libraryFilters', {source: $('sourceSelect').value, category: $('channelSelect').value, sort: $('sortSelect').value, search: $('searchInput').value});
|
|
}
|
|
|
|
function resetFilters() {
|
|
resetOnlineSearch();
|
|
$('sourceSelect').value = '';
|
|
$('searchInput').value = '';
|
|
$('sortSelect').value = 'default';
|
|
pendingCategory = null;
|
|
rebuildChannels(true);
|
|
renderDramas();
|
|
persistFilters();
|
|
app.shell.resetScroll();
|
|
}
|
|
|
|
function toggleBatch(value) {
|
|
batch = value;
|
|
if (!batch) selected.clear();
|
|
document.body.classList.toggle('library-batch', batch);
|
|
$('libraryBatchBar').hidden = !batch;
|
|
$('batchSelectBtn').setAttribute('aria-pressed', String(batch));
|
|
$('batchSelectLabel').textContent = batch ? '退出批量' : '批量选择';
|
|
$('batchSelectBtn').setAttribute('aria-label', batch ? '退出批量选择' : '批量选择');
|
|
$('batchSelectBtn').title = batch ? '退出批量选择' : '批量选择';
|
|
renderDramas();
|
|
}
|
|
|
|
function refreshFollowing(nodes = cards.querySelectorAll('.card')) {
|
|
for (const card of nodes) {
|
|
const id = card.dataset.dramaId, drama = byID.get(id);
|
|
if (!drama) continue;
|
|
const save = card.querySelector('[data-save-id]');
|
|
if (save) {
|
|
const saved = Boolean(app.following.get(id)?.saved);
|
|
save.setAttribute('aria-pressed', String(saved));
|
|
save.setAttribute('aria-label', (saved ? '取消收藏 ' : '收藏 ') + dramaTitle(drama));
|
|
save.title = saved ? '取消收藏' : '加入想看';
|
|
save.disabled = app.following.busy(id);
|
|
}
|
|
const history = window.JukuHistory.get(id);
|
|
const watch = card.querySelector('.watch-button');
|
|
watch.querySelector('.watch-label').textContent = history ? '续播' : '播放';
|
|
watch.classList.toggle('has-progress', Boolean(history));
|
|
watch.setAttribute('aria-label', (history ? '继续观看 ' : '播放 ') + dramaTitle(drama));
|
|
const progress = watch.querySelector('.poster-progress');
|
|
progress.hidden = !(history?.duration > 0);
|
|
progress.firstElementChild.style.width = history?.duration > 0 ? Math.min(100, Math.max(0, history.position / history.duration * 100)) + '%' : '0%';
|
|
card.querySelector('.meta').textContent = dramaMetaText(drama);
|
|
card.querySelector('.meta').title = dramaMetaText(drama);
|
|
}
|
|
}
|
|
|
|
function searchHistory() {
|
|
const items = readPreference('recentSearches', []);
|
|
return Array.isArray(items) ? items.filter(item => typeof item === 'string' && item.length <= 80).slice(0, 10) : [];
|
|
}
|
|
|
|
function rememberSearch(raw) {
|
|
const query = raw.trim().slice(0, 80);
|
|
if (query) savePreference('recentSearches', [query, ...searchHistory().filter(item => item !== query)].slice(0, 10));
|
|
$('recentSearches').hidden = true;
|
|
}
|
|
|
|
function renderSearchHistory() {
|
|
const list = searchHistory(), target = $('recentSearches');
|
|
target.replaceChildren();
|
|
target.hidden = !list.length;
|
|
if (!list.length) return;
|
|
target.appendChild(element('span', 'small', '最近搜索'));
|
|
list.forEach(query => target.appendChild(button(query, () => {
|
|
$('searchInput').value = query;
|
|
resetOnlineSearch();
|
|
renderDramas();
|
|
persistFilters();
|
|
target.hidden = true;
|
|
}, false, 'secondary')));
|
|
target.appendChild(button('清除记录', () => {savePreference('recentSearches', []); target.hidden = true;}, false, 'quiet'));
|
|
}
|
|
|
|
function init() {
|
|
recommendations.init();
|
|
window.addEventListener('jukuvipfilterchange', event => {renderDramas(); app.shell.resetScroll(); if (event.detail?.interactive) void refreshVIPMetadata(event.detail.priority || visibleIDs);});
|
|
rebuildSources();
|
|
const stored = readPreference('libraryFilters', {});
|
|
const saved = stored && typeof stored === 'object' ? stored : {};
|
|
const allowedSources = app.viewer?.sources || ['huangguo','huangdou','hongguo'];
|
|
$('sourceSelect').value = ['', ...allowedSources].includes(saved.source) ? saved.source : allowedSources.includes('hongguo') ? 'hongguo' : allowedSources[0] || '';
|
|
$('sortSelect').value = window.JukuLibrarySort.modes.includes(saved.sort) ? saved.sort : 'default';
|
|
$('searchInput').value = typeof saved.search === 'string' ? saved.search.slice(0, 80) : '';
|
|
pendingCategory = typeof saved.category === 'string' ? saved.category : null;
|
|
rebuildChannels(false);
|
|
refreshFilterSummary();
|
|
const filters = () => {persistFilters(); renderDramas(); app.shell.resetScroll();};
|
|
$('refreshBtn').addEventListener('click', () => loadDramas(true));
|
|
$('retryLibraryBtn').addEventListener('click', () => loadDramas(false));
|
|
$('searchInput').addEventListener('input', () => {
|
|
resetOnlineSearch();
|
|
persistFilters();
|
|
refreshFilterSummary();
|
|
clearTimeout(filterTimer);
|
|
filterTimer = setTimeout(() => {renderDramas(); app.shell.resetScroll();}, 90);
|
|
});
|
|
$('searchInput').addEventListener('focus', renderSearchHistory);
|
|
$('searchInput').addEventListener('blur', () => setTimeout(() => {
|
|
if (!$('recentSearches').contains(document.activeElement)) $('recentSearches').hidden = true;
|
|
}, 150));
|
|
$('searchInput').addEventListener('keydown', event => {
|
|
if (event.key === 'Enter' && !event.isComposing) {event.preventDefault(); searchOnline();}
|
|
});
|
|
$('clearSearchBtn').addEventListener('click', () => {$('searchInput').value = ''; resetOnlineSearch(); filters(); $('searchInput').focus();});
|
|
$('onlineSearchBtn').addEventListener('click', searchOnline);
|
|
$('sourceSelect').addEventListener('change', () => {pendingCategory = null; resetOnlineSearch(); rebuildChannels(true); filters();});
|
|
$('channelSelect').addEventListener('change', filters);
|
|
$('sortSelect').addEventListener('change', filters);
|
|
$('resetFiltersBtn').addEventListener('click', resetFilters);
|
|
$('batchSelectBtn').addEventListener('click', () => toggleBatch(!batch));
|
|
$('finishBatchBtn').addEventListener('click', () => toggleBatch(false));
|
|
$('selectVisibleBtn').addEventListener('click', () => {visibleIDs.forEach(id => selected.add(id)); renderDramas();});
|
|
$('invertVisibleBtn').addEventListener('click', () => {visibleIDs.forEach(id => selected.has(id) ? selected.delete(id) : selected.add(id)); renderDramas();});
|
|
$('deselectDramasBtn').addEventListener('click', () => {selected.clear(); renderDramas();});
|
|
$('enqueueBtn').addEventListener('click', enqueueSelected);
|
|
for (let index = 0; index < 12; index++) {const item = element('div', 'skeleton-card'); item.setAttribute('aria-hidden', 'true'); cards.appendChild(item);}
|
|
return loadDramas(false);
|
|
}
|
|
|
|
return {init, refreshDrama, layout: viewport.refresh, get: id => byID.get(id), all: () => dramas, render: renderDramas, refreshFollowing, repairCover: coverRepair.touch, coverFailed: coverRepair.failed, coverLoaded: coverRepair.loaded, refresh: () => {libraryRevision = 0; return loadDramas(false);}, retryCovers: () => {cards.querySelectorAll('.card').forEach(card => card.retryCover?.()); app.details.retryCover(); app.following.retryCovers();}};
|
|
|
|
}
|