feat(xtream): add recently added views

This commit is contained in:
4gray committed 2026-01-12 00:54:00 +01:00
1 parent 16469256df
commit c173553d78
10 files changed
+245 -33

No files matched your search

@@ -87,11 +87,7 @@ ipcMain.handle('DB_GET_RECENT_ITEMS', async (event, playlistId: string) => {
schema.content,
eq(schema.recentlyViewed.contentId, schema.content.id)
)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(eq(schema.categories.playlistId, playlistId))
.where(eq(schema.recentlyViewed.playlistId, playlistId))
.orderBy(desc(schema.recentlyViewed.viewedAt))
.limit(100);
return result;
@@ -53,11 +53,15 @@ export class CategoryContentViewComponent implements OnInit {
// Only set category if it's different from the currently selected one
// This preserves the page state when navigating back from detail view
if (
categoryId &&
this.store.selectedCategoryId() !== Number(categoryId)
) {
this.store.setSelectedCategory(categoryId);
if (categoryId) {
if (this.store.selectedCategoryId() !== Number(categoryId)) {
this.store.setSelectedCategory(categoryId);
}
} else {
// No categoryId in route means "All Items"
if (this.store.selectedCategoryId() !== null) {
this.store.setSelectedCategory(null);
}
}
}
@@ -99,9 +103,17 @@ export class CategoryContentViewComponent implements OnInit {
if (this.isStalker) {
this.store.setSelectedItem(selectedItem);
} else {
this.router.navigate([item.xtream_id], {
relativeTo: this.activatedRoute,
});
// When viewing "Recently Added" (no category selected), include category_id in path
const categoryId = this.store.selectedCategoryId();
if (categoryId) {
this.router.navigate([item.xtream_id], {
relativeTo: this.activatedRoute,
});
} else {
this.router.navigate([item.category_id, item.xtream_id], {
relativeTo: this.activatedRoute,
});
}
}
}
@@ -61,6 +61,18 @@
{{ 'PORTALS.SIDEBAR.LIBRARY' | translate }}
</div> -->
<mat-nav-list>
@if (!isStalkerPlaylist()) {
<a
mat-list-item
routerLink="./recently-added"
routerLinkActive="active"
[matTooltip]="'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate"
matTooltipPosition="right"
(click)="pageSwitch('recently-added')"
>
<mat-icon matListItemIcon>new_releases</mat-icon>
</a>
}
<a
mat-list-item
routerLink="./search"
@@ -37,7 +37,7 @@ export class NavigationComponent {
readonly selectedContentType = input<string>();
readonly categoryClick = output<string>();
readonly pageClicked = output<'search' | 'recent' | 'favorites'>();
readonly pageClicked = output<'search' | 'recent' | 'favorites' | 'recently-added'>();
readonly currentPlaylist = this.store.selectSignal(
selectPlaylistById(this.activatedRoute.snapshot.params.id)
@@ -113,7 +113,7 @@ export class NavigationComponent {
});
}
pageSwitch(page: 'search' | 'recent' | 'favorites') {
pageSwitch(page: 'search' | 'recent' | 'favorites' | 'recently-added') {
this.pageClicked.emit(page);
this.xtreamStore.setSelectedContentType(undefined);
}
@@ -0,0 +1,57 @@
<div class="recently-added-page">
<h2>{{ 'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate }}</h2>
@if (recentlyAddedVod().length > 0) {
<div class="section">
<h3>{{ 'PORTALS.SIDEBAR.MOVIES' | translate }}</h3>
<div class="carousel">
@for (item of recentlyAddedVod(); track item.xtream_id) {
<app-search-result-item
class="carousel-item"
[title]="item.title || item.name"
[description]="getDate(item) | date : 'mediumDate'"
[posterUrl]="item.poster_url || item.stream_icon"
[type]="'movie'"
(itemClick)="openItem(item, 'vod')"
/>
}
</div>
</div>
}
@if (recentlyAddedSeries().length > 0) {
<div class="section">
<h3>{{ 'PORTALS.SIDEBAR.SERIES' | translate }}</h3>
<div class="carousel">
@for (item of recentlyAddedSeries(); track item.xtream_id) {
<app-search-result-item
class="carousel-item"
[title]="item.title || item.name"
[description]="getDate(item) | date : 'mediumDate'"
[posterUrl]="item.poster_url || item.cover"
[type]="'series'"
(itemClick)="openItem(item, 'series')"
/>
}
</div>
</div>
}
@if (recentlyAddedLive().length > 0) {
<div class="section">
<h3>{{ 'PORTALS.SIDEBAR.LIVE_TV' | translate }}</h3>
<div class="carousel">
@for (item of recentlyAddedLive(); track item.xtream_id) {
<app-search-result-item
class="carousel-item"
[title]="item.title || item.name"
[description]="getDate(item) | date : 'mediumDate'"
[posterUrl]="item.poster_url || item.stream_icon"
[type]="'live'"
(itemClick)="openItem(item, 'live')"
/>
}
</div>
</div>
}
</div>
@@ -0,0 +1,37 @@
:host {
display: block;
height: 100%;
overflow-y: auto;
}
.recently-added-page {
padding: 20px;
}
h2 {
margin-bottom: 24px;
}
.section {
margin-bottom: 32px;
h3 {
margin-bottom: 16px;
opacity: 0.8;
}
}
.carousel {
display: flex;
overflow-x: auto;
gap: 16px;
padding-bottom: 16px;
scrollbar-width: thin;
.carousel-item {
min-width: 160px;
width: 160px;
flex-shrink: 0;
}
}
@@ -0,0 +1,59 @@
import { DatePipe } from '@angular/common';
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
import { ContentType } from '../xtream-state';
import { XtreamStore } from '../stores/xtream.store';
@Component({
selector: 'app-recently-added',
templateUrl: './recently-added.component.html',
styleUrls: ['./recently-added.component.scss'],
imports: [DatePipe, SearchResultItemComponent, TranslatePipe],
})
export class RecentlyAddedComponent {
private readonly xtreamStore = inject(XtreamStore);
private readonly router = inject(Router);
private readonly activatedRoute = inject(ActivatedRoute);
readonly recentlyAddedLive = computed(() =>
this.getRecentlyAdded(this.xtreamStore.liveStreams())
);
readonly recentlyAddedVod = computed(() =>
this.getRecentlyAdded(this.xtreamStore.vodStreams())
);
readonly recentlyAddedSeries = computed(() =>
this.getRecentlyAdded(this.xtreamStore.serialStreams(), true)
);
private getRecentlyAdded(items: any[], isSeries = false) {
return [...items]
.sort((a, b) => {
const dateA = parseInt(isSeries ? a.last_modified : a.added) || 0;
const dateB = parseInt(isSeries ? b.last_modified : b.added) || 0;
return dateB - dateA;
})
.slice(0, 20);
}
getDate(item: any): number {
const timestamp = item.added || item.last_modified;
return parseInt(timestamp) * 1000;
}
openItem(item: any, type: ContentType) {
this.xtreamStore.setSelectedContentType(type);
if (type === 'live') {
this.router.navigate(['..', type, item.category_id], {
relativeTo: this.activatedRoute,
});
} else {
const itemId = item.xtream_id || (type === 'series' ? item.series_id : item.stream_id);
this.router.navigate(['..', type, item.category_id, itemId], {
relativeTo: this.activatedRoute,
});
}
}
}
@@ -60,30 +60,43 @@ export class XtreamMainContainerComponent implements OnInit {
}
getContentLabel(): string {
if (
this.xtreamStore.getSelectedCategory() === null ||
this.xtreamStore.getSelectedCategory() === undefined
) {
return this.translateService.instant('PORTALS.SELECT_CATEGORY');
} else {
const selectedCategory = this.xtreamStore.getSelectedCategory();
const categoryName = selectedCategory
? (selectedCategory as any).name
: 'Category Content';
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
// Show page number when viewing category content (not detail view)
// When no category is selected, show "Recently Added"
if (selectedCategoryId === null || selectedCategoryId === undefined) {
const recentlyAddedLabel = this.translateService.instant('PORTALS.SIDEBAR.RECENTLY_ADDED');
// Show page number when viewing recently added (not detail view)
if (
!this.xtreamStore.selectedItem() &&
this.xtreamStore.getTotalPages() > 1
) {
const currentPage = this.xtreamStore.page() + 1; // +1 because page is 0-indexed
const currentPage = this.xtreamStore.page() + 1;
const totalPages = this.xtreamStore.getTotalPages();
const pageLabel = this.translateService.instant('PORTALS.PAGE');
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
return `${recentlyAddedLabel} (${pageLabel} ${currentPage}/${totalPages})`;
}
return `Content for ${categoryName}`;
return recentlyAddedLabel;
}
const selectedCategory = this.xtreamStore.getSelectedCategory();
const categoryName = selectedCategory
? (selectedCategory as any).name
: 'Category Content';
// Show page number when viewing category content (not detail view)
if (
!this.xtreamStore.selectedItem() &&
this.xtreamStore.getTotalPages() > 1
) {
const currentPage = this.xtreamStore.page() + 1; // +1 because page is 0-indexed
const totalPages = this.xtreamStore.getTotalPages();
const pageLabel = this.translateService.instant('PORTALS.PAGE');
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
}
return categoryName;
}
historyBack() {
@@ -105,6 +118,7 @@ export class XtreamMainContainerComponent implements OnInit {
data: {
playlistId,
contentType,
itemCounts: this.categoryItemCounts(),
},
width: '500px',
maxHeight: '80vh',
@@ -57,11 +57,16 @@ export class XtreamShellComponent {
this.route.params.pipe(takeUntilDestroyed()).subscribe(async (params) => {
const newPlaylistId = params['id'];
// Skip if playlist ID hasn't changed (after first load)
if (!this.isFirstLoad && this.currentPlaylistId === newPlaylistId) {
return;
}
if (this.isFirstLoad) {
// First load - just set the playlist ID without resetting
this.xtreamStore.setPlaylistId(newPlaylistId);
this.isFirstLoad = false;
} else if (this.currentPlaylistId !== newPlaylistId) {
} else {
// Switching to a different playlist - reset and set new ID
this.xtreamStore.resetStore(newPlaylistId);
}
@@ -85,7 +90,6 @@ export class XtreamShellComponent {
this.xtreamStore.initializeContent();
}
},
{ allowSignalWrites: true }
);
}
@@ -96,10 +100,10 @@ export class XtreamShellComponent {
});
}
handlePageClick(page: 'search' | 'recent' | 'favorites') {
handlePageClick(page: 'search' | 'recent' | 'favorites' | 'recently-added') {
this.xtreamStore.setSelectedContentType(undefined);
this.router.navigate([page], {
relativeTo: this.route,
});
}
}
}
@@ -36,6 +36,13 @@ export const xtreamRoutes: Route[] = [
(c) => c.XtreamMainContainerComponent
),
children: [
{
path: '',
loadComponent: () =>
import(
'./category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
{
path: ':categoryId',
loadComponent: () =>
@@ -59,6 +66,13 @@ export const xtreamRoutes: Route[] = [
(c) => c.XtreamMainContainerComponent
),
children: [
{
path: '',
loadComponent: () =>
import(
'./category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
{
path: ':categoryId',
loadComponent: () =>
@@ -96,6 +110,13 @@ export const xtreamRoutes: Route[] = [
(c) => c.SearchResultsComponent
),
},
{
path: 'recently-added',
loadComponent: () =>
import('./recently-added/recently-added.component').then(
(c) => c.RecentlyAddedComponent
),
},
],
},
];