- @for (item of filteredDownloads(); track item.id) {
-
-
- @if (item.posterUrl) {
-
![]()
- } @else {
-
movie
- }
-
-
-
-
- {{ item.title }}
- @if (item.contentType === 'episode') {
-
- {{ formatEpisodeLabel(item) }}
-
- }
-
-
-
-
- {{
- getStatusIcon(item.status)
- }}
- {{
- 'DOWNLOADS.STATUS.' +
- item.status.toUpperCase()
- | translate
- }}
-
-
- @if (
- item.status === 'downloading' ||
- item.status === 'completed'
- ) {
-
- {{ formatBytes(item.bytesDownloaded) }}
- @if (item.totalBytes) {
- / {{ formatBytes(item.totalBytes) }}
- }
-
- }
-
- @if (item.errorMessage) {
-
- {{ item.errorMessage }}
-
- }
-
-
- @if (item.status === 'downloading') {
-
- }
-
-
-
- @switch (item.status) {
- @case ('queued') {
-
- }
- @case ('downloading') {
-
- }
- @case ('completed') {
-
-
-
- }
- @case ('failed') {
-
-
- }
- @case ('canceled') {
-
-
- }
- }
-
-
- }
+
+ @if (!isWorkspaceLayout) {
+
}
+
+
+
+
+ @if (!isAvailable()) {
+
+
desktop_access_disabled
+
{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}
+
{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}
+
+ } @else if (!hasDownloads()) {
+
+
download
+
{{ 'DOWNLOADS.EMPTY' | translate }}
+
{{ 'DOWNLOADS.EMPTY_HINT' | translate }}
+
+ } @else if (filteredDownloads().length === 0) {
+
+
search_off
+
No downloads match the current filter
+
Try a different search term
+
+ } @else {
+
+
+ @for (item of filteredDownloads(); track item.id) {
+
+
+ @if (item.posterUrl) {
+
![]()
+ } @else {
+
movie
+ }
+
+
+
+
+ {{ item.title }}
+ @if (item.contentType === 'episode') {
+
+ {{ formatEpisodeLabel(item) }}
+
+ }
+
+
+
+
+ {{
+ getStatusIcon(item.status)
+ }}
+ {{
+ 'DOWNLOADS.STATUS.' +
+ item.status.toUpperCase()
+ | translate
+ }}
+
+
+ @if (
+ item.status === 'downloading' ||
+ item.status === 'completed'
+ ) {
+
+ {{
+ formatBytes(
+ item.bytesDownloaded
+ )
+ }}
+ @if (item.totalBytes) {
+ /
+ {{
+ formatBytes(item.totalBytes)
+ }}
+ }
+
+ }
+
+ @if (item.errorMessage) {
+
+ {{ item.errorMessage }}
+
+ }
+
+
+ @if (item.status === 'downloading') {
+
+ }
+
+
+
+ @switch (item.status) {
+ @case ('queued') {
+
+ }
+ @case ('downloading') {
+
+ }
+ @case ('completed') {
+
+
+
+ }
+ @case ('failed') {
+
+
+ }
+ @case ('canceled') {
+
+
+ }
+ }
+
+
+ }
+
+
+ }
+
diff --git a/apps/web/src/app/xtream-electron/downloads/downloads.component.scss b/apps/web/src/app/xtream-electron/downloads/downloads.component.scss
index 9d7567b68..41116f809 100644
--- a/apps/web/src/app/xtream-electron/downloads/downloads.component.scss
+++ b/apps/web/src/app/xtream-electron/downloads/downloads.component.scss
@@ -1,11 +1,25 @@
:host {
width: 100%;
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+}
+
+.downloads-layout {
+ display: flex;
+ flex: 1;
+ width: 100%;
+ min-width: 0;
+ min-height: 0;
}
.downloads {
+ flex: 1;
display: flex;
flex-direction: column;
- height: calc(100vh - 20px);
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
overflow: hidden;
&__header {
@@ -59,6 +73,9 @@
&-actions {
display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: wrap;
gap: 0.5rem;
}
}
@@ -72,29 +89,40 @@
font-weight: 600;
}
- &__folder {
- display: flex;
+ &__folder-inline {
+ display: inline-flex;
align-items: center;
- gap: 0.75rem;
- padding: 12px 14px;
- margin: 0 16px 1rem;
- background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
- border: 1px solid var(--mat-sys-primary);
- border-radius: 12px;
+ gap: 8px;
+ max-width: min(42vw, 460px);
+ padding: 7px 10px;
+ background: color-mix(
+ in srgb,
+ var(--mat-sys-surface-container-high) 86%,
+ transparent
+ );
+ border: 1px solid var(--mat-sys-outline-variant);
+ border-radius: 8px;
+ box-shadow: inset 0 1px 0
+ color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
mat-icon {
+ width: 16px;
+ height: 16px;
+ font-size: 16px;
color: var(--mat-sys-primary);
+ flex-shrink: 0;
}
+ }
- &-path {
- flex: 1;
- font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
- font-size: 0.875rem;
- color: var(--mat-sys-on-surface);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
+ &__folder-inline-path {
+ font-family:
+ 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas,
+ monospace;
+ font-size: 0.78rem;
+ color: var(--mat-sys-on-surface);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
&__empty {
@@ -129,58 +157,63 @@
&__list-wrapper {
flex: 1;
overflow-y: auto;
+ overflow-x: hidden;
padding: 0 16px;
+ container-type: inline-size;
}
&__list {
- display: flex;
- flex-direction: column;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
gap: 12px;
padding-bottom: 24px;
+ align-content: start;
}
&__item {
- display: flex;
+ display: grid;
+ grid-template-columns: 64px minmax(0, 1fr) auto;
+ grid-template-areas: 'poster info actions';
align-items: center;
gap: 1rem;
padding: 1rem;
- background: var(--mat-sys-surface-container-low);
- border-radius: 16px;
- border: 1px solid var(--mat-sys-outline-variant);
+ min-width: 0;
+ background: color-mix(
+ in srgb,
+ var(--mat-sys-surface-container-high) 90%,
+ var(--mat-sys-surface) 10%
+ );
+ border-radius: 0;
+ border: 1px solid
+ color-mix(
+ in srgb,
+ var(--mat-sys-outline-variant) 80%,
+ var(--mat-sys-on-surface) 20%
+ );
+ box-shadow: inset 0 1px 0
+ color-mix(in srgb, var(--mat-sys-on-surface) 7%, transparent);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
- &:hover {
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
- transform: translateY(-2px);
- background: var(--mat-sys-surface-container);
- border-color: var(--mat-sys-outline);
+ &--clickable {
+ cursor: pointer;
- .downloads__item-actions {
- opacity: 1;
- transform: translateX(0);
+ &:focus-visible {
+ outline: 2px solid var(--mat-sys-primary);
+ outline-offset: 2px;
}
}
- // Status colors using Material semantics where possible, falling back to clean hexes
- &.status-downloading {
- border-left: 4px solid #2196f3;
- background: color-mix(in srgb, #2196f3 4%, var(--mat-sys-surface-container-low));
- }
-
- &.status-completed {
- border-left: 4px solid #4caf50;
- }
-
- &.status-failed {
- border-left: 4px solid var(--mat-sys-error);
- background: color-mix(in srgb, var(--mat-sys-error) 4%, var(--mat-sys-surface-container-low));
- }
-
- &.status-queued {
- border-left: 4px solid var(--mat-sys-outline);
+ &:hover {
+ background: color-mix(
+ in srgb,
+ var(--mat-sys-surface-container-high) 72%,
+ var(--mat-sys-surface-container) 28%
+ );
+ border-color: var(--mat-sys-outline);
}
&-poster {
+ grid-area: poster;
width: 64px;
height: 90px;
border-radius: 8px;
@@ -208,6 +241,7 @@
}
&-info {
+ grid-area: info;
flex: 1;
min-width: 0;
display: flex;
@@ -230,6 +264,7 @@
&-meta {
display: flex;
align-items: center;
+ flex-wrap: wrap;
gap: 1rem;
font-size: 0.85rem;
color: var(--mat-sys-on-surface-variant);
@@ -262,7 +297,11 @@
&.status-failed {
color: var(--mat-sys-error);
- background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
+ background: color-mix(
+ in srgb,
+ var(--mat-sys-error) 12%,
+ transparent
+ );
}
&.status-queued {
@@ -271,7 +310,9 @@
}
&-size {
- font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
+ font-family:
+ 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas,
+ monospace;
background: var(--mat-sys-surface-container-high);
padding: 2px 8px;
border-radius: 12px;
@@ -280,17 +321,22 @@
&-error {
color: var(--mat-sys-error);
font-size: 0.8rem;
- background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
+ background: color-mix(
+ in srgb,
+ var(--mat-sys-error) 12%,
+ transparent
+ );
padding: 2px 8px;
border-radius: 12px;
}
&-actions {
+ grid-area: actions;
display: flex;
gap: 0.25rem;
flex-shrink: 0;
- opacity: 0.7;
- transform: translateX(4px);
+ justify-self: end;
+ opacity: 1;
transition: all 0.2s ease;
}
@@ -312,3 +358,51 @@
}
}
+.sidebar-header h2 {
+ margin: 0;
+}
+
+@media (max-width: 980px) {
+ .downloads {
+ &__header {
+ align-items: flex-start;
+ gap: 12px;
+ }
+
+ &__header-actions {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ &__folder-inline {
+ max-width: 100%;
+ }
+
+ &__list {
+ grid-template-columns: 1fr;
+ }
+ }
+}
+
+@container (max-width: 760px) {
+ .downloads__item {
+ grid-template-columns: 64px minmax(0, 1fr);
+ grid-template-areas:
+ 'poster info'
+ 'actions actions';
+ align-items: start;
+ row-gap: 10px;
+ }
+
+ .downloads__item-actions {
+ width: 100%;
+ justify-content: flex-end;
+ }
+
+ .downloads__item-title {
+ white-space: normal;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ }
+}
diff --git a/apps/web/src/app/xtream-electron/downloads/downloads.component.ts b/apps/web/src/app/xtream-electron/downloads/downloads.component.ts
index ddbf7735e..7b6fab643 100644
--- a/apps/web/src/app/xtream-electron/downloads/downloads.component.ts
+++ b/apps/web/src/app/xtream-electron/downloads/downloads.component.ts
@@ -2,37 +2,51 @@ import {
ChangeDetectionStrategy,
Component,
computed,
+ effect,
inject,
- OnInit,
+ OnDestroy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
-import { map } from 'rxjs';
+import { ResizableDirective } from 'components';
+import { firstValueFrom, map } from 'rxjs';
+import { DatabaseService, PlaylistsService } from 'services';
+import { XtreamCategory } from 'shared-interfaces';
import {
DownloadItem,
DownloadsService,
} from '../../services/downloads.service';
+import { FavoritesContextService } from '../../workspace/favorites-context.service';
+import { CategoryViewComponent } from '../category-view/category-view.component';
+
+type PortalSource = 'xtream' | 'stalker';
@Component({
selector: 'app-downloads',
templateUrl: './downloads.component.html',
- styleUrls: ['./downloads.component.scss'],
+ styleUrls: ['./downloads.component.scss', '../sidebar.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
+ CategoryViewComponent,
MatButtonModule,
MatIcon,
MatProgressBarModule,
MatTooltip,
+ ResizableDirective,
TranslatePipe,
],
})
-export class DownloadsComponent implements OnInit {
+export class DownloadsComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+ private readonly dbService = inject(DatabaseService);
+ private readonly playlistsService = inject(PlaylistsService);
+ private readonly favoritesCtx = inject(FavoritesContextService);
readonly downloadsService = inject(DownloadsService);
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
@@ -42,6 +56,11 @@ export class DownloadsComponent implements OnInit {
readonly isAvailable = this.downloadsService.isAvailable;
readonly hasDownloads = this.downloadsService.hasDownloads;
readonly activeCount = this.downloadsService.activeCount;
+ readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
+ readonly playlistId = toSignal(
+ this.route.paramMap.pipe(map((params) => params.get('id') ?? '')),
+ { initialValue: this.route.snapshot.params['id'] ?? '' }
+ );
readonly searchTerm = toSignal(
this.route.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim().toLowerCase())
@@ -49,21 +68,71 @@ export class DownloadsComponent implements OnInit {
{ initialValue: '' }
);
+ readonly scopedDownloads = computed(() => {
+ const playlistId = this.playlistId();
+ const downloads = this.downloads();
+ return playlistId
+ ? downloads.filter((item) => item.playlistId === playlistId)
+ : downloads;
+ });
+
+ readonly categories = computed
(() => {
+ const downloads = this.scopedDownloads();
+ const moviesCount = downloads.filter(
+ (item) => item.contentType === 'vod'
+ ).length;
+ const seriesCount = downloads.filter(
+ (item) => item.contentType === 'episode'
+ ).length;
+
+ return [
+ {
+ id: 1,
+ category_id: 'all',
+ category_name: 'All',
+ count: downloads.length,
+ parent_id: 0,
+ },
+ {
+ id: 2,
+ category_id: 'movie',
+ category_name: 'Movies',
+ count: moviesCount,
+ parent_id: 0,
+ },
+ {
+ id: 3,
+ category_id: 'series',
+ category_name: 'Series',
+ count: seriesCount,
+ parent_id: 0,
+ },
+ ];
+ });
+
/** Filter downloads for current playlist and sort by newest first */
readonly filteredDownloads = computed(() => {
- const playlistId = this.route.snapshot.params['id'];
+ const selectedCategoryId = this.selectedCategoryId();
const term = this.searchTerm();
- const downloads = playlistId
- ? this.downloads().filter((d) => d.playlistId === playlistId)
- : this.downloads();
+ const downloads = this.scopedDownloads();
+
+ const filteredByCategory = downloads.filter((item) => {
+ if (selectedCategoryId === 'movie') {
+ return item.contentType === 'vod';
+ }
+ if (selectedCategoryId === 'series') {
+ return item.contentType === 'episode';
+ }
+ return true;
+ });
const filteredByTerm = term
- ? downloads.filter((item) =>
+ ? filteredByCategory.filter((item) =>
`${item.title ?? ''} ${item.errorMessage ?? ''}`
.toLowerCase()
.includes(term)
)
- : downloads;
+ : filteredByCategory;
// Sort by createdAt descending (newest first)
return [...filteredByTerm].sort((a, b) => {
@@ -73,9 +142,24 @@ export class DownloadsComponent implements OnInit {
});
});
- ngOnInit() {
- const playlistId = this.route.snapshot.params['id'];
- this.downloadsService.loadDownloads(playlistId);
+ constructor() {
+ effect(() => {
+ const playlistId = this.playlistId();
+ this.favoritesCtx.setCategoryId('all');
+ void this.downloadsService.loadDownloads(playlistId || undefined);
+ });
+
+ effect(() => {
+ this.favoritesCtx.setCategories(this.categories());
+ });
+ }
+
+ setCategoryId(categoryId: string) {
+ this.favoritesCtx.setCategoryId(categoryId);
+ }
+
+ ngOnDestroy(): void {
+ this.favoritesCtx.reset();
}
getProgress(item: DownloadItem): number {
@@ -149,8 +233,7 @@ export class DownloadsComponent implements OnInit {
}
async clearCompleted() {
- const playlistId = this.route.snapshot.params['id'];
- await this.downloadsService.clearCompleted(playlistId);
+ await this.downloadsService.clearCompleted(this.playlistId());
}
formatEpisodeLabel(item: DownloadItem): string {
@@ -159,4 +242,216 @@ export class DownloadsComponent implements OnInit {
const e = item.episodeNumber?.toString().padStart(2, '0') || '00';
return `S${s}E${e}`;
}
+
+ isItemNavigable(item: DownloadItem): boolean {
+ return !!item.playlistId && this.getTargetContentId(item) !== null;
+ }
+
+ onItemCardKeydown(event: KeyboardEvent, item: DownloadItem): void {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+
+ if (event.key !== 'Enter' && event.key !== ' ') {
+ return;
+ }
+
+ event.preventDefault();
+ void this.openInLibrary(item);
+ }
+
+ async openInLibrary(item: DownloadItem): Promise {
+ if (!this.isItemNavigable(item)) {
+ return;
+ }
+
+ const source = await this.resolveSourceType(item.playlistId);
+ if (!source) {
+ return;
+ }
+
+ if (source === 'xtream') {
+ await this.openXtreamItem(item);
+ return;
+ }
+
+ await this.openStalkerItem(item);
+ }
+
+ private getTargetContentId(item: DownloadItem): number | null {
+ const id =
+ item.contentType === 'episode'
+ ? (item.seriesXtreamId ?? item.xtreamId)
+ : item.xtreamId;
+ const numeric = Number(id);
+ return Number.isFinite(numeric) ? numeric : null;
+ }
+
+ private buildPlaylistRoute(
+ source: PortalSource,
+ playlistId: string,
+ segments: Array
+ ): Array {
+ const sourceSegment = source === 'stalker' ? 'stalker' : 'xtreams';
+ if (this.isWorkspaceLayout) {
+ return ['/workspace', sourceSegment, playlistId, ...segments];
+ }
+ return [`/${sourceSegment}`, playlistId, ...segments];
+ }
+
+ private async resolveSourceType(
+ playlistId: string
+ ): Promise {
+ try {
+ const playlist = await firstValueFrom(
+ this.playlistsService.getPlaylistById(playlistId)
+ );
+
+ if (!playlist) {
+ return null;
+ }
+
+ if (playlist.portalUrl && playlist.macAddress) {
+ return 'stalker';
+ }
+
+ return 'xtream';
+ } catch {
+ return null;
+ }
+ }
+
+ private async openXtreamItem(item: DownloadItem): Promise {
+ const targetId = this.getTargetContentId(item);
+ if (targetId === null) return;
+
+ const contentType = item.contentType === 'episode' ? 'series' : 'vod';
+ const content = await this.dbService.getContentByXtreamId(
+ targetId,
+ item.playlistId
+ );
+ const categoryId = content?.category_id;
+
+ if (categoryId === null || categoryId === undefined) {
+ await this.router.navigate(
+ this.buildPlaylistRoute('xtream', item.playlistId, [
+ contentType,
+ ])
+ );
+ return;
+ }
+
+ await this.router.navigate(
+ this.buildPlaylistRoute('xtream', item.playlistId, [
+ contentType,
+ String(categoryId),
+ String(targetId),
+ ])
+ );
+ }
+
+ private normalizePortalItemId(value: unknown): string {
+ const raw = String(value ?? '').trim();
+ if (!raw) return '';
+ return raw.includes(':') ? raw.split(':')[0] : raw;
+ }
+
+ private normalizeStalkerCategoryId(
+ value: unknown,
+ fallback: 'vod' | 'series'
+ ): 'vod' | 'series' | 'itv' {
+ const normalized = String(value ?? '').toLowerCase();
+ if (normalized === 'movie') return 'vod';
+ if (
+ normalized === 'vod' ||
+ normalized === 'series' ||
+ normalized === 'itv'
+ ) {
+ return normalized;
+ }
+ return fallback;
+ }
+
+ private findMatchingStalkerRecentItem(
+ items: Array>,
+ targetId: number
+ ): Record | undefined {
+ const expectedId = String(targetId);
+ return items.find((recentItem) => {
+ const candidates = [
+ recentItem['id'],
+ recentItem['movie_id'],
+ recentItem['series_id'],
+ recentItem['stream_id'],
+ ];
+
+ return candidates.some(
+ (candidate) =>
+ this.normalizePortalItemId(candidate) === expectedId
+ );
+ });
+ }
+
+ private async buildStalkerOpenState(
+ item: DownloadItem,
+ targetId: number,
+ fallbackCategory: 'vod' | 'series'
+ ): Promise> {
+ try {
+ const items = (await firstValueFrom(
+ this.playlistsService.getPortalRecentlyViewed(item.playlistId)
+ )) as Array>;
+
+ const matched = this.findMatchingStalkerRecentItem(items, targetId);
+
+ if (matched) {
+ return {
+ ...matched,
+ id:
+ matched['id'] ??
+ matched['series_id'] ??
+ matched['movie_id'] ??
+ String(targetId),
+ category_id: this.normalizeStalkerCategoryId(
+ matched['category_id'],
+ fallbackCategory
+ ),
+ title: matched['title'] ?? item.title,
+ name: matched['name'] ?? matched['o_name'] ?? item.title,
+ };
+ }
+ } catch {
+ // Ignore and use fallback state below.
+ }
+
+ return {
+ id: String(targetId),
+ category_id: fallbackCategory,
+ title: item.title,
+ name: item.title,
+ o_name: item.title,
+ cover: item.posterUrl,
+ logo: item.posterUrl,
+ };
+ }
+
+ private async openStalkerItem(item: DownloadItem): Promise {
+ const targetId = this.getTargetContentId(item);
+ if (targetId === null) return;
+
+ const fallbackCategory =
+ item.contentType === 'episode' ? 'series' : 'vod';
+ const openRecentItem = await this.buildStalkerOpenState(
+ item,
+ targetId,
+ fallbackCategory
+ );
+
+ await this.router.navigate(
+ this.buildPlaylistRoute('stalker', item.playlistId, ['recent']),
+ {
+ state: { openRecentItem },
+ }
+ );
+ }
}