feat: collapse the Downloading, Completed and Subscriptions sections (closes #1070)

All three section headers become toggles. Collapsing removes the section
from the DOM rather than hiding it, so a long completed list stops
costing render work while it is put away.

The chevron sits at the right edge of the header. Putting it before the
label indents that title past the ones without a chevron, and the three
section titles are a left-aligned column; keeping them aligned matters
more than keeping the affordance next to the word. Direction follows the
Advanced Options disclosure already in the form — down when open, right
when closed.

The request covered Completed and Subscriptions only, but leaving
Downloading as the one fixed section is arbitrary once its neighbours
move. Each section remembers its own state in a metube_* cookie, matching
how every other client-side preference here is persisted; the request
asked for localStorage, but a second mechanism for the same job is not
worth it. All three default to expanded, so an upgrade doesn't hide
anything a user was already looking at.

The Downloading and Completed blocks hold the viewChild.required targets
behind their select-all checkboxes. Nothing reads them while a section is
collapsed: the only caller is the checkbox's own (changed) output, and
the queueChanged/doneChanged subscriptions reach it through an optional
viewChild. Verified live on a download that finished while the section
was put away.
This commit is contained in:
Alex Shnitman
2026-08-28 09:19:07 +03:00
parent 1251613f45
commit 79388370e9
4 changed files with 476 additions and 387 deletions
+408 -387
View File
@@ -690,403 +690,424 @@
Connecting to server... Connecting to server...
</div> </div>
} }
<div class="metube-section-header">Downloading</div> <div class="metube-section-header">
<div class="px-2 py-3 border-bottom"> <button type="button" class="metube-section-toggle" (click)="toggleDownloadingCollapsed()" [attr.aria-expanded]="!downloadingCollapsed">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" />&nbsp; Cancel selected</button> <span>Downloading</span>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" />&nbsp; Download selected</button> <fa-icon [icon]="downloadingCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
</th>
<th scope="col">Video</th>
<th scope="col" style="width: 7rem;">Format</th>
<th scope="col" style="width: 8rem;">Speed</th>
<th scope="col" style="width: 7rem;">ETA</th>
<th scope="col" style="width: 6rem;"></th>
</tr>
</thead>
<tbody>
@for (download of downloads.queue | keyvalue: asIsOrder; track download.key) {
<tr [class.disabled]='download.value.deleting'>
<td>
<app-item-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
</td>
<td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div class="d-flex align-items-center flex-wrap gap-2">
<span>{{ download.value.title }}</span>
@if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
<span class="badge bg-danger">LIVE</span>
}
</div>
@if (download.value.status === 'scheduled') {
<span class="badge bg-warning text-dark">
<fa-icon [icon]="faClock" />
Waiting for stream
@if (liveCountdownSeconds(download.value); as secs) {
- starts in {{ secs | eta }}
}
</span>
} @else {
<ngb-progressbar height="1.5rem" [showValue]="!isIndeterminate(download.value)" [striped]="isIndeterminate(download.value)" [animated]="isIndeterminate(download.value)" type="success"
[value]="isIndeterminate(download.value) ? 100 : download.value.percent" class="download-progressbar">
@if (download.value.status === 'postprocessing') {
<span>Post-processing</span>
}
</ngb-progressbar>
}
</div>
</td>
<td class="text-nowrap">{{ formatLabel(download.value) }}</td>
<td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td>
<td>
<div class="d-flex">
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
}
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon [icon]="faExternalLinkAlt" /></a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="metube-section-header">Completed</div>
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" />&nbsp; {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone" (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" />&nbsp; Clear completed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" />&nbsp; Clear failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" />&nbsp; Retry failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" />&nbsp; Download Selected</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" [orderedIds]="cachedSortedDoneIds" (changed)="doneSelectionChanged($event)" />
</th>
<th scope="col">Video</th>
<th scope="col">Type</th>
<th scope="col">Quality</th>
<th scope="col">Codec / Format</th>
<th scope="col">File Size</th>
<th scope="col">Downloaded</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
@for (entry of cachedSortedDone; track entry[0]) {
<tr [class.disabled]='entry[1].deleting'>
<td>
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
@if (entry[1].status === 'finished') {
<fa-icon [icon]="faCheckCircle" class="text-success" />
}
@if (entry[1].status === 'error') {
<button type="button" class="btn btn-link p-0"
(click)="toggleErrorDetail(entry[0])"
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
[attr.aria-expanded]="isErrorExpanded(entry[0])">
<fa-icon [icon]="faTimesCircle" class="text-danger" />
</button>
}
</div>
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
} @else {
@if (entry[1].status === 'error') {
<button type="button" class="btn btn-link p-0 text-start align-baseline" (click)="toggleErrorDetail(entry[0])">
{{entry[1].title}}
@if (!isErrorExpanded(entry[0])) {
<small class="text-danger ms-2">
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
</small>
}
</button>
} @else {
<span>{{entry[1].title}}</span>
}
}</span>
@if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
<div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
@if (entry[1].msg) {
<div class="mb-1"><strong>Message:</strong> {{entry[1].msg}}</div>
}
@if (entry[1].error) {
<div class="mb-1"><strong>Error:</strong> {{entry[1].error}}</div>
}
<div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0"
(click)="copyErrorMessage(entry[0], entry[1]); $event.stopPropagation()"
ngbTooltip="Copy error details to clipboard">
@if (lastCopiedErrorId === entry[0]) {
<span class="text-success">Copied!</span>
} @else {
<fa-icon [icon]="faCopy" />
}
</button>
</div>
</div>
}
</td>
<td class="text-nowrap">
{{ downloadTypeLabel(entry[1]) }}
</td>
<td class="text-nowrap">
{{ formatQualityLabel(entry[1]) }}
</td>
<td class="text-nowrap">
{{ formatCodecLabel(entry[1]) }}
</td>
<td>
@if (entry[1].size) {
<span>{{ entry[1].size | fileSize }}</span>
}
</td>
<td class="text-nowrap">
@if (entry[1].timestamp) {
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
}
</td>
<td>
<div class="d-flex">
@if (entry[1].status === 'error') {
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
}
@if (entry[1].filename) {
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
}
@if (entry[1].filename && canShareDownloads()) {
<button type="button" class="btn btn-link" [attr.aria-label]="'Share result file for ' + entry[1].title" (click)="shareDownload(entry[1])"><fa-icon [icon]="faShareNodes" /></button>
}
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
</div>
</td>
</tr>
@if (entry[1].chapter_files && entry[1].chapter_files.length > 0) {
@for (chapterFile of entry[1].chapter_files; track chapterFile.filename) {
<tr [class.disabled]='entry[1].deleting'>
<td></td>
<td>
<div style="padding-left: 2rem;">
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank" [attr.aria-label]="'Open chapter file ' + getChapterFileName(chapterFile.filename)">{{
getChapterFileName(chapterFile.filename) }}</a>
</div>
</td>
<td></td>
<td></td>
<td></td>
<td>
@if (chapterFile.size) {
<span>{{ chapterFile.size | fileSize }}</span>
}
</td>
<td></td>
<td>
<div class="d-flex">
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download [attr.aria-label]="'Download chapter file ' + getChapterFileName(chapterFile.filename)"
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
<div class="metube-section-header">Subscriptions</div>
<div class="px-2 py-3 border-bottom">
@if (checkingAllSubscriptions) {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
</button>
} @else {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="checkAllSubscriptions()"
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
<fa-icon [icon]="faRedoAlt" />&nbsp; Check all now
</button>
}
@if (checkingSelectedSubscriptions) {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
</button>
} @else {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="checkSelectedSubscriptions()"
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
<fa-icon [icon]="faRedoAlt" />&nbsp; Check selected
</button>
}
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="deleteSelectedSubscriptions()"
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
<fa-icon [icon]="faTrashAlt" />&nbsp; Delete selected
</button> </button>
</div> </div>
<div class="overflow-auto"> @if (!downloadingCollapsed) {
<table class="table"> <div class="px-2 py-3 border-bottom">
<thead> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" />&nbsp; Cancel selected</button>
<tr> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" />&nbsp; Download selected</button>
<th scope="col" style="width: 1rem;"> </div>
<input type="checkbox" class="form-check-input" <div class="overflow-auto">
[checked]="allSubsSelected()" <table class="table">
(change)="toggleSubMaster($event)" <thead>
[disabled]="downloads.loading || cachedSubs.length === 0"
aria-label="Select all subscriptions" />
</th>
<th scope="col">Name</th>
<th scope="col">URL</th>
<th scope="col" class="text-nowrap"><span class="help-title" ngbPopover="Subscriptions only — which new video titles to queue when this feed is checked. Does not affect manual downloads." triggers="click" autoClose="outside" container="body">Filter</span></th>
<th scope="col" class="text-nowrap">Interval (min)</th>
<th scope="col" class="text-nowrap">Last checked</th>
<th scope="col">Status</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
@for (entry of cachedSubs; track entry[0]) {
<tr> <tr>
<td> <th scope="col" style="width: 1rem;">
<input type="checkbox" class="form-check-input" <app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
[checked]="isSubSelected(entry[0])" </th>
(change)="toggleSubSelected(entry[0])" <th scope="col">Video</th>
[disabled]="downloads.loading" <th scope="col" style="width: 7rem;">Format</th>
[attr.aria-label]="'Select subscription ' + entry[1].name" /> <th scope="col" style="width: 8rem;">Speed</th>
</td> <th scope="col" style="width: 7rem;">ETA</th>
<td> <th scope="col" style="width: 6rem;"></th>
@if (editingNameId === entry[0]) { </tr>
<div class="d-flex flex-wrap gap-1 align-items-center"> </thead>
<input type="text" <tbody>
class="form-control form-control-sm flex-grow-1" @for (download of downloads.queue | keyvalue: asIsOrder; track download.key) {
[name]="'subName' + entry[0]" <tr [class.disabled]='download.value.deleting'>
[(ngModel)]="nameEditDraft" <td>
[maxlength]="subscriptionNameMaxLength" <app-item-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
[disabled]="downloads.loading" </td>
[attr.aria-label]="'Subscription name for ' + entry[1].name" /> <td title="{{ download.value.filename }}">
<button type="button" class="btn btn-sm btn-outline-secondary" <div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
(click)="saveName(entry[0])" <div class="d-flex align-items-center flex-wrap gap-2">
[disabled]="downloads.loading">Save</button> <span>{{ download.value.title }}</span>
<button type="button" class="btn btn-sm btn-outline-secondary" @if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
(click)="cancelEditName()" <span class="badge bg-danger">LIVE</span>
[disabled]="downloads.loading">Cancel</button> }
</div> </div>
} @else { @if (download.value.status === 'scheduled') {
<div class="d-flex flex-wrap gap-1 align-items-center"> <span class="badge bg-warning text-dark">
<span class="text-break">{{ entry[1].name }}</span> <fa-icon [icon]="faClock" />
<button type="button" class="btn btn-link btn-sm p-0" Waiting for stream
(click)="beginEditName(entry[0], entry[1].name)" @if (liveCountdownSeconds(download.value); as secs) {
[disabled]="downloads.loading" - starts in {{ secs | eta }}
ngbTooltip="Rename this subscription (display name only; does not affect the download folder)">Edit</button> }
</div> </span>
}
</td>
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
<td>
@if (editingTitleRegexId === entry[0]) {
<div class="d-flex flex-wrap gap-1 align-items-center">
<input type="text"
class="form-control form-control-sm flex-grow-1"
[name]="'subTitleRegex' + entry[0]"
[(ngModel)]="titleRegexEditDraft"
[disabled]="downloads.loading" />
<button type="button" class="btn btn-sm btn-outline-secondary"
(click)="saveTitleRegex(entry[0])"
[disabled]="downloads.loading">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
(click)="cancelEditTitleRegex()"
[disabled]="downloads.loading">Cancel</button>
</div>
} @else {
<div class="d-flex flex-wrap gap-1 align-items-center">
<span class="text-muted small text-break"
[class.text-secondary]="!entry[1].title_regex">{{ entry[1].title_regex || '—' }}</span>
<button type="button" class="btn btn-link btn-sm p-0"
(click)="beginEditTitleRegex(entry[0], entry[1].title_regex)"
[disabled]="downloads.loading"
ngbTooltip="Edit subscription title filter (subscriptions only; not for one-off downloads)">Edit</button>
</div>
}
</td>
<td>{{ entry[1].check_interval_minutes }}</td>
<td class="text-nowrap">
@if (entry[1].last_checked !== null) {
<span>{{ entry[1].last_checked! * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</span>
} @else {
<span class="text-muted"></span>
}
</td>
<td>
@if (entry[1].error) {
<span class="text-danger small">{{ entry[1].error }}</span>
} @else if (entry[1].enabled) {
<span class="text-success">Active</span>
} @else {
<span class="text-secondary">Paused</span>
}
</td>
<td>
<div class="d-flex flex-wrap gap-1">
@if (isSubscriptionChecking(entry[0])) {
<button type="button" class="btn btn-link btn-sm p-0 me-2"
disabled
[attr.aria-label]="'Checking ' + entry[1].name"
ngbTooltip="Checking now">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</button>
} @else {
<button type="button" class="btn btn-link btn-sm p-0 me-2"
(click)="checkSubscriptionNow(entry[0])"
[disabled]="downloads.loading"
[attr.aria-label]="'Check now ' + entry[1].name"
ngbTooltip="Check now">
<fa-icon [icon]="faRedoAlt" />
</button>
}
<button type="button" class="btn btn-link btn-sm p-0 me-2"
(click)="toggleSubscriptionEnabled(entry[1])"
[disabled]="downloads.loading"
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
@if (entry[1].enabled) {
<fa-icon [icon]="faPause" />
} @else { } @else {
<fa-icon [icon]="faPlay" /> <ngb-progressbar height="1.5rem" [showValue]="!isIndeterminate(download.value)" [striped]="isIndeterminate(download.value)" [animated]="isIndeterminate(download.value)" type="success"
[value]="isIndeterminate(download.value) ? 100 : download.value.percent" class="download-progressbar">
@if (download.value.status === 'postprocessing') {
<span>Post-processing</span>
}
</ngb-progressbar>
} }
</button> </div>
<button type="button" class="btn btn-link btn-sm p-0 text-danger" </td>
(click)="deleteSubscription(entry[0])" <td class="text-nowrap">{{ formatLabel(download.value) }}</td>
[disabled]="downloads.loading" <td>{{ download.value.speed | speed }}</td>
[attr.aria-label]="'Delete subscription ' + entry[1].name"> <td>{{ download.value.eta | eta }}</td>
<fa-icon [icon]="faTrashAlt" /> <td>
</button> <div class="d-flex">
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
}
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon [icon]="faExternalLinkAlt" /></a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="metube-section-header">
<button type="button" class="metube-section-toggle" (click)="toggleCompletedCollapsed()" [attr.aria-expanded]="!completedCollapsed">
<span>Completed</span>
<fa-icon [icon]="completedCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
</button>
</div>
@if (!completedCollapsed) {
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" />&nbsp; {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone" (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" />&nbsp; Clear completed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" />&nbsp; Clear failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" />&nbsp; Retry failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" />&nbsp; Download Selected</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" [orderedIds]="cachedSortedDoneIds" (changed)="doneSelectionChanged($event)" />
</th>
<th scope="col">Video</th>
<th scope="col">Type</th>
<th scope="col">Quality</th>
<th scope="col">Codec / Format</th>
<th scope="col">File Size</th>
<th scope="col">Downloaded</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
@for (entry of cachedSortedDone; track entry[0]) {
<tr [class.disabled]='entry[1].deleting'>
<td>
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
@if (entry[1].status === 'finished') {
<fa-icon [icon]="faCheckCircle" class="text-success" />
}
@if (entry[1].status === 'error') {
<button type="button" class="btn btn-link p-0"
(click)="toggleErrorDetail(entry[0])"
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
[attr.aria-expanded]="isErrorExpanded(entry[0])">
<fa-icon [icon]="faTimesCircle" class="text-danger" />
</button>
}
</div>
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
} @else {
@if (entry[1].status === 'error') {
<button type="button" class="btn btn-link p-0 text-start align-baseline" (click)="toggleErrorDetail(entry[0])">
{{entry[1].title}}
@if (!isErrorExpanded(entry[0])) {
<small class="text-danger ms-2">
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
</small>
}
</button>
} @else {
<span>{{entry[1].title}}</span>
}
}</span>
@if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
<div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
@if (entry[1].msg) {
<div class="mb-1"><strong>Message:</strong> {{entry[1].msg}}</div>
}
@if (entry[1].error) {
<div class="mb-1"><strong>Error:</strong> {{entry[1].error}}</div>
}
<div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0"
(click)="copyErrorMessage(entry[0], entry[1]); $event.stopPropagation()"
ngbTooltip="Copy error details to clipboard">
@if (lastCopiedErrorId === entry[0]) {
<span class="text-success">Copied!</span>
} @else {
<fa-icon [icon]="faCopy" />
}
</button>
</div>
</div>
}
</td>
<td class="text-nowrap">
{{ downloadTypeLabel(entry[1]) }}
</td>
<td class="text-nowrap">
{{ formatQualityLabel(entry[1]) }}
</td>
<td class="text-nowrap">
{{ formatCodecLabel(entry[1]) }}
</td>
<td>
@if (entry[1].size) {
<span>{{ entry[1].size | fileSize }}</span>
}
</td>
<td class="text-nowrap">
@if (entry[1].timestamp) {
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
}
</td>
<td>
<div class="d-flex">
@if (entry[1].status === 'error') {
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
}
@if (entry[1].filename) {
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
}
@if (entry[1].filename && canShareDownloads()) {
<button type="button" class="btn btn-link" [attr.aria-label]="'Share result file for ' + entry[1].title" (click)="shareDownload(entry[1])"><fa-icon [icon]="faShareNodes" /></button>
}
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
</div>
</td>
</tr>
@if (entry[1].chapter_files && entry[1].chapter_files.length > 0) {
@for (chapterFile of entry[1].chapter_files; track chapterFile.filename) {
<tr [class.disabled]='entry[1].deleting'>
<td></td>
<td>
<div style="padding-left: 2rem;">
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank" [attr.aria-label]="'Open chapter file ' + getChapterFileName(chapterFile.filename)">{{
getChapterFileName(chapterFile.filename) }}</a>
</div>
</td>
<td></td>
<td></td>
<td></td>
<td>
@if (chapterFile.size) {
<span>{{ chapterFile.size | fileSize }}</span>
}
</td>
<td></td>
<td>
<div class="d-flex">
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download [attr.aria-label]="'Download chapter file ' + getChapterFileName(chapterFile.filename)"
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
</div> </div>
</td> </td>
</tr> </tr>
}
} }
</tbody> }
</table> </tbody>
</table>
</div>
}
<div class="metube-section-header">
<button type="button" class="metube-section-toggle" (click)="toggleSubscriptionsCollapsed()" [attr.aria-expanded]="!subscriptionsCollapsed">
<span>Subscriptions</span>
<fa-icon [icon]="subscriptionsCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
</button>
</div> </div>
@if (!subscriptionsCollapsed) {
<div class="px-2 py-3 border-bottom">
@if (checkingAllSubscriptions) {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
</button>
} @else {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="checkAllSubscriptions()"
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
<fa-icon [icon]="faRedoAlt" />&nbsp; Check all now
</button>
}
@if (checkingSelectedSubscriptions) {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
</button>
} @else {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="checkSelectedSubscriptions()"
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
<fa-icon [icon]="faRedoAlt" />&nbsp; Check selected
</button>
}
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="deleteSelectedSubscriptions()"
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
<fa-icon [icon]="faTrashAlt" />&nbsp; Delete selected
</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<input type="checkbox" class="form-check-input"
[checked]="allSubsSelected()"
(change)="toggleSubMaster($event)"
[disabled]="downloads.loading || cachedSubs.length === 0"
aria-label="Select all subscriptions" />
</th>
<th scope="col">Name</th>
<th scope="col">URL</th>
<th scope="col" class="text-nowrap"><span class="help-title" ngbPopover="Subscriptions only — which new video titles to queue when this feed is checked. Does not affect manual downloads." triggers="click" autoClose="outside" container="body">Filter</span></th>
<th scope="col" class="text-nowrap">Interval (min)</th>
<th scope="col" class="text-nowrap">Last checked</th>
<th scope="col">Status</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
@for (entry of cachedSubs; track entry[0]) {
<tr>
<td>
<input type="checkbox" class="form-check-input"
[checked]="isSubSelected(entry[0])"
(change)="toggleSubSelected(entry[0])"
[disabled]="downloads.loading"
[attr.aria-label]="'Select subscription ' + entry[1].name" />
</td>
<td>
@if (editingNameId === entry[0]) {
<div class="d-flex flex-wrap gap-1 align-items-center">
<input type="text"
class="form-control form-control-sm flex-grow-1"
[name]="'subName' + entry[0]"
[(ngModel)]="nameEditDraft"
[maxlength]="subscriptionNameMaxLength"
[disabled]="downloads.loading"
[attr.aria-label]="'Subscription name for ' + entry[1].name" />
<button type="button" class="btn btn-sm btn-outline-secondary"
(click)="saveName(entry[0])"
[disabled]="downloads.loading">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
(click)="cancelEditName()"
[disabled]="downloads.loading">Cancel</button>
</div>
} @else {
<div class="d-flex flex-wrap gap-1 align-items-center">
<span class="text-break">{{ entry[1].name }}</span>
<button type="button" class="btn btn-link btn-sm p-0"
(click)="beginEditName(entry[0], entry[1].name)"
[disabled]="downloads.loading"
ngbTooltip="Rename this subscription (display name only; does not affect the download folder)">Edit</button>
</div>
}
</td>
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
<td>
@if (editingTitleRegexId === entry[0]) {
<div class="d-flex flex-wrap gap-1 align-items-center">
<input type="text"
class="form-control form-control-sm flex-grow-1"
[name]="'subTitleRegex' + entry[0]"
[(ngModel)]="titleRegexEditDraft"
[disabled]="downloads.loading" />
<button type="button" class="btn btn-sm btn-outline-secondary"
(click)="saveTitleRegex(entry[0])"
[disabled]="downloads.loading">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
(click)="cancelEditTitleRegex()"
[disabled]="downloads.loading">Cancel</button>
</div>
} @else {
<div class="d-flex flex-wrap gap-1 align-items-center">
<span class="text-muted small text-break"
[class.text-secondary]="!entry[1].title_regex">{{ entry[1].title_regex || '—' }}</span>
<button type="button" class="btn btn-link btn-sm p-0"
(click)="beginEditTitleRegex(entry[0], entry[1].title_regex)"
[disabled]="downloads.loading"
ngbTooltip="Edit subscription title filter (subscriptions only; not for one-off downloads)">Edit</button>
</div>
}
</td>
<td>{{ entry[1].check_interval_minutes }}</td>
<td class="text-nowrap">
@if (entry[1].last_checked !== null) {
<span>{{ entry[1].last_checked! * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</span>
} @else {
<span class="text-muted"></span>
}
</td>
<td>
@if (entry[1].error) {
<span class="text-danger small">{{ entry[1].error }}</span>
} @else if (entry[1].enabled) {
<span class="text-success">Active</span>
} @else {
<span class="text-secondary">Paused</span>
}
</td>
<td>
<div class="d-flex flex-wrap gap-1">
@if (isSubscriptionChecking(entry[0])) {
<button type="button" class="btn btn-link btn-sm p-0 me-2"
disabled
[attr.aria-label]="'Checking ' + entry[1].name"
ngbTooltip="Checking now">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</button>
} @else {
<button type="button" class="btn btn-link btn-sm p-0 me-2"
(click)="checkSubscriptionNow(entry[0])"
[disabled]="downloads.loading"
[attr.aria-label]="'Check now ' + entry[1].name"
ngbTooltip="Check now">
<fa-icon [icon]="faRedoAlt" />
</button>
}
<button type="button" class="btn btn-link btn-sm p-0 me-2"
(click)="toggleSubscriptionEnabled(entry[1])"
[disabled]="downloads.loading"
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
@if (entry[1].enabled) {
<fa-icon [icon]="faPause" />
} @else {
<fa-icon [icon]="faPlay" />
}
</button>
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
(click)="deleteSubscription(entry[0])"
[disabled]="downloads.loading"
[attr.aria-label]="'Delete subscription ' + entry[1].name">
<fa-icon [icon]="faTrashAlt" />
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</main><!-- /.container --> </main><!-- /.container -->
<footer class="footer navbar-dark bg-dark py-3 mt-5"> <footer class="footer navbar-dark bg-dark py-3 mt-5">
+22
View File
@@ -10,6 +10,28 @@
padding: 0.5rem 0 padding: 0.5rem 0
margin-top: 3.5rem margin-top: 3.5rem
.metube-section-toggle
// Positioned so it paints above the header's full-bleed :before overlay.
position: relative
display: flex
align-items: center
// Title left, chevron against the right edge, so all three section titles
// stay on the same left margin whether or not a chevron is present.
justify-content: space-between
gap: 0.75rem
width: 100%
padding: 0
border: 0
background: none
color: inherit
font: inherit
text-align: left
.metube-section-chevron
font-size: 1.1rem
width: 1.1rem
color: var(--bs-secondary-color)
.metube-section-header:before .metube-section-header:before
content: "" content: ""
position: absolute position: absolute
+25
View File
@@ -168,6 +168,31 @@ describe('App', () => {
expect(fixture.componentInstance.folder).toBe('music'); expect(fixture.componentInstance.folder).toBe('music');
}); });
it('collapses each section independently and remembers it (#1070)', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const app = fixture.componentInstance;
const cookies = TestBed.inject(CookieService);
expect(app.downloadingCollapsed).toBe(false);
expect(app.completedCollapsed).toBe(false);
expect(app.subscriptionsCollapsed).toBe(false);
app.toggleCompletedCollapsed();
expect(app.completedCollapsed).toBe(true);
expect(app.downloadingCollapsed).toBe(false);
expect(app.subscriptionsCollapsed).toBe(false);
expect(cookies.get('metube_completed_collapsed')).toBe('true');
// A fresh component picks the state back up from the cookie.
const restored = TestBed.createComponent(App);
restored.detectChanges();
expect(restored.componentInstance.completedCollapsed).toBe(true);
expect(restored.componentInstance.downloadingCollapsed).toBe(false);
expect(restored.componentInstance.subscriptionsCollapsed).toBe(false);
});
it('asIsOrder returns a stable comparator value (insertion order preserved)', () => { it('asIsOrder returns a stable comparator value (insertion order preserved)', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance; const app = fixture.componentInstance;
+21
View File
@@ -136,6 +136,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
metubeVersion: string | null = null; metubeVersion: string | null = null;
isAdvancedOpen = false; isAdvancedOpen = false;
sortAscending = false; sortAscending = false;
downloadingCollapsed = false;
completedCollapsed = false;
subscriptionsCollapsed = false;
expandedErrors: Set<string> = new Set<string>(); expandedErrors: Set<string> = new Set<string>();
cachedSortedDone: [string, Download][] = []; cachedSortedDone: [string, Download][] = [];
// The done ids in rendered order, so a shift-click range follows the sort // The done ids in rendered order, so a shift-click range follows the sort
@@ -289,6 +292,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.previousDownloadType = this.downloadType; this.previousDownloadType = this.downloadType;
this.saveSelection(this.downloadType); this.saveSelection(this.downloadType);
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true'; this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
this.downloadingCollapsed = this.cookieService.get('metube_downloading_collapsed') === 'true';
this.completedCollapsed = this.cookieService.get('metube_completed_collapsed') === 'true';
this.subscriptionsCollapsed = this.cookieService.get('metube_subscriptions_collapsed') === 'true';
const ci = parseInt(this.cookieService.get('metube_check_interval') || '', 10); const ci = parseInt(this.cookieService.get('metube_check_interval') || '', 10);
if (!Number.isNaN(ci) && ci >= 1) { if (!Number.isNaN(ci) && ci >= 1) {
@@ -1569,6 +1575,21 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.rebuildSortedDone(); this.rebuildSortedDone();
} }
toggleDownloadingCollapsed() {
this.downloadingCollapsed = !this.downloadingCollapsed;
this.cookieService.set('metube_downloading_collapsed', this.downloadingCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
}
toggleCompletedCollapsed() {
this.completedCollapsed = !this.completedCollapsed;
this.cookieService.set('metube_completed_collapsed', this.completedCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
}
toggleSubscriptionsCollapsed() {
this.subscriptionsCollapsed = !this.subscriptionsCollapsed;
this.cookieService.set('metube_subscriptions_collapsed', this.subscriptionsCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
}
private rebuildSortedDone() { private rebuildSortedDone() {
const result: [string, Download][] = []; const result: [string, Download][] = [];
this.downloads.done.forEach((dl, key) => { this.downloads.done.forEach((dl, key) => {