import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { downloadStore, stateLabel } from '@store/download-store';
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
import { libraryStore } from '@store/library-store';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
type Tab = 'requests' | 'downloads';
/**
* The downloads page: what music the user has asked for, and what has
* actually been attempted.
*
* These are two different lists on purpose. A Request is durable — "get
* this whenever available" — and stays around, retried on a backoff,
* until it is satisfied or removed. A Download is one search-and-grab
* attempt; it can fail or complete and that is the end of its story. The
* Requests tab is the list the durable, not-a-failure-just-because-it's-
* still-here content the wanted list used to be; the Downloads tab is the
* attempt history nothing rendered before this page existed.
*/
@customElement('downloads-view')
export class DownloadsView extends ViewLifecycleMixin(LitElement) {
@state() private tab: Tab = 'requests';
@state() private requests: Request[] = [];
@state() private downloads: DownloadRecord[] = [];
@state() private checking = false;
@state() private lastSummary: RequestSummary | null = null;
/** True when at least one download client is enabled. */
@state() private canDownload = false;
/** Ticks so "next check in …" ages while the page is open. */
@state() private nowMs = Date.now();
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
css`
:host {
display: block;
height: 100%;
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
}
/* The header supplies its own padding and rule, so it runs
to the edge of a host that pads its own content. */
page-header {
margin: -20px -20px 1em;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--yj-text-primary, #fff);
flex: 1;
}
.subtitle {
margin: 0 0 20px;
font-size: 13px;
color: var(--yj-text-secondary, #b3b3b3);
}
.tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
}
.tab {
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: var(--yj-text-secondary, #b3b3b3);
cursor: pointer;
border: none;
border-bottom: 2px solid transparent;
background: none;
font-family: inherit;
user-select: none;
}
.tab:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.tab:hover {
color: var(--yj-text-primary, #fff);
}
.tab.active {
color: var(--yj-text-primary, #fff);
border-bottom-color: var(--yj-accent, #ffd43b);
}
h2 {
margin: 24px 0 8px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-secondary, #b3b3b3);
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 6px;
background: var(--yj-bg-surface, #181818);
}
.row + .row {
margin-top: 6px;
}
.row-main {
flex: 1;
min-width: 0;
}
.title {
font-size: 14px;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail {
font-size: 12px;
color: var(--yj-text-tertiary, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail.error {
color: var(--wa-color-danger-fill-loud, #c65f5f);
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
color: var(--yj-text-secondary, #b3b3b3);
flex-shrink: 0;
}
.empty {
padding: 40px 20px;
text-align: center;
color: var(--yj-text-tertiary, #888);
font-size: 14px;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.summary {
font-size: 12px;
color: var(--yj-text-secondary, #b3b3b3);
margin: 8px 0 0;
}
.notice {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
border-radius: 6px;
font-size: 12px;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
/* The only place in the app that puts text on bgOverlay,
which is too light on the dark ramp to carry anything
but primary: secondary measured 3.90:1 here. A notice
is the last thing that should be hard to read. */
color: var(--yj-text-primary, #fff);
}
.section-hint {
margin: 0 0 8px;
font-size: 12px;
color: var(--yj-text-tertiary, #888);
}
`,
];
protected override onViewActivate(): void {
this.unsubscribe = downloadStore.subscribe(() => {
this.requests = downloadStore.requests;
this.downloads = downloadStore.downloads;
this.canDownload = downloadStore.available;
});
void downloadStore.init().then(() => {
this.requests = downloadStore.requests;
this.downloads = downloadStore.downloads;
this.canDownload = downloadStore.available;
});
// A "next check" that never moves reads as a stuck page, so the
// relative times re-render on their own — while the page is on
// screen, where a re-render can be seen.
this.intervalWhileActive(() => {
this.nowMs = Date.now();
}, 30_000);
}
protected override onViewDeactivate(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
override render() {
return html`
Music you have requested, and the download attempts that have run for it. A request that cannot be found today stays on the list and is looked for again later — roughly every six hours at first, then less often the longer it goes unfound. “Check now” skips that wait and searches everything on the list immediately.
Requested, not found yet. Nothing is wrong — each of these is searched again on the schedule below, and moves to “Found” the moment it lands in your library, however it got there.
${wanted.map((r) => this.renderRequest(r))} ` : nothing} ${this.renderRequestSection('Paused', paused, (r) => this.renderRequest(r))} ${this.renderRequestSection('Found', satisfied, (r) => this.renderRequest(r))} `; } private renderEmptyRequests() { return html`${parts.join(' · ')}
`; } // "Nothing happened" needs a reason, or the button looks broken. const idle = s.noProviders ? 'Nothing was searched: no download client is enabled.' : s.waiting > 0 ? `Searched all ${s.waiting} request${s.waiting === 1 ? '' : 's'} — no source has anything new yet.` : 'Nothing on the list to search for.'; return html`${idle}
`; } private renderRequestSection( title: string, items: Request[], renderer: (request: Request) => unknown, ) { if (items.length === 0) return nothing; return html`