jaynesmax commited on
Commit
6a88eb7
Β·
verified Β·
1 Parent(s): 06b128b

Upload 2 files

Browse files
Files changed (2) hide show
  1. dl-sw.js +8 -0
  2. index.html +286 -35
dl-sw.js CHANGED
@@ -44,6 +44,14 @@ self.addEventListener('backgroundfetchsuccess', (event) => {
44
  const w = await fh.createWritable();
45
  await res.body.pipeTo(w); // stream β€” haitumii RAM
46
  const size = (await fh.getFile()).size;
 
 
 
 
 
 
 
 
47
  await jmWriteMeta(dir, oid, { ...meta, state: 'done', size });
48
  ok = true;
49
  } catch (e) {
 
44
  const w = await fh.createWritable();
45
  await res.body.pipeTo(w); // stream β€” haitumii RAM
46
  const size = (await fh.getFile()).size;
47
+ // Thibitisha ukubwa: usikubali "imehifadhiwa" kwa faili lililokatika nusu
48
+ let expected = 0;
49
+ const cr = /bytes\s+\d+-\d+\/(\d+)/i.exec(res.headers.get('content-range') || '');
50
+ if (cr) expected = Number(cr[1]);
51
+ else if (!res.headers.get('content-encoding')) expected = Number(res.headers.get('content-length')) || 0;
52
+ if (!size) throw new Error('empty_file');
53
+ if (expected && size !== expected) throw new Error('size_mismatch');
54
+ if (bgf.downloadTotal && size < bgf.downloadTotal) throw new Error('incomplete');
55
  await jmWriteMeta(dir, oid, { ...meta, state: 'done', size });
56
  ok = true;
57
  } catch (e) {
index.html CHANGED
@@ -2288,6 +2288,8 @@ const I18N = {
2288
  dq_saving: 'Inahifadhi kwenye app…',
2289
  dq_saved_app: 'Imehifadhiwa kwenye app βœ”',
2290
  dq_sent_browser: 'Imetumwa kwa kivinjari (angalia Downloads za kivinjari)',
 
 
2291
  dq_tab_saved: 'β–Ά Zimehifadhiwa',
2292
  dq_play: 'Cheza',
2293
  dq_delete: 'Futa',
@@ -2425,6 +2427,8 @@ const I18N = {
2425
  dq_saving: 'Saving to app…',
2426
  dq_saved_app: 'Saved in the app βœ”',
2427
  dq_sent_browser: 'Sent to the browser (check your browser Downloads)',
 
 
2428
  dq_tab_saved: 'β–Ά Saved',
2429
  dq_play: 'Play',
2430
  dq_delete: 'Delete',
@@ -2818,6 +2822,13 @@ const I18N_EXTRA7 = {"sw": {"not_live_msg": "Haipatikani kwa LIVE", "vol_warn":
2818
  Object.keys(I18N_EXTRA7).forEach(l => { I18N[l] = Object.assign(I18N[l] || {}, I18N_EXTRA7[l]); });
2819
  const I18N_EXTRA8 = {"sw": {"dq_paused_resume": "Imesimamishwa β€” gusa ↻ kuendelea"}, "en": {"dq_paused_resume": "Paused β€” tap ↻ to resume"}, "ar": {"dq_paused_resume": "Ω…ΨͺΩˆΩ‚Ω Ω…Ψ€Ω‚ΨͺΨ§Ω‹ β€” Ψ§ΨΆΨΊΨ· ↻ Ω„Ω„Ω…ΨͺΨ§Ψ¨ΨΉΨ©"}, "fr": {"dq_paused_resume": "En pause β€” touchez ↻ pour reprendre"}};
2820
  Object.keys(I18N_EXTRA8).forEach(l => { I18N[l] = Object.assign(I18N[l] || {}, I18N_EXTRA8[l]); });
 
 
 
 
 
 
 
2821
  const I18N_EXTRA10 = {"sw": {}, "en": {}, "ar": {}, "fr": {}};
2822
  Object.keys(I18N_EXTRA10).forEach(l => { I18N[l] = Object.assign(I18N[l] || {}, I18N_EXTRA10[l]); });
2823
  function T(k, v) { return t(k, v); }
@@ -7793,29 +7804,203 @@ async function offlineCheckQuota(needBytes){
7793
  }
7794
 
7795
  // Kutoka kwenye foleni ya downloads: pakua moja kwa moja kwenda OPFS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7796
  async function saveItemOffline(item, url, filename){
7797
- const res = await fetchRetry(proxiedDownloadUrl(url, filename), { mode: 'cors' });
7798
- if (!res.ok || !res.body) throw new Error('no_stream');
7799
- const ct = (res.headers.get('content-type') || '').toLowerCase();
7800
- if (/mpegurl|text\/|html|json/.test(ct)) throw new Error('not_direct_video');
7801
- const total = Number(res.headers.get('content-length')) || item.sizeBytes || 0;
7802
- await offlineCheckQuota(total);
7803
- await requestPersistentStorage();
7804
- const id = offlineNewId();
7805
- item.phase = 'download'; item.dlBytes = 0; item.dlTotal = total || 0; item.dlSpeed = 0;
7806
- item.progressText = t('dq_saving');
7807
- renderDownloadsScreenIfActive();
7808
- const size = await offlineWriteStream(id, res.body, total, (d, tot, speed) => {
7809
- item.dlBytes = d; item.dlTotal = tot || item.dlTotal || 0; item.dlSpeed = speed || 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7810
  item.progressText = t('dq_saving');
7811
- updateDqProgressDom(item); // sasisha namba kwenye kipengele pekee β€” hakuna flicker
7812
- });
7813
- const list = getOfflineMeta();
7814
- list.push({ id, title: item.title, sub: item.sub || '', poster: item.poster || '', thumbLabel: item.thumbLabel || '',
7815
- filename, size, mime: 'video/mp4', addedAt: Date.now(), source: 'download' });
7816
- setOfflineMeta(list);
7817
- await offlineStoreSubEntries(id, dlSubCache.get(item.id));
7818
- return { id, size };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7819
  }
7820
 
7821
  // ══════════════════════════════════════════════════════════════
@@ -7860,6 +8045,16 @@ async function bgMetaWrite(oid, obj){
7860
  }
7861
  async function bgMetaDelete(oid){ try { const d = await offlineDirHandle(); await d.removeEntry(oid + '.json'); } catch (_) {} }
7862
  async function bgMergeDone(oid, m){
 
 
 
 
 
 
 
 
 
 
7863
  const list = getOfflineMeta();
7864
  if (!list.some(x => x.id === oid)) {
7865
  list.push({ id: oid, title: m.title, sub: m.sub || '', poster: m.poster || '', thumbLabel: m.thumbLabel || '',
@@ -7906,7 +8101,7 @@ function bgTrack(item, bgf, oid, total){
7906
  const check = async () => {
7907
  if (finished) return;
7908
  const m = await bgMetaRead(oid);
7909
- if (m && m.state === 'done') finish(resolve, await bgMergeDone(oid, m));
7910
  else if (m && m.state === 'failed') {
7911
  await bgMetaDelete(oid);
7912
  const err = new Error(m.reason === 'aborted' ? t('dq_status_error') : (m.reason || 'bg_failed'));
@@ -7978,8 +8173,12 @@ async function bgSyncAll(){
7978
  const m = await bgMetaRead(oid); if (!m) continue;
7979
  const item = dlQueueItems.find(x => x.bgId === 'jm-' + oid);
7980
  if (m.state === 'done') {
7981
- const saved = await bgMergeDone(oid, m);
7982
- if (item && item.status !== 'done') bgFinishItem(item, saved);
 
 
 
 
7983
  changed = true;
7984
  } else if (m.state === 'failed') {
7985
  await bgMetaDelete(oid);
@@ -7997,7 +8196,7 @@ async function bgResumeItems(){
7997
  for (const item of pend) {
7998
  const oid = item.bgId.replace(/^jm-/, '');
7999
  const m = await bgMetaRead(oid);
8000
- if (m && m.state === 'done') { bgFinishItem(item, await bgMergeDone(oid, m)); continue; }
8001
  let bgf = null;
8002
  try { bgf = reg && reg.backgroundFetch ? await reg.backgroundFetch.get(item.bgId) : null; } catch (_) {}
8003
  if (bgf && !(m && m.state === 'failed')) {
@@ -8874,7 +9073,16 @@ function loadDlQueueFromStorage(){
8874
  // Upakuaji uliokuwa 'downloading' ulipofungwa ukurasa haukuendelea kweli β€”
8875
  // urudishe kwenye 'waiting' ili foleni iuanze upya.
8876
  // Usianze upakuaji kiotomatiki app ikifunguliwa: vilivyokatizwa/vilivyokuwa vinasubiri vinasimamishwa hadi mtumiaji aguse ↻.
8877
- dlQueueItems = saved.map(it => ((it.status === 'downloading' && !it.bgId) || it.status === 'waiting') ? { ...it, status: 'error', autoRetries: 0, progressText: T('dq_paused_resume') } : it);
 
 
 
 
 
 
 
 
 
8878
  dlIdSeq = dlQueueItems.reduce((max, it) => Math.max(max, it.id || 0), 0) + 1;
8879
  } catch(_) {}
8880
  }
@@ -8893,7 +9101,7 @@ async function notifyDownloadDone(item, ok){
8893
  }
8894
 
8895
  function updateDownloadsBadge(){
8896
- const n = dlQueueItems.filter(it => it.status === 'waiting' || it.status === 'downloading').length;
8897
  const badge = document.getElementById('dqNavBadge');
8898
  if (!badge) return;
8899
  if (n > 0) { badge.style.display = 'flex'; badge.textContent = n > 99 ? '99+' : String(n); }
@@ -9039,17 +9247,19 @@ async function processQueueItem(item){
9039
  if (!videoUrl) throw new Error(t('dq_err_no_video'));
9040
  dlSubCache.set(item.id, await fetchSubSidecars(subtitles)); // nakala za .vtt kwa ajili ya CC ya kicheza
9041
  // HATUA 1: muunganisho (mux) wa video + manukuu β€” hii SIO download yenyewe
9042
- const finalUrl = await muxAndDownload(videoUrl, subtitles, filename, (msg) => {
 
9043
  item.phase = 'mux';
9044
  item.progressText = msg;
9045
  updateDqProgressDom(item);
9046
  if (!document.querySelector('[data-dq-id="' + item.id + '"]')) renderDownloadsScreenIfActive();
9047
  });
 
9048
  // HATUA 2: download halisi β€” inaonyesha speed (MB/s) na ukubwa (kilichopakuliwa / jumla)
9049
  let saved = null;
9050
  if (offlineAutoOn() && offlineCanSave()) {
9051
  // Njia bora: Background Fetch β€” inaendelea hata ukifunga app, na Android inaonyesha asilimia kwenye notification
9052
- if (bgFetchSupported() && await dlSwReady()) {
9053
  try { saved = await saveItemViaBackgroundFetch(item, finalUrl, filename); }
9054
  catch (e) {
9055
  if (item.cancelled) return;
@@ -9059,10 +9269,19 @@ async function processQueueItem(item){
9059
  }
9060
  if (!saved) {
9061
  try { saved = await saveItemOffline(item, finalUrl, filename); }
9062
- catch (e) { console.warn('offline save failed β€” nitumia download ya kawaida', e); }
 
 
 
 
 
 
 
 
9063
  }
9064
  }
9065
  item.bgId = null;
 
9066
  item.phase = 'done'; item.dlSpeed = 0;
9067
  if (saved) {
9068
  item.offlineId = saved.id; item.dlBytes = saved.size; item.dlTotal = saved.size;
@@ -9076,6 +9295,11 @@ async function processQueueItem(item){
9076
  dlSubCache.delete(item.id);
9077
  if (!(saved && saved.viaBg)) notifyDownloadDone(item, true);
9078
  } catch (e) {
 
 
 
 
 
9079
  const isPermanent = !!(e && e.permanent) || /DRM/i.test(e.message || '');
9080
  item.phase = null; item.dlSpeed = 0;
9081
  item.autoRetries = item.autoRetries || 0;
@@ -9137,13 +9361,24 @@ function dqStatusShort(it){
9137
  if (it.phase === 'mux') return t('dq_phase_mux');
9138
  return t('dq_status_downloading');
9139
  }
 
 
 
 
 
 
 
 
9140
  function dqProgressInner(it){
9141
  if (it.phase === 'download') {
9142
  const p = dqPercent(it);
 
9143
  const bar = p != null ? `<div class="dq-bar-fill det" style="width:${p}%"></div>` : `<div class="dq-bar-fill"></div>`;
9144
  const sizeTxt = fmtSize(it.dlBytes) + ' ' + t('dq_of') + ' ' + (it.dlTotal ? fmtSize(it.dlTotal) : '?');
9145
- return `<div class="dq-bar-track">${bar}</div>
9146
- <div class="dq-meta"><span>${esc(sizeTxt)}</span><span>${esc(fmtSpeed(it.dlSpeed))}</span></div>`;
 
 
9147
  }
9148
  return `<div class="dq-bar-track"><div class="dq-bar-fill"></div></div>
9149
  <div class="dq-meta"><span>${esc(it.progressText || t('dq_status_downloading'))}</span></div>`;
@@ -9165,6 +9400,7 @@ function dqItemNode(it){
9165
  if (dqSelectMode) div.style.cursor = 'pointer';
9166
  const statusClass = it.status === 'error' ? 'err' : it.status === 'done' ? 'ok' : '';
9167
  const statusLabel = it.status === 'waiting' ? (it.heldByWifi ? t('dq_waiting_wifi') : t('dq_status_waiting'))
 
9168
  : it.status === 'downloading' ? dqStatusShort(it)
9169
  : it.status === 'done' ? t('dq_status_done')
9170
  : (it.progressText || t('dq_status_error'));
@@ -9173,7 +9409,7 @@ function dqItemNode(it){
9173
  <div class="dq-text">
9174
  <div class="dq-title">${esc(it.title)}</div>
9175
  <div class="dq-sub">${esc(it.sub)}${it.sizeBytes ? ' Β· ' + esc(formatBytes(it.sizeBytes)) : ''}</div>
9176
- ${it.status === 'downloading' ? `<div class="dq-prog">${dqProgressInner(it)}</div>` : ''}
9177
  ${it.status === 'done' ? `<div class="dq-meta"><span>${esc(it.progressText || '')}</span>${it.dlBytes ? `<span>${esc(fmtSize(it.dlBytes))}</span>` : ''}</div>` : ''}
9178
  </div>
9179
  <div class="dq-status">${dqSelectMode
@@ -9192,6 +9428,19 @@ function dqItemNode(it){
9192
  btn.className = 'dq-action-btn'; btn.textContent = '↻'; btn.title = t('dq_retry');
9193
  btn.onclick = () => { it.status = 'waiting'; it.autoRetries = 0; it.progressText = ''; saveDlQueue(); renderDownloadsScreen(); pumpDownloadQueue(); };
9194
  statusWrap.appendChild(btn);
 
 
 
 
 
 
 
 
 
 
 
 
 
9195
  } else if (it.status === 'downloading' && it.bgId) {
9196
  const cb = document.createElement('button');
9197
  cb.className = 'dq-action-btn'; cb.textContent = 'βœ•'; cb.title = t('dq_remove');
@@ -9209,7 +9458,7 @@ function dqItemNode(it){
9209
  }
9210
  const btn = document.createElement('button');
9211
  btn.className = 'dq-action-btn'; btn.textContent = 'βœ•'; btn.title = t('dq_remove');
9212
- btn.onclick = (ev) => { ev.stopPropagation(); dlQueueItems = dlQueueItems.filter(x => x.id !== it.id); saveDlQueue(); renderDownloadsScreen(); updateDownloadsBadge(); };
9213
  statusWrap.appendChild(btn);
9214
  }
9215
  return div;
@@ -9217,7 +9466,7 @@ function dqItemNode(it){
9217
 
9218
  function renderDownloadsScreen(){
9219
  const waiting = dlQueueItems.filter(it => it.status === 'waiting');
9220
- const downloading = dlQueueItems.filter(it => it.status === 'downloading');
9221
  const done = dlQueueItems.filter(it => it.status === 'done' || it.status === 'error');
9222
  if (dqActiveTab === 'offline') { renderOfflineTab(waiting.length, downloading.length, done.length); updateDownloadsBadge(); return; }
9223
  const list = dqActiveTab === 'waiting' ? waiting : dqActiveTab === 'downloading' ? downloading : done;
@@ -9263,6 +9512,7 @@ function renderDownloadsScreen(){
9263
  });
9264
  const clearDoneBtn = document.getElementById('dqClearDoneBtn');
9265
  if (clearDoneBtn) clearDoneBtn.onclick = () => {
 
9266
  dlQueueItems = dlQueueItems.filter(x => x.status !== 'done' && x.status !== 'error');
9267
  saveDlQueue();
9268
  renderDownloadsScreen();
@@ -9275,6 +9525,7 @@ function renderDownloadsScreen(){
9275
  const deleteSelectedBtn = document.getElementById('dqDeleteSelectedBtn');
9276
  if (deleteSelectedBtn) deleteSelectedBtn.onclick = () => {
9277
  dlQueueItems.filter(x => dqSelection.has(x.id)).forEach(bgAbortItem);
 
9278
  dlQueueItems = dlQueueItems.filter(x => !dqSelection.has(x.id));
9279
  dqSelectMode = false; dqSelection.clear();
9280
  saveDlQueue(); renderDownloadsScreen(); updateDownloadsBadge();
 
2288
  dq_saving: 'Inahifadhi kwenye app…',
2289
  dq_saved_app: 'Imehifadhiwa kwenye app βœ”',
2290
  dq_sent_browser: 'Imetumwa kwa kivinjari (angalia Downloads za kivinjari)',
2291
+ dq_err_net: 'Mtandao ulikatika β€” download haijakamilika. Gusa ↻ kujaribu tena.',
2292
+ dq_reconnecting: 'Mtandao umekatika, inaunganisha tena…',
2293
  dq_tab_saved: 'β–Ά Zimehifadhiwa',
2294
  dq_play: 'Cheza',
2295
  dq_delete: 'Futa',
 
2427
  dq_saving: 'Saving to app…',
2428
  dq_saved_app: 'Saved in the app βœ”',
2429
  dq_sent_browser: 'Sent to the browser (check your browser Downloads)',
2430
+ dq_err_net: 'Connection lost β€” download not complete. Tap ↻ to retry.',
2431
+ dq_reconnecting: 'Connection lost, reconnecting…',
2432
  dq_tab_saved: 'β–Ά Saved',
2433
  dq_play: 'Play',
2434
  dq_delete: 'Delete',
 
2822
  Object.keys(I18N_EXTRA7).forEach(l => { I18N[l] = Object.assign(I18N[l] || {}, I18N_EXTRA7[l]); });
2823
  const I18N_EXTRA8 = {"sw": {"dq_paused_resume": "Imesimamishwa β€” gusa ↻ kuendelea"}, "en": {"dq_paused_resume": "Paused β€” tap ↻ to resume"}, "ar": {"dq_paused_resume": "Ω…ΨͺΩˆΩ‚Ω Ω…Ψ€Ω‚ΨͺΨ§Ω‹ β€” Ψ§ΨΆΨΊΨ· ↻ Ω„Ω„Ω…ΨͺΨ§Ψ¨ΨΉΨ©"}, "fr": {"dq_paused_resume": "En pause β€” touchez ↻ pour reprendre"}};
2824
  Object.keys(I18N_EXTRA8).forEach(l => { I18N[l] = Object.assign(I18N[l] || {}, I18N_EXTRA8[l]); });
2825
+ const I18N_EXTRA9 = {
2826
+ sw: { dq_pause: 'Simamisha', dq_continue: 'Endelea', dq_paused: 'Imesimamishwa', dq_eta_left: 'Imebaki ~{x}', dq_sec: 'sek', dq_min: 'dk', dq_hr: 'saa' },
2827
+ en: { dq_pause: 'Pause', dq_continue: 'Resume', dq_paused: 'Paused', dq_eta_left: '~{x} left', dq_sec: 's', dq_min: 'min', dq_hr: 'h' },
2828
+ ar: { dq_pause: 'Ψ₯ΩŠΩ‚Ψ§Ω Ω…Ψ€Ω‚Ψͺ', dq_continue: 'Ω…ΨͺΨ§Ψ¨ΨΉΨ©', dq_paused: 'Ω…ΨͺΩˆΩ‚Ω Ω…Ψ€Ω‚ΨͺΨ§Ω‹', dq_eta_left: 'Ω…ΨͺΨ¨Ω‚ΩŠ ~{x}', dq_sec: 'Ψ«', dq_min: 'Ψ―', dq_hr: 'Ψ³' },
2829
+ fr: { dq_pause: 'Pause', dq_continue: 'Reprendre', dq_paused: 'En pause', dq_eta_left: '~{x} restantes', dq_sec: 's', dq_min: 'min', dq_hr: 'h' }
2830
+ };
2831
+ Object.keys(I18N_EXTRA9).forEach(l => { I18N[l] = Object.assign(I18N[l] || {}, I18N_EXTRA9[l]); });
2832
  const I18N_EXTRA10 = {"sw": {}, "en": {}, "ar": {}, "fr": {}};
2833
  Object.keys(I18N_EXTRA10).forEach(l => { I18N[l] = Object.assign(I18N[l] || {}, I18N_EXTRA10[l]); });
2834
  function T(k, v) { return t(k, v); }
 
7804
  }
7805
 
7806
  // Kutoka kwenye foleni ya downloads: pakua moja kwa moja kwenda OPFS
7807
+ // β€’ Sehemu nyingi (Range) kwa wakati mmoja = kasi zaidi β€’ Simamisha/Endelea β€’ resume mtandao ukikatika
7808
+ // β€’ "Imekamilika" tu kama kila sehemu imefika na ukubwa unalingana
7809
+ const JM_DL_USE_BG = false; // true = rudisha Background Fetch (inaendelea app ikifungwa, lakini haina Simamisha wala kasi ya ziada)
7810
+ const DL_SEG = 8 * 1024 * 1024;
7811
+ const dlRuns = new Map(); // item.id -> { ac, pause, cancel, fail }
7812
+ function dlConns(){ return Math.max(2, Math.min(6, Math.floor(8 / Math.max(1, dlActiveCount)))); }
7813
+ function _dlWaitOnline(ms){
7814
+ return new Promise(res => {
7815
+ if (navigator.onLine !== false) return setTimeout(res, ms);
7816
+ const h = () => { window.removeEventListener('online', h); setTimeout(res, 1000); };
7817
+ window.addEventListener('online', h);
7818
+ setTimeout(() => { window.removeEventListener('online', h); res(); }, 60000);
7819
+ });
7820
+ }
7821
+ function dlResetPartialFields(item){ item.partId = null; item.segDone = null; item.partTotal = 0; item.partEtag = null; item.finalUrl = null; }
7822
+ async function dlDiscardPartial(item){
7823
+ if (!item) return;
7824
+ const run = dlRuns.get(item.id);
7825
+ if (run) { run.cancel = true; try { run.ac.abort(); } catch (_) {} }
7826
+ const pid = item.partId;
7827
+ dlResetPartialFields(item);
7828
+ if (pid) { try { const d = await offlineDirHandle(); await d.removeEntry(pid + '.bin'); } catch (_) {} }
7829
+ }
7830
+ function dlPause(item){
7831
+ const run = dlRuns.get(item.id);
7832
+ if (!run || item.noPause) return;
7833
+ run.pause = true; item.progressText = t('dq_paused');
7834
+ try { run.ac.abort(); } catch (_) {}
7835
+ }
7836
+ function dlContinue(item){
7837
+ if (!item || item.status !== 'paused') return;
7838
+ item.status = 'waiting'; item.autoRetries = 0; item.progressText = '';
7839
+ saveDlQueue(); renderDownloadsScreenIfActive(); pumpDownloadQueue();
7840
+ }
7841
+
7842
  async function saveItemOffline(item, url, filename){
7843
+ const src = proxiedDownloadUrl(url, filename);
7844
+ const dir = await offlineDirHandle();
7845
+ const ac = new AbortController();
7846
+ const run = { ac, pause: false, cancel: false, fail: false };
7847
+ dlRuns.set(item.id, run);
7848
+ item.noPause = false;
7849
+ let id = item.partId || offlineNewId();
7850
+ let segDone = new Set(Array.isArray(item.segDone) ? item.segDone : []);
7851
+ let resumed = !!(item.partId && segDone.size);
7852
+ const SEG = item.segSize || DL_SEG;
7853
+ let total = 0, w = null, fh = null, chain = Promise.resolve(), tick = null, single = false;
7854
+ let doneBytes = 0; const inflight = new Map(); let singleBytes = 0;
7855
+ const segLen = (i) => Math.min(SEG, total - i * SEG);
7856
+ const bytesNow = () => { let b = single ? singleBytes : doneBytes; inflight.forEach(v => { b += v; }); return b; };
7857
+ const writeAt = (pos, data) => (chain = chain.then(() => w.write({ type: 'write', position: pos, data })));
7858
+ const closeW = async () => { try { await chain; } catch (_) {} try { if (w) await w.close(); } catch (_) {} w = null; };
7859
+ const abortW = async () => { try { await chain; } catch (_) {} try { if (w) await w.abort(); } catch (_) {} w = null; };
7860
+ const removeFile = async (fid) => { try { await dir.removeEntry(fid + '.bin'); } catch (_) {} };
7861
+ const stop = () => run.pause || run.cancel || run.fail;
7862
+ try {
7863
+ // 1) Uchunguzi: ukubwa kamili + je seva inaunga mkono Range?
7864
+ const probe = await fetchRetry(src, { mode: 'cors', headers: { Range: 'bytes=0-0' }, signal: ac.signal });
7865
+ if (!(probe.ok || probe.status === 206)) { const e = new Error('http_' + probe.status); e.fatal = probe.status >= 400 && probe.status < 500 && probe.status !== 429; throw e; }
7866
+ const ct = (probe.headers.get('content-type') || '').toLowerCase();
7867
+ if (/mpegurl|text\/|html|json/.test(ct)) throw new Error('not_direct_video');
7868
+ let rangeOK = false;
7869
+ if (probe.status === 206) {
7870
+ const m = /bytes\s+\d+-\d+\/(\d+)/i.exec(probe.headers.get('content-range') || '');
7871
+ if (m) { rangeOK = true; total = Number(m[1]); }
7872
+ }
7873
+ if (!rangeOK) total = Number(probe.headers.get('content-length')) || Number(item.sizeBytes) || 0;
7874
+ const etag = probe.headers.get('etag') || '';
7875
+ try { if (probe.body) probe.body.cancel(); } catch (_) {}
7876
+
7877
+ // 2) Resume: thibitisha kipande cha zamani bado ni cha faili lilelile
7878
+ if (resumed) {
7879
+ let okPart = rangeOK && total && total === item.partTotal && (!item.partEtag || !etag || item.partEtag === etag);
7880
+ if (okPart) {
7881
+ try {
7882
+ const f = await (await dir.getFileHandle(id + '.bin')).getFile();
7883
+ let maxEnd = 0; segDone.forEach(i => { maxEnd = Math.max(maxEnd, Math.min(total, (i + 1) * SEG)); });
7884
+ if (f.size < maxEnd) okPart = false;
7885
+ } catch (_) { okPart = false; }
7886
+ }
7887
+ if (!okPart) { await removeFile(id); id = offlineNewId(); segDone = new Set(); resumed = false; dlResetPartialFields(item); }
7888
+ }
7889
+ segDone.forEach(i => { doneBytes += segLen(i); });
7890
+
7891
+ if (!resumed) { await offlineCheckQuota(total); await requestPersistentStorage(); }
7892
+ fh = await dir.getFileHandle(id + '.bin', { create: true });
7893
+ w = await fh.createWritable({ keepExistingData: resumed });
7894
+ item.partId = id; item.partTotal = total; item.segSize = SEG; item.partEtag = etag; item.segDone = [...segDone];
7895
+ item.phase = 'download'; item.dlBytes = doneBytes; item.dlTotal = total || 0; item.dlSpeed = 0;
7896
  item.progressText = t('dq_saving');
7897
+ saveDlQueue(); renderDownloadsScreenIfActive();
7898
+
7899
+ // 3) Ticker: speed + ETA
7900
+ let lastT = Date.now(), lastB = bytesNow(), sp = 0;
7901
+ tick = setInterval(() => {
7902
+ const n = Date.now(), bb = bytesNow(), dt = (n - lastT) / 1000;
7903
+ if (dt > 0) { const inst = (bb - lastB) / dt; sp = sp ? sp * 0.7 + inst * 0.3 : inst; }
7904
+ lastT = n; lastB = bb;
7905
+ item.dlBytes = bb; item.dlTotal = total || item.dlTotal || 0; item.dlSpeed = sp;
7906
+ item.progressText = t('dq_saving');
7907
+ updateDqProgressDom(item);
7908
+ }, 700);
7909
+
7910
+ if (rangeOK && total > 0) {
7911
+ // ── Sehemu nyingi kwa wakati mmoja ──
7912
+ const nSeg = Math.ceil(total / SEG);
7913
+ const todo = []; for (let i = 0; i < nSeg; i++) if (!segDone.has(i)) todo.push(i);
7914
+ let next = 0;
7915
+ const ckptTimer = setInterval(() => { // hifadhi kwa kudumu kila dakika 5 (kuokoa data app ikifungwa ghafla)
7916
+ if (stop() || !w) return;
7917
+ chain = chain.then(async () => { await w.close(); w = await fh.createWritable({ keepExistingData: true }); item.segDone = [...segDone]; saveDlQueue(); }).catch(() => {});
7918
+ }, 300000);
7919
+ const worker = async () => {
7920
+ try {
7921
+ while (!stop()) {
7922
+ const i = todo[next++]; if (i === undefined) return;
7923
+ const a = i * SEG, len = segLen(i), b2 = a + len - 1;
7924
+ let ok = false, lastE = null;
7925
+ for (let tr = 0; tr < 6 && !stop(); tr++) {
7926
+ try {
7927
+ inflight.set(i, 0);
7928
+ const res = await fetch(src, { mode: 'cors', headers: { Range: 'bytes=' + a + '-' + b2 }, signal: ac.signal });
7929
+ if (res.status !== 206 || !res.body) {
7930
+ const e = new Error(res.status === 200 ? 'range_unsupported' : 'http_' + res.status);
7931
+ e.fatal = (res.status >= 400 && res.status < 500 && res.status !== 429) || res.status === 200;
7932
+ throw e;
7933
+ }
7934
+ const buf = new Uint8Array(len); let off = 0;
7935
+ const reader = res.body.getReader();
7936
+ while (true) {
7937
+ const { value, done } = await reader.read(); if (done) break;
7938
+ if (off + value.byteLength > len) throw new Error('overrun');
7939
+ buf.set(value, off); off += value.byteLength; inflight.set(i, off);
7940
+ }
7941
+ if (off !== len) throw new Error('incomplete');
7942
+ await writeAt(a, buf);
7943
+ segDone.add(i); doneBytes += len; inflight.delete(i); ok = true; break;
7944
+ } catch (e) {
7945
+ inflight.delete(i);
7946
+ if (stop()) return;
7947
+ lastE = e;
7948
+ if (e && e.fatal) throw e;
7949
+ await _dlWaitOnline(1500 * (tr + 1));
7950
+ }
7951
+ }
7952
+ if (!ok) { if (stop()) return; throw lastE || new Error('incomplete'); }
7953
+ }
7954
+ } catch (e) { run.fail = true; try { ac.abort(); } catch (_) {} throw e; }
7955
+ };
7956
+ const results = await Promise.allSettled(Array.from({ length: Math.min(dlConns(), todo.length) }, worker));
7957
+ clearInterval(ckptTimer);
7958
+ const bad = results.find(r => r.status === 'rejected');
7959
+ if (run.cancel) { await abortW(); await removeFile(id); dlResetPartialFields(item); const e = new Error('cancelled'); e.cancelled = true; throw e; }
7960
+ if (run.pause && !bad) { await closeW(); item.segDone = [...segDone]; saveDlQueue(); const e = new Error('paused'); e.paused = true; throw e; }
7961
+ if (bad) {
7962
+ const e = bad.reason;
7963
+ if (e && e.fatal) { await abortW(); await removeFile(id); dlResetPartialFields(item); }
7964
+ else { await closeW(); item.segDone = [...segDone]; saveDlQueue(); } // acha kipande β€” jaribio lijalo litaendelea
7965
+ throw e;
7966
+ }
7967
+ } else {
7968
+ // ── Seva haiungi Range: mtiririko mmoja (hakuna Simamisha) ──
7969
+ single = true; item.noPause = true; renderDownloadsScreenIfActive();
7970
+ const res = await fetchRetry(src, { mode: 'cors', signal: ac.signal });
7971
+ if (!res.ok || !res.body) throw new Error('no_stream');
7972
+ const reader = res.body.getReader();
7973
+ let pos = 0;
7974
+ while (true) {
7975
+ const { value, done } = await reader.read(); if (done) break;
7976
+ await writeAt(pos, value); pos += value.byteLength; singleBytes = pos;
7977
+ }
7978
+ if (total && pos < total) throw new Error('incomplete');
7979
+ total = pos;
7980
+ }
7981
+
7982
+ // 4) Maliza + thibitisha
7983
+ clearInterval(tick); tick = null;
7984
+ await chain; await w.close(); w = null;
7985
+ const real = (await (await dir.getFileHandle(id + '.bin')).getFile()).size;
7986
+ if (!real || (total && real !== total)) throw new Error('size_mismatch');
7987
+ const size = real;
7988
+ const list = getOfflineMeta();
7989
+ list.push({ id, title: item.title, sub: item.sub || '', poster: item.poster || '', thumbLabel: item.thumbLabel || '',
7990
+ filename, size, mime: 'video/mp4', addedAt: Date.now(), source: 'download' });
7991
+ setOfflineMeta(list);
7992
+ await offlineStoreSubEntries(id, dlSubCache.get(item.id));
7993
+ return { id, size };
7994
+ } catch (e) {
7995
+ if (!(e && (e.paused || e.cancelled))) {
7996
+ await abortW();
7997
+ if (single || !item.segDone || !item.segDone.length) { await removeFile(id); dlResetPartialFields(item); }
7998
+ }
7999
+ throw e;
8000
+ } finally {
8001
+ if (tick) clearInterval(tick);
8002
+ dlRuns.delete(item.id);
8003
+ }
8004
  }
8005
 
8006
  // ══════════════════════════════════════════════════════════════
 
8045
  }
8046
  async function bgMetaDelete(oid){ try { const d = await offlineDirHandle(); await d.removeEntry(oid + '.json'); } catch (_) {} }
8047
  async function bgMergeDone(oid, m){
8048
+ // Thibitisha kwamba faili lipo kweli na ukubwa unalingana kabla ya kusema "imekamilika"
8049
+ try {
8050
+ const d0 = await offlineDirHandle();
8051
+ const f0 = await (await d0.getFileHandle(oid + '.bin')).getFile();
8052
+ if (!f0.size || (m.size && f0.size !== m.size)) throw new Error('size_mismatch');
8053
+ } catch (e) {
8054
+ try { const d1 = await offlineDirHandle(); await d1.removeEntry(oid + '.bin'); } catch (_) {}
8055
+ await bgMetaDelete(oid);
8056
+ throw new Error(t('dq_err_net') || 'Faili halikukamilika β€” jaribu tena.');
8057
+ }
8058
  const list = getOfflineMeta();
8059
  if (!list.some(x => x.id === oid)) {
8060
  list.push({ id: oid, title: m.title, sub: m.sub || '', poster: m.poster || '', thumbLabel: m.thumbLabel || '',
 
8101
  const check = async () => {
8102
  if (finished) return;
8103
  const m = await bgMetaRead(oid);
8104
+ if (m && m.state === 'done') { try { finish(resolve, await bgMergeDone(oid, m)); } catch (e) { finish(reject, e); } }
8105
  else if (m && m.state === 'failed') {
8106
  await bgMetaDelete(oid);
8107
  const err = new Error(m.reason === 'aborted' ? t('dq_status_error') : (m.reason || 'bg_failed'));
 
8173
  const m = await bgMetaRead(oid); if (!m) continue;
8174
  const item = dlQueueItems.find(x => x.bgId === 'jm-' + oid);
8175
  if (m.state === 'done') {
8176
+ try {
8177
+ const saved = await bgMergeDone(oid, m);
8178
+ if (item && item.status !== 'done') bgFinishItem(item, saved);
8179
+ } catch (e) {
8180
+ if (item) { item.bgId = null; item.phase = null; item.status = 'error'; item.autoRetries = 0; item.progressText = (e && e.message) || t('dq_status_error'); }
8181
+ }
8182
  changed = true;
8183
  } else if (m.state === 'failed') {
8184
  await bgMetaDelete(oid);
 
8196
  for (const item of pend) {
8197
  const oid = item.bgId.replace(/^jm-/, '');
8198
  const m = await bgMetaRead(oid);
8199
+ if (m && m.state === 'done') { try { bgFinishItem(item, await bgMergeDone(oid, m)); } catch (e) { item.bgId = null; item.phase = null; item.status = 'error'; item.autoRetries = 0; item.progressText = (e && e.message) || t('dq_status_error'); } continue; }
8200
  let bgf = null;
8201
  try { bgf = reg && reg.backgroundFetch ? await reg.backgroundFetch.get(item.bgId) : null; } catch (_) {}
8202
  if (bgf && !(m && m.state === 'failed')) {
 
9073
  // Upakuaji uliokuwa 'downloading' ulipofungwa ukurasa haukuendelea kweli β€”
9074
  // urudishe kwenye 'waiting' ili foleni iuanze upya.
9075
  // Usianze upakuaji kiotomatiki app ikifunguliwa: vilivyokatizwa/vilivyokuwa vinasubiri vinasimamishwa hadi mtumiaji aguse ↻.
9076
+ dlQueueItems = saved.map(it => {
9077
+ if ((it.status === 'downloading' && !it.bgId) || it.status === 'waiting') {
9078
+ if (it.partId && Array.isArray(it.segDone) && it.segDone.length) {
9079
+ const sb = Math.min(it.partTotal || 0, it.segDone.length * (it.segSize || DL_SEG));
9080
+ return { ...it, status: 'paused', phase: 'download', dlBytes: sb, dlTotal: it.partTotal || 0, dlSpeed: 0, autoRetries: 0, progressText: T('dq_paused') };
9081
+ }
9082
+ return { ...it, status: 'error', autoRetries: 0, progressText: T('dq_paused_resume') };
9083
+ }
9084
+ return it;
9085
+ });
9086
  dlIdSeq = dlQueueItems.reduce((max, it) => Math.max(max, it.id || 0), 0) + 1;
9087
  } catch(_) {}
9088
  }
 
9101
  }
9102
 
9103
  function updateDownloadsBadge(){
9104
+ const n = dlQueueItems.filter(it => it.status === 'waiting' || it.status === 'downloading' || it.status === 'paused').length;
9105
  const badge = document.getElementById('dqNavBadge');
9106
  if (!badge) return;
9107
  if (n > 0) { badge.style.display = 'flex'; badge.textContent = n > 99 ? '99+' : String(n); }
 
9247
  if (!videoUrl) throw new Error(t('dq_err_no_video'));
9248
  dlSubCache.set(item.id, await fetchSubSidecars(subtitles)); // nakala za .vtt kwa ajili ya CC ya kicheza
9249
  // HATUA 1: muunganisho (mux) wa video + manukuu β€” hii SIO download yenyewe
9250
+ const _resumeOk = !!(item.finalUrl && item.partId && Array.isArray(item.segDone) && item.segDone.length);
9251
+ const finalUrl = _resumeOk ? item.finalUrl : await muxAndDownload(videoUrl, subtitles, filename, (msg) => {
9252
  item.phase = 'mux';
9253
  item.progressText = msg;
9254
  updateDqProgressDom(item);
9255
  if (!document.querySelector('[data-dq-id="' + item.id + '"]')) renderDownloadsScreenIfActive();
9256
  });
9257
+ item.finalUrl = finalUrl; saveDlQueue();
9258
  // HATUA 2: download halisi β€” inaonyesha speed (MB/s) na ukubwa (kilichopakuliwa / jumla)
9259
  let saved = null;
9260
  if (offlineAutoOn() && offlineCanSave()) {
9261
  // Njia bora: Background Fetch β€” inaendelea hata ukifunga app, na Android inaonyesha asilimia kwenye notification
9262
+ if (JM_DL_USE_BG && bgFetchSupported() && await dlSwReady()) {
9263
  try { saved = await saveItemViaBackgroundFetch(item, finalUrl, filename); }
9264
  catch (e) {
9265
  if (item.cancelled) return;
 
9269
  }
9270
  if (!saved) {
9271
  try { saved = await saveItemOffline(item, finalUrl, filename); }
9272
+ catch (e) {
9273
+ console.warn('offline save failed', e);
9274
+ // Kosa la mtandao/ukubwa/nafasi: USIONYESHE "imekamilika" β€” lisababishe retry / "Imeshindwa".
9275
+ // Kutuma kwa kivinjari ni kwa video isiyo ya moja kwa moja tu.
9276
+ if (!(e && (e.message === 'not_direct_video' || e.message === 'no_stream'))) {
9277
+ if (e && /incomplete|size_mismatch|Failed to fetch|NetworkError|network/i.test(e.message || '')) e.message = t('dq_err_net') || 'Mtandao ulikatika β€” jaribu tena.';
9278
+ throw e;
9279
+ }
9280
+ }
9281
  }
9282
  }
9283
  item.bgId = null;
9284
+ item.partId = null; item.segDone = null; item.finalUrl = null; item.partTotal = 0; item.noPause = false;
9285
  item.phase = 'done'; item.dlSpeed = 0;
9286
  if (saved) {
9287
  item.offlineId = saved.id; item.dlBytes = saved.size; item.dlTotal = saved.size;
 
9295
  dlSubCache.delete(item.id);
9296
  if (!(saved && saved.viaBg)) notifyDownloadDone(item, true);
9297
  } catch (e) {
9298
+ if (e && e.paused) { // mtumiaji alibonyeza Simamisha
9299
+ item.status = 'paused'; item.phase = 'download'; item.dlSpeed = 0; item.progressText = t('dq_paused');
9300
+ saveDlQueue(); renderDownloadsScreenIfActive(); updateDownloadsBadge(); return;
9301
+ }
9302
+ if (e && e.cancelled) return; // mtumiaji aliondoa download
9303
  const isPermanent = !!(e && e.permanent) || /DRM/i.test(e.message || '');
9304
  item.phase = null; item.dlSpeed = 0;
9305
  item.autoRetries = item.autoRetries || 0;
 
9361
  if (it.phase === 'mux') return t('dq_phase_mux');
9362
  return t('dq_status_downloading');
9363
  }
9364
+ function fmtEta(sec){
9365
+ if (!isFinite(sec) || sec <= 0) return '';
9366
+ sec = Math.round(sec);
9367
+ if (sec < 60) return sec + ' ' + t('dq_sec');
9368
+ const m = Math.floor(sec / 60);
9369
+ if (m < 60) return m + ' ' + t('dq_min') + (sec % 60 ? ' ' + (sec % 60) + ' ' + t('dq_sec') : '');
9370
+ return Math.floor(m / 60) + ' ' + t('dq_hr') + ' ' + (m % 60) + ' ' + t('dq_min');
9371
+ }
9372
  function dqProgressInner(it){
9373
  if (it.phase === 'download') {
9374
  const p = dqPercent(it);
9375
+ const paused = it.status === 'paused';
9376
  const bar = p != null ? `<div class="dq-bar-fill det" style="width:${p}%"></div>` : `<div class="dq-bar-fill"></div>`;
9377
  const sizeTxt = fmtSize(it.dlBytes) + ' ' + t('dq_of') + ' ' + (it.dlTotal ? fmtSize(it.dlTotal) : '?');
9378
+ const eta = (!paused && it.dlSpeed > 0 && it.dlTotal > it.dlBytes) ? fmtEta((it.dlTotal - it.dlBytes) / it.dlSpeed) : '';
9379
+ return `<div class="dq-bar-track">${paused ? bar.replace('dq-bar-fill det', 'dq-bar-fill det') : bar}</div>
9380
+ <div class="dq-meta"><span>${esc(sizeTxt)}</span><span>${esc(paused ? t('dq_paused') : fmtSpeed(it.dlSpeed))}</span></div>
9381
+ ${eta ? `<div class="dq-meta"><span>${esc(t('dq_eta_left', { x: eta }))}</span><span>${p != null ? p + '%' : ''}</span></div>` : ''}`;
9382
  }
9383
  return `<div class="dq-bar-track"><div class="dq-bar-fill"></div></div>
9384
  <div class="dq-meta"><span>${esc(it.progressText || t('dq_status_downloading'))}</span></div>`;
 
9400
  if (dqSelectMode) div.style.cursor = 'pointer';
9401
  const statusClass = it.status === 'error' ? 'err' : it.status === 'done' ? 'ok' : '';
9402
  const statusLabel = it.status === 'waiting' ? (it.heldByWifi ? t('dq_waiting_wifi') : t('dq_status_waiting'))
9403
+ : it.status === 'paused' ? t('dq_paused')
9404
  : it.status === 'downloading' ? dqStatusShort(it)
9405
  : it.status === 'done' ? t('dq_status_done')
9406
  : (it.progressText || t('dq_status_error'));
 
9409
  <div class="dq-text">
9410
  <div class="dq-title">${esc(it.title)}</div>
9411
  <div class="dq-sub">${esc(it.sub)}${it.sizeBytes ? ' Β· ' + esc(formatBytes(it.sizeBytes)) : ''}</div>
9412
+ ${(it.status === 'downloading' || it.status === 'paused') ? `<div class="dq-prog">${dqProgressInner(it)}</div>` : ''}
9413
  ${it.status === 'done' ? `<div class="dq-meta"><span>${esc(it.progressText || '')}</span>${it.dlBytes ? `<span>${esc(fmtSize(it.dlBytes))}</span>` : ''}</div>` : ''}
9414
  </div>
9415
  <div class="dq-status">${dqSelectMode
 
9428
  btn.className = 'dq-action-btn'; btn.textContent = '↻'; btn.title = t('dq_retry');
9429
  btn.onclick = () => { it.status = 'waiting'; it.autoRetries = 0; it.progressText = ''; saveDlQueue(); renderDownloadsScreen(); pumpDownloadQueue(); };
9430
  statusWrap.appendChild(btn);
9431
+ } else if (it.status === 'paused' || (it.status === 'downloading' && !it.bgId && it.phase === 'download' && dlRuns.has(it.id))) {
9432
+ const paused = it.status === 'paused';
9433
+ if (paused || !it.noPause) {
9434
+ const pb = document.createElement('button');
9435
+ pb.className = 'dq-action-btn cc'; pb.style.cssText = 'height:26px;font-size:11px;white-space:nowrap';
9436
+ pb.textContent = paused ? 'β–Ά ' + t('dq_continue') : '⏸ ' + t('dq_pause');
9437
+ pb.onclick = (ev) => { ev.stopPropagation(); if (paused) dlContinue(it); else dlPause(it); };
9438
+ statusWrap.appendChild(pb);
9439
+ }
9440
+ const xb = document.createElement('button');
9441
+ xb.className = 'dq-action-btn'; xb.textContent = 'βœ•'; xb.title = t('dq_remove');
9442
+ xb.onclick = (ev) => { ev.stopPropagation(); dlDiscardPartial(it); dlQueueItems = dlQueueItems.filter(x => x.id !== it.id); saveDlQueue(); renderDownloadsScreen(); updateDownloadsBadge(); };
9443
+ statusWrap.appendChild(xb);
9444
  } else if (it.status === 'downloading' && it.bgId) {
9445
  const cb = document.createElement('button');
9446
  cb.className = 'dq-action-btn'; cb.textContent = 'βœ•'; cb.title = t('dq_remove');
 
9458
  }
9459
  const btn = document.createElement('button');
9460
  btn.className = 'dq-action-btn'; btn.textContent = 'βœ•'; btn.title = t('dq_remove');
9461
+ btn.onclick = (ev) => { ev.stopPropagation(); dlDiscardPartial(it); dlQueueItems = dlQueueItems.filter(x => x.id !== it.id); saveDlQueue(); renderDownloadsScreen(); updateDownloadsBadge(); };
9462
  statusWrap.appendChild(btn);
9463
  }
9464
  return div;
 
9466
 
9467
  function renderDownloadsScreen(){
9468
  const waiting = dlQueueItems.filter(it => it.status === 'waiting');
9469
+ const downloading = dlQueueItems.filter(it => it.status === 'downloading' || it.status === 'paused');
9470
  const done = dlQueueItems.filter(it => it.status === 'done' || it.status === 'error');
9471
  if (dqActiveTab === 'offline') { renderOfflineTab(waiting.length, downloading.length, done.length); updateDownloadsBadge(); return; }
9472
  const list = dqActiveTab === 'waiting' ? waiting : dqActiveTab === 'downloading' ? downloading : done;
 
9512
  });
9513
  const clearDoneBtn = document.getElementById('dqClearDoneBtn');
9514
  if (clearDoneBtn) clearDoneBtn.onclick = () => {
9515
+ dlQueueItems.filter(x => x.status === 'error').forEach(dlDiscardPartial);
9516
  dlQueueItems = dlQueueItems.filter(x => x.status !== 'done' && x.status !== 'error');
9517
  saveDlQueue();
9518
  renderDownloadsScreen();
 
9525
  const deleteSelectedBtn = document.getElementById('dqDeleteSelectedBtn');
9526
  if (deleteSelectedBtn) deleteSelectedBtn.onclick = () => {
9527
  dlQueueItems.filter(x => dqSelection.has(x.id)).forEach(bgAbortItem);
9528
+ dlQueueItems.filter(x => dqSelection.has(x.id)).forEach(dlDiscardPartial);
9529
  dlQueueItems = dlQueueItems.filter(x => !dqSelection.has(x.id));
9530
  dqSelectMode = false; dqSelection.clear();
9531
  saveDlQueue(); renderDownloadsScreen(); updateDownloadsBadge();