feat: 新增内嵌视频播放器,支持在线播放已完成视频
This commit is contained in:
parent
90c4115f20
commit
451cc3e0c3
|
|
@ -76,7 +76,8 @@
|
||||||
(clearCompleted)="clearCompletedDownloads()"
|
(clearCompleted)="clearCompletedDownloads()"
|
||||||
(clearFailed)="clearFailedDownloads()"
|
(clearFailed)="clearFailedDownloads()"
|
||||||
(retryFailed)="retryFailedDownloads()"
|
(retryFailed)="retryFailedDownloads()"
|
||||||
(downloadSelected)="downloadSelectedFiles($event)">
|
(downloadSelected)="downloadSelectedFiles($event)"
|
||||||
|
(play)="openPlayer($event)">
|
||||||
</app-download-list>
|
</app-download-list>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -146,4 +147,16 @@
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- In-page video player -->
|
||||||
|
<div class="scrim" *ngIf="playerUrl" (click)="closePlayer()"></div>
|
||||||
|
<div class="player" *ngIf="playerUrl" role="dialog" aria-label="视频播放">
|
||||||
|
<div class="player-head">
|
||||||
|
<span class="player-title" [title]="playerTitle">{{ playerTitle }}</span>
|
||||||
|
<button type="button" class="icon-btn neutral" (click)="closePlayer()" aria-label="关闭">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<video class="player-video" [src]="playerUrl" controls autoplay></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
<app-toasts></app-toasts>
|
<app-toasts></app-toasts>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,45 @@
|
||||||
font-weight: 600
|
font-weight: 600
|
||||||
color: var(--fg)
|
color: var(--fg)
|
||||||
|
|
||||||
|
// In-page video player
|
||||||
|
.player
|
||||||
|
position: fixed
|
||||||
|
top: 50%
|
||||||
|
left: 50%
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
width: min(960px, 92vw)
|
||||||
|
background: var(--surface)
|
||||||
|
border: 1px solid var(--border)
|
||||||
|
border-radius: 12px
|
||||||
|
box-shadow: 0 24px 64px oklch(20% 0.02 240 / 0.35)
|
||||||
|
z-index: 50
|
||||||
|
overflow: hidden
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
|
||||||
|
.player-head
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 12px
|
||||||
|
padding: 10px 14px
|
||||||
|
border-bottom: 1px solid var(--border)
|
||||||
|
flex: none
|
||||||
|
|
||||||
|
.player-title
|
||||||
|
flex: 1
|
||||||
|
min-width: 0
|
||||||
|
font-size: 14px
|
||||||
|
font-weight: 600
|
||||||
|
color: var(--fg)
|
||||||
|
white-space: nowrap
|
||||||
|
overflow: hidden
|
||||||
|
text-overflow: ellipsis
|
||||||
|
|
||||||
|
.player-video
|
||||||
|
width: 100%
|
||||||
|
max-height: 78vh
|
||||||
|
background: #000
|
||||||
|
|
||||||
.icon-btn
|
.icon-btn
|
||||||
margin-left: auto
|
margin-left: auto
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, HostListener, OnInit } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, distinctUntilChanged, map } from 'rxjs';
|
import { Observable, distinctUntilChanged, map } from 'rxjs';
|
||||||
|
|
||||||
|
|
@ -45,6 +45,10 @@ export class AppComponent implements OnInit {
|
||||||
sidebarOpen = false;
|
sidebarOpen = false;
|
||||||
activityOpen = false;
|
activityOpen = false;
|
||||||
|
|
||||||
|
// In-page video player (completed items)
|
||||||
|
playerUrl: string | null = null;
|
||||||
|
playerTitle = '';
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
activeDownloads = 0;
|
activeDownloads = 0;
|
||||||
queuedDownloads = 0;
|
queuedDownloads = 0;
|
||||||
|
|
@ -136,6 +140,23 @@ export class AppComponent implements OnInit {
|
||||||
this.isAdvancedOpen = open === undefined ? !this.isAdvancedOpen : open;
|
this.isAdvancedOpen = open === undefined ? !this.isAdvancedOpen : open;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- In-page player ----------
|
||||||
|
|
||||||
|
openPlayer(row: KeyedDownload): void {
|
||||||
|
this.playerUrl = this.buildDownloadLink(row.value);
|
||||||
|
this.playerTitle = row.value.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
closePlayer(): void {
|
||||||
|
this.playerUrl = null;
|
||||||
|
this.playerTitle = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscape(): void {
|
||||||
|
if (this.playerUrl) this.closePlayer();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Data getters ----------
|
// ---------- Data getters ----------
|
||||||
|
|
||||||
statusEntries(tab: DownloadTab): KeyedDownload[] {
|
statusEntries(tab: DownloadTab): KeyedDownload[] {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,10 @@
|
||||||
<span class="num card-size">{{ row.value.size ? (row.value.size | fileSize) : '—' }}</span>
|
<span class="num card-size">{{ row.value.size ? (row.value.size | fileSize) : '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<a *ngIf="downloadUrl" class="icon-btn neutral" [href]="downloadUrl" target="_blank" title="下载文件">
|
<button *ngIf="downloadUrl" type="button" class="icon-btn neutral" (click)="play.emit()" title="在线播放" aria-label="在线播放">
|
||||||
|
<fa-icon [icon]="faPlay"></fa-icon>
|
||||||
|
</button>
|
||||||
|
<a *ngIf="downloadUrl" class="icon-btn neutral" [href]="downloadUrl" [attr.download]="row.value.filename" title="下载文件">
|
||||||
<fa-icon [icon]="faDownload"></fa-icon>
|
<fa-icon [icon]="faDownload"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
<button *ngIf="row.value.status === 'error'" type="button" class="icon-btn neutral" (click)="retry.emit({ key: row.key, dl: row.value })" title="重试">
|
<button *ngIf="row.value.status === 'error'" type="button" class="icon-btn neutral" (click)="retry.emit({ key: row.key, dl: row.value })" title="重试">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
import { faDownload, faExternalLinkAlt, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faDownload, faExternalLinkAlt, faPlay, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { Download, DownloadsService } from './downloads.service';
|
import { Download, DownloadsService } from './downloads.service';
|
||||||
import { KeyedDownload } from './active-downloads.component';
|
import { KeyedDownload } from './active-downloads.component';
|
||||||
|
|
@ -22,9 +22,11 @@ export class DownloadCardComponent {
|
||||||
@Output() del = new EventEmitter<string>();
|
@Output() del = new EventEmitter<string>();
|
||||||
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
|
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
|
||||||
@Output() folderChange = new EventEmitter<{ key: string; folder: string }>();
|
@Output() folderChange = new EventEmitter<{ key: string; folder: string }>();
|
||||||
|
@Output() play = new EventEmitter<void>();
|
||||||
|
|
||||||
faDownload = faDownload;
|
faDownload = faDownload;
|
||||||
faExternalLinkAlt = faExternalLinkAlt;
|
faExternalLinkAlt = faExternalLinkAlt;
|
||||||
|
faPlay = faPlay;
|
||||||
faRedoAlt = faRedoAlt;
|
faRedoAlt = faRedoAlt;
|
||||||
faTrashAlt = faTrashAlt;
|
faTrashAlt = faTrashAlt;
|
||||||
faCheckCircle = faCheckCircle;
|
faCheckCircle = faCheckCircle;
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@
|
||||||
(start)="start.emit([$event])"
|
(start)="start.emit([$event])"
|
||||||
(del)="del.emit([$event])"
|
(del)="del.emit([$event])"
|
||||||
(retry)="retry.emit($event)"
|
(retry)="retry.emit($event)"
|
||||||
(folderChange)="folderEdit.emit($event)">
|
(folderChange)="folderEdit.emit($event)"
|
||||||
|
(play)="play.emit(row)">
|
||||||
</app-download-row>
|
</app-download-row>
|
||||||
</div>
|
</div>
|
||||||
</cdk-virtual-scroll-viewport>
|
</cdk-virtual-scroll-viewport>
|
||||||
|
|
@ -74,7 +75,8 @@
|
||||||
(selectChange)="toggleSelect(row.key, $event)"
|
(selectChange)="toggleSelect(row.key, $event)"
|
||||||
(del)="del.emit([$event])"
|
(del)="del.emit([$event])"
|
||||||
(retry)="retry.emit($event)"
|
(retry)="retry.emit($event)"
|
||||||
(folderChange)="folderEdit.emit($event)">
|
(folderChange)="folderEdit.emit($event)"
|
||||||
|
(play)="play.emit(row)">
|
||||||
</app-download-card>
|
</app-download-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export class DownloadListComponent implements OnChanges {
|
||||||
@Output() clearFailed = new EventEmitter<void>();
|
@Output() clearFailed = new EventEmitter<void>();
|
||||||
@Output() retryFailed = new EventEmitter<void>();
|
@Output() retryFailed = new EventEmitter<void>();
|
||||||
@Output() downloadSelected = new EventEmitter<string[]>();
|
@Output() downloadSelected = new EventEmitter<string[]>();
|
||||||
|
@Output() play = new EventEmitter<KeyedDownload>();
|
||||||
|
|
||||||
faList = faList;
|
faList = faList;
|
||||||
faThLarge = faThLarge;
|
faThLarge = faThLarge;
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,10 @@
|
||||||
<button *ngIf="status === 'queued'" type="button" class="icon-btn neutral" (click)="start.emit(row.key)" title="立即开始" aria-label="立即开始">
|
<button *ngIf="status === 'queued'" type="button" class="icon-btn neutral" (click)="start.emit(row.key)" title="立即开始" aria-label="立即开始">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<a *ngIf="status === 'completed' && downloadUrl" class="icon-btn neutral" [href]="downloadUrl" target="_blank" title="下载文件">
|
<button *ngIf="status === 'completed' && downloadUrl" type="button" class="icon-btn neutral" (click)="play.emit()" title="在线播放" aria-label="在线播放">
|
||||||
|
<fa-icon [icon]="faPlay"></fa-icon>
|
||||||
|
</button>
|
||||||
|
<a *ngIf="status === 'completed' && downloadUrl" class="icon-btn neutral" [href]="downloadUrl" [attr.download]="row.value.filename" title="下载文件">
|
||||||
<fa-icon [icon]="faDownload"></fa-icon>
|
<fa-icon [icon]="faDownload"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
<button *ngIf="status === 'completed' && row.value.status === 'error'" type="button" class="icon-btn neutral" (click)="retry.emit({ key: row.key, dl: row.value })" title="重试">
|
<button *ngIf="status === 'completed' && row.value.status === 'error'" type="button" class="icon-btn neutral" (click)="retry.emit({ key: row.key, dl: row.value })" title="重试">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
import { faDownload, faExternalLinkAlt, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faDownload, faExternalLinkAlt, faPlay, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { Download, DownloadsService } from './downloads.service';
|
import { Download, DownloadsService } from './downloads.service';
|
||||||
import { KeyedDownload } from './active-downloads.component';
|
import { KeyedDownload } from './active-downloads.component';
|
||||||
|
|
@ -24,9 +24,11 @@ export class DownloadRowComponent {
|
||||||
@Output() del = new EventEmitter<string>();
|
@Output() del = new EventEmitter<string>();
|
||||||
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
|
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
|
||||||
@Output() folderChange = new EventEmitter<{ key: string; folder: string }>();
|
@Output() folderChange = new EventEmitter<{ key: string; folder: string }>();
|
||||||
|
@Output() play = new EventEmitter<void>();
|
||||||
|
|
||||||
faDownload = faDownload;
|
faDownload = faDownload;
|
||||||
faExternalLinkAlt = faExternalLinkAlt;
|
faExternalLinkAlt = faExternalLinkAlt;
|
||||||
|
faPlay = faPlay;
|
||||||
faRedoAlt = faRedoAlt;
|
faRedoAlt = faRedoAlt;
|
||||||
faTrashAlt = faTrashAlt;
|
faTrashAlt = faTrashAlt;
|
||||||
faCheckCircle = faCheckCircle;
|
faCheckCircle = faCheckCircle;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue