Compare commits
No commits in common. "4a85bdfaf6354db6b30869b9b8ca86f685a58872" and "90c4115f2090761a6b6ccd0c49743cd158c065c4" have entirely different histories.
4a85bdfaf6
...
90c4115f20
|
|
@ -27,8 +27,8 @@ default, e.g. via QEMU emulation).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker buildx build --platform linux/amd64 \
|
docker buildx build --platform linux/amd64 \
|
||||||
--build-arg VERSION=1.17 \
|
--build-arg VERSION=1.16 \
|
||||||
-t 192.168.2.212:3000/tigeren/metube:1.17 \
|
-t 192.168.2.212:3000/tigeren/metube:1.16 \
|
||||||
--push .
|
--push .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
81
app/main.py
81
app/main.py
|
|
@ -357,100 +357,39 @@ def sniff_image_type(data: bytes) -> str:
|
||||||
return 'application/octet-stream'
|
return 'application/octet-stream'
|
||||||
|
|
||||||
|
|
||||||
def _placeholder_thumb_response():
|
|
||||||
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
|
|
||||||
|
|
||||||
|
|
||||||
async def _probe_duration(video_path):
|
|
||||||
"""Duration of a local media file in seconds via ffprobe, or None."""
|
|
||||||
cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
|
||||||
'-of', 'csv=p=0', video_path]
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
|
||||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=15)
|
|
||||||
return float(out.decode().strip())
|
|
||||||
except Exception: # noqa: BLE001 - probing is best-effort
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _extract_video_frame(video_path, thumb_path, duration):
|
|
||||||
"""Extract a frame from a local video file with ffmpeg. Returns True on success."""
|
|
||||||
if not duration:
|
|
||||||
duration = await _probe_duration(video_path)
|
|
||||||
seek = 1.0
|
|
||||||
if duration:
|
|
||||||
try:
|
|
||||||
duration = float(duration)
|
|
||||||
seek = max(0.0, min(duration * 0.25, duration - 1.0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
cmd = ['ffmpeg', '-hide_banner', '-loglevel', 'error',
|
|
||||||
'-ss', str(seek), '-i', video_path,
|
|
||||||
'-frames:v', '1', '-vf', 'scale=480:-2', '-q:v', '4',
|
|
||||||
# thumb_path has a generic .img extension: force the JPEG encoder and
|
|
||||||
# image2 muxer explicitly (ffmpeg would otherwise guess from the name)
|
|
||||||
'-c:v', 'mjpeg', '-f', 'image2',
|
|
||||||
'-y', thumb_path]
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE)
|
|
||||||
_, err = await asyncio.wait_for(proc.communicate(), timeout=30)
|
|
||||||
except Exception as exc: # noqa: BLE001 - includes TimeoutError; fall through to other sources
|
|
||||||
log.warning(f"ffmpeg frame extraction failed for {video_path}: {exc}")
|
|
||||||
try:
|
|
||||||
proc.kill()
|
|
||||||
except Exception: # noqa: BLE001 - process may not exist
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
if proc.returncode != 0:
|
|
||||||
log.warning(f"ffmpeg frame extraction failed for {video_path}: {err.decode(errors='replace')[-200:]}")
|
|
||||||
return False
|
|
||||||
return os.path.exists(thumb_path)
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX + 'thumbnail')
|
@routes.get(config.URL_PREFIX + 'thumbnail')
|
||||||
async def thumbnail(request):
|
async def thumbnail(request):
|
||||||
"""Serve a cached thumbnail for a download.
|
"""Serve a cached copy of a download's remote thumbnail.
|
||||||
|
|
||||||
Sources, in order: a frame extracted from the local file (ffmpeg), the
|
The URL is taken from stored yt-dlp metadata for the given download key —
|
||||||
remote thumbnail URL from stored yt-dlp metadata — clients can never point
|
clients can never point this endpoint at arbitrary URLs. Missing/invalid
|
||||||
this endpoint at arbitrary URLs — and finally a placeholder SVG.
|
thumbnails fall back to a placeholder SVG.
|
||||||
"""
|
"""
|
||||||
key = request.query.get('id')
|
key = request.query.get('id')
|
||||||
if not key:
|
if not key:
|
||||||
raise web.HTTPBadRequest()
|
raise web.HTTPBadRequest()
|
||||||
|
|
||||||
info = dqueue.find_info(key)
|
info = dqueue.find_info(key)
|
||||||
thumb_url = getattr(info, 'thumbnail', None) if info is not None else None
|
if info is None or not info.thumbnail:
|
||||||
log.debug(f"[Thumb] key={key[:60]} found={info is not None} has_url={bool(thumb_url)}")
|
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
|
||||||
|
|
||||||
thumb_dir = os.path.join(config.STATE_DIR, 'thumbnails')
|
thumb_dir = os.path.join(config.STATE_DIR, 'thumbnails')
|
||||||
os.makedirs(thumb_dir, exist_ok=True)
|
os.makedirs(thumb_dir, exist_ok=True)
|
||||||
thumb_path = os.path.join(thumb_dir, hashlib.sha1(key.encode('utf-8')).hexdigest() + '.img')
|
thumb_path = os.path.join(thumb_dir, hashlib.sha1(key.encode('utf-8')).hexdigest() + '.img')
|
||||||
|
|
||||||
if not os.path.exists(thumb_path):
|
if not os.path.exists(thumb_path):
|
||||||
# Prefer a frame from the local file: works even for downloads whose
|
|
||||||
# metadata predates thumbnail storage, and survives expiring remote URLs
|
|
||||||
local_file = dqueue.find_download_file(key)
|
|
||||||
log.debug(f"[Thumb] local_file={local_file}")
|
|
||||||
if local_file:
|
|
||||||
await _extract_video_frame(local_file, thumb_path, getattr(info, 'duration', None))
|
|
||||||
|
|
||||||
if not os.path.exists(thumb_path) and thumb_url:
|
|
||||||
try:
|
try:
|
||||||
timeout = aiohttp.ClientTimeout(total=15)
|
timeout = aiohttp.ClientTimeout(total=15)
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
async with session.get(thumb_url, headers={'User-Agent': 'Mozilla/5.0 MeTube'}) as resp:
|
async with session.get(info.thumbnail, headers={'User-Agent': 'Mozilla/5.0 MeTube'}) as resp:
|
||||||
if resp.status == 200:
|
if resp.status != 200:
|
||||||
|
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
with open(thumb_path, 'wb') as f:
|
with open(thumb_path, 'wb') as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
except Exception as exc: # noqa: BLE001 - fall back to placeholder on any fetch error
|
except Exception as exc: # noqa: BLE001 - fall back to placeholder on any fetch error
|
||||||
log.warning(f"Failed to fetch thumbnail for {key}: {exc}")
|
log.warning(f"Failed to fetch thumbnail for {key}: {exc}")
|
||||||
|
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
|
||||||
if not os.path.exists(thumb_path):
|
|
||||||
return _placeholder_thumb_response()
|
|
||||||
|
|
||||||
with open(thumb_path, 'rb') as f:
|
with open(thumb_path, 'rb') as f:
|
||||||
data = f.read()
|
data = f.read()
|
||||||
|
|
|
||||||
25
app/ytdl.py
25
app/ytdl.py
|
|
@ -326,11 +326,6 @@ class PersistentQueue:
|
||||||
# Ensure file_exists field exists
|
# Ensure file_exists field exists
|
||||||
if not hasattr(v, 'file_exists'):
|
if not hasattr(v, 'file_exists'):
|
||||||
v.file_exists = None
|
v.file_exists = None
|
||||||
# Ensure thumbnail/duration fields exist (added for the thumbnail UI)
|
|
||||||
if not hasattr(v, 'thumbnail'):
|
|
||||||
v.thumbnail = None
|
|
||||||
if not hasattr(v, 'duration'):
|
|
||||||
v.duration = None
|
|
||||||
self.dict[k] = Download(None, None, None, None, None, None, {}, v)
|
self.dict[k] = Download(None, None, None, None, None, None, {}, v)
|
||||||
|
|
||||||
def exists(self, key):
|
def exists(self, key):
|
||||||
|
|
@ -401,20 +396,6 @@ class DownloadQueue:
|
||||||
return v
|
return v
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def find_download_file(self, key):
|
|
||||||
"""Absolute path of the downloaded file for key, if it exists on disk."""
|
|
||||||
info = self.find_info(key)
|
|
||||||
if info is None:
|
|
||||||
return None
|
|
||||||
filename = getattr(info, 'filename', None)
|
|
||||||
if not filename:
|
|
||||||
return None
|
|
||||||
dldirectory, error = self.__calc_download_path(info.quality, info.format, getattr(info, 'folder', None))
|
|
||||||
if error is not None or not dldirectory:
|
|
||||||
return None
|
|
||||||
path = os.path.join(dldirectory, filename)
|
|
||||||
return path if os.path.exists(path) else None
|
|
||||||
|
|
||||||
async def __import_queue(self):
|
async def __import_queue(self):
|
||||||
for k, v in self.queue.saved_items():
|
for k, v in self.queue.saved_items():
|
||||||
await self.__add_download(v, True)
|
await self.__add_download(v, True)
|
||||||
|
|
@ -494,12 +475,6 @@ class DownloadQueue:
|
||||||
if full_entry and 'title' in full_entry:
|
if full_entry and 'title' in full_entry:
|
||||||
title = full_entry['title']
|
title = full_entry['title']
|
||||||
log.debug(f"[PreCheck] Got real title from full extraction: '{title}'")
|
log.debug(f"[PreCheck] Got real title from full extraction: '{title}'")
|
||||||
# Backfill thumbnail/duration for flat playlist entries
|
|
||||||
if not getattr(dl, 'thumbnail', None):
|
|
||||||
thumbnails = full_entry.get('thumbnails') or []
|
|
||||||
dl.thumbnail = full_entry.get('thumbnail') or (thumbnails[-1].get('url') if thumbnails else None)
|
|
||||||
if not getattr(dl, 'duration', None):
|
|
||||||
dl.duration = full_entry.get('duration')
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"[PreCheck] Failed to get full info: {e}, using placeholder title")
|
log.warning(f"[PreCheck] Failed to get full info: {e}, using placeholder title")
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
|
|
@ -76,8 +76,7 @@
|
||||||
(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>
|
||||||
|
|
@ -147,16 +146,4 @@
|
||||||
</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,45 +62,6 @@
|
||||||
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, HostListener, OnInit } from '@angular/core';
|
import { Component, 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,10 +45,6 @@ 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;
|
||||||
|
|
@ -140,23 +136,6 @@ 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[] {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<div class="card" [class.selected]="selected">
|
<div class="card" [class.selected]="selected">
|
||||||
<input type="checkbox" class="card-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
|
<input type="checkbox" class="card-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
|
||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
|
<img *ngIf="row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
|
||||||
|
<span class="thumb-ph" *ngIf="!row.value.thumbnail">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
|
||||||
|
</span>
|
||||||
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
|
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
|
||||||
<span *ngIf="row.value.file_exists === false" class="file-warn" title="文件不存在">
|
<span *ngIf="row.value.file_exists === false" class="file-warn" title="文件不存在">
|
||||||
<fa-icon [icon]="faTimesCircle"></fa-icon>
|
<fa-icon [icon]="faTimesCircle"></fa-icon>
|
||||||
|
|
@ -16,10 +19,7 @@
|
||||||
<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">
|
||||||
<button *ngIf="downloadUrl" type="button" class="icon-btn neutral" (click)="play.emit()" title="在线播放" aria-label="在线播放">
|
<a *ngIf="downloadUrl" class="icon-btn neutral" [href]="downloadUrl" target="_blank" title="下载文件">
|
||||||
<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, faPlay, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faDownload, faExternalLinkAlt, 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,11 +22,9 @@ 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,8 +58,7 @@
|
||||||
(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>
|
||||||
|
|
@ -75,8 +74,7 @@
|
||||||
(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,7 +37,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<div class="q-row" [class.selected]="selected">
|
<div class="q-row" [class.selected]="selected">
|
||||||
<input type="checkbox" class="q-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
|
<input type="checkbox" class="q-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
|
||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img *ngIf="status === 'completed' || row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
|
<img *ngIf="row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
|
||||||
<span class="thumb-ph" *ngIf="status !== 'completed' && !row.value.thumbnail">
|
<span class="thumb-ph" *ngIf="!row.value.thumbnail">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
|
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
|
||||||
|
|
@ -39,10 +39,7 @@
|
||||||
<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>
|
||||||
<button *ngIf="status === 'completed' && downloadUrl" type="button" class="icon-btn neutral" (click)="play.emit()" title="在线播放" aria-label="在线播放">
|
<a *ngIf="status === 'completed' && downloadUrl" class="icon-btn neutral" [href]="downloadUrl" target="_blank" title="下载文件">
|
||||||
<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, faPlay, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faDownload, faExternalLinkAlt, 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,11 +24,9 @@ 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