metube/ui/src/app/app.html
2026-05-29 14:13:47 +03:00

1062 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<nav class="navbar navbar-expand-md navbar-dark">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="#">
<img src="assets/icons/android-chrome-192x192.png" alt="MeTube Logo" height="32" class="me-2">
MeTube
</a>
<div class="download-metrics">
@if (activeDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faDownload" class="text-primary" />
<span>{{activeDownloads}} downloading</span>
</div>
}
@if (queuedDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faClock" class="text-warning" />
<span>{{queuedDownloads}} queued</span>
</div>
}
@if (completedDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faCheck" class="text-success" />
<span>{{completedDownloads}} completed</span>
</div>
}
@if (failedDownloads > 0) {
<div class="metric">
<fa-icon [icon]="faTimesCircle" class="text-danger" />
<span>{{failedDownloads}} failed</span>
</div>
}
@if ((totalSpeed | speed) !== '') {
<div class="metric">
<fa-icon [icon]="faTachometerAlt" class="text-info" />
<span>{{totalSpeed | speed }}</span>
</div>
}
</div>
<!--
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsDefault" aria-controls="navbarsDefault" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarsDefault">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
</li>
</ul>
</div>
-->
<div class="navbar-nav ms-auto">
<div class="nav-item dropdown" ngbDropdown placement="bottom-end">
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
id="theme-select"
type="button"
aria-expanded="false"
ngbDropdownToggle>
@if(activeTheme){
<fa-icon [icon]="activeTheme.icon" />
}
</button>
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select" ngbDropdownMenu>
@for (theme of themes; track theme) {
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
[class.active]="activeTheme === theme"
ngbDropdownItem
(click)="themeChanged(theme)">
<span class="me-2 opacity-50">
<fa-icon [icon]="theme.icon" />
</span>
{{ theme.displayName }}
<span class="ms-auto"
[class.d-none]="activeTheme !== theme">
<fa-icon [icon]="faCheck" />
</span>
</button>
</li>
}
</ul>
</div>
</div>
</div>
</nav>
<main role="main" class="container container-xl">
<form #f="ngForm">
<div class="container add-url-box">
<!-- Main URL Input with Download Button -->
<div class="row mb-4">
<div class="col">
<ng-template #urlBarActions>
@if (addInProgress && cancelRequested) {
<button class="btn btn-warning btn-lg px-3" type="button" disabled>
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Canceling...
</button>
} @else if (addInProgress) {
<button class="btn btn-secondary btn-lg px-3 add-progress-btn" type="button" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Adding...
</button>
<button class="btn btn-outline-danger btn-lg px-3 add-cancel-btn"
type="button"
(click)="cancelAdding()"
aria-label="Cancel adding URL"
title="Cancel adding URL">
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
</button>
} @else if (subscribeInProgress) {
<button class="btn btn-primary btn-lg px-4" type="button" disabled>
Download
</button>
<button class="btn btn-outline-secondary btn-lg px-3" type="button" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Subscribing...
</button>
} @else {
<button class="btn btn-primary btn-lg px-4" type="submit"
(click)="addDownload()"
[disabled]="downloads.loading">
Download
</button>
<button class="btn btn-outline-secondary btn-lg px-3" type="button"
(click)="addSubscription()"
[disabled]="downloads.loading">
Subscribe
</button>
}
</ng-template>
<!-- Narrow viewports: full-width field, then Bootstrap btn-group (no faux input-group strip) -->
<div class="vstack gap-2 d-md-none">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video, channel, or playlist URL"
[(ngModel)]="addUrl"
[ngModelOptions]="{standalone: true}"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<div class="btn-group w-100" role="group" aria-label="Download or subscribe">
<ng-container [ngTemplateOutlet]="urlBarActions" />
</div>
</div>
<!-- md and up: standard input-group so Bootstrap handles fused borders -->
<div class="input-group input-group-lg shadow-sm d-none d-md-flex">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video, channel, or playlist URL"
[(ngModel)]="addUrl"
[ngModelOptions]="{standalone: true}"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<ng-container [ngTemplateOutlet]="urlBarActions" />
</div>
</div>
</div>
<!-- Options Row -->
<div class="row mb-3 g-3">
@if (downloadType === 'video') {
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text">Type</span>
<select class="form-select"
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text">Codec</span>
<select class="form-select"
name="codec"
[(ngModel)]="codec"
(change)="codecChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (vc of videoCodecs; track vc.id) {
<option [ngValue]="vc.id">{{ vc.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text">Quality</span>
<select class="form-select"
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading || !showQualitySelector()">
@for (q of qualities; track q.id) {
<option [ngValue]="q.id">{{ q.text }}</option>
}
</select>
</div>
</div>
} @else if (downloadType === 'audio') {
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Type</span>
<select class="form-select"
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Quality</span>
<select class="form-select"
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (q of qualities; track q.id) {
<option [ngValue]="q.id">{{ q.text }}</option>
}
</select>
</div>
</div>
} @else if (downloadType === 'captions') {
<!-- 4× col-md-3 is too tight at ~768px (long addons wrap the 4th field); 2×2 mdlg, one row lg+ -->
<div class="col-12 col-md-6 col-lg-3">
<div class="input-group">
<span class="input-group-text">Type</span>
<select class="form-select"
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
</select>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Subtitle output format for captions mode." triggers="click" autoClose="outside" container="body">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
</select>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Subtitle language (you can type any language code)." triggers="click" autoClose="outside" container="body">Language</span>
<input class="form-control"
type="text"
list="subtitleLanguageOptions"
name="subtitleLanguage"
[(ngModel)]="subtitleLanguage"
(change)="subtitleLanguageChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
placeholder="e.g. en, es, zh-Hans">
<datalist id="subtitleLanguageOptions">
@for (lang of subtitleLanguages; track lang.id) {
<option [value]="lang.id">{{ lang.text }}</option>
}
</datalist>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Choose manual, auto, or fallback preference for captions mode." triggers="click" autoClose="outside" container="body">Subtitle Source</span>
<select class="form-select"
name="subtitleMode"
[(ngModel)]="subtitleMode"
(change)="subtitleModeChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (mode of subtitleModes; track mode.id) {
<option [ngValue]="mode.id">{{ mode.text }}</option>
}
</select>
</div>
</div>
} @else {
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Type</span>
<select class="form-select"
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Format</span>
<input class="form-control" value="JPG" disabled>
</div>
</div>
}
</div>
<div class="row mb-3 g-3">
<div class="col-12 text-start">
<button type="button"
class="btn btn-link p-0 text-decoration-none"
(click)="toggleAdvanced()"
[attr.aria-expanded]="isAdvancedOpen"
aria-controls="advancedOptions">
Advanced Options
<fa-icon
[icon]="isAdvancedOpen ? faChevronDown : faChevronRight"
class="ms-1" />
</button>
</div>
</div>
<!-- Advanced Options Panel -->
<div class="row">
<div class="col-12">
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
<div class="pt-1 pb-2">
<!-- Output -->
<div class="settings-section-label">Output</div>
<div class="row g-3">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Type to filter existing folders, or enter a new folder name." triggers="click" autoClose="outside" container="body">Download Folder</span>
<input type="text"
class="form-control"
placeholder="Default"
name="folder"
[(ngModel)]="folder"
[ngbTypeahead]="searchFolder"
[editable]="!!downloads.configuration['CREATE_CUSTOM_DIRS']"
(focus)="folderFocus$.next($any($event.target).value)"
(click)="folderClick$.next($any($event.target).value)"
#folderTypeahead="ngbTypeahead"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Add a prefix to downloaded filenames." triggers="click" autoClose="outside" container="body">Custom Name Prefix</span>
<input type="text"
class="form-control"
placeholder="Default"
name="customNamePrefix"
[(ngModel)]="customNamePrefix"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
</div>
</div>
<div class="col-12">
<div class="row g-2 align-items-center">
<div class="col-auto">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
name="splitByChapters" [(ngModel)]="splitByChapters" (change)="splitByChaptersChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
</div>
</div>
@if (splitByChapters) {
<div class="col">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Output template for chapter files." triggers="click" autoClose="outside" container="body">Template</span>
<input type="text" class="form-control" name="chapterTemplate" [(ngModel)]="chapterTemplate"
(change)="chapterTemplateChanged()" [disabled]="addInProgress || subscribeInProgress || downloads.loading">
</div>
</div>
}
</div>
</div>
@if (downloadType === 'video' || downloadType === 'audio') {
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Optional start time (seconds, M:SS, or H:MM:SS). Blank = from start or YouTube &t= in URL." triggers="click" autoClose="outside" container="body">Clip start</span>
<input type="text"
class="form-control"
name="clipStart"
[(ngModel)]="clipStart"
(change)="clipStartChanged()"
placeholder="e.g. 2:26"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Optional end time. Blank = until end of media." triggers="click" autoClose="outside" container="body">Clip end</span>
<input type="text"
class="form-control"
name="clipEnd"
[(ngModel)]="clipEnd"
(change)="clipEndChanged()"
placeholder="e.g. 3:24"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
</div>
</div>
}
</div>
<!-- Behavior -->
<div class="settings-section-label">Behavior</div>
<div class="row g-3">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Automatically start downloads when added." triggers="click" autoClose="outside" container="body">Auto Start</span>
<select class="form-select"
name="autoStart"
[(ngModel)]="autoStart"
(change)="autoStartChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Maximum number of items to download from a playlist or channel (0 = no limit)." triggers="click" autoClose="outside" container="body">Items Limit</span>
<input type="number"
min="0"
class="form-control"
placeholder="Default"
name="playlistItemLimit"
(keydown)="isNumber($event)"
[(ngModel)]="playlistItemLimit"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="How often to poll subscriptions for new videos." triggers="click" autoClose="outside" container="body">Subscription Check (min)</span>
<input type="number"
min="1"
class="form-control"
name="checkIntervalMinutes"
(keydown)="isNumber($event)"
[(ngModel)]="checkIntervalMinutes"
(ngModelChange)="checkIntervalChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="In subscriptions, only titles matching this Python-style regex are queued. Empty = all. Case-sensitive; use (?i) in the pattern for case-insensitive." triggers="click" autoClose="outside" container="body">Subscription Title Filter</span>
<input type="text"
class="form-control"
name="titleRegex"
[(ngModel)]="titleRegex"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
placeholder="Optional regex">
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-skip-subscriber-only"
name="skipSubscriberOnly" [(ngModel)]="skipSubscriberOnly"
[disabled]="addInProgress || subscribeInProgress || downloads.loading" />
<label class="form-check-label" for="checkbox-skip-subscriber-only">
<span class="help-title" tabindex="0" role="button"
ngbPopover="When enabled, subscription checks skip videos marked members-only by yt-dlp (channel Join). Ignored for one-off downloads."
triggers="click" autoClose="outside" container="body"
(click)="$event.preventDefault(); $event.stopPropagation()"
(keydown.enter)="$event.preventDefault(); $event.stopPropagation()"
(keydown.space)="$event.preventDefault(); $event.stopPropagation()">Skip members-only subscription videos</span>
</label>
</div>
</div>
</div>
<!-- yt-dlp -->
<div class="settings-section-label">yt-dlp</div>
<div class="row g-3">
<div class="col-12" [class.col-md-6]="allowYtdlOptionsOverrides()">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Choose one or more yt-dlp option presets configured on the server (applied in order)." triggers="click" autoClose="outside" container="body">Option Presets</span>
<ng-select
class="flex-grow-1"
name="ytdlOptionsPresets"
[items]="ytdlOptionPresetNames"
[multiple]="true"
[closeOnSelect]="false"
placeholder="Default"
[(ngModel)]="ytdlOptionsPresets"
(ngModelChange)="ytdlOptionsPresetsChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading" />
</div>
</div>
@if (allowYtdlOptionsOverrides()) {
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text help-title" ngbPopover="Optional per-download yt-dlp overrides as a JSON object." triggers="click" autoClose="outside" container="body">Custom yt-dlp Options</span>
<input type="text"
class="form-control"
placeholder='e.g. {"writesubtitles": true}'
name="ytdlOptionsOverrides"
[(ngModel)]="ytdlOptionsOverrides"
(change)="ytdlOptionsOverridesChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
</div>
</div>
}
</div>
<!-- Tools -->
<div class="settings-section-label">Tools</div>
<div class="row g-3">
<div class="col-md-4">
<div class="action-group-label help-title" ngbPopover="Upload a cookies.txt file from your browser to authenticate restricted or private downloads." triggers="click" autoClose="outside" container="body">Cookies</div>
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
(change)="onCookieFileSelect($event)"
[disabled]="cookieUploadInProgress || addInProgress">
<div class="btn-group w-100" role="group">
<label class="btn mb-0"
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
[class.disabled]="cookieUploadInProgress || addInProgress"
for="cookie-upload">
@if (cookieUploadInProgress) {
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
} @else {
<fa-icon [icon]="faUpload" class="me-2" />
}
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
</label>
@if (hasCookies) {
<button type="button" class="btn btn-outline-danger"
(click)="deleteCookies()"
[disabled]="cookieUploadInProgress || addInProgress"
ngbTooltip="Remove uploaded cookies">
<fa-icon [icon]="faTrashAlt" />
</button>
}
</div>
<div class="cookie-status" [class.active]="hasCookies">
@if (hasCookies) {
<fa-icon [icon]="faCheckCircle" class="me-1" />
Cookies active
} @else {
No cookies configured
}
</div>
</div>
<div class="col-md-8">
<div class="action-group-label">Bulk Actions</div>
<div class="row g-2">
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="openBatchImportModal()">
<fa-icon [icon]="faFileImport" class="me-2" />
Import URLs
</button>
</div>
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="exportBatchUrls('all')">
<fa-icon [icon]="faFileExport" class="me-2" />
Export URLs
</button>
</div>
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="copyBatchUrls('all')">
<fa-icon [icon]="faCopy" class="me-2" />
Copy URLs
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- Batch Import Modal -->
<div class="modal fade" tabindex="-1" role="dialog"
aria-modal="true"
aria-labelledby="batch-import-modal-title"
[class.show]="batchImportModalOpen"
[style.display]="batchImportModalOpen ? 'block' : 'none'">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 id="batch-import-modal-title" class="modal-title">Batch Import URLs</h5>
<button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button>
</div>
<div class="modal-body">
<textarea id="batch-import-textarea" [(ngModel)]="batchImportText" class="form-control" rows="6"
placeholder="Paste one video URL per line"></textarea>
<div class="mt-2">
@if (batchImportStatus) {
<small>{{ batchImportStatus }}</small>
}
@if (batchImportTotal > 0) {
<div class="progress mt-2" style="height: 20px;">
<div class="progress-bar"
[class.bg-danger]="batchImportFailures > 0"
role="progressbar"
[style.width.%]="(batchImportCount / batchImportTotal) * 100"
[attr.aria-valuenow]="batchImportCount"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="batchImportTotal">
{{ batchImportCount }} / {{ batchImportTotal }}
</div>
</div>
}
</div>
</div>
<div class="modal-footer">
@if (importInProgress) {
<button type="button" class="btn btn-danger me-auto" (click)="cancelBatchImport()">
Cancel Import
</button>
}
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
Import URLs
</button>
</div>
</div>
</div>
</div>
@if (downloads.loading) {
<div class="alert alert-info" role="alert">
Connecting to server...
</div>
}
<div class="metube-section-header">Downloading</div>
<div class="px-2 py-3 border-bottom">
<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>
<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>
</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: 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.value.id) {
<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>{{ download.value.title }} </div>
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
</div>
</td>
<td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td>
<td>
<div class="d-flex">
@if (download.value.status === 'pending') {
<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" (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[1].id) {
<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>
}
<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>
</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">Sub. title 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>{{ entry[1].name }}</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 -->
<footer class="footer navbar-dark bg-dark py-3 mt-5">
<div class="container text-center">
@if (ytDlpVersion && metubeVersion) {
<div class="footer-content">
<div class="version-item">
<span class="version-label">yt-dlp</span>
<span class="version-value">{{ytDlpVersion}}</span>
</div>
<div class="version-separator"></div>
<div class="version-item">
<span class="version-label">MeTube</span>
<span class="version-value">{{metubeVersion}}</span>
</div>
<div class="version-separator"></div>
@if (ytDlpOptionsUpdateTime) {
<div class="version-item">
<span class="version-label">yt-dlp-options</span>
<span class="version-value">{{ytDlpOptionsUpdateTime}}</span>
</div>
}
@if (ytDlpOptionsUpdateTime) {
<div class="version-separator"></div>
}
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
<fa-icon [icon]="faGithub" />
<span>GitHub</span>
</a>
</div>
}
</div>
</footer>