mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
Merge pull request #747 from 4gray/refactor/xtream-stalker-code-generalization
Refactor/xtream stalker code generalization
This commit is contained in:
64 files changed
+2929
-833
No files matched your search
@@ -18,7 +18,14 @@
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(xargs sed:*)",
|
||||
"Skill(frontend-design)"
|
||||
"Skill(frontend-design)",
|
||||
"mcp__angular-cli__get_best_practices",
|
||||
"mcp__nx-mcp__nx_workspace",
|
||||
"Bash(grep:*)",
|
||||
"Bash(npx nx lint:*)",
|
||||
"Bash(npx nx build:*)",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(npx nx run:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/4gray/iptvnator/build-and-test.yaml?style=for-the-badge&logo=github"> <a href="https://github.com/4gray/iptvnator/releases"><img src="https://img.shields.io/github/downloads/4gray/iptvnator/total?style=for-the-badge&logo=github" alt="Releases"></a> <a href="https://codecov.io/gh/4gray/iptvnator"><img alt="Codecov" src="https://img.shields.io/codecov/c/github/4gray/iptvnator?style=for-the-badge"></a> <a href="https://t.me/iptvnator"><img src="https://img.shields.io/badge/telegram-iptvnator-blue?logo=telegram&style=for-the-badge" alt="Telegram"></a> <a href="https://bsky.app/profile/iptvnator.bsky.social"><img src="https://img.shields.io/badge/bluesky-iptvnator-darkblue?logo=bluesky&style=for-the-badge" alt="Bluesky"></a>
|
||||
</p>
|
||||
|
||||
<a href="https://t.me/iptvnator">Telegram channel for discussions</a>
|
||||
<a href="https://t.me/iptvnator">Telegram channel for discussions</a> | <a href="https://ko-fi.com/4gray" target="_blank">Buy me a coffee</a> | <a href="https://github.com/sponsors/4gray">GitHub Sponsors</a>
|
||||
|
||||
**IPTVnator** is a video player application that provides support for IPTV playlist playback (m3u, m3u8). The application allows users to import playlists using remote URLs or by uploading files from the local file system. Additionally, it supports EPG information in XMLTV format which can be provided via URL.
|
||||
|
||||
|
||||
@@ -8,6 +8,31 @@ import { ipcMain } from 'electron';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
|
||||
/**
|
||||
* Ensure playlist exists in SQLite (auto-create if missing)
|
||||
* This handles legacy playlists that weren't synced to SQLite
|
||||
*/
|
||||
async function ensurePlaylistExists(
|
||||
db: any,
|
||||
playlistId: string,
|
||||
playlistType: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url' = 'stalker'
|
||||
): Promise<void> {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, playlistId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
// Create a minimal placeholder entry for the playlist
|
||||
await db.insert(schema.playlists).values({
|
||||
id: playlistId,
|
||||
name: 'Imported Playlist',
|
||||
type: playlistType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save/update playback position for content
|
||||
*/
|
||||
@@ -15,13 +40,11 @@ ipcMain.handle(
|
||||
'DB_SAVE_PLAYBACK_POSITION',
|
||||
async (event, playlistId: string, data: any) => {
|
||||
try {
|
||||
console.log(
|
||||
'[DatabaseEvents] Saving playback position:',
|
||||
playlistId,
|
||||
data
|
||||
);
|
||||
const db = await getDatabase();
|
||||
|
||||
// Ensure playlist exists (handles legacy playlists not in SQLite)
|
||||
await ensurePlaylistExists(db, playlistId, data.playlistType);
|
||||
|
||||
// Prepare values
|
||||
const values = {
|
||||
playlistId,
|
||||
@@ -83,9 +106,6 @@ ipcMain.handle(
|
||||
contentType: 'vod' | 'episode'
|
||||
) => {
|
||||
try {
|
||||
console.log(
|
||||
`[DatabaseEvents] Getting playback position: playlistId=${playlistId}, id=${contentXtreamId}, type=${contentType}`
|
||||
);
|
||||
const db = await getDatabase();
|
||||
const result = await db
|
||||
.select()
|
||||
|
||||
@@ -21,6 +21,8 @@ ipcMain.handle(
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
macAddress?: string;
|
||||
url?: string;
|
||||
type: string;
|
||||
}
|
||||
) => {
|
||||
@@ -32,6 +34,8 @@ ipcMain.handle(
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
macAddress: playlist.macAddress,
|
||||
url: playlist.url,
|
||||
// enforce supported types
|
||||
type: playlist.type as
|
||||
| 'xtream'
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<mat-drawer-container class="main-container">
|
||||
<mat-drawer-container class="main-container" autosize>
|
||||
<!-- sidebar content -->
|
||||
<mat-drawer #drawer mode="side" opened disableClose class="mat-drawer">
|
||||
<mat-drawer
|
||||
#drawer
|
||||
mode="side"
|
||||
opened
|
||||
disableClose
|
||||
class="mat-drawer"
|
||||
appResizable
|
||||
[minWidth]="200"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="350"
|
||||
storageKey="iptvnator_video_sidebar_width"
|
||||
handlePosition="right"
|
||||
>
|
||||
@defer {
|
||||
<app-sidebar [channels]="(channels$ | async)!" />
|
||||
} @loading {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: hidden; // Prevent horizontal scrollbar from resize handle
|
||||
}
|
||||
|
||||
.mat-drawer-content {
|
||||
@@ -13,8 +14,18 @@
|
||||
}
|
||||
|
||||
.mat-drawer {
|
||||
width: 400px;
|
||||
min-width: 400px;
|
||||
// Width is controlled by appResizable directive
|
||||
// Default: 350px, min: 200px, max: 600px
|
||||
overflow-x: hidden !important; // Hide horizontal scrollbar
|
||||
|
||||
.resize-handle--right {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure mat-drawer inner container fills the drawer width
|
||||
::ng-deep .mat-drawer-inner-container {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.custom-player {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
HtmlVideoPlayerComponent,
|
||||
InfoOverlayComponent,
|
||||
MultiEpgContainerComponent,
|
||||
ResizableDirective,
|
||||
SidebarComponent,
|
||||
ToolbarComponent,
|
||||
VjsPlayerComponent,
|
||||
@@ -64,6 +65,7 @@ import { SettingsComponent } from '../../settings/settings.component';
|
||||
HtmlVideoPlayerComponent,
|
||||
InfoOverlayComponent,
|
||||
MatSidenavModule,
|
||||
ResizableDirective,
|
||||
RouterLink,
|
||||
SidebarComponent,
|
||||
ToolbarComponent,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<div class="sidebar">
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from 'components';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component';
|
||||
|
||||
@Component({
|
||||
@@ -23,6 +23,7 @@ import { CategoryViewComponent } from '../../../xtream-tauri/category-view/categ
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
PlaylistSwitcherComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ import { WatchedBadgeComponent } from '../../../xtream-tauri/shared/watched-badg
|
||||
<mat-icon>star</mat-icon>{{ rating }}
|
||||
</div>
|
||||
}
|
||||
@let title = i.title ?? i.name;
|
||||
@let title = i.title ?? i.o_name ?? i.name;
|
||||
<mat-card-actions>
|
||||
<div class="title">
|
||||
{{ title || 'No name' }}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
export interface StalkerSerialDetails {
|
||||
name: string;
|
||||
description: string;
|
||||
director: string;
|
||||
actors: string;
|
||||
year: string;
|
||||
genres_str: string;
|
||||
age: string;
|
||||
rating_imdb: string;
|
||||
rating_kinopoisk: string;
|
||||
screenshot_uri: string;
|
||||
added: string;
|
||||
}
|
||||
/**
|
||||
* @deprecated Import from 'shared-interfaces' instead.
|
||||
* This re-export exists for backward compatibility.
|
||||
*/
|
||||
export { StalkerSerialDetails } from 'shared-interfaces';
|
||||
@@ -1,15 +1,8 @@
|
||||
export interface StalkerVodDetails {
|
||||
id: string;
|
||||
cmd: string;
|
||||
info: {
|
||||
movie_image: string;
|
||||
description: string;
|
||||
name: string;
|
||||
actors: string;
|
||||
director: string;
|
||||
releasedate: string;
|
||||
genre: string;
|
||||
rating_imdb: string;
|
||||
rating_kinopoisk: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* @deprecated Import from 'shared-interfaces' instead.
|
||||
* This re-export exists for backward compatibility.
|
||||
*/
|
||||
export {
|
||||
StalkerVodDetails,
|
||||
StalkerVodInfo,
|
||||
} from 'shared-interfaces';
|
||||
@@ -1,30 +1,29 @@
|
||||
@if (itemDetails) {
|
||||
<button
|
||||
mat-button
|
||||
(click)="itemDetails = null"
|
||||
[attr.aria-label]="'SETTINGS.BACK_TO_HOME' | translate"
|
||||
class="back-button"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
{{ 'SETTINGS.BACK_TO_HOME' | translate }}
|
||||
</button>
|
||||
@switch (itemDetails.category_id) {
|
||||
@case ('vod') {
|
||||
<app-vod-details
|
||||
[item]="itemDetails"
|
||||
(playClicked)="
|
||||
createLinkToPlayVodItv(
|
||||
itemDetails.cmd,
|
||||
itemDetails.info.name,
|
||||
itemDetails.info.movie_image
|
||||
)
|
||||
"
|
||||
(addToFavoritesClicked)="addToFavorites($event)"
|
||||
(removeFromFavoritesClicked)="removeFromFavorites($any($event))"
|
||||
/>
|
||||
@if (itemDetails.series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub) -->
|
||||
<app-stalker-series-view
|
||||
[vodWithSeries]="itemDetails"
|
||||
(backClicked)="itemDetails = null; vodDetailsItem = null"
|
||||
/>
|
||||
} @else if (itemDetails.is_series === true) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view
|
||||
(backClicked)="itemDetails = null; vodDetailsItem = null"
|
||||
/>
|
||||
} @else if (vodDetailsItem) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('series') {
|
||||
<app-stalker-series-view />
|
||||
<app-stalker-series-view (backClicked)="itemDetails = null" />
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { rxResource } from '@angular/core/rxjs-interop';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistsService } from 'services';
|
||||
import {
|
||||
VodDetailsItem,
|
||||
StalkerVodDetails,
|
||||
createStalkerVodItem,
|
||||
} from 'shared-interfaces';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
@@ -14,10 +17,7 @@ import { StalkerStore } from '../stalker.store';
|
||||
templateUrl: './recently-viewed.component.html',
|
||||
imports: [
|
||||
FavoritesLayoutComponent,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
StalkerSeriesViewComponent,
|
||||
TranslatePipe,
|
||||
VodDetailsComponent,
|
||||
],
|
||||
styles: [
|
||||
@@ -42,6 +42,7 @@ export class RecentlyViewedComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
itemDetails: any = null;
|
||||
vodDetailsItem: VodDetailsItem | null = null;
|
||||
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
@@ -130,9 +131,30 @@ export class RecentlyViewedComponent {
|
||||
this.stalkerStore.setSelectedContentType(item.category_id);
|
||||
switch (item.category_id) {
|
||||
case 'itv':
|
||||
this.createLinkToPlayVodItv(item.cmd, item.name, item.logo);
|
||||
this.createLinkToPlayVodItv(item.cmd, item.o_name || item.name, item.logo);
|
||||
break;
|
||||
case 'vod':
|
||||
// Normalize the item to ensure is_series flag is properly set
|
||||
const normalizedItem = this.normalizeRecentItem(item);
|
||||
this.itemDetails = normalizedItem;
|
||||
this.stalkerStore.setSelectedItem(normalizedItem);
|
||||
|
||||
// Check if this VOD item is actually a series (Ministra is_series=1)
|
||||
const isVodSeries =
|
||||
normalizedItem.is_series === true ||
|
||||
normalizedItem.series?.length > 0;
|
||||
|
||||
if (isVodSeries) {
|
||||
// VOD series - will be rendered as series view
|
||||
this.vodDetailsItem = null;
|
||||
} else {
|
||||
// Regular VOD - create VodDetailsItem for the component
|
||||
this.vodDetailsItem = createStalkerVodItem(
|
||||
normalizedItem as StalkerVodDetails,
|
||||
this.currentPlaylist()?._id ?? ''
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'series':
|
||||
this.itemDetails = item;
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
@@ -142,6 +164,40 @@ export class RecentlyViewedComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle play from vod-details component */
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
if (item.type === 'stalker') {
|
||||
this.createLinkToPlayVodItv(
|
||||
item.cmd,
|
||||
item.data.info?.name,
|
||||
item.data.info?.movie_image
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle favorite toggle from vod-details component */
|
||||
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
|
||||
if (event.item.type === 'stalker') {
|
||||
if (event.isFavorite) {
|
||||
this.addToFavorites({
|
||||
...event.item.data,
|
||||
category_id: 'vod',
|
||||
title: event.item.data.info?.name,
|
||||
cover: event.item.data.info?.movie_image,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
this.removeFromFavorites(event.item.data.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle back from vod-details component */
|
||||
onVodBack(): void {
|
||||
this.itemDetails = null;
|
||||
this.vodDetailsItem = null;
|
||||
}
|
||||
|
||||
async createLinkToPlayVodItv(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
@@ -158,4 +214,33 @@ export class RecentlyViewedComponent {
|
||||
removeFromFavorites(favoriteId: string) {
|
||||
this.stalkerStore.removeFromFavorites(favoriteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize recent item to ensure is_series flag is properly set
|
||||
* and all required fields are present for the series view
|
||||
*/
|
||||
private normalizeRecentItem(item: any): any {
|
||||
// Normalize is_series to boolean true (can be 1, "1", or true)
|
||||
const rawIsSeries = item.is_series;
|
||||
const normalizedIsSeries =
|
||||
rawIsSeries === true ||
|
||||
rawIsSeries === 1 ||
|
||||
rawIsSeries === '1';
|
||||
|
||||
// Ensure info object exists
|
||||
const info = item.info || {
|
||||
name: item.o_name || item.name || item.title || 'Unknown',
|
||||
o_name: item.o_name,
|
||||
movie_image: item.cover || item.screenshot_uri || item.logo,
|
||||
description: item.description,
|
||||
};
|
||||
|
||||
return {
|
||||
...item,
|
||||
info: info,
|
||||
is_series: normalizedIsSeries ? true : undefined,
|
||||
// Preserve video_id for season fetching
|
||||
video_id: item.video_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
@if (itemDetails) {
|
||||
@switch (itemDetails.category_id) {
|
||||
@case ('vod') {
|
||||
<app-vod-details
|
||||
[item]="itemDetails.details"
|
||||
(playClicked)="
|
||||
createLinkToPlayVodItv(
|
||||
itemDetails.details.cmd,
|
||||
itemDetails.details.info.name,
|
||||
itemDetails.details.info.movie_image
|
||||
)
|
||||
"
|
||||
(backClicked)="itemDetails = null"
|
||||
/>
|
||||
@if (itemDetails.details?.series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub) -->
|
||||
<app-stalker-series-view
|
||||
[vodWithSeries]="itemDetails.details"
|
||||
(backClicked)="itemDetails = null; vodDetailsItem = null"
|
||||
/>
|
||||
} @else if (
|
||||
itemDetails.details?.is_series === true ||
|
||||
itemDetails.details?.is_series === 1 ||
|
||||
itemDetails.details?.is_series === '1'
|
||||
) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view
|
||||
(backClicked)="itemDetails = null; vodDetailsItem = null"
|
||||
/>
|
||||
} @else if (vodDetailsItem) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem"
|
||||
[isFavorite]="true"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('series') {
|
||||
<app-stalker-series-view (backClicked)="itemDetails = null" />
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { rxResource } from '@angular/core/rxjs-interop';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistsService } from 'services';
|
||||
import {
|
||||
VodDetailsItem,
|
||||
StalkerVodDetails,
|
||||
createStalkerVodItem,
|
||||
} from 'shared-interfaces';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
@@ -39,6 +42,7 @@ export class StalkerFavoritesComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
itemDetails: any = null;
|
||||
vodDetailsItem: VodDetailsItem | null = null;
|
||||
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
@@ -130,12 +134,32 @@ export class StalkerFavoritesComponent {
|
||||
switch (item.category_id) {
|
||||
case 'itv':
|
||||
this.stalkerStore.setSelectedContentType('itv');
|
||||
this.createLinkToPlayVodItv(item.cmd, item.name, item.logo);
|
||||
this.createLinkToPlayVodItv(item.cmd, item.o_name || item.name, item.logo);
|
||||
break;
|
||||
case 'vod':
|
||||
this.itemDetails = normalizedItem;
|
||||
this.stalkerStore.setSelectedItem(normalizedItem.details);
|
||||
this.stalkerStore.setSelectedContentType('vod');
|
||||
|
||||
// Check if this VOD item is actually a series (Ministra is_series=1)
|
||||
const isVodSeries =
|
||||
normalizedItem.details?.is_series === true ||
|
||||
normalizedItem.details?.is_series === 1 ||
|
||||
normalizedItem.details?.is_series === '1' ||
|
||||
item.is_series === true ||
|
||||
item.is_series === 1 ||
|
||||
item.is_series === '1';
|
||||
|
||||
if (isVodSeries || normalizedItem.details?.series?.length > 0) {
|
||||
// VOD series - will be rendered as series view
|
||||
this.vodDetailsItem = null;
|
||||
} else {
|
||||
// Regular VOD - create VodDetailsItem for the component
|
||||
this.vodDetailsItem = createStalkerVodItem(
|
||||
normalizedItem.details as StalkerVodDetails,
|
||||
this.currentPlaylist()?._id ?? ''
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'series':
|
||||
this.itemDetails = normalizedItem;
|
||||
@@ -147,6 +171,30 @@ export class StalkerFavoritesComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle play from vod-details component */
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
if (item.type === 'stalker') {
|
||||
this.createLinkToPlayVodItv(
|
||||
item.cmd,
|
||||
item.data.info?.name,
|
||||
item.data.info?.movie_image
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle favorite toggle from vod-details component */
|
||||
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
|
||||
if (!event.isFavorite && event.item.type === 'stalker') {
|
||||
this.removeFromFavorites({ id: event.item.data.id });
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle back from vod-details component */
|
||||
onVodBack(): void {
|
||||
this.itemDetails = null;
|
||||
this.vodDetailsItem = null;
|
||||
}
|
||||
|
||||
private normalizeFavoriteItem(item: any): any {
|
||||
if (item.details && item.details.info) {
|
||||
return item;
|
||||
@@ -154,7 +202,8 @@ export class StalkerFavoritesComponent {
|
||||
|
||||
const source = item.details || item;
|
||||
const info = source.info || {
|
||||
name: item.name || item.title || 'Unknown',
|
||||
name: item.o_name || item.name || item.title || 'Unknown',
|
||||
o_name: item.o_name,
|
||||
movie_image: item.cover || item.screenshot_uri || item.logo,
|
||||
description: item.description,
|
||||
releasedate: item.releasedate || item.year,
|
||||
@@ -169,6 +218,14 @@ export class StalkerFavoritesComponent {
|
||||
[item.cover, item.screenshot_uri].filter(Boolean),
|
||||
};
|
||||
|
||||
// Normalize is_series to boolean true (can be 1, "1", or true)
|
||||
const rawIsSeries =
|
||||
item.is_series || source.is_series;
|
||||
const normalizedIsSeries =
|
||||
rawIsSeries === true ||
|
||||
rawIsSeries === 1 ||
|
||||
rawIsSeries === '1';
|
||||
|
||||
return {
|
||||
...item,
|
||||
details: {
|
||||
@@ -177,7 +234,9 @@ export class StalkerFavoritesComponent {
|
||||
cmd: item.cmd || source.cmd,
|
||||
id: item.stream_id || item.id || source.id,
|
||||
series: item.series || source.series,
|
||||
is_series: item.is_series || source.is_series,
|
||||
is_series: normalizedIsSeries ? true : undefined,
|
||||
// Preserve video_id for season fetching
|
||||
video_id: item.video_id || source.video_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<div class="sidebar" style="display: flex; flex-direction: column">
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
@let isItvCategorySelected =
|
||||
stalkerStore.selectedCategoryId() &&
|
||||
@@ -31,7 +38,7 @@
|
||||
"
|
||||
alt="logo"
|
||||
/>
|
||||
{{ item.name }}
|
||||
{{ item.o_name || item.name }}
|
||||
<button
|
||||
mat-icon-button
|
||||
matListItemMeta
|
||||
@@ -90,7 +97,7 @@
|
||||
}
|
||||
|
||||
@if (!isItvCategorySelected) {
|
||||
{{ selectedCategoryTitle() }}
|
||||
{{ getContentLabel() }}
|
||||
}
|
||||
|
||||
@if (!contentItems() && !isContentLoading()) {
|
||||
|
||||
@@ -8,17 +8,17 @@ import {
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { PlaylistSwitcherComponent } from 'components';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent } from '../xtream-tauri/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component';
|
||||
import { StalkerStore } from './stalker.store';
|
||||
@@ -33,7 +33,6 @@ import { StalkerStore } from './stalker.store';
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatListModule,
|
||||
@@ -42,12 +41,14 @@ import { StalkerStore } from './stalker.store';
|
||||
NgxSkeletonLoaderModule,
|
||||
PlaylistErrorViewComponent,
|
||||
PlaylistSwitcherComponent,
|
||||
ResizableDirective,
|
||||
RouterOutlet,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
|
||||
currentLayout:
|
||||
| 'category_content'
|
||||
@@ -282,7 +283,7 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
createLinkToPlayItv(item: any) {
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
this.stalkerStore.createLinkToPlayVod(item.cmd, item.name, item.logo);
|
||||
this.stalkerStore.createLinkToPlayVod(item.cmd, item.o_name || item.name, item.logo);
|
||||
}
|
||||
|
||||
toggleFavorite(item: any) {
|
||||
@@ -296,7 +297,7 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
|
||||
this.stalkerStore.addToFavorites({
|
||||
...item,
|
||||
category_id: 'itv',
|
||||
title: item.name,
|
||||
title: item.o_name || item.name,
|
||||
cover: item.logo,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
@@ -329,4 +330,21 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
|
||||
goBackToList() {
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
}
|
||||
|
||||
getContentLabel(): string {
|
||||
const categoryName = this.stalkerStore.getSelectedCategoryName();
|
||||
|
||||
// Show page number when viewing category content (not detail view)
|
||||
if (
|
||||
!this.stalkerStore.selectedItem() &&
|
||||
this.stalkerStore.getTotalPages() > 1
|
||||
) {
|
||||
const currentPage = this.stalkerStore.page() + 1;
|
||||
const totalPages = this.stalkerStore.getTotalPages();
|
||||
const pageLabel = this.translateService.instant('PORTALS.PAGE');
|
||||
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
|
||||
}
|
||||
|
||||
return categoryName;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
@if (itemDetails) {
|
||||
@switch (selectedFilterType()) {
|
||||
@case ('vod') {
|
||||
<app-vod-details
|
||||
[item]="itemDetails"
|
||||
(playClicked)="
|
||||
createLinkToPlayVodItv(
|
||||
itemDetails.cmd,
|
||||
itemDetails.info.name,
|
||||
itemDetails.info.movie_image
|
||||
)
|
||||
"
|
||||
(addToFavoritesClicked)="addToFavorites($event)"
|
||||
(removeFromFavoritesClicked)="removeFromFavorites($any($event))"
|
||||
(backClicked)="itemDetails = null"
|
||||
/>
|
||||
@if (itemDetails.series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub or Ministra) -->
|
||||
<app-stalker-series-view
|
||||
[vodWithSeries]="itemDetails"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
} @else if (itemDetails.is_series === true) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view (backClicked)="onVodBack()" />
|
||||
} @else if (vodDetailsItem) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('series') {
|
||||
<app-stalker-series-view (backClicked)="itemDetails = null" />
|
||||
@@ -43,7 +48,7 @@
|
||||
track item.id
|
||||
) {
|
||||
<app-search-result-item
|
||||
[title]="item.name"
|
||||
[title]="item.o_name || item.name"
|
||||
[posterUrl]="item.screenshot_uri"
|
||||
[type]="item.type"
|
||||
(itemClick)="selectItem(item)"
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
Playlist,
|
||||
STALKER_REQUEST,
|
||||
StalkerPortalActions,
|
||||
VodDetailsItem,
|
||||
StalkerVodDetails,
|
||||
createStalkerVodItem,
|
||||
} from 'shared-interfaces';
|
||||
import { SearchFormComponent } from '../../shared/components/search-form/search-form.component';
|
||||
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
|
||||
@@ -82,7 +85,8 @@ export class StalkerSearchComponent {
|
||||
|
||||
readonly selectedFilterType = signal('vod');
|
||||
|
||||
itemDetails = null;
|
||||
itemDetails: any = null;
|
||||
vodDetailsItem: VodDetailsItem | null = null;
|
||||
|
||||
readonly searchResultsResource = resource({
|
||||
params: () => ({
|
||||
@@ -130,7 +134,11 @@ export class StalkerSearchComponent {
|
||||
}
|
||||
);
|
||||
if (response) {
|
||||
return response.js?.data;
|
||||
// Process items to convert relative URLs to absolute
|
||||
const items = response.js?.data || [];
|
||||
return items.map((item: any) =>
|
||||
this.processItemUrls(item, portalUrl)
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Error: ${response.message} (Status: ${response.status})`
|
||||
@@ -144,15 +152,62 @@ export class StalkerSearchComponent {
|
||||
}
|
||||
|
||||
selectItem(item: any) {
|
||||
// Check if item has embedded series data (vclub mode or Ministra with episodes)
|
||||
const hasEmbeddedSeries = item.series?.length > 0;
|
||||
|
||||
// Detect if this VOD item is a series that needs API fetch (Ministra is_series flag WITHOUT embedded series)
|
||||
// Only set is_series when we need to fetch seasons from API
|
||||
const needsSeriesFetch =
|
||||
this.selectedFilterType() === 'vod' &&
|
||||
!hasEmbeddedSeries &&
|
||||
(item.is_series === '1' || item.is_series === 1);
|
||||
|
||||
// Structure item exactly like category-content-view does
|
||||
this.itemDetails = {
|
||||
...item,
|
||||
info: { ...item, movie_image: item.screenshot_uri },
|
||||
id: item.id,
|
||||
cmd: item.cmd,
|
||||
// For VOD items with embedded series array (Stalker vclub or Ministra with episode numbers)
|
||||
series: item.series,
|
||||
// Preserve has_files for cmd transformation during playback
|
||||
has_files: item.has_files,
|
||||
// Flag for VOD items that need to fetch seasons from API (Ministra plugin without embedded series)
|
||||
is_series: needsSeriesFetch ? true : undefined,
|
||||
// Store video_id for season fetching if available
|
||||
video_id: item.video_id,
|
||||
info: {
|
||||
movie_image: item.screenshot_uri,
|
||||
description: item.description,
|
||||
name: item.o_name || item.name,
|
||||
o_name: item.o_name,
|
||||
director: item.director,
|
||||
releasedate: item.year,
|
||||
genre: item.genres_str,
|
||||
actors: item.actors,
|
||||
rating_imdb: item.rating_imdb,
|
||||
rating_kinopoisk: item.rating_kinopoisk,
|
||||
},
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
console.log('[StalkerSearch] selectItem - hasEmbeddedSeries:', hasEmbeddedSeries);
|
||||
console.log('[StalkerSearch] selectItem - needsSeriesFetch:', needsSeriesFetch);
|
||||
console.log('[StalkerSearch] selectItem - itemDetails:', this.itemDetails);
|
||||
|
||||
this.stalkerStore.setSelectedItem(this.itemDetails);
|
||||
|
||||
switch (this.selectedFilterType()) {
|
||||
case 'vod':
|
||||
this.stalkerStore.setSelectedContentType('vod');
|
||||
// Only create VodDetailsItem for regular VODs (not series of any kind)
|
||||
if (!hasEmbeddedSeries && !needsSeriesFetch) {
|
||||
const playlist = this.currentPlaylist();
|
||||
this.vodDetailsItem = createStalkerVodItem(
|
||||
this.itemDetails as StalkerVodDetails,
|
||||
playlist?._id ?? ''
|
||||
);
|
||||
} else {
|
||||
this.vodDetailsItem = null;
|
||||
}
|
||||
break;
|
||||
case 'series':
|
||||
this.stalkerStore.setSelectedContentType('series');
|
||||
@@ -162,6 +217,40 @@ export class StalkerSearchComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle play from vod-details component */
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
if (item.type === 'stalker') {
|
||||
this.createLinkToPlayVodItv(
|
||||
item.cmd,
|
||||
item.data.info?.name,
|
||||
item.data.info?.movie_image
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle favorite toggle from vod-details component */
|
||||
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
|
||||
if (event.item.type === 'stalker') {
|
||||
if (event.isFavorite) {
|
||||
this.addToFavorites({
|
||||
...event.item.data,
|
||||
category_id: 'vod',
|
||||
title: event.item.data.info?.name,
|
||||
cover: event.item.data.info?.movie_image,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
this.removeFromFavorites(event.item.data.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle back from vod-details component */
|
||||
onVodBack(): void {
|
||||
this.itemDetails = null;
|
||||
this.vodDetailsItem = null;
|
||||
}
|
||||
|
||||
onFiltersChange(event: { vod: boolean; live: boolean; series: boolean }) {
|
||||
const selectedFilter = Object.keys(event).find((key) => event[key]);
|
||||
if (selectedFilter) {
|
||||
@@ -178,4 +267,47 @@ export class StalkerSearchComponent {
|
||||
console.debug('Remove from favorites', favoriteId);
|
||||
this.stalkerStore.removeFromFavorites(favoriteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert relative URLs to absolute URLs using the portal base URL.
|
||||
* Ministra portals often return relative paths for screenshot_uri.
|
||||
*/
|
||||
private processItemUrls(item: any, portalUrl: string): any {
|
||||
const processed = { ...item };
|
||||
|
||||
// Convert screenshot_uri to absolute URL
|
||||
if (processed.screenshot_uri) {
|
||||
processed.screenshot_uri = this.makeAbsoluteUrl(
|
||||
portalUrl,
|
||||
processed.screenshot_uri
|
||||
);
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert relative URL to absolute using portal base URL
|
||||
*/
|
||||
private makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
|
||||
if (!relativePath) return '';
|
||||
// Already absolute URL
|
||||
if (
|
||||
relativePath.startsWith('http://') ||
|
||||
relativePath.startsWith('https://')
|
||||
) {
|
||||
return relativePath;
|
||||
}
|
||||
// Parse the base URL to get origin
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
// Ensure the relative path starts with /
|
||||
const path = relativePath.startsWith('/')
|
||||
? relativePath
|
||||
: `/${relativePath}`;
|
||||
return `${url.origin}${path}`;
|
||||
} catch {
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ContentHeroComponent } from 'components';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
import { SeasonContainerComponent } from '../../xtream-tauri/season-container/season-container.component';
|
||||
import { XtreamSerieEpisode } from 'shared-interfaces';
|
||||
import { XtreamStore } from '../../xtream-tauri/stores/xtream.store';
|
||||
|
||||
/**
|
||||
* VOD series season with episodes loaded dynamically
|
||||
@@ -57,6 +58,7 @@ interface StalkerSeriesSeason {
|
||||
})
|
||||
export class StalkerSeriesViewComponent {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
readonly backClicked = output<void>();
|
||||
|
||||
/**
|
||||
@@ -74,10 +76,15 @@ export class StalkerSeriesViewComponent {
|
||||
|
||||
/**
|
||||
* Indicates if this is a VOD series item (Ministra is_series=1)
|
||||
* Note: is_series can be true, 1, or "1" depending on the source
|
||||
*/
|
||||
readonly isVodSeries = computed(() => {
|
||||
const item = this.displayItem();
|
||||
return item?.is_series === true;
|
||||
return (
|
||||
item?.is_series === true ||
|
||||
item?.is_series === 1 ||
|
||||
item?.is_series === '1'
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -95,7 +102,11 @@ export class StalkerSeriesViewComponent {
|
||||
// Effect to load VOD series seasons when a VOD series item is selected
|
||||
effect(() => {
|
||||
const item = this.displayItem();
|
||||
if (item?.is_series === true) {
|
||||
const isSeries =
|
||||
item?.is_series === true ||
|
||||
item?.is_series === 1 ||
|
||||
item?.is_series === '1';
|
||||
if (isSeries) {
|
||||
// Get seasons from the resource
|
||||
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
|
||||
if (seasons && seasons.length > 0) {
|
||||
@@ -118,6 +129,22 @@ export class StalkerSeriesViewComponent {
|
||||
this.vodSeriesSeasons.set([]);
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to load playback positions for Stalker series
|
||||
effect(() => {
|
||||
const item = this.displayItem();
|
||||
const playlist = this.stalkerStore.currentPlaylist();
|
||||
if (item && playlist?._id) {
|
||||
const seriesId = Number(item.id);
|
||||
console.log(
|
||||
`[StalkerSeriesView] Loading positions for series: id=${item.id}, seriesId=${seriesId}, is_series=${item.is_series}`
|
||||
);
|
||||
if (!isNaN(seriesId)) {
|
||||
// Load playback positions from the XtreamStore (works for any playlist type)
|
||||
this.xtreamStore.loadSeriesPositions(playlist._id, seriesId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +176,42 @@ export class StalkerSeriesViewComponent {
|
||||
return this.vodWithSeries() || this.selectedItem();
|
||||
});
|
||||
|
||||
/**
|
||||
* Simple hash function to generate consistent numeric IDs from strings
|
||||
*/
|
||||
private hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique episode ID for Stalker episodes.
|
||||
* For VOD series: ALWAYS hash with season + episode number because
|
||||
* the API returns the same ID (series/movie ID) for all episodes.
|
||||
* For regular series: hash the cmd + episode number
|
||||
*/
|
||||
private generateEpisodeId(
|
||||
episodeId: any,
|
||||
episodeNum: number,
|
||||
seasonKey: string,
|
||||
isVodSeries: boolean
|
||||
): number {
|
||||
if (isVodSeries) {
|
||||
// For VOD series, ALWAYS create unique ID from season + episode number
|
||||
// The API-provided ep.id is the same for all episodes (it's the series ID)
|
||||
return this.hashString(`vod_${seasonKey}_${episodeNum}`);
|
||||
} else {
|
||||
// For regular series, create unique ID from cmd + episode number
|
||||
const cmd = String(episodeId || '');
|
||||
return this.hashString(`${cmd}_ep_${episodeNum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent.
|
||||
* Record<string, XtreamSerieEpisode[]> where string is season number/name.
|
||||
@@ -164,13 +227,13 @@ export class StalkerSeriesViewComponent {
|
||||
season.season_number || season.name || season.id;
|
||||
// Map existing episodes or empty array
|
||||
mapped[seasonKey] = (season.episodes || []).map(
|
||||
(ep) =>
|
||||
({
|
||||
episode_num:
|
||||
ep.series_number || ep.episode_num || 0,
|
||||
(ep) => {
|
||||
const episodeNum = ep.series_number || ep.episode_num || 0;
|
||||
return {
|
||||
episode_num: episodeNum,
|
||||
title:
|
||||
ep.name ||
|
||||
`Episode ${ep.series_number || ep.episode_num}`,
|
||||
`Episode ${episodeNum}`,
|
||||
container_extension: 'mpg',
|
||||
info: {
|
||||
movie_image:
|
||||
@@ -181,9 +244,13 @@ export class StalkerSeriesViewComponent {
|
||||
? `${ep.duration} min`
|
||||
: '',
|
||||
},
|
||||
id: ep.id,
|
||||
// Use unique ID for watched status tracking
|
||||
id: this.generateEpisodeId(ep.id, episodeNum, seasonKey, true),
|
||||
// Store original API id for playback
|
||||
originalId: ep.id,
|
||||
custom_sid: 'vod-series', // marker
|
||||
}) as unknown as XtreamSerieEpisode
|
||||
} as unknown as XtreamSerieEpisode;
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -201,7 +268,10 @@ export class StalkerSeriesViewComponent {
|
||||
this.displayItem().info.movie_image,
|
||||
},
|
||||
custom_sid: 'regular-series', // marker
|
||||
id: season.cmd, // Store cmd in id for playEpisodeClicked
|
||||
// Use unique ID for watched status tracking
|
||||
id: this.generateEpisodeId(season.cmd, epNum, seasonKey, false),
|
||||
// Store cmd for playback
|
||||
originalCmd: season.cmd,
|
||||
}) as unknown as XtreamSerieEpisode
|
||||
);
|
||||
});
|
||||
@@ -290,32 +360,49 @@ export class StalkerSeriesViewComponent {
|
||||
* Handles episode click from the container
|
||||
*/
|
||||
onEpisodeClicked(episode: XtreamSerieEpisode) {
|
||||
if (episode.custom_sid === 'vod-series') {
|
||||
// It's a VOD series episode
|
||||
// We reconstructed the episode object, but playVodSeriesEpisode expects the raw object
|
||||
// or at least { id, name, series_number }
|
||||
if ((episode as any).custom_sid === 'vod-series') {
|
||||
// It's a VOD series episode (is_series=1 mode)
|
||||
// Use originalId for playback URL, but use generated id for tracking
|
||||
this.playVodSeriesEpisode({
|
||||
id: episode.id,
|
||||
originalId: (episode as any).originalId, // For constructing playback URL
|
||||
trackingId: episode.id, // The generated unique ID for position tracking
|
||||
name: episode.title,
|
||||
series_number: episode.episode_num,
|
||||
});
|
||||
} else {
|
||||
// Regular series
|
||||
// episode.id contains the 'cmd' from mapping
|
||||
this.playEpisodeClicked(episode.episode_num, episode.id);
|
||||
// Regular series or vclub mode (VOD with embedded series array)
|
||||
// Use originalCmd for playback, episode.id for tracking
|
||||
const trackingId = Number(episode.id);
|
||||
this.playEpisodeClicked(
|
||||
episode.episode_num,
|
||||
(episode as any).originalCmd,
|
||||
trackingId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play episode - handles both regular series and VOD series
|
||||
* Play episode - handles regular series and vclub mode
|
||||
*/
|
||||
playEpisodeClicked(episodeNum: any, cmd?: string) {
|
||||
playEpisodeClicked(episodeNum: any, cmd?: string, trackingId?: number) {
|
||||
const item = this.displayItem();
|
||||
console.log(
|
||||
`[StalkerSeriesView] playEpisodeClicked: episodeNum=${episodeNum}, cmd=${cmd}, trackingId=${trackingId}, seriesId=${item.id}`
|
||||
);
|
||||
|
||||
// Get playback position from XtreamStore if available
|
||||
const position = trackingId
|
||||
? this.xtreamStore.playbackPositions().get(`episode_${trackingId}`)
|
||||
: undefined;
|
||||
const startTime = position?.positionSeconds;
|
||||
|
||||
this.stalkerStore.createLinkToPlayVod(
|
||||
cmd,
|
||||
item.info.name,
|
||||
item.info.movie_image,
|
||||
episodeNum
|
||||
episodeNum,
|
||||
trackingId, // unique episode ID for playback tracking
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,13 +411,29 @@ export class StalkerSeriesViewComponent {
|
||||
*/
|
||||
playVodSeriesEpisode(episode: any) {
|
||||
const item = this.displayItem();
|
||||
const cmd = `/media/file_${episode.id}.mpg`;
|
||||
// Use originalId for playback URL (this is what the Stalker API expects)
|
||||
const cmd = `/media/file_${episode.originalId}.mpg`;
|
||||
const episodeName = episode.name || `Episode ${episode.series_number}`;
|
||||
// Use trackingId (generated unique ID) for playback position tracking
|
||||
const trackingId = Number(episode.trackingId);
|
||||
|
||||
console.log(
|
||||
`[StalkerSeriesView] playVodSeriesEpisode: originalId=${episode.originalId}, trackingId=${trackingId}, seriesId=${item.id}, episodeName=${episodeName}`
|
||||
);
|
||||
|
||||
// Get playback position from XtreamStore if available
|
||||
const position = this.xtreamStore
|
||||
.playbackPositions()
|
||||
.get(`episode_${trackingId}`);
|
||||
const startTime = position?.positionSeconds;
|
||||
|
||||
this.stalkerStore.createLinkToPlayVod(
|
||||
cmd,
|
||||
`${item.info.name} - ${episodeName}`,
|
||||
item.info.movie_image
|
||||
item.info.movie_image,
|
||||
episode.series_number, // episode number for API
|
||||
trackingId, // unique episode ID for playback tracking
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -441,8 +441,15 @@ export const StalkerStore = signalStore(
|
||||
}),
|
||||
loader: async ({ params }) => {
|
||||
const item = params.selectedItem;
|
||||
// Debug logging
|
||||
console.log('[StalkerStore] vodSeriesSeasonsResource loader called');
|
||||
console.log('[StalkerStore] item:', item);
|
||||
console.log('[StalkerStore] item?.is_series:', item?.is_series);
|
||||
console.log('[StalkerStore] currentPlaylist:', store.currentPlaylist());
|
||||
|
||||
// Only fetch if item is a VOD series (has is_series flag)
|
||||
if (!store.currentPlaylist() || !item || !item.is_series) {
|
||||
console.log('[StalkerStore] vodSeriesSeasonsResource - early return, conditions not met');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -473,13 +480,17 @@ export const StalkerStore = signalStore(
|
||||
}
|
||||
|
||||
if (!response?.js?.data) {
|
||||
console.log('[StalkerStore] vodSeriesSeasonsResource - no response data');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('[StalkerStore] vodSeriesSeasonsResource - response.js.data:', response.js.data);
|
||||
|
||||
// Filter for season items (is_season: true)
|
||||
const seasons = response.js.data.filter(
|
||||
(item: any) => item.is_season === true
|
||||
);
|
||||
console.log('[StalkerStore] vodSeriesSeasonsResource - filtered seasons:', seasons);
|
||||
return sortByNumericValue(seasons);
|
||||
},
|
||||
}),
|
||||
@@ -572,8 +583,40 @@ export const StalkerStore = signalStore(
|
||||
setPage(page: number) {
|
||||
patchState(store, { page });
|
||||
},
|
||||
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
|
||||
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
|
||||
// Ensure Stalker playlist exists in SQLite for playback positions
|
||||
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
|
||||
if (
|
||||
playlist &&
|
||||
dataService.isElectron &&
|
||||
playlist._id &&
|
||||
playlist.macAddress &&
|
||||
playlist.portalUrl
|
||||
) {
|
||||
try {
|
||||
const playlistId = String(playlist._id);
|
||||
// Check if playlist exists in SQLite
|
||||
const existing =
|
||||
await window.electron.dbGetPlaylist(playlistId);
|
||||
if (!existing) {
|
||||
// Create playlist in SQLite
|
||||
await window.electron.dbCreatePlaylist({
|
||||
id: playlistId,
|
||||
name: playlist.title || '',
|
||||
macAddress: playlist.macAddress || '',
|
||||
url: playlist.portalUrl || '',
|
||||
type: 'stalker',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error syncing Stalker playlist to SQLite:',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
setSelectedItem(selectedItem: any) {
|
||||
// TODO: check the item type and proper property either selectedVodId or serialsId etc
|
||||
@@ -666,9 +709,31 @@ export const StalkerStore = signalStore(
|
||||
return episodes;
|
||||
},
|
||||
/** getters */
|
||||
getSelectedCategory() {
|
||||
return signal<string>(store.selectedCategoryId());
|
||||
},
|
||||
getSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
if (!categoryId) {
|
||||
return {
|
||||
id: 0,
|
||||
name: 'All Items',
|
||||
type: store.selectedContentType(),
|
||||
};
|
||||
}
|
||||
|
||||
// Get categories based on content type
|
||||
const contentType = store.selectedContentType();
|
||||
let categories: any[] = [];
|
||||
if (contentType === 'vod') {
|
||||
categories = store.vodCategories();
|
||||
} else if (contentType === 'series') {
|
||||
categories = store.seriesCategories();
|
||||
} else if (contentType === 'itv') {
|
||||
categories = store.itvCategories();
|
||||
}
|
||||
|
||||
return categories.find(
|
||||
(c: any) => String(c.category_id) === String(categoryId)
|
||||
) || { id: categoryId, name: '', type: contentType };
|
||||
}),
|
||||
/** API */
|
||||
async fetchLinkToPlay(
|
||||
portalUrl: string,
|
||||
@@ -891,11 +956,22 @@ export const StalkerStore = signalStore(
|
||||
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* Play VOD or episode content
|
||||
* @param cmd The media command/path
|
||||
* @param title Display title
|
||||
* @param thumbnail Thumbnail URL
|
||||
* @param episodeNum Episode number (for series param in API)
|
||||
* @param episodeId Optional episode ID for playback tracking (defaults to item.id)
|
||||
* @param startTime Optional start time in seconds for resume playback
|
||||
*/
|
||||
async createLinkToPlayVod(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string,
|
||||
episode?: number
|
||||
episodeNum?: number,
|
||||
episodeId?: number,
|
||||
startTime?: number
|
||||
) {
|
||||
try {
|
||||
const item = this.selectedItem();
|
||||
@@ -919,7 +995,7 @@ export const StalkerStore = signalStore(
|
||||
this.currentPlaylist().portalUrl,
|
||||
this.currentPlaylist().macAddress,
|
||||
cmdToUse,
|
||||
episode
|
||||
episodeNum
|
||||
);
|
||||
this.addToRecentlyViewed({
|
||||
...item,
|
||||
@@ -929,11 +1005,13 @@ export const StalkerStore = signalStore(
|
||||
title,
|
||||
});
|
||||
const playlist = this.currentPlaylist();
|
||||
const isEpisode = episodeNum !== undefined || episodeId !== undefined;
|
||||
const contentInfo = {
|
||||
playlistId: playlist._id,
|
||||
contentXtreamId: Number(item.id),
|
||||
contentType: episode ? 'episode' : 'vod',
|
||||
seriesXtreamId: episode ? Number(item.id) : undefined, // For Stalker VOD series, use item.id as series ID
|
||||
// For episodes, use episodeId if provided, otherwise fall back to item.id
|
||||
contentXtreamId: isEpisode && episodeId ? episodeId : Number(item.id),
|
||||
contentType: isEpisode ? 'episode' : 'vod',
|
||||
seriesXtreamId: isEpisode ? Number(item.id) : undefined,
|
||||
};
|
||||
|
||||
playerService.openPlayer(
|
||||
@@ -945,7 +1023,8 @@ export const StalkerStore = signalStore(
|
||||
playlist?.userAgent,
|
||||
playlist?.referrer,
|
||||
playlist?.origin,
|
||||
contentInfo
|
||||
contentInfo,
|
||||
startTime
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
+11
-15
@@ -2,26 +2,22 @@
|
||||
@if (isStalker && selectedItem()) {
|
||||
@if (contentType() === 'vod') {
|
||||
@if (selectedItem().series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub) -->
|
||||
<!-- VOD item with embedded series (vclub or Ministra) -->
|
||||
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
|
||||
} @else if (selectedItem().is_series) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view />
|
||||
} @else {
|
||||
<app-vod-details
|
||||
[item]="selectedItem()"
|
||||
(playClicked)="
|
||||
createLinkToPlayVod(
|
||||
selectedItem().cmd,
|
||||
selectedItem().info.name,
|
||||
selectedItem().info.movie_image
|
||||
)
|
||||
"
|
||||
(addToFavoritesClicked)="addToFavorites($event)"
|
||||
(removeFromFavoritesClicked)="
|
||||
removeFromFavorites($any($event))
|
||||
"
|
||||
/>
|
||||
@if (vodDetailsItem()) {
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem()!"
|
||||
[playbackPosition]="selectedVodPlaybackPosition()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(resumeClicked)="onVodResume($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
} @else if (contentType() === 'series') {
|
||||
<app-stalker-series-view />
|
||||
|
||||
+239
-26
@@ -1,12 +1,19 @@
|
||||
import { Component, inject, OnInit, computed } from '@angular/core';
|
||||
import { Component, inject, OnInit, OnDestroy, computed, signal, effect } from '@angular/core';
|
||||
import { PageEvent } from '@angular/material/paginator';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
VodDetailsItem,
|
||||
StalkerVodDetails,
|
||||
createStalkerVodItem,
|
||||
PlaybackPositionData,
|
||||
} from 'shared-interfaces';
|
||||
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
|
||||
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
|
||||
import { StalkerStore } from '../../stalker/stalker.store';
|
||||
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
|
||||
import { XTREAM_DATA_SOURCE } from '../data-sources';
|
||||
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
|
||||
@@ -22,15 +29,19 @@ import { XtreamStore } from '../stores/xtream.store';
|
||||
VodDetailsComponent,
|
||||
],
|
||||
})
|
||||
export class CategoryContentViewComponent implements OnInit {
|
||||
export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
|
||||
|
||||
readonly isStalker = this.activatedRoute.snapshot.data['api'] === 'stalker';
|
||||
private readonly store = this.isStalker
|
||||
? inject(StalkerStore)
|
||||
: inject(XtreamStore);
|
||||
|
||||
/** Unsubscribe function for playback position updates */
|
||||
private unsubscribePositionUpdates: (() => void) | null = null;
|
||||
|
||||
readonly contentType = this.store.selectedContentType;
|
||||
|
||||
readonly limit = this.store.limit;
|
||||
@@ -39,32 +50,103 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
readonly selectedCategory = this.store.getSelectedCategory;
|
||||
readonly paginatedContent = this.store.getPaginatedContent;
|
||||
|
||||
/** Stalker playback positions (key: `${contentType}_${id}`) */
|
||||
private readonly stalkerPositions = signal<Map<string, PlaybackPositionData>>(new Map());
|
||||
/** Stalker series positions (key: seriesId, value: episode positions) */
|
||||
private readonly stalkerSeriesPositions = signal<Map<number, PlaybackPositionData[]>>(new Map());
|
||||
/** Track which playlist we've loaded positions for */
|
||||
private loadedPositionsForPlaylistId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// Effect to load Stalker positions when playlist becomes available
|
||||
if (this.isStalker) {
|
||||
effect(() => {
|
||||
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
|
||||
const playlist = stalkerStore.currentPlaylist();
|
||||
const playlistId = playlist?._id;
|
||||
|
||||
if (playlistId && playlistId !== this.loadedPositionsForPlaylistId) {
|
||||
this.loadedPositionsForPlaylistId = playlistId;
|
||||
this.loadStalkerPositions(playlistId);
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to load position for selected VOD item (ensures fresh data after playback)
|
||||
effect(() => {
|
||||
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
|
||||
const item = this.selectedItem();
|
||||
const playlist = stalkerStore.currentPlaylist();
|
||||
|
||||
if (item && playlist?._id && this.contentType() === 'vod') {
|
||||
this.loadSingleVodPosition(playlist._id, Number(item.id));
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for real-time playback position updates from video player
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
this.unsubscribePositionUpdates = window.electron.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
if (data.contentType === 'vod' && data.contentXtreamId) {
|
||||
const key = `vod_${data.contentXtreamId}`;
|
||||
const updated = new Map(this.stalkerPositions());
|
||||
updated.set(key, data);
|
||||
this.stalkerPositions.set(updated);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly contentWithProgress = computed(() => {
|
||||
const items = this.store.getPaginatedContent();
|
||||
if (!items) return [];
|
||||
|
||||
if (this.isStalker) return items;
|
||||
if (this.isStalker) {
|
||||
// Enrich Stalker items with progress data
|
||||
const positions = this.stalkerPositions();
|
||||
const seriesPositions = this.stalkerSeriesPositions();
|
||||
const contentType = this.contentType();
|
||||
|
||||
return items.map((item: any) => {
|
||||
const id = Number(item.id);
|
||||
|
||||
// Check if this item has series/episode progress (watched episodes)
|
||||
// This works even when is_series flag isn't set on the item
|
||||
const hasSeriesProgress = seriesPositions.has(id) && seriesPositions.get(id)!.length > 0;
|
||||
|
||||
// For series content type, or items with is_series flag
|
||||
const isSeries = contentType === 'series' || item.is_series === '1' || item.is_series === 1;
|
||||
|
||||
if (hasSeriesProgress) {
|
||||
return { ...item, hasSeriesProgress: true };
|
||||
} else if (isSeries) {
|
||||
return { ...item, hasSeriesProgress: false };
|
||||
} else {
|
||||
// Regular VOD item
|
||||
const vodKey = `vod_${id}`;
|
||||
const vodPosition = positions.get(vodKey);
|
||||
const vodProgress = this.calculateProgress(vodPosition);
|
||||
return {
|
||||
...item,
|
||||
progress: vodProgress,
|
||||
isWatched: vodProgress >= 90,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const xtreamStore = this.store as any;
|
||||
if (!xtreamStore.getProgressPercent) return items;
|
||||
|
||||
return items.map((item: any) => {
|
||||
const isSeries = this.contentType() === 'series';
|
||||
// Use xtream_id (DB) or series_id/stream_id (API)
|
||||
const id = Number(
|
||||
item.xtream_id || item.series_id || item.stream_id
|
||||
);
|
||||
|
||||
// console.log(`[CategoryContent] Processing item ${id}. isSeries=${isSeries}`);
|
||||
const id = Number(item.xtream_id || item.series_id || item.stream_id);
|
||||
|
||||
if (isSeries) {
|
||||
const hasProgress = xtreamStore.hasSeriesProgress(id);
|
||||
console.log(
|
||||
`[CategoryContent] Checking series ${id} (xtream_id=${item.xtream_id}, series_id=${item.series_id}). Has progress: ${hasProgress}`
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
hasSeriesProgress: hasProgress,
|
||||
hasSeriesProgress: xtreamStore.hasSeriesProgress(id),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
@@ -81,15 +163,39 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
readonly totalPages = this.store.getTotalPages;
|
||||
readonly bigStore = inject(Store);
|
||||
|
||||
/** Computed VodDetailsItem for the vod-details component */
|
||||
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
|
||||
const item = this.selectedItem();
|
||||
if (!item || !this.isStalker) return null;
|
||||
// Access currentPlaylist from the store (type-safe since we're in stalker mode)
|
||||
const stalkerStore = this.store as unknown as {
|
||||
currentPlaylist: () => { _id: string } | null;
|
||||
};
|
||||
return createStalkerVodItem(
|
||||
item as StalkerVodDetails,
|
||||
stalkerStore.currentPlaylist()?._id ?? ''
|
||||
);
|
||||
});
|
||||
|
||||
/** Playback position for the selected VOD item (Stalker) */
|
||||
readonly selectedVodPlaybackPosition = computed<number | null>(() => {
|
||||
if (!this.isStalker) return null;
|
||||
const item = this.selectedItem();
|
||||
if (!item) return null;
|
||||
|
||||
const id = Number(item.id);
|
||||
const vodKey = `vod_${id}`;
|
||||
const position = this.stalkerPositions().get(vodKey);
|
||||
|
||||
return position?.positionSeconds ?? null;
|
||||
});
|
||||
|
||||
seasons = [];
|
||||
|
||||
ngOnInit() {
|
||||
const { categoryId } = this.activatedRoute.snapshot.params;
|
||||
console.log(
|
||||
`[CategoryContent] ngOnInit. contentType=${this.contentType()}, isStalker=${this.isStalker}, categoryId=${categoryId}`
|
||||
);
|
||||
|
||||
// Ensure playback positions are loaded (Xtream only)
|
||||
// Ensure playback positions are loaded (Xtream only - Stalker is handled via effect in constructor)
|
||||
if (!this.isStalker) {
|
||||
const xtreamStore = this.store as any;
|
||||
if (xtreamStore.currentPlaylist()?.id) {
|
||||
@@ -101,20 +207,71 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
// This ensures the content-header is visible
|
||||
this.store.setSelectedItem(null);
|
||||
|
||||
// Only set category if it's different from the currently selected one
|
||||
// Set category - setSelectedCategory only resets page if category actually changes
|
||||
// This preserves the page state when navigating back from detail view
|
||||
if (categoryId) {
|
||||
if (this.store.selectedCategoryId() !== Number(categoryId)) {
|
||||
this.store.setSelectedCategory(categoryId);
|
||||
}
|
||||
this.store.setSelectedCategory(categoryId);
|
||||
} else {
|
||||
// No categoryId in route means "All Items"
|
||||
if (this.store.selectedCategoryId() !== null) {
|
||||
this.store.setSelectedCategory(null);
|
||||
}
|
||||
this.store.setSelectedCategory(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all playback positions for a Stalker portal
|
||||
*/
|
||||
private async loadStalkerPositions(playlistId: string): Promise<void> {
|
||||
const positions = await this.dataSource.getAllPlaybackPositions(playlistId);
|
||||
|
||||
const positionsMap = new Map<string, PlaybackPositionData>();
|
||||
const seriesMap = new Map<number, PlaybackPositionData[]>();
|
||||
|
||||
positions.forEach((pos) => {
|
||||
const key = `${pos.contentType}_${pos.contentXtreamId}`;
|
||||
positionsMap.set(key, pos);
|
||||
|
||||
// Group episodes by series ID
|
||||
if (pos.contentType === 'episode' && pos.seriesXtreamId) {
|
||||
const existing = seriesMap.get(pos.seriesXtreamId) || [];
|
||||
existing.push(pos);
|
||||
seriesMap.set(pos.seriesXtreamId, existing);
|
||||
}
|
||||
});
|
||||
|
||||
this.stalkerPositions.set(positionsMap);
|
||||
this.stalkerSeriesPositions.set(seriesMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load playback position for a single VOD item
|
||||
*/
|
||||
private async loadSingleVodPosition(playlistId: string, vodId: number): Promise<void> {
|
||||
const position = await this.dataSource.getPlaybackPosition(playlistId, vodId, 'vod');
|
||||
|
||||
if (position) {
|
||||
const key = `vod_${vodId}`;
|
||||
const updated = new Map(this.stalkerPositions());
|
||||
updated.set(key, position);
|
||||
this.stalkerPositions.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress percentage from position data
|
||||
*/
|
||||
private calculateProgress(position: PlaybackPositionData | undefined): number {
|
||||
if (!position || !position.durationSeconds) return 0;
|
||||
|
||||
const percent = (position.positionSeconds / position.durationSeconds) * 100;
|
||||
|
||||
// If watched > 10s but percent < 1, return 1 to show visual progress
|
||||
if (position.positionSeconds > 10 && percent < 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.round(percent));
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent) {
|
||||
this.store.setPage(event.pageIndex);
|
||||
this.store.setLimit(event.pageSize);
|
||||
@@ -140,7 +297,8 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
info: {
|
||||
movie_image: item.screenshot_uri,
|
||||
description: item.description,
|
||||
name: item.name || item.o_name,
|
||||
name: item.o_name || item.name,
|
||||
o_name: item.o_name,
|
||||
director: item.director,
|
||||
releasedate: item.year,
|
||||
genre: item.genres_str,
|
||||
@@ -184,4 +342,59 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
console.debug('Remove from favorites', favoriteId);
|
||||
this.store.removeFromFavorites(favoriteId);
|
||||
}
|
||||
|
||||
/** Handle play from vod-details component */
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
if (item.type === 'stalker') {
|
||||
this.createLinkToPlayVod(
|
||||
item.cmd,
|
||||
item.data.info?.name,
|
||||
item.data.info?.movie_image
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle resume from vod-details component */
|
||||
onVodResume(event: { item: VodDetailsItem; positionSeconds: number }): void {
|
||||
if (event.item.type === 'stalker') {
|
||||
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
|
||||
stalkerStore.createLinkToPlayVod(
|
||||
event.item.cmd,
|
||||
event.item.data.info?.name,
|
||||
event.item.data.info?.movie_image,
|
||||
undefined, // episodeNum
|
||||
undefined, // episodeId
|
||||
event.positionSeconds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle favorite toggle from vod-details component */
|
||||
onVodFavoriteToggled(event: {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}): void {
|
||||
if (event.item.type === 'stalker') {
|
||||
if (event.isFavorite) {
|
||||
this.addToFavorites({
|
||||
...event.item.data,
|
||||
category_id: 'vod',
|
||||
title: event.item.data.info?.name,
|
||||
cover: event.item.data.info?.movie_image,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
this.removeFromFavorites(event.item.data.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle back from vod-details component */
|
||||
onVodBack(): void {
|
||||
this.store.setSelectedItem(null);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribePositionUpdates?.();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ export class CategoryViewComponent {
|
||||
const selectedCategory = this.selectedCategoryId();
|
||||
const itemId = Number((item as any).category_id ?? item.id);
|
||||
|
||||
return selectedCategory !== null && selectedCategory === itemId;
|
||||
// Compare as strings to handle both number and string category IDs
|
||||
return selectedCategory != null && String(selectedCategory) === String(itemId);
|
||||
}
|
||||
|
||||
getItemCount(item: XtreamCategory): number {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.downloads {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
height: calc(100vh - 20px);
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
@@ -40,11 +40,19 @@ export class DownloadsComponent implements OnInit {
|
||||
readonly hasDownloads = this.downloadsService.hasDownloads;
|
||||
readonly activeCount = this.downloadsService.activeCount;
|
||||
|
||||
/** Filter downloads for current playlist */
|
||||
/** Filter downloads for current playlist and sort by newest first */
|
||||
readonly filteredDownloads = computed(() => {
|
||||
const playlistId = this.route.snapshot.params['id'];
|
||||
if (!playlistId) return this.downloads();
|
||||
return this.downloads().filter((d) => d.playlistId === playlistId);
|
||||
const downloads = playlistId
|
||||
? this.downloads().filter((d) => d.playlistId === playlistId)
|
||||
: this.downloads();
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
return [...downloads].sort((a, b) => {
|
||||
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
@let isEmbeddedPlayer =
|
||||
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
|
||||
|
||||
<div class="sidebar">
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
@if (selectedCategoryId()) {
|
||||
<button mat-icon-button (click)="backToCategories()">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<h2>{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}</h2>
|
||||
<h2>{{ selectedCategoryInfo()?.name }} ({{ selectedCategoryInfo()?.count }})</h2>
|
||||
} @else {
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
|
||||
@@ -9,9 +9,29 @@
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
// Width controlled by resizable directive
|
||||
width: 400px; // Default, will be overridden by directive
|
||||
min-width: 250px;
|
||||
max-width: 600px;
|
||||
border-right: 1px solid #111;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
app-portal-channels-list {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
app-category-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
@@ -14,7 +15,7 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from 'components';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
@@ -44,6 +45,7 @@ import { XtreamStore } from '../stores/xtream.store';
|
||||
/* MpvPlayerBarComponent, */
|
||||
PlaylistSwitcherComponent,
|
||||
PortalChannelsListComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
@@ -62,6 +64,22 @@ export class LiveStreamLayoutComponent implements OnInit {
|
||||
readonly epgItems = this.xtreamStore.epgItems;
|
||||
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
|
||||
|
||||
readonly selectedCategoryInfo = computed(() => {
|
||||
const categoryId = this.selectedCategoryId();
|
||||
if (!categoryId) return null;
|
||||
|
||||
const categories = this.categories();
|
||||
const category = categories?.find(
|
||||
(c: any) => (c.category_id ?? c.id) === categoryId
|
||||
);
|
||||
const count = this.categoryItemCounts()?.get(categoryId) ?? 0;
|
||||
|
||||
return {
|
||||
name: category?.category_name ?? category?.name ?? 'Channels',
|
||||
count,
|
||||
};
|
||||
});
|
||||
|
||||
readonly player = this.settingsStore.player;
|
||||
streamUrl: string;
|
||||
favorites = new Map<number, boolean>();
|
||||
|
||||
+13
-22
@@ -11,32 +11,28 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-nav-list>
|
||||
<div class="channels-list">
|
||||
<cdk-virtual-scroll-viewport
|
||||
[itemSize]="48"
|
||||
[itemSize]="68"
|
||||
class="scroll-viewport-portals"
|
||||
>
|
||||
<mat-list-item
|
||||
<div
|
||||
*cdkVirtualFor="
|
||||
let item of channels() | filterBy: searchString() : 'title';
|
||||
trackBy: trackBy;
|
||||
templateCacheSize: 0
|
||||
"
|
||||
templateCacheSize="200"
|
||||
class="channel-item"
|
||||
[class.active]="xtreamStore.selectedItem()?.xtream_id === item.xtream_id"
|
||||
(click)="playClicked.emit(item)"
|
||||
[activated]="
|
||||
xtreamStore.selectedItem()?.xtream_id === item.xtream_id
|
||||
"
|
||||
>
|
||||
<img
|
||||
matListItemAvatar
|
||||
class="stream-icon"
|
||||
[src]="item.stream_icon ?? item.poster_url"
|
||||
(error)="
|
||||
$event.target.src = './assets/images/default-poster.png'
|
||||
"
|
||||
(error)="$event.target.src = './assets/images/default-poster.png'"
|
||||
alt="channel icon"
|
||||
/>
|
||||
<div matListItemTitle class="channel-info">
|
||||
<div class="channel-info">
|
||||
<div class="title">{{ item.title }}</div>
|
||||
@if (currentPrograms.get(item.xtream_id)) {
|
||||
<div class="program-info">
|
||||
@@ -53,9 +49,7 @@
|
||||
<div
|
||||
class="progress"
|
||||
[style.width.%]="
|
||||
currentProgramsProgress.get(
|
||||
item.xtream_id
|
||||
)
|
||||
currentProgramsProgress.get(item.xtream_id)
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
@@ -78,16 +72,13 @@
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
mat-icon-button
|
||||
matListItemMeta
|
||||
class="favorite-btn"
|
||||
(click)="toggleFavorite($event, item)"
|
||||
>
|
||||
<mat-icon>
|
||||
{{
|
||||
favorites.get(item.xtream_id) ? 'star' : 'star_outline'
|
||||
}}
|
||||
{{ favorites.get(item.xtream_id) ? 'star' : 'star_outline' }}
|
||||
</mat-icon>
|
||||
</button>
|
||||
</mat-list-item>
|
||||
</div>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</mat-nav-list>
|
||||
</div>
|
||||
+181
-132
@@ -1,70 +1,176 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
mat-nav-list {
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
mat-list-item {
|
||||
width: 370px !important;
|
||||
.channels-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 56px); // Account for search panel
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scroll-viewport-portals {
|
||||
min-height: calc(100vh - 114px) !important;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: calc(100%); // Account for avatar and favorite button
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
mat-list-item {
|
||||
width: 325px;
|
||||
min-width: 0; // Prevents flex items from overflowing
|
||||
box-sizing: border-box;
|
||||
height: 80px !important;
|
||||
transition: background-color 0.2s ease;
|
||||
border-radius: 4px;
|
||||
margin: 2px 8px;
|
||||
|
||||
::ng-deep .mdc-list-item__content {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
// Ensure the virtual scroll content respects parent width
|
||||
::ng-deep .cdk-virtual-scroll-content-wrapper {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
contain: layout style;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
margin: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
height: 64px;
|
||||
box-sizing: border-box;
|
||||
gap: 8px;
|
||||
// Critical: use margin-based calc and ensure it doesn't overflow
|
||||
width: calc(100% - 8px);
|
||||
min-width: 0; // Allow shrinking below content size
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
z-index: 1;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
.channel-info {
|
||||
.title {
|
||||
color: #1db954;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #1db954;
|
||||
}
|
||||
}
|
||||
|
||||
&.mat-mdc-list-item-active {
|
||||
&.active {
|
||||
background-color: rgba(29, 185, 84, 0.2);
|
||||
.channel-info .title {
|
||||
|
||||
.title {
|
||||
color: #1db954;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 90%;
|
||||
@include mat.form-field-density(-5);
|
||||
.stream-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
flex: 1 1 0; // grow, shrink, basis=0 forces it to shrink
|
||||
min-width: 0; // Critical for text truncation in flex
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.current-program {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
|
||||
span {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
min-width: 20px;
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #1db954;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-epg-placeholder {
|
||||
opacity: 0.5;
|
||||
|
||||
.placeholder-bar {
|
||||
height: 10px;
|
||||
width: 60%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 12px;
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
@@ -79,111 +185,54 @@ mat-list-item {
|
||||
}
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
|
||||
.title {
|
||||
font-weight: 600; // Slightly bolder
|
||||
font-size: 14px;
|
||||
transition: color 0.2s ease;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
animation: marquee 6s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.current-program {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
margin-top: 2px;
|
||||
|
||||
&:hover {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
animation: marquee 6s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.program-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// Container queries for responsive behavior
|
||||
@container (max-width: 350px) {
|
||||
.channel-item {
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
padding: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
padding: 0 2px;
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.6;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #1db954;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
}
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.no-epg-placeholder {
|
||||
opacity: 0.7;
|
||||
.placeholder-bar {
|
||||
height: 10px;
|
||||
width: 60%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
opacity: 0.5;
|
||||
|
||||
.progress-bar {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
@container (max-width: 300px) {
|
||||
.stream-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(-100%));
|
||||
|
||||
.favorite-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Override Material styles to allow animation
|
||||
::ng-deep .mdc-list-item__primary-text {
|
||||
overflow: visible !important;
|
||||
@container (max-width: 260px) {
|
||||
.stream-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.channel-item {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
+1
-9
@@ -14,13 +14,9 @@ import {
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FilterPipe } from '@iptvnator/pipes';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
@@ -46,14 +42,10 @@ interface EpgProgram {
|
||||
FilterPipe,
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
ScrollingModule,
|
||||
MatCardModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatListModule,
|
||||
MatInputModule,
|
||||
ScrollingModule,
|
||||
TranslatePipe,
|
||||
MatTooltipModule,
|
||||
],
|
||||
})
|
||||
export class PortalChannelsListComponent implements AfterViewInit {
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
<section class="seasons-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Seasons and Episodes</h3>
|
||||
<div class="section-header__left">
|
||||
<h3 class="section-title">Seasons and Episodes</h3>
|
||||
@if (selectedSeason) {
|
||||
<button class="back-btn" (click)="selectedSeason = undefined">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Back to seasons</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (selectedSeason) {
|
||||
<button class="back-btn" (click)="selectedSeason = undefined">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Back to seasons</span>
|
||||
</button>
|
||||
<div class="view-toggle">
|
||||
<mat-button-toggle-group
|
||||
[value]="viewMode()"
|
||||
(change)="setViewMode($event.value)"
|
||||
hideSingleSelectionIndicator
|
||||
>
|
||||
<mat-button-toggle value="grid" [matTooltip]="'Grid view'">
|
||||
<mat-icon>grid_view</mat-icon>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle value="list" [matTooltip]="'List view'">
|
||||
<mat-icon>view_list</mat-icon>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -45,64 +63,197 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Episodes Grid -->
|
||||
<div class="episodes-grid">
|
||||
@for (episode of seasons()[selectedSeason]; track $index) {
|
||||
@let episodeInfo = getEpisodeInfo(episode);
|
||||
<article
|
||||
class="episode-card"
|
||||
(click)="selectEpisode(episode)"
|
||||
[class.episode-card--watched]="
|
||||
isEpisodeWatched(episode)
|
||||
"
|
||||
[class.episode-card--in-progress]="
|
||||
isEpisodeInProgress(episode)
|
||||
"
|
||||
>
|
||||
<div class="episode-card__thumbnail">
|
||||
@if (episodeInfo?.movie_image) {
|
||||
<img
|
||||
[src]="episodeInfo.movie_image"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-episode.png'
|
||||
"
|
||||
alt="Episode thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<div class="episode-card__placeholder">
|
||||
<mat-icon>play_circle_outline</mat-icon>
|
||||
</div>
|
||||
}
|
||||
@if (getEpisodeProgress(episode) > 0) {
|
||||
<app-progress-capsule
|
||||
[progress]="getEpisodeProgress(episode)"
|
||||
/>
|
||||
@if (getEpisodePositionText(episode); as time) {
|
||||
<div class="episode-card__position-text">
|
||||
{{ time }}
|
||||
@if (viewMode() === 'grid') {
|
||||
<!-- Episodes Grid View -->
|
||||
<div class="episodes-grid">
|
||||
@for (episode of seasons()[selectedSeason]; track $index) {
|
||||
@let episodeInfo = getEpisodeInfo(episode);
|
||||
<article
|
||||
class="episode-card"
|
||||
(click)="selectEpisode(episode)"
|
||||
[class.episode-card--watched]="
|
||||
isEpisodeWatched(episode)
|
||||
"
|
||||
[class.episode-card--in-progress]="
|
||||
isEpisodeInProgress(episode)
|
||||
"
|
||||
>
|
||||
<div class="episode-card__thumbnail">
|
||||
@if (episodeInfo?.movie_image) {
|
||||
<img
|
||||
[src]="episodeInfo.movie_image"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-episode.png'
|
||||
"
|
||||
alt="Episode thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<div class="episode-card__placeholder">
|
||||
<mat-icon>play_circle_outline</mat-icon>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<app-watched-badge
|
||||
[isWatched]="isEpisodeWatched(episode)"
|
||||
/>
|
||||
<div class="episode-card__actions">
|
||||
@if (getEpisodeProgress(episode) > 0) {
|
||||
<app-progress-capsule
|
||||
[progress]="getEpisodeProgress(episode)"
|
||||
/>
|
||||
@if (getEpisodePositionText(episode); as time) {
|
||||
<div class="episode-card__position-text">
|
||||
{{ time }}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<app-watched-badge
|
||||
[isWatched]="isEpisodeWatched(episode)"
|
||||
/>
|
||||
<div class="episode-card__actions">
|
||||
@if (isElectron()) {
|
||||
@if (isEpisodeDownloaded(episode)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="download-btn download-btn--completed"
|
||||
(click)="playFromLocal($event, episode)"
|
||||
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
|
||||
>
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
</button>
|
||||
} @else if (isEpisodeDownloading(episode)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="download-btn download-btn--downloading"
|
||||
disabled
|
||||
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
|
||||
>
|
||||
<mat-icon>downloading</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="download-btn"
|
||||
(click)="downloadEpisode($event, episode)"
|
||||
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
class="toggle-watched-btn"
|
||||
(click)="toggleWatched($event, episode)"
|
||||
[matTooltip]="
|
||||
(isEpisodeWatched(episode)
|
||||
? 'XTREAM.MARK_UNWATCHED'
|
||||
: 'XTREAM.MARK_WATCHED') | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isEpisodeWatched(episode)
|
||||
? 'check_circle'
|
||||
: 'radio_button_unchecked'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="episode-card__overlay">
|
||||
<div class="episode-card__play">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__number">
|
||||
{{ episode.episode_num }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__info">
|
||||
<h4 class="episode-card__title">
|
||||
{{ episode.episode_num }}. {{ episode.title }}
|
||||
</h4>
|
||||
@if (episodeInfo?.plot) {
|
||||
<p class="episode-card__plot">
|
||||
{{ episodeInfo.plot }}
|
||||
</p>
|
||||
}
|
||||
@if (episodeInfo?.duration) {
|
||||
<span class="episode-card__duration">
|
||||
{{ episodeInfo.duration }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Episodes List View -->
|
||||
<div class="episodes-list">
|
||||
@for (episode of seasons()[selectedSeason]; track $index) {
|
||||
@let episodeInfo = getEpisodeInfo(episode);
|
||||
<article
|
||||
class="episode-list-item"
|
||||
(click)="selectEpisode(episode)"
|
||||
[class.episode-list-item--watched]="isEpisodeWatched(episode)"
|
||||
[class.episode-list-item--in-progress]="isEpisodeInProgress(episode)"
|
||||
>
|
||||
<div class="episode-list-item__number">
|
||||
{{ episode.episode_num }}
|
||||
</div>
|
||||
<div class="episode-list-item__content">
|
||||
<div class="episode-list-item__header">
|
||||
<h4 class="episode-list-item__title">
|
||||
{{ episode.title }}
|
||||
@if (isEpisodeWatched(episode)) {
|
||||
<mat-icon class="watched-icon">check_circle</mat-icon>
|
||||
}
|
||||
</h4>
|
||||
<div class="episode-list-item__meta">
|
||||
@if (episodeInfo?.duration) {
|
||||
<span class="episode-list-item__duration">
|
||||
{{ episodeInfo.duration }}
|
||||
</span>
|
||||
}
|
||||
@if (getEpisodePositionText(episode); as time) {
|
||||
<span class="episode-list-item__progress-text">
|
||||
{{ time }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (episodeInfo?.plot) {
|
||||
<p class="episode-list-item__description">
|
||||
{{ episodeInfo.plot }}
|
||||
</p>
|
||||
}
|
||||
@if (getEpisodeProgress(episode) > 0) {
|
||||
<div class="episode-list-item__progress-bar">
|
||||
<div
|
||||
class="episode-list-item__progress-fill"
|
||||
[style.width.%]="getEpisodeProgress(episode)"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="episode-list-item__actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-btn play-btn"
|
||||
(click)="selectEpisode(episode); $event.stopPropagation()"
|
||||
[matTooltip]="'Play'"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
@if (isElectron()) {
|
||||
@if (isEpisodeDownloaded(episode)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="download-btn download-btn--completed"
|
||||
class="action-btn action-btn--success"
|
||||
(click)="playFromLocal($event, episode)"
|
||||
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
|
||||
>
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</button>
|
||||
} @else if (isEpisodeDownloading(episode)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="download-btn download-btn--downloading"
|
||||
class="action-btn"
|
||||
disabled
|
||||
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
|
||||
>
|
||||
@@ -111,7 +262,7 @@
|
||||
} @else {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="download-btn"
|
||||
class="action-btn"
|
||||
(click)="downloadEpisode($event, episode)"
|
||||
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
|
||||
>
|
||||
@@ -121,13 +272,14 @@
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
class="toggle-watched-btn"
|
||||
class="action-btn"
|
||||
(click)="toggleWatched($event, episode)"
|
||||
[matTooltip]="
|
||||
(isEpisodeWatched(episode)
|
||||
? 'XTREAM.MARK_UNWATCHED'
|
||||
: 'XTREAM.MARK_WATCHED') | translate
|
||||
"
|
||||
[class.action-btn--success]="isEpisodeWatched(episode)"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isEpisodeWatched(episode)
|
||||
@@ -136,33 +288,10 @@
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="episode-card__overlay">
|
||||
<div class="episode-card__play">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__number">
|
||||
{{ episode.episode_num }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__info">
|
||||
<h4 class="episode-card__title">
|
||||
{{ episode.episode_num }}. {{ episode.title }}
|
||||
</h4>
|
||||
@if (episodeInfo?.plot) {
|
||||
<p class="episode-card__plot">
|
||||
{{ episodeInfo.plot }}
|
||||
</p>
|
||||
}
|
||||
@if (episodeInfo?.duration) {
|
||||
<span class="episode-card__duration">
|
||||
{{ episodeInfo.duration }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -9,6 +9,14 @@
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
min-height: 32px;
|
||||
gap: 16px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
@@ -44,6 +52,56 @@
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
mat-button-toggle-group {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
|
||||
::ng-deep {
|
||||
.mat-button-toggle {
|
||||
background: transparent;
|
||||
|
||||
.mat-button-toggle-button {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.mat-button-toggle-checked {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
mat-icon {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.mat-button-toggle-checked) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle-appearance-standard {
|
||||
.mat-button-toggle-label-content {
|
||||
line-height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-button-toggle + .mat-button-toggle {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -426,3 +484,204 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// Episodes List View
|
||||
.episodes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.episode-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
|
||||
.episode-list-item__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--watched {
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--in-progress {
|
||||
border-color: rgba(229, 9, 20, 0.4);
|
||||
background: rgba(229, 9, 20, 0.05);
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(229, 9, 20, 0.6);
|
||||
background: rgba(229, 9, 20, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.watched-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #46d369;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&__progress-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #e50914;
|
||||
padding: 2px 8px;
|
||||
background: rgba(229, 9, 20, 0.15);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__progress-bar {
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__progress-fill {
|
||||
height: 100%;
|
||||
background: #e50914;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&.play-btn {
|
||||
color: var(--accent-color);
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-color-rgb), 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&--success {
|
||||
color: #46d369;
|
||||
|
||||
&:hover {
|
||||
background: rgba(70, 211, 105, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always show actions on touch devices
|
||||
@media (hover: none) {
|
||||
.episode-list-item__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { KeyValuePipe } from '@angular/common';
|
||||
import { Component, EventEmitter, Output, input, inject } from '@angular/core';
|
||||
import { Component, input, inject, output, signal, OnInit } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
@@ -12,6 +13,9 @@ import { ProgressCapsuleComponent } from '../shared/progress-capsule/progress-ca
|
||||
import { WatchedBadgeComponent } from '../shared/watched-badge/watched-badge.component';
|
||||
import { DownloadsService } from '../../services/downloads.service';
|
||||
|
||||
type EpisodeViewMode = 'grid' | 'list';
|
||||
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
@@ -19,6 +23,7 @@ import { DownloadsService } from '../../services/downloads.service';
|
||||
imports: [
|
||||
KeyValuePipe,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
@@ -27,7 +32,7 @@ import { DownloadsService } from '../../services/downloads.service';
|
||||
WatchedBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class SeasonContainerComponent {
|
||||
export class SeasonContainerComponent implements OnInit {
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
@@ -39,6 +44,23 @@ export class SeasonContainerComponent {
|
||||
|
||||
readonly isElectron = this.downloadsService.isAvailable;
|
||||
|
||||
/** Current view mode for episodes (grid or list) */
|
||||
readonly viewMode = signal<EpisodeViewMode>('grid');
|
||||
|
||||
ngOnInit() {
|
||||
// Load saved view mode preference
|
||||
const savedMode = localStorage.getItem(EPISODE_VIEW_MODE_KEY) as EpisodeViewMode;
|
||||
if (savedMode === 'grid' || savedMode === 'list') {
|
||||
this.viewMode.set(savedMode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle between grid and list view modes */
|
||||
setViewMode(mode: EpisodeViewMode) {
|
||||
this.viewMode.set(mode);
|
||||
localStorage.setItem(EPISODE_VIEW_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if episode is from Stalker portal based on custom_sid marker
|
||||
*/
|
||||
@@ -60,27 +82,62 @@ export class SeasonContainerComponent {
|
||||
return this.xtreamStore.currentPlaylist()?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content ID for playback position lookup.
|
||||
* For Stalker VOD series, use the generated unique ID (episode.id).
|
||||
* The API-provided originalId is the same for all episodes (it's the series ID),
|
||||
* so we use the hash-generated ID which is unique per episode.
|
||||
* For Xtream episodes, use episode.id directly.
|
||||
*/
|
||||
private getEpisodeContentId(episode: XtreamSerieEpisode): number {
|
||||
const customSid = (episode as any).custom_sid;
|
||||
|
||||
if (customSid === 'vod-series') {
|
||||
// For vod-series, use the generated unique ID (episode.id)
|
||||
// NOT originalId, which is the same for all episodes (series ID)
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
if (customSid === 'regular-series') {
|
||||
// For regular-series, the hashed id is used for tracking
|
||||
// (regular series don't have individual episode IDs from API)
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
// For xtream (non-Stalker), use episode.id directly
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a stable numeric ID for an episode (used for downloads tracking)
|
||||
* For regular-series, episode.id contains cmd string, so we extract ID from it
|
||||
* For vod-series and xtream, episode.id is already numeric
|
||||
* For Stalker episodes, use originalCmd/originalId; for Xtream use episode.id
|
||||
*/
|
||||
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
|
||||
const customSid = (episode as any).custom_sid;
|
||||
|
||||
if (customSid === 'regular-series') {
|
||||
// For regular-series, episode.id is cmd like "/media/file_123.mpg"
|
||||
// Extract the numeric part if possible
|
||||
const cmd = episode.id as unknown as string;
|
||||
const match = cmd?.match(/file_(\d+)/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
// For regular-series, use originalCmd (like "/media/file_123.mpg")
|
||||
const cmd = (episode as any).originalCmd as string;
|
||||
if (cmd) {
|
||||
const match = cmd.match(/file_(\d+)/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
// Fallback: hash the cmd string to get a consistent number
|
||||
return this.hashString(cmd);
|
||||
}
|
||||
// Fallback: hash the cmd string to get a consistent number
|
||||
return this.hashString(cmd || '');
|
||||
// Final fallback: use the hashed id
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
// For vod-series and xtream, use the numeric id directly
|
||||
if (customSid === 'vod-series') {
|
||||
// For vod-series, use originalId from the API
|
||||
const originalId = (episode as any).originalId;
|
||||
const numId = Number(originalId);
|
||||
return isNaN(numId) ? this.hashString(String(originalId)) : numId;
|
||||
}
|
||||
|
||||
// For xtream (non-Stalker), use the numeric id directly
|
||||
const numId = Number(episode.id);
|
||||
return isNaN(numId) ? this.hashString(String(episode.id)) : numId;
|
||||
}
|
||||
@@ -98,8 +155,8 @@ export class SeasonContainerComponent {
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
@Output() episodeClicked = new EventEmitter<any>();
|
||||
@Output() seasonSelected = new EventEmitter<string>();
|
||||
readonly episodeClicked = output<any>();
|
||||
readonly seasonSelected = output<string>();
|
||||
|
||||
selectedSeason: string;
|
||||
|
||||
@@ -118,10 +175,23 @@ export class SeasonContainerComponent {
|
||||
|
||||
toggleWatched(event: Event, episode: XtreamSerieEpisode) {
|
||||
event.stopPropagation();
|
||||
const playlistId = this.xtreamStore.currentPlaylist().id;
|
||||
const playlistId = this.getPlaylistId(episode);
|
||||
if (!playlistId) {
|
||||
console.warn('[SeasonContainer] Cannot toggle watched: no playlist ID');
|
||||
return;
|
||||
}
|
||||
|
||||
// For Stalker episodes, we need to use the content ID that matches
|
||||
// what the player uses (originalId for vod-series)
|
||||
const contentId = this.getEpisodeContentId(episode);
|
||||
const episodeWithCorrectId = {
|
||||
...episode,
|
||||
id: String(contentId),
|
||||
} as XtreamSerieEpisode;
|
||||
|
||||
this.xtreamStore.toggleEpisodeWatched(
|
||||
playlistId,
|
||||
episode,
|
||||
episodeWithCorrectId,
|
||||
this.seriesId()
|
||||
);
|
||||
}
|
||||
@@ -140,26 +210,33 @@ export class SeasonContainerComponent {
|
||||
}
|
||||
|
||||
isEpisodeWatched(episode: XtreamSerieEpisode) {
|
||||
return this.xtreamStore.isWatched(Number(episode.id), 'episode');
|
||||
const contentId = this.getEpisodeContentId(episode);
|
||||
return this.xtreamStore.isWatched(contentId, 'episode');
|
||||
}
|
||||
|
||||
isEpisodeInProgress(episode: XtreamSerieEpisode) {
|
||||
return this.xtreamStore.isInProgress(Number(episode.id), 'episode');
|
||||
const contentId = this.getEpisodeContentId(episode);
|
||||
const inProgress = this.xtreamStore.isInProgress(contentId, 'episode');
|
||||
if (inProgress) {
|
||||
console.log(
|
||||
`[SeasonContainer] Episode ${episode.title} (contentId=${contentId}, originalId=${(episode as any).originalId}) is IN PROGRESS`
|
||||
);
|
||||
}
|
||||
return inProgress;
|
||||
}
|
||||
|
||||
getEpisodeProgress(episode: XtreamSerieEpisode) {
|
||||
return this.xtreamStore.getProgressPercent(
|
||||
Number(episode.id),
|
||||
'episode'
|
||||
);
|
||||
const contentId = this.getEpisodeContentId(episode);
|
||||
return this.xtreamStore.getProgressPercent(contentId, 'episode');
|
||||
}
|
||||
|
||||
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
|
||||
if (this.isEpisodeWatched(episode)) return null;
|
||||
|
||||
const contentId = this.getEpisodeContentId(episode);
|
||||
const position = this.xtreamStore
|
||||
.playbackPositions()
|
||||
.get(`episode_${episode.id}`);
|
||||
.get(`episode_${contentId}`);
|
||||
if (!position || !position.positionSeconds) return null;
|
||||
|
||||
let seconds = position.positionSeconds;
|
||||
@@ -258,11 +335,12 @@ export class SeasonContainerComponent {
|
||||
let cmd: string;
|
||||
|
||||
if (customSid === 'vod-series') {
|
||||
// VOD series episode - construct the cmd using episode id
|
||||
cmd = `/media/file_${episode.id}.mpg`;
|
||||
// VOD series episode - use originalId for the cmd
|
||||
const originalId = (episode as any).originalId;
|
||||
cmd = `/media/file_${originalId}.mpg`;
|
||||
} else {
|
||||
// Regular series - episode.id contains the cmd
|
||||
cmd = episode.id as unknown as string;
|
||||
// Regular series - use originalCmd for the cmd
|
||||
cmd = (episode as any).originalCmd as string;
|
||||
}
|
||||
|
||||
// Resolve the actual stream URL via Stalker API
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
.sidebar {
|
||||
// Width is controlled by the resizable directive
|
||||
// Default fallback if directive not applied
|
||||
width: 400px;
|
||||
min-width: 250px;
|
||||
max-width: 600px;
|
||||
border-right: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0; // Prevent sidebar from shrinking
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
|
||||
@@ -130,22 +130,16 @@ export function withPlaybackPositions() {
|
||||
playlistId
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[withPlaybackPositions] Loaded all positions (${positions.length})`
|
||||
);
|
||||
|
||||
const positionsMap = new Map<string, PlaybackPositionData>();
|
||||
const seriesMap = new Map<number, PlaybackPositionData[]>();
|
||||
|
||||
positions.forEach((pos) => {
|
||||
// Populate playbackPositions map
|
||||
const key = getPositionKey(
|
||||
pos.contentType,
|
||||
pos.contentXtreamId
|
||||
);
|
||||
positionsMap.set(key, pos);
|
||||
|
||||
// Populate seriesPositions map
|
||||
if (
|
||||
pos.contentType === 'episode' &&
|
||||
pos.seriesXtreamId
|
||||
@@ -157,10 +151,6 @@ export function withPlaybackPositions() {
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[withPlaybackPositions] Series map populated: ${seriesMap.size} series`
|
||||
);
|
||||
|
||||
patchState(store, {
|
||||
playbackPositions: positionsMap,
|
||||
seriesPositions: seriesMap,
|
||||
@@ -174,13 +164,7 @@ export function withPlaybackPositions() {
|
||||
const positions = store
|
||||
.seriesPositions()
|
||||
.get(seriesXtreamId);
|
||||
|
||||
if (positions && positions.length > 0) {
|
||||
console.log(`[withPlaybackPositions] Series ${seriesXtreamId} HAS progress`);
|
||||
return true;
|
||||
}
|
||||
// console.log(`[withPlaybackPositions] Series ${seriesXtreamId} NO progress`);
|
||||
return false;
|
||||
return positions !== undefined && positions.length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -196,11 +180,6 @@ export function withPlaybackPositions() {
|
||||
'vod'
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[withPlaybackPositions] Loaded VOD position for ${vodXtreamId}:`,
|
||||
position
|
||||
);
|
||||
|
||||
if (position) {
|
||||
const key = getPositionKey('vod', vodXtreamId);
|
||||
const updated = new Map(store.playbackPositions());
|
||||
@@ -221,11 +200,6 @@ export function withPlaybackPositions() {
|
||||
seriesXtreamId
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[withPlaybackPositions] Loaded series positions for ${seriesXtreamId}:`,
|
||||
positions
|
||||
);
|
||||
|
||||
const updated = new Map(store.seriesPositions());
|
||||
updated.set(seriesXtreamId, positions);
|
||||
patchState(store, { seriesPositions: updated });
|
||||
@@ -255,10 +229,6 @@ export function withPlaybackPositions() {
|
||||
data.contentType,
|
||||
data.contentXtreamId
|
||||
);
|
||||
console.log(
|
||||
`[withPlaybackPositions] Saving position for ${key}:`,
|
||||
data
|
||||
);
|
||||
const updated = new Map(store.playbackPositions());
|
||||
updated.set(key, data);
|
||||
patchState(store, { playbackPositions: updated });
|
||||
@@ -373,18 +343,8 @@ export function withPlaybackPositions() {
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
unsubscribe = window.electron.onPlaybackPositionUpdate(
|
||||
(data: any) => {
|
||||
console.log(
|
||||
'[withPlaybackPositions] Received update:',
|
||||
data
|
||||
);
|
||||
// Ensure playlistId is present
|
||||
if (data.playlistId) {
|
||||
store.savePosition(data.playlistId, data);
|
||||
} else {
|
||||
console.warn(
|
||||
'[withPlaybackPositions] Missing playlistId in update',
|
||||
data
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -278,13 +278,20 @@ export function withSelection() {
|
||||
|
||||
/**
|
||||
* Set the selected category
|
||||
* Only resets page to 0 when category actually changes
|
||||
*/
|
||||
setSelectedCategory(categoryId: number | null): void {
|
||||
patchState(store, {
|
||||
selectedCategoryId:
|
||||
categoryId !== null ? Number(categoryId) : null,
|
||||
page: 0,
|
||||
});
|
||||
const newCategoryId =
|
||||
categoryId !== null ? Number(categoryId) : null;
|
||||
const currentCategoryId = store.selectedCategoryId();
|
||||
|
||||
// Only reset page if category actually changed
|
||||
if (currentCategoryId !== newCategoryId) {
|
||||
patchState(store, {
|
||||
selectedCategoryId: newCategoryId,
|
||||
page: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
File renamed without changes.
+13
-3
@@ -9,10 +9,20 @@ import { XtreamVodDetails } from 'shared-interfaces';
|
||||
import { DownloadsService } from '../../services/downloads.service';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
import { SafePipe } from './safe.pipe';
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
|
||||
/**
|
||||
* Route-based VOD details container for Xtream.
|
||||
*
|
||||
* This is a "smart" component that:
|
||||
* - Reads route params (vodId, categoryId)
|
||||
* - Fetches data via XtreamStore
|
||||
* - Manages playback and favorites
|
||||
*
|
||||
* Used for route-based navigation: /xtreams/:id/vod/:categoryId/:vodId
|
||||
*/
|
||||
@Component({
|
||||
templateUrl: './vod-details.component.html',
|
||||
templateUrl: './vod-details-route.component.html',
|
||||
styleUrls: ['../detail-view.scss'],
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
@@ -23,7 +33,7 @@ import { SafePipe } from './safe.pipe';
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
})
|
||||
export class VodDetailsComponent implements OnInit, OnDestroy {
|
||||
export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
private location = inject(Location);
|
||||
private settingsStore = inject(SettingsStore);
|
||||
private route = inject(ActivatedRoute);
|
||||
@@ -1,4 +1,11 @@
|
||||
<div class="sidebar">
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from 'components';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import {
|
||||
CategoryManagementDialogComponent,
|
||||
@@ -24,6 +24,7 @@ import { XtreamStore } from './stores/xtream.store';
|
||||
MatIconButton,
|
||||
MatTooltipModule,
|
||||
PlaylistSwitcherComponent,
|
||||
ResizableDirective,
|
||||
RouterOutlet,
|
||||
TranslateModule,
|
||||
],
|
||||
|
||||
@@ -49,26 +49,20 @@ export class XtreamShellComponent {
|
||||
];
|
||||
|
||||
private currentPlaylistId: string | null = null;
|
||||
private isFirstLoad = true;
|
||||
|
||||
constructor() {
|
||||
// Subscribe to route params to handle switching between playlists
|
||||
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) {
|
||||
// Skip if playlist ID hasn't changed
|
||||
if (this.currentPlaylistId === newPlaylistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isFirstLoad) {
|
||||
// First load - just set the playlist ID without resetting
|
||||
this.xtreamStore.setPlaylistId(newPlaylistId);
|
||||
this.isFirstLoad = false;
|
||||
} else {
|
||||
// Switching to a different playlist - reset and set new ID
|
||||
this.xtreamStore.resetStore(newPlaylistId);
|
||||
}
|
||||
// Always reset the store when playlist changes to prevent stale data
|
||||
// from previous sessions showing up (the store is a singleton)
|
||||
this.xtreamStore.resetStore(newPlaylistId);
|
||||
this.currentPlaylistId = newPlaylistId;
|
||||
|
||||
this.store.dispatch(
|
||||
@@ -83,13 +77,16 @@ export class XtreamShellComponent {
|
||||
await this.xtreamStore.checkPortalStatus();
|
||||
});
|
||||
|
||||
effect(
|
||||
() => {
|
||||
if (this.xtreamStore.currentPlaylist() !== null) {
|
||||
this.xtreamStore.initializeContent();
|
||||
}
|
||||
},
|
||||
);
|
||||
effect(() => {
|
||||
const playlist = this.xtreamStore.currentPlaylist();
|
||||
const playlistId = this.xtreamStore.playlistId();
|
||||
|
||||
// Only initialize content when playlist is loaded AND matches the current playlistId
|
||||
// This prevents stale data from a previous session from being used
|
||||
if (playlist !== null && playlist.id === playlistId) {
|
||||
this.xtreamStore.initializeContent();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleCategoryClick(category: 'vod' | 'live' | 'series') {
|
||||
|
||||
@@ -53,9 +53,9 @@ export const xtreamRoutes: Route[] = [
|
||||
{
|
||||
path: ':categoryId/:vodId',
|
||||
loadComponent: () =>
|
||||
import('./vod-details/vod-details.component').then(
|
||||
(c) => c.VodDetailsComponent
|
||||
),
|
||||
import(
|
||||
'./vod-details/vod-details-route.component'
|
||||
).then((c) => c.VodDetailsRouteComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,65 +1,77 @@
|
||||
@let meta = normalizedMeta();
|
||||
|
||||
<!-- Hero Section with Backdrop -->
|
||||
<app-content-hero
|
||||
[title]="item.info.name"
|
||||
[description]="item.info.description"
|
||||
[posterUrl]="item.info.movie_image"
|
||||
[backdropUrl]="item.info.backdrop_path?.[0]"
|
||||
[title]="meta.title"
|
||||
[description]="meta.description"
|
||||
[posterUrl]="meta.posterUrl"
|
||||
[backdropUrl]="meta.backdropUrl"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<!-- Tags: Year, Genre, Duration, Rating -->
|
||||
<ng-container hero-tags>
|
||||
@if (item.info?.releasedate) {
|
||||
<span class="details__tag">{{
|
||||
item.info.releasedate | slice: 0 : 4
|
||||
}}</span>
|
||||
@if (meta.year) {
|
||||
<span class="details__tag">{{ meta.year }}</span>
|
||||
}
|
||||
@if (item.info?.genre) {
|
||||
<span class="details__tag">{{ item.info.genre }}</span>
|
||||
@if (meta.genre) {
|
||||
<span class="details__tag">{{ meta.genre }}</span>
|
||||
}
|
||||
@if (item.info?.duration) {
|
||||
<span class="details__tag">{{ item.info.duration }}</span>
|
||||
@if (meta.duration) {
|
||||
<span class="details__tag">{{ meta.duration }}</span>
|
||||
}
|
||||
@if (item.info?.rating_imdb) {
|
||||
@if (meta.ratingImdb) {
|
||||
<span class="details__tag details__tag--rating">
|
||||
⭐ {{ item.info.rating_imdb }}
|
||||
⭐ {{ meta.ratingImdb }}
|
||||
</span>
|
||||
}
|
||||
@if (item.info?.country) {
|
||||
<span class="details__tag">{{ item.info.country }}</span>
|
||||
@if (meta.country) {
|
||||
<span class="details__tag">{{ meta.country }}</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<ng-container hero-meta>
|
||||
@if (item.info?.actors) {
|
||||
@if (meta.actors) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
<span class="value">{{ item.info.actors }}</span>
|
||||
<span class="value">{{ meta.actors }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (item.info?.director) {
|
||||
@if (meta.director) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
|
||||
<span class="value">{{ item.info.director }}</span>
|
||||
<span class="value">{{ meta.director }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (item.info?.rating_kinopoisk) {
|
||||
@if (meta.ratingKinopoisk) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.KINOPOISK_RATING' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ item.info.rating_kinopoisk }}</span>
|
||||
<span class="label">{{ 'XTREAM.KINOPOISK_RATING' | translate }}</span>
|
||||
<span class="value">{{ meta.ratingKinopoisk }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ng-container hero-actions>
|
||||
<button class="play-btn" (click)="playClicked.emit(item)">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.PLAY' | translate }}
|
||||
</button>
|
||||
@if (!isFavorite) {
|
||||
<!-- Resume/Play buttons -->
|
||||
@if (hasPlaybackPosition()) {
|
||||
<button class="play-btn play-btn--resume" (click)="onResume()">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.RESUME' | translate }} {{ formattedPosition() }}
|
||||
</button>
|
||||
<button class="play-btn play-btn--restart" (click)="onPlay()">
|
||||
<mat-icon>replay</mat-icon>
|
||||
{{ 'XTREAM.RESTART' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button class="play-btn" (click)="onPlay()">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.PLAY' | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Favorites button -->
|
||||
@if (!isFavorite()) {
|
||||
<button class="favorite-btn" (click)="toggleFavorite()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
@@ -73,19 +85,21 @@
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (isElectron) {
|
||||
@if (isDownloaded) {
|
||||
|
||||
<!-- Download buttons (Electron only) -->
|
||||
@if (isElectron()) {
|
||||
@if (isDownloaded()) {
|
||||
<button class="download-btn download-btn--completed" (click)="playFromLocal()">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
|
||||
</button>
|
||||
} @else if (isDownloading) {
|
||||
} @else if (isDownloading()) {
|
||||
<button class="download-btn download-btn--downloading" disabled>
|
||||
<mat-icon>downloading</mat-icon>
|
||||
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button class="download-btn" (click)="downloadVod()">
|
||||
<button class="download-btn" (click)="onDownload()">
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
|
||||
</button>
|
||||
@@ -93,16 +107,13 @@
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- YouTube Trailer -->
|
||||
@if (item.info?.youtube_trailer) {
|
||||
<!-- YouTube Trailer (Xtream only) -->
|
||||
@if (meta.youtubeTrailer) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="
|
||||
'https://www.youtube.com/embed/' +
|
||||
item.info.youtube_trailer | safe
|
||||
"
|
||||
[src]="'https://www.youtube.com/embed/' + meta.youtubeTrailer | safe"
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
@@ -112,4 +123,3 @@
|
||||
</div>
|
||||
}
|
||||
</app-content-hero>
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
import { Location, SlicePipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent } from 'components';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { XtreamVodDetails } from 'shared-interfaces';
|
||||
import { StalkerStore } from '../../stalker/stalker.store';
|
||||
import { SafePipe } from '../../xtream-tauri/vod-details/safe.pipe';
|
||||
import {
|
||||
VodDetailsItem,
|
||||
normalizeVodDetails,
|
||||
getVodNumericId,
|
||||
} from 'shared-interfaces';
|
||||
import { DownloadsService } from '../../services/downloads.service';
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
import { StalkerStore } from '../../stalker/stalker.store';
|
||||
|
||||
/**
|
||||
* Unified VOD details component for both Xtream and Stalker portals.
|
||||
*
|
||||
* Uses discriminated union (VodDetailsItem) for type-safe handling.
|
||||
* All actions are emitted as outputs - parent components handle
|
||||
* store-specific operations (play, favorites, downloads).
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <app-vod-details
|
||||
* [item]="vodItem"
|
||||
* [isFavorite]="isFavorite()"
|
||||
* [playbackPosition]="position()"
|
||||
* (playClicked)="onPlay($event)"
|
||||
* (resumeClicked)="onResume($event)"
|
||||
* (favoriteToggled)="onToggleFavorite($event)"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-vod-details',
|
||||
templateUrl: './vod-details.component.html',
|
||||
@@ -27,259 +47,148 @@ import { DownloadsService } from '../../services/downloads.service';
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
SafePipe,
|
||||
SlicePipe,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class VodDetailsComponent implements OnInit {
|
||||
@Input({ required: true }) item: XtreamVodDetails;
|
||||
export class VodDetailsComponent implements OnDestroy {
|
||||
// ============ Inputs ============
|
||||
|
||||
@Output() addToFavoritesClicked = new EventEmitter<any>();
|
||||
@Output() playClicked = new EventEmitter<XtreamVodDetails>();
|
||||
@Output() removeFromFavoritesClicked = new EventEmitter<number>();
|
||||
@Output() backClicked = new EventEmitter<void>();
|
||||
/** VOD item with discriminated union type */
|
||||
readonly item = input.required<VodDetailsItem>();
|
||||
|
||||
/** Whether this item is in favorites (managed by parent) */
|
||||
readonly isFavorite = input<boolean>(false);
|
||||
|
||||
/** Playback position in seconds for resume feature (managed by parent) */
|
||||
readonly playbackPosition = input<number | null>(null);
|
||||
|
||||
// ============ Outputs ============
|
||||
|
||||
/** Emitted when play button is clicked */
|
||||
readonly playClicked = output<VodDetailsItem>();
|
||||
|
||||
/** Emitted when resume button is clicked (includes position) */
|
||||
readonly resumeClicked = output<{ item: VodDetailsItem; positionSeconds: number }>();
|
||||
|
||||
/** Emitted when favorite toggle is clicked */
|
||||
readonly favoriteToggled = output<{ item: VodDetailsItem; isFavorite: boolean }>();
|
||||
|
||||
/** Emitted when back button is clicked */
|
||||
readonly backClicked = output<void>();
|
||||
|
||||
/** Emitted when download is requested (parent handles URL construction) */
|
||||
readonly downloadRequested = output<VodDetailsItem>();
|
||||
|
||||
// ============ Services ============
|
||||
|
||||
private location = inject(Location);
|
||||
private playlistService = inject(PlaylistsService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private stalkerStore = inject(StalkerStore);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private portalId =
|
||||
this.route.snapshot.paramMap.get('id') ??
|
||||
this.route.parent.snapshot.params.id;
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
|
||||
/**
|
||||
* Check if this is a Stalker item
|
||||
* Uses both cmd property check and stalkerStore playlist to be more reliable
|
||||
*/
|
||||
private get isStalker(): boolean {
|
||||
const hasCmd = !!(this.item as any)?.cmd;
|
||||
const hasStalkerPlaylist = !!(
|
||||
this.stalkerStore.currentPlaylist()?.portalUrl &&
|
||||
this.stalkerStore.currentPlaylist()?.macAddress
|
||||
);
|
||||
return hasCmd || hasStalkerPlaylist;
|
||||
}
|
||||
// ============ Computed State ============
|
||||
|
||||
/**
|
||||
* Get the correct playlist ID based on mode
|
||||
* Stalker uses _id, Xtream uses portalId from route
|
||||
*/
|
||||
private getPlaylistIdForDownloads(): string | null {
|
||||
if (this.isStalker) {
|
||||
return this.stalkerStore.currentPlaylist()?._id || null;
|
||||
/** Whether running in Electron (downloads available) */
|
||||
readonly isElectron = computed(() => this.downloadsService.isAvailable());
|
||||
|
||||
/** Normalized metadata for display */
|
||||
readonly normalizedMeta = computed(() => {
|
||||
return normalizeVodDetails(this.item());
|
||||
});
|
||||
|
||||
/** Whether item is from Stalker portal */
|
||||
readonly isStalkerItem = computed(() => this.item().type === 'stalker');
|
||||
|
||||
/** Whether there's a playback position to resume from */
|
||||
readonly hasPlaybackPosition = computed(() => {
|
||||
const pos = this.playbackPosition();
|
||||
return pos !== null && pos > 0;
|
||||
});
|
||||
|
||||
/** Formatted playback position (e.g., "12:34" or "1:23:45") */
|
||||
readonly formattedPosition = computed(() => {
|
||||
const pos = this.playbackPosition();
|
||||
if (!pos || pos <= 0) return '';
|
||||
|
||||
const hours = Math.floor(pos / 3600);
|
||||
const minutes = Math.floor((pos % 3600) / 60);
|
||||
const seconds = Math.floor(pos % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return this.portalId;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
/** Whether downloads are available (Electron only) */
|
||||
get isElectron(): boolean {
|
||||
return this.downloadsService.isAvailable();
|
||||
}
|
||||
/** Whether VOD is already downloaded */
|
||||
readonly isDownloaded = computed(() => {
|
||||
const item = this.item();
|
||||
// Access signal to create reactive dependency
|
||||
this.downloadsService.downloads();
|
||||
const vodId = getVodNumericId(item);
|
||||
return this.downloadsService.isDownloaded(vodId, item.playlistId, 'vod');
|
||||
});
|
||||
|
||||
isFavorite = false;
|
||||
isLoading = false;
|
||||
/** Whether VOD is currently downloading */
|
||||
readonly isDownloading = computed(() => {
|
||||
const item = this.item();
|
||||
// Access signal to create reactive dependency
|
||||
this.downloadsService.downloads();
|
||||
const vodId = getVodNumericId(item);
|
||||
return this.downloadsService.isDownloading(vodId, item.playlistId, 'vod');
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.checkFavoriteStatus();
|
||||
}
|
||||
// ============ Lifecycle ============
|
||||
|
||||
checkFavoriteStatus() {
|
||||
this.playlistService
|
||||
.getPortalFavorites(this.portalId)
|
||||
.subscribe((favorites) => {
|
||||
if (!favorites || favorites.length === 0) {
|
||||
this.isFavorite = false;
|
||||
} else {
|
||||
this.isFavorite = favorites.some((i) => {
|
||||
const hasStreamId =
|
||||
i?.stream_id !== undefined &&
|
||||
this.item?.movie_data?.stream_id !== undefined;
|
||||
const hasId =
|
||||
(i as any)?.details?.id !== undefined &&
|
||||
(this.item as any)?.id !== undefined;
|
||||
|
||||
return (
|
||||
(hasStreamId &&
|
||||
i.stream_id ===
|
||||
this.item.movie_data.stream_id) ||
|
||||
(hasId &&
|
||||
(i as any).details.id === (this.item as any).id)
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleFavorite() {
|
||||
if (this.isFavorite) {
|
||||
this.removeFromFavoritesClicked.emit(
|
||||
this.item?.movie_data?.stream_id || (this.item as any)?.id
|
||||
);
|
||||
} else {
|
||||
// stalker mode
|
||||
if ((this.item as any).cmd) {
|
||||
this.addToFavoritesClicked.emit({
|
||||
name: this.item.info.name,
|
||||
stream_id: (this.item as any).id,
|
||||
cover: this.item.info.movie_image,
|
||||
cmd: (this.item as any).cmd || '',
|
||||
details: this.item,
|
||||
});
|
||||
} else {
|
||||
this.addToFavoritesClicked.emit({
|
||||
name: this.item.movie_data.name,
|
||||
stream_id: this.item.movie_data.stream_id,
|
||||
container_extension:
|
||||
this.item.movie_data.container_extension,
|
||||
cover: this.item.info.movie_image,
|
||||
stream_type: 'movie',
|
||||
});
|
||||
}
|
||||
}
|
||||
this.isFavorite = !this.isFavorite;
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.backClicked.emit();
|
||||
if (this.isStalker) {
|
||||
// Stalker mode: clear selectedItem to return to category view
|
||||
ngOnDestroy(): void {
|
||||
// Clear selected item when leaving Stalker mode
|
||||
if (this.isStalkerItem()) {
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
} else {
|
||||
// Xtream mode: use browser history navigation
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if VOD is already downloaded */
|
||||
get isDownloaded(): boolean {
|
||||
const vodId = this.getVodId();
|
||||
const playlistId = this.getPlaylistIdForDownloads();
|
||||
if (!vodId || !playlistId) return false;
|
||||
// Access downloads signal to make this reactive
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isDownloaded(vodId, playlistId, 'vod');
|
||||
// ============ Actions ============
|
||||
|
||||
/** Handle play button click */
|
||||
onPlay(): void {
|
||||
this.playClicked.emit(this.item());
|
||||
}
|
||||
|
||||
/** Check if VOD is currently downloading */
|
||||
get isDownloading(): boolean {
|
||||
const vodId = this.getVodId();
|
||||
const playlistId = this.getPlaylistIdForDownloads();
|
||||
if (!vodId || !playlistId) return false;
|
||||
// Access downloads signal to make this reactive
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isDownloading(vodId, playlistId, 'vod');
|
||||
}
|
||||
|
||||
/** Get VOD ID from item */
|
||||
private getVodId(): number | null {
|
||||
if (this.isStalker) {
|
||||
return Number((this.item as any)?.id) || null;
|
||||
}
|
||||
return Number(this.item?.movie_data?.stream_id) || null;
|
||||
}
|
||||
|
||||
/** Get stream URL for download */
|
||||
private getStreamUrl(): string | null {
|
||||
if (this.isStalker) {
|
||||
// For Stalker, the stream URL is in the cmd property
|
||||
return (this.item as any)?.cmd || null;
|
||||
}
|
||||
// For Xtream, construct the URL from playlist info
|
||||
const playlist = this.stalkerStore.currentPlaylist();
|
||||
if (!playlist) return null;
|
||||
|
||||
const serverUrl = playlist.serverUrl?.replace(/\/$/, '') || '';
|
||||
const username = playlist.username || '';
|
||||
const password = playlist.password || '';
|
||||
const streamId = this.item?.movie_data?.stream_id;
|
||||
const extension = this.item?.movie_data?.container_extension || 'mp4';
|
||||
|
||||
if (!streamId) return null;
|
||||
return `${serverUrl}/movie/${username}/${password}/${streamId}.${extension}`;
|
||||
}
|
||||
|
||||
/** Download VOD */
|
||||
async downloadVod() {
|
||||
const vodId = this.getVodId();
|
||||
if (!vodId) return;
|
||||
|
||||
const playlistId = this.getPlaylistIdForDownloads();
|
||||
if (!playlistId) {
|
||||
console.error('[VodDetails] No playlist ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
let url: string;
|
||||
|
||||
if (this.isStalker) {
|
||||
// For Stalker, resolve the actual stream URL via the API
|
||||
const playlist = this.stalkerStore.currentPlaylist();
|
||||
const cmd = (this.item as any)?.cmd;
|
||||
if (!cmd || !playlist?.portalUrl || !playlist?.macAddress) {
|
||||
console.error('[VodDetails] Missing Stalker data: cmd, portalUrl, or macAddress');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
url = await this.stalkerStore.fetchLinkToPlay(
|
||||
playlist.portalUrl,
|
||||
playlist.macAddress,
|
||||
cmd
|
||||
);
|
||||
if (!url) {
|
||||
console.error('[VodDetails] Failed to resolve Stalker stream URL');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VodDetails] Error resolving Stalker stream URL:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.downloadsService.startDownload({
|
||||
playlistId,
|
||||
xtreamId: vodId,
|
||||
contentType: 'vod',
|
||||
title: this.item?.info?.name || 'Unknown',
|
||||
url,
|
||||
posterUrl: this.item?.info?.movie_image,
|
||||
headers: {
|
||||
userAgent: playlist?.userAgent,
|
||||
referer: playlist?.referrer,
|
||||
origin: playlist?.origin,
|
||||
},
|
||||
// Playlist info for auto-creation (Stalker playlists)
|
||||
playlistName: playlist?.title || 'Stalker Portal',
|
||||
playlistType: 'stalker',
|
||||
portalUrl: playlist?.portalUrl,
|
||||
macAddress: playlist?.macAddress,
|
||||
});
|
||||
} else {
|
||||
// For Xtream, construct the URL from playlist info
|
||||
const streamUrl = this.getStreamUrl();
|
||||
if (!streamUrl) return;
|
||||
url = streamUrl;
|
||||
|
||||
await this.downloadsService.startDownload({
|
||||
playlistId,
|
||||
xtreamId: vodId,
|
||||
contentType: 'vod',
|
||||
title: this.item?.info?.name || 'Unknown',
|
||||
url,
|
||||
posterUrl: this.item?.info?.movie_image,
|
||||
/** Handle resume button click */
|
||||
onResume(): void {
|
||||
const pos = this.playbackPosition();
|
||||
if (pos && pos > 0) {
|
||||
this.resumeClicked.emit({
|
||||
item: this.item(),
|
||||
positionSeconds: pos,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Play from local file */
|
||||
async playFromLocal() {
|
||||
const vodId = this.getVodId();
|
||||
const playlistId = this.getPlaylistIdForDownloads();
|
||||
if (!vodId || !playlistId) return;
|
||||
/** Handle favorite toggle - emits the desired new state */
|
||||
toggleFavorite(): void {
|
||||
this.favoriteToggled.emit({
|
||||
item: this.item(),
|
||||
isFavorite: !this.isFavorite(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle back navigation - emit event for parent to handle */
|
||||
goBack(): void {
|
||||
this.backClicked.emit();
|
||||
}
|
||||
|
||||
/** Handle download request */
|
||||
onDownload(): void {
|
||||
this.downloadRequested.emit(this.item());
|
||||
}
|
||||
|
||||
/** Play from local downloaded file */
|
||||
async playFromLocal(): Promise<void> {
|
||||
const item = this.item();
|
||||
const vodId = getVodNumericId(item);
|
||||
|
||||
const filePath = this.downloadsService.getDownloadedFilePath(
|
||||
vodId,
|
||||
playlistId,
|
||||
item.playlistId,
|
||||
'vod'
|
||||
);
|
||||
|
||||
|
||||
@@ -56,10 +56,9 @@
|
||||
}
|
||||
@case ('vod-details') {
|
||||
<app-vod-details
|
||||
[item]="$any(vodDetails)"
|
||||
(playClicked)="playVod($event)"
|
||||
(addToFavoritesClicked)="addToFavorites($event)"
|
||||
(removeFromFavoritesClicked)="removeFromFavorites($event)"
|
||||
[item]="getVodDetailsItem()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
/>
|
||||
}
|
||||
@case ('serie-details') {
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
XtreamSerieDetails,
|
||||
XtreamSerieEpisode,
|
||||
XtreamVodDetails,
|
||||
VodDetailsItem,
|
||||
createXtreamVodItem,
|
||||
} from 'shared-interfaces';
|
||||
import { LiveStreamLayoutComponent } from 'shared-portals';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
@@ -393,6 +395,40 @@ export class XtreamMainContainerComponent implements OnInit, OnDestroy {
|
||||
this.openPlayer(streamUrl, vodItem.info.name);
|
||||
}
|
||||
|
||||
/** Creates a VodDetailsItem for the shared vod-details component */
|
||||
getVodDetailsItem(): VodDetailsItem {
|
||||
const playlist = this.currentPlaylist() as Playlist;
|
||||
return createXtreamVodItem(
|
||||
this.vodDetails as XtreamVodDetails,
|
||||
playlist?._id ?? '',
|
||||
this.contentId
|
||||
);
|
||||
}
|
||||
|
||||
/** Handle play from vod-details component */
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
if (item.type === 'xtream') {
|
||||
this.playVod(item.data);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle favorite toggle from vod-details component */
|
||||
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
|
||||
if (event.item.type === 'xtream') {
|
||||
if (event.isFavorite) {
|
||||
this.addToFavorites({
|
||||
name: event.item.data.movie_data.name,
|
||||
stream_id: event.item.data.movie_data.stream_id,
|
||||
container_extension: event.item.data.movie_data.container_extension,
|
||||
cover: event.item.data.info.movie_image,
|
||||
stream_type: 'movie',
|
||||
});
|
||||
} else {
|
||||
this.removeFromFavorites(event.item.vodId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playEpisode(episode: XtreamSerieEpisode) {
|
||||
const playlist = this.currentPlaylist() as Playlist;
|
||||
const { serverUrl, username, password, userAgent, referrer, origin } =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @import 'themes/';
|
||||
@import '@yangkghjh/videojs-aspect-ratio-panel/dist/videojs-aspect-ratio-panel.css';
|
||||
@import '../../../libs/ui/components/src/lib/resizable/resizable.scss';
|
||||
|
||||
// macOS specific styles - space for traffic lights
|
||||
app-root.macos-platform app-header .modern-header {
|
||||
|
||||
@@ -300,7 +300,7 @@ export class PlaylistEffects {
|
||||
}),
|
||||
map(async (playlist) => {
|
||||
if (playlist.serverUrl && this.dataService.isElectron) {
|
||||
// Use Electron database API
|
||||
// Create Xtream playlist in SQLite
|
||||
await window.electron.dbCreatePlaylist({
|
||||
id: playlist._id.toString(),
|
||||
name: playlist.title || '',
|
||||
@@ -309,8 +309,16 @@ export class PlaylistEffects {
|
||||
password: playlist.password || '',
|
||||
type: 'xtream',
|
||||
});
|
||||
console.log('Playlist created in database');
|
||||
this.router.navigate(['/xtreams/', playlist._id]);
|
||||
} else if (playlist.macAddress && this.dataService.isElectron) {
|
||||
// Create Stalker playlist in SQLite
|
||||
await window.electron.dbCreatePlaylist({
|
||||
id: playlist._id.toString(),
|
||||
name: playlist.title || '',
|
||||
macAddress: playlist.macAddress || '',
|
||||
url: playlist.portalUrl || '',
|
||||
type: 'stalker',
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -27,3 +27,11 @@ export * from './lib/xtream-serie-details.interface';
|
||||
export * from './lib/xtream-serie-item.interface';
|
||||
export * from './lib/xtream-vod-details.interface';
|
||||
export * from './lib/xtream-vod-stream.interface';
|
||||
|
||||
// Stalker interfaces
|
||||
export * from './lib/stalker-vod-details.interface';
|
||||
export * from './lib/stalker-serial-details.interface';
|
||||
|
||||
// Unified VOD details (discriminated union + adapters)
|
||||
export * from './lib/vod-details-item.interface';
|
||||
export * from './lib/vod-details-adapters';
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Stalker portal series/serial details response structure.
|
||||
* Contains series metadata from Stalker/Ministra portals.
|
||||
*/
|
||||
export interface StalkerSerialDetails {
|
||||
/** Series title */
|
||||
name: string;
|
||||
/** Plot description */
|
||||
description: string;
|
||||
/** Director name */
|
||||
director: string;
|
||||
/** Comma-separated list of actors */
|
||||
actors: string;
|
||||
/** Release year */
|
||||
year: string;
|
||||
/** Comma-separated genres */
|
||||
genres_str: string;
|
||||
/** Age rating */
|
||||
age: string;
|
||||
/** IMDB rating as string */
|
||||
rating_imdb: string;
|
||||
/** Kinopoisk rating as string */
|
||||
rating_kinopoisk: string;
|
||||
/** Screenshot/poster URL */
|
||||
screenshot_uri: string;
|
||||
/** Date added to portal */
|
||||
added: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Stalker portal VOD details response structure.
|
||||
* Contains movie metadata from Stalker/Ministra portals.
|
||||
*/
|
||||
export interface StalkerVodDetails {
|
||||
/** Unique identifier for the VOD item */
|
||||
id: string;
|
||||
/** Command string used to play the stream (e.g., "/media/file_123.mpg") */
|
||||
cmd: string;
|
||||
/** VOD metadata */
|
||||
info: StalkerVodInfo;
|
||||
}
|
||||
|
||||
export interface StalkerVodInfo {
|
||||
/** Poster/cover image URL */
|
||||
movie_image: string;
|
||||
/** Plot description */
|
||||
description: string;
|
||||
/** Formatted/system title (often generic like "video_name_format") */
|
||||
name: string;
|
||||
/** Original title (preferred, more descriptive) */
|
||||
o_name?: string;
|
||||
/** Comma-separated list of actors */
|
||||
actors: string;
|
||||
/** Director name */
|
||||
director: string;
|
||||
/** Release date (format varies) */
|
||||
releasedate: string;
|
||||
/** Genre(s) */
|
||||
genre: string;
|
||||
/** IMDB rating as string */
|
||||
rating_imdb: string;
|
||||
/** Kinopoisk rating as string */
|
||||
rating_kinopoisk: string;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { XtreamVodDetails } from './xtream-vod-details.interface';
|
||||
import { StalkerVodDetails } from './stalker-vod-details.interface';
|
||||
import {
|
||||
NormalizedVodMeta,
|
||||
VodDetailsItem,
|
||||
XtreamVodItem,
|
||||
StalkerVodItem,
|
||||
} from './vod-details-item.interface';
|
||||
|
||||
/**
|
||||
* Normalizes Xtream VOD details into a display-friendly format.
|
||||
* Handles the various field name variations in Xtream API responses.
|
||||
*/
|
||||
export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
|
||||
const info = item?.info;
|
||||
const movieData = item?.movie_data;
|
||||
|
||||
return {
|
||||
title: info?.name || movieData?.name || 'Unknown',
|
||||
description: info?.description || info?.plot,
|
||||
posterUrl: info?.movie_image || info?.cover_big,
|
||||
backdropUrl: info?.backdrop_path?.[0],
|
||||
year: extractYear(info?.releasedate),
|
||||
genre: info?.genre,
|
||||
duration: info?.duration || formatDuration(info?.duration_secs),
|
||||
country: info?.country,
|
||||
director: info?.director,
|
||||
actors: info?.actors || info?.cast,
|
||||
ratingImdb: info?.rating_imdb,
|
||||
ratingKinopoisk: info?.rating_kinopoisk,
|
||||
youtubeTrailer: info?.youtube_trailer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes Stalker VOD details into a display-friendly format.
|
||||
* Stalker has fewer metadata fields than Xtream.
|
||||
* Prefers o_name (original title) over name (often formatted/generic).
|
||||
*/
|
||||
export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta {
|
||||
const info = item?.info;
|
||||
|
||||
return {
|
||||
title: info?.o_name || info?.name || 'Unknown',
|
||||
description: info?.description,
|
||||
posterUrl: info?.movie_image,
|
||||
backdropUrl: undefined, // Stalker doesn't provide backdrop
|
||||
year: extractYear(info?.releasedate),
|
||||
genre: info?.genre,
|
||||
duration: undefined, // Stalker doesn't provide duration
|
||||
country: undefined, // Stalker doesn't provide country
|
||||
director: info?.director,
|
||||
actors: info?.actors,
|
||||
ratingImdb: info?.rating_imdb,
|
||||
ratingKinopoisk: info?.rating_kinopoisk,
|
||||
youtubeTrailer: undefined, // Stalker doesn't provide trailers
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes any VodDetailsItem to display-friendly format.
|
||||
* Uses discriminated union for type-safe handling.
|
||||
*/
|
||||
export function normalizeVodDetails(item: VodDetailsItem): NormalizedVodMeta {
|
||||
if (item.type === 'xtream') {
|
||||
return normalizeXtreamVod(item.data);
|
||||
}
|
||||
return normalizeStalkerVod(item.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an XtreamVodItem from raw data and context.
|
||||
*/
|
||||
export function createXtreamVodItem(
|
||||
data: XtreamVodDetails,
|
||||
playlistId: string,
|
||||
vodId?: number
|
||||
): XtreamVodItem {
|
||||
return {
|
||||
type: 'xtream',
|
||||
data,
|
||||
playlistId,
|
||||
vodId: vodId ?? data.movie_data?.stream_id ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a StalkerVodItem from raw data and context.
|
||||
*/
|
||||
export function createStalkerVodItem(
|
||||
data: StalkerVodDetails,
|
||||
playlistId: string
|
||||
): StalkerVodItem {
|
||||
return {
|
||||
type: 'stalker',
|
||||
data,
|
||||
playlistId,
|
||||
cmd: data.cmd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the unique identifier for a VOD item.
|
||||
* Returns vodId for Xtream, id for Stalker.
|
||||
*/
|
||||
export function getVodId(item: VodDetailsItem): number | string {
|
||||
if (item.type === 'xtream') {
|
||||
return item.vodId;
|
||||
}
|
||||
return item.data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the numeric ID for download/favorite tracking.
|
||||
*/
|
||||
export function getVodNumericId(item: VodDetailsItem): number {
|
||||
if (item.type === 'xtream') {
|
||||
return item.vodId;
|
||||
}
|
||||
return Number(item.data.id) || 0;
|
||||
}
|
||||
|
||||
// ============ Helper Functions ============
|
||||
|
||||
/**
|
||||
* Extracts 4-digit year from various date formats.
|
||||
*/
|
||||
function extractYear(dateString?: string): string | undefined {
|
||||
if (!dateString) return undefined;
|
||||
|
||||
// Try to extract 4-digit year from beginning
|
||||
const yearMatch = dateString.match(/^(\d{4})/);
|
||||
if (yearMatch) {
|
||||
return yearMatch[1];
|
||||
}
|
||||
|
||||
// Try to parse as date and extract year
|
||||
const date = new Date(dateString);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.getFullYear().toString();
|
||||
}
|
||||
|
||||
return dateString.slice(0, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration from seconds to human-readable string.
|
||||
*/
|
||||
function formatDuration(seconds?: number): string | undefined {
|
||||
if (!seconds || seconds <= 0) return undefined;
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { XtreamVodDetails } from './xtream-vod-details.interface';
|
||||
import { StalkerVodDetails } from './stalker-vod-details.interface';
|
||||
|
||||
/**
|
||||
* Discriminated union for VOD details across portal types.
|
||||
* Enables type-safe handling without runtime type checks.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* function handleVod(item: VodDetailsItem) {
|
||||
* if (item.type === 'xtream') {
|
||||
* // TypeScript knows item.data is XtreamVodDetails
|
||||
* console.log(item.data.movie_data.stream_id);
|
||||
* } else {
|
||||
* // TypeScript knows item.data is StalkerVodDetails
|
||||
* console.log(item.data.cmd);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type VodDetailsItem = XtreamVodItem | StalkerVodItem;
|
||||
|
||||
/**
|
||||
* Xtream Codes VOD item wrapper with context.
|
||||
*/
|
||||
export interface XtreamVodItem {
|
||||
/** Discriminator for Xtream Codes portals */
|
||||
readonly type: 'xtream';
|
||||
/** Full VOD details from Xtream API */
|
||||
data: XtreamVodDetails;
|
||||
/** Playlist/portal ID for favorites and downloads */
|
||||
playlistId: string;
|
||||
/** Stream ID from Xtream API */
|
||||
vodId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stalker portal VOD item wrapper with context.
|
||||
*/
|
||||
export interface StalkerVodItem {
|
||||
/** Discriminator for Stalker portals */
|
||||
readonly type: 'stalker';
|
||||
/** Full VOD details from Stalker API */
|
||||
data: StalkerVodDetails;
|
||||
/** Playlist/portal ID for favorites and downloads */
|
||||
playlistId: string;
|
||||
/** Command string for stream playback */
|
||||
cmd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized metadata for display in templates.
|
||||
* Provides a consistent interface regardless of portal type.
|
||||
*/
|
||||
export interface NormalizedVodMeta {
|
||||
/** Movie title */
|
||||
title: string;
|
||||
/** Plot description */
|
||||
description?: string;
|
||||
/** Poster/cover image URL */
|
||||
posterUrl?: string;
|
||||
/** Backdrop image URL (Xtream only) */
|
||||
backdropUrl?: string;
|
||||
/** Release year (4 digits) */
|
||||
year?: string;
|
||||
/** Genre(s) */
|
||||
genre?: string;
|
||||
/** Duration string (e.g., "1h 30m") */
|
||||
duration?: string;
|
||||
/** Country of origin (Xtream only) */
|
||||
country?: string;
|
||||
/** Director name */
|
||||
director?: string;
|
||||
/** Actors/cast (comma-separated) */
|
||||
actors?: string;
|
||||
/** IMDB rating */
|
||||
ratingImdb?: string;
|
||||
/** Kinopoisk rating */
|
||||
ratingKinopoisk?: string;
|
||||
/** YouTube trailer ID (Xtream only) */
|
||||
youtubeTrailer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if item is Xtream VOD.
|
||||
*/
|
||||
export function isXtreamVod(item: VodDetailsItem): item is XtreamVodItem {
|
||||
return item.type === 'xtream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if item is Stalker VOD.
|
||||
*/
|
||||
export function isStalkerVod(item: VodDetailsItem): item is StalkerVodItem {
|
||||
return item.type === 'stalker';
|
||||
}
|
||||
@@ -19,3 +19,4 @@ export * from './lib/recent-playlists/recent-playlists.component';
|
||||
export * from './lib/video-player/sidebar/sidebar.component';
|
||||
export * from './lib/video-player/toolbar/toolbar.component';
|
||||
export * from './lib/vjs-player/vjs-player.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
Renderer2,
|
||||
input,
|
||||
output,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
effect,
|
||||
signal,
|
||||
AfterViewInit,
|
||||
} from '@angular/core';
|
||||
|
||||
/**
|
||||
* ResizableDirective - Makes an element horizontally resizable by dragging its edge.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* <div appResizable [minWidth]="200" [maxWidth]="600" [storageKey]="'sidebar-width'">
|
||||
* Content here
|
||||
* </div>
|
||||
* ```
|
||||
*
|
||||
* Features:
|
||||
* - Smooth drag handle with visual feedback
|
||||
* - Persists width to localStorage
|
||||
* - Respects min/max width constraints
|
||||
* - Emits width changes for reactive updates
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
standalone: true,
|
||||
})
|
||||
export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
|
||||
/** Minimum width in pixels */
|
||||
readonly minWidth = input<number>(200);
|
||||
|
||||
/** Maximum width in pixels */
|
||||
readonly maxWidth = input<number>(600);
|
||||
|
||||
/** LocalStorage key for persisting width. If not set, width won't be persisted. */
|
||||
readonly storageKey = input<string>('');
|
||||
|
||||
/** Default width to use when no stored value exists */
|
||||
readonly defaultWidth = input<number>(400);
|
||||
|
||||
/** Position of the resize handle: 'right' or 'left' */
|
||||
readonly handlePosition = input<'right' | 'left'>('right');
|
||||
|
||||
/** Emits when width changes during resize */
|
||||
readonly widthChange = output<number>();
|
||||
|
||||
/** Emits when resize starts */
|
||||
readonly resizeStart = output<void>();
|
||||
|
||||
/** Emits when resize ends */
|
||||
readonly resizeEnd = output<number>();
|
||||
|
||||
private handleElement: HTMLElement | null = null;
|
||||
private isResizing = signal(false);
|
||||
private startX = 0;
|
||||
private startWidth = 0;
|
||||
private currentWidth = signal(0);
|
||||
|
||||
// Event listener references for cleanup
|
||||
private boundMouseMove: ((e: MouseEvent) => void) | null = null;
|
||||
private boundMouseUp: ((e: MouseEvent) => void) | null = null;
|
||||
private boundTouchMove: ((e: TouchEvent) => void) | null = null;
|
||||
private boundTouchEnd: ((e: TouchEvent) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly el: ElementRef<HTMLElement>,
|
||||
private readonly renderer: Renderer2
|
||||
) {
|
||||
// Apply resizing class when dragging
|
||||
effect(() => {
|
||||
if (this.isResizing()) {
|
||||
this.renderer.addClass(document.body, 'resizing-active');
|
||||
this.renderer.addClass(this.el.nativeElement, 'is-resizing');
|
||||
} else {
|
||||
this.renderer.removeClass(document.body, 'resizing-active');
|
||||
this.renderer.removeClass(this.el.nativeElement, 'is-resizing');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load persisted width FIRST to avoid flash of default width
|
||||
this.loadPersistedWidth();
|
||||
this.setupStyles();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.createResizeHandle();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.removeGlobalListeners();
|
||||
if (this.handleElement) {
|
||||
this.handleElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private setupStyles(): void {
|
||||
const el = this.el.nativeElement;
|
||||
|
||||
// Ensure the element has position relative for handle positioning
|
||||
const computedStyle = window.getComputedStyle(el);
|
||||
if (computedStyle.position === 'static') {
|
||||
this.renderer.setStyle(el, 'position', 'relative');
|
||||
}
|
||||
|
||||
// Add transition for smooth width changes (only when not dragging)
|
||||
this.renderer.addClass(el, 'resizable-element');
|
||||
}
|
||||
|
||||
private createResizeHandle(): void {
|
||||
const handle = this.renderer.createElement('div');
|
||||
this.handleElement = handle;
|
||||
|
||||
// Set up handle classes based on position
|
||||
this.renderer.addClass(handle, 'resize-handle');
|
||||
this.renderer.addClass(
|
||||
handle,
|
||||
`resize-handle--${this.handlePosition()}`
|
||||
);
|
||||
|
||||
// Create inner visual elements for the handle
|
||||
const handleLine = this.renderer.createElement('div');
|
||||
this.renderer.addClass(handleLine, 'resize-handle__line');
|
||||
this.renderer.appendChild(handle, handleLine);
|
||||
|
||||
// Create grip dots
|
||||
const gripDots = this.renderer.createElement('div');
|
||||
this.renderer.addClass(gripDots, 'resize-handle__grip');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dot = this.renderer.createElement('span');
|
||||
this.renderer.appendChild(gripDots, dot);
|
||||
}
|
||||
this.renderer.appendChild(handle, gripDots);
|
||||
|
||||
// Mouse events
|
||||
this.renderer.listen(handle, 'mousedown', (e: MouseEvent) =>
|
||||
this.onMouseDown(e)
|
||||
);
|
||||
|
||||
// Touch events for mobile support
|
||||
this.renderer.listen(handle, 'touchstart', (e: TouchEvent) =>
|
||||
this.onTouchStart(e)
|
||||
);
|
||||
|
||||
// Append handle to element
|
||||
this.renderer.appendChild(this.el.nativeElement, handle);
|
||||
}
|
||||
|
||||
private loadPersistedWidth(): void {
|
||||
const key = this.storageKey();
|
||||
let widthToApply = this.defaultWidth();
|
||||
|
||||
if (key) {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const width = parseInt(stored, 10);
|
||||
if (!isNaN(width)) {
|
||||
widthToApply = this.clampWidth(width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply width immediately without transition to avoid flash
|
||||
this.setWidthImmediate(widthToApply);
|
||||
}
|
||||
|
||||
/** Set width immediately without CSS transition */
|
||||
private setWidthImmediate(width: number): void {
|
||||
this.currentWidth.set(width);
|
||||
// Temporarily disable transition for immediate application
|
||||
this.renderer.setStyle(this.el.nativeElement, 'transition', 'none');
|
||||
this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
|
||||
|
||||
// Re-enable transition after a frame
|
||||
requestAnimationFrame(() => {
|
||||
this.renderer.removeStyle(this.el.nativeElement, 'transition');
|
||||
});
|
||||
}
|
||||
|
||||
private onMouseDown(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.startResize(e.clientX);
|
||||
|
||||
this.boundMouseMove = (event: MouseEvent) => this.onMouseMove(event);
|
||||
this.boundMouseUp = (event: MouseEvent) => this.onMouseUp(event);
|
||||
|
||||
document.addEventListener('mousemove', this.boundMouseMove);
|
||||
document.addEventListener('mouseup', this.boundMouseUp);
|
||||
}
|
||||
|
||||
private onTouchStart(e: TouchEvent): void {
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.startResize(e.touches[0].clientX);
|
||||
|
||||
this.boundTouchMove = (event: TouchEvent) => this.onTouchMove(event);
|
||||
this.boundTouchEnd = (event: TouchEvent) => this.onTouchEnd(event);
|
||||
|
||||
document.addEventListener('touchmove', this.boundTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener('touchend', this.boundTouchEnd);
|
||||
}
|
||||
|
||||
private startResize(clientX: number): void {
|
||||
this.isResizing.set(true);
|
||||
this.startX = clientX;
|
||||
this.startWidth = this.el.nativeElement.offsetWidth;
|
||||
this.resizeStart.emit();
|
||||
}
|
||||
|
||||
private onMouseMove(e: MouseEvent): void {
|
||||
if (!this.isResizing()) return;
|
||||
this.resize(e.clientX);
|
||||
}
|
||||
|
||||
private onTouchMove(e: TouchEvent): void {
|
||||
if (!this.isResizing() || e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
this.resize(e.touches[0].clientX);
|
||||
}
|
||||
|
||||
private resize(clientX: number): void {
|
||||
const delta =
|
||||
this.handlePosition() === 'right'
|
||||
? clientX - this.startX
|
||||
: this.startX - clientX;
|
||||
|
||||
const newWidth = this.clampWidth(this.startWidth + delta);
|
||||
this.setWidth(newWidth);
|
||||
this.widthChange.emit(newWidth);
|
||||
}
|
||||
|
||||
private onMouseUp(_e: MouseEvent): void {
|
||||
this.endResize();
|
||||
}
|
||||
|
||||
private onTouchEnd(_e: TouchEvent): void {
|
||||
this.endResize();
|
||||
}
|
||||
|
||||
private endResize(): void {
|
||||
if (!this.isResizing()) return;
|
||||
|
||||
this.isResizing.set(false);
|
||||
this.removeGlobalListeners();
|
||||
this.persistWidth();
|
||||
this.resizeEnd.emit(this.currentWidth());
|
||||
}
|
||||
|
||||
private removeGlobalListeners(): void {
|
||||
if (this.boundMouseMove) {
|
||||
document.removeEventListener('mousemove', this.boundMouseMove);
|
||||
this.boundMouseMove = null;
|
||||
}
|
||||
if (this.boundMouseUp) {
|
||||
document.removeEventListener('mouseup', this.boundMouseUp);
|
||||
this.boundMouseUp = null;
|
||||
}
|
||||
if (this.boundTouchMove) {
|
||||
document.removeEventListener('touchmove', this.boundTouchMove);
|
||||
this.boundTouchMove = null;
|
||||
}
|
||||
if (this.boundTouchEnd) {
|
||||
document.removeEventListener('touchend', this.boundTouchEnd);
|
||||
this.boundTouchEnd = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clampWidth(width: number): number {
|
||||
return Math.max(this.minWidth(), Math.min(this.maxWidth(), width));
|
||||
}
|
||||
|
||||
private setWidth(width: number): void {
|
||||
this.currentWidth.set(width);
|
||||
this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
|
||||
}
|
||||
|
||||
private persistWidth(): void {
|
||||
const key = this.storageKey();
|
||||
if (key) {
|
||||
localStorage.setItem(key, this.currentWidth().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Resizable Directive Styles
|
||||
*
|
||||
* A refined, subtle resize handle that provides clear affordance
|
||||
* without being visually intrusive. Uses a vertical grip pattern
|
||||
* that becomes more prominent on hover/active states.
|
||||
*/
|
||||
|
||||
// Prevent text selection during resize
|
||||
body.resizing-active {
|
||||
user-select: none !important;
|
||||
cursor: col-resize !important;
|
||||
|
||||
* {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Base element styles
|
||||
.resizable-element {
|
||||
// Smooth transition when not actively resizing
|
||||
&:not(.is-resizing) {
|
||||
transition: width 0.15s ease-out;
|
||||
}
|
||||
|
||||
&.is-resizing {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Resize handle base styles
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
z-index: 100;
|
||||
cursor: col-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// Visual feedback on hover/active
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&:hover::before,
|
||||
&:active::before {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
// Position variants
|
||||
&--right {
|
||||
right: -6px;
|
||||
|
||||
&::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&--left {
|
||||
left: -6px;
|
||||
|
||||
&::before {
|
||||
right: 50%;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
}
|
||||
|
||||
// The main visible line
|
||||
&__line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover &__line {
|
||||
width: 2px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active &__line {
|
||||
width: 2px;
|
||||
background: var(--mdc-theme-primary, #6366f1);
|
||||
box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
// Grip dots indicator
|
||||
&__grip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 1;
|
||||
|
||||
span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__grip {
|
||||
opacity: 1;
|
||||
|
||||
span {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
&:active &__grip {
|
||||
opacity: 1;
|
||||
|
||||
span {
|
||||
background: var(--mdc-theme-primary, #6366f1);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light theme adjustments (if needed)
|
||||
.light-theme {
|
||||
.resize-handle {
|
||||
&::before {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:hover::before,
|
||||
&:active::before {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&__line {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&:hover &__line {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:active &__line {
|
||||
background: var(--mdc-theme-primary, #6366f1);
|
||||
box-shadow: 0 0 12px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
&__grip {
|
||||
span {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__grip {
|
||||
span {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './lib/filter.pipe';
|
||||
export * from './lib/moment-date.pipe';
|
||||
export * from './lib/safe.pipe';
|
||||
export * from './lib/sort.pipe';
|
||||
File renamed without changes.
@@ -5,7 +5,14 @@
|
||||
: 'itv-container-epg'
|
||||
"
|
||||
>
|
||||
<div class="sidebar">
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-content">
|
||||
<div class="search-panel">
|
||||
<mat-form-field class="search-bar">
|
||||
|
||||
+17
-4
@@ -6,7 +6,8 @@
|
||||
|
||||
.itv-container {
|
||||
display: grid;
|
||||
grid-template-columns: 330px 1fr 1fr;
|
||||
// Use auto for sidebar to respect the directive's width
|
||||
grid-template-columns: auto 1fr 1fr;
|
||||
grid-template-rows: 50% 50%;
|
||||
gap: 0 0;
|
||||
grid-auto-flow: row;
|
||||
@@ -18,7 +19,8 @@
|
||||
|
||||
.itv-container-epg {
|
||||
display: grid;
|
||||
grid-template-columns: 330px 1fr 1fr;
|
||||
// Use auto for sidebar to respect the directive's width
|
||||
grid-template-columns: auto 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
gap: 0 0;
|
||||
grid-auto-flow: row;
|
||||
@@ -29,7 +31,18 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar { grid-area: sidebar; }
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
// Default width (controlled by resizable directive)
|
||||
width: 400px;
|
||||
min-width: 250px;
|
||||
max-width: 600px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
border-right: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-player { grid-area: video-player; }
|
||||
|
||||
@@ -66,7 +79,7 @@
|
||||
}
|
||||
|
||||
mat-list-option {
|
||||
width: 320px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { FilterPipe } from '@iptvnator/pipes';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { EpgItem, VideoPlayer, XtreamItem } from 'shared-interfaces';
|
||||
import { EpgViewComponent } from '../epg-view/epg-view.component';
|
||||
import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component';
|
||||
@@ -32,6 +33,7 @@ import { WebPlayerViewComponent } from '../web-player-view/web-player-view.compo
|
||||
MatInputModule,
|
||||
MatFormFieldModule,
|
||||
NgIf,
|
||||
ResizableDirective,
|
||||
ScrollingModule,
|
||||
WebPlayerViewComponent,
|
||||
TranslateModule,
|
||||
|
||||
Reference in new issue
Block a user