feat: playback position persistence for all players and grid indicators

This commit is contained in:
4gray committed 2026-01-18 11:40:27 +01:00
1 parent d218dc0ad2
commit adb89145a3
46 files changed
+2310 -146

No files matched your search

@@ -90,7 +90,9 @@ export class ElectronService extends DataService {
data.title ?? '',
data['user-agent'] ?? undefined,
data.referer ?? undefined,
data.origin ?? undefined
data.origin ?? undefined,
data.contentInfo,
data.startTime
);
/* thumbnail: data.thumbnail ?? '', */
} catch (error: any) {
@@ -113,7 +115,9 @@ export class ElectronService extends DataService {
data.title ?? '',
data['user-agent'] ?? undefined,
data.referer ?? undefined,
data.origin ?? undefined
data.origin ?? undefined,
data.contentInfo,
data.startTime
);
} catch (error: any) {
const errorMessage = error?.message || String(error);
+8 -2
View File
@@ -29,7 +29,9 @@ export class PlayerService {
isLiveContent = false,
userAgent?: string,
referer?: string,
origin?: string
origin?: string,
contentInfo?: any,
startTime?: number
) {
const player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
@@ -44,6 +46,8 @@ export class PlayerService {
'user-agent': userAgent,
referer: referer,
origin: origin,
contentInfo,
startTime,
});
} else if (player === VideoPlayer.VLC) {
if (!hideExternalInfoDialog) {
@@ -56,12 +60,14 @@ export class PlayerService {
'user-agent': userAgent,
referer: referer,
origin: origin,
contentInfo,
startTime,
});
} else {
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
PlayerDialogComponent,
{
data: { streamUrl, title },
data: { streamUrl, title, contentInfo, startTime },
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
@@ -25,6 +25,13 @@ mat-card:hover {
margin: 0;
}
.card-thumbnail-container {
position: relative;
width: 100%;
border-radius: 12px;
overflow: hidden;
}
.stream-icon {
width: 100%;
position: relative !important;
@@ -6,6 +6,8 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistErrorViewComponent } from '../../../xtream-tauri/playlist-error-view/playlist-error-view.component';
import { ProgressCapsuleComponent } from '../../../xtream-tauri/shared/progress-capsule/progress-capsule.component';
import { WatchedBadgeComponent } from '../../../xtream-tauri/shared/watched-badge/watched-badge.component';
@Component({
selector: 'app-grid-list',
@@ -19,18 +21,35 @@ import { PlaylistErrorViewComponent } from '../../../xtream-tauri/playlist-error
@let i = $any(item);
<mat-card (click)="itemClicked.emit(item)">
@let poster = i.poster_url ?? i.cover;
<img
class="stream-icon"
[src]="
poster || './assets/images/default-poster.png'
"
(error)="
$event.target.src =
<div class="card-thumbnail-container">
<img
class="stream-icon"
[src]="
poster ||
'./assets/images/default-poster.png'
"
loading="lazy"
alt="logo"
/>
"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
loading="lazy"
alt="logo"
/>
@if (i.progress && i.progress > 0) {
<app-progress-capsule [progress]="i.progress" />
}
@if (i.isWatched) {
<app-watched-badge
[isWatched]="true"
icon="check_circle"
/>
} @else if (i.hasSeriesProgress) {
<app-watched-badge
[isWatched]="true"
icon="remove_red_eye"
/>
}
</div>
@let rating = i.rating ?? i.rating_imdb;
@if (rating) {
<div
@@ -82,6 +101,8 @@ import { PlaylistErrorViewComponent } from '../../../xtream-tauri/playlist-error
MatProgressSpinner,
MatTooltip,
MatPaginatorModule,
ProgressCapsuleComponent,
WatchedBadgeComponent,
],
})
export class GridListComponent {
@@ -62,6 +62,7 @@
@if (mappedSeasons()) {
<app-season-container
[seasons]="mappedSeasons()"
[seriesId]="serial.id"
[isLoading]="
isVodSeriesSeasonsLoading() ||
(seasonContainer?.selectedSeason &&
+9 -1
View File
@@ -929,6 +929,13 @@ export const StalkerStore = signalStore(
title,
});
const playlist = this.currentPlaylist();
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
};
playerService.openPlayer(
url,
title,
@@ -937,7 +944,8 @@ export const StalkerStore = signalStore(
false,
playlist?.userAgent,
playlist?.referrer,
playlist?.origin
playlist?.origin,
contentInfo
);
} catch (error) {
console.error(
@@ -1,6 +1,6 @@
@if (selectedCategory()) {
@if (isStalker && selectedItem()) {
@if (contentType === 'vod') {
@if (contentType() === 'vod') {
@if (selectedItem().series?.length > 0) {
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
@@ -23,13 +23,13 @@
"
/>
}
} @else if (contentType === 'series') {
} @else if (contentType() === 'series') {
<app-stalker-series-view />
}
} @else {
<app-grid-list
[isLoading]="isPaginatedContentLoading()"
[items]="paginatedContent()"
[items]="contentWithProgress()"
(itemClicked)="onItemClick($event)"
[pageIndex]="pageIndex()"
[totalPages]="totalPages()"
@@ -1,4 +1,4 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject, OnInit, computed } from '@angular/core';
import { PageEvent } from '@angular/material/paginator';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -27,16 +27,55 @@ export class CategoryContentViewComponent implements OnInit {
private readonly router = inject(Router);
readonly isStalker = this.activatedRoute.snapshot.data['api'] === 'stalker';
readonly contentType = this.activatedRoute.snapshot.data['contentType'];
private readonly store = this.isStalker
? inject(StalkerStore)
: inject(XtreamStore);
readonly contentType = this.store.selectedContentType;
readonly limit = this.store.limit;
readonly pageIndex = this.store.page;
readonly pageSizeOptions = this.isStalker ? [14] : [10, 25, 50, 100];
readonly selectedCategory = this.store.getSelectedCategory;
readonly paginatedContent = this.store.getPaginatedContent;
readonly contentWithProgress = computed(() => {
const items = this.store.getPaginatedContent();
if (!items) return [];
if (this.isStalker) return items;
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}`);
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,
};
} else {
return {
...item,
progress: xtreamStore.getProgressPercent(id, 'vod'),
isWatched: xtreamStore.isWatched(id, 'vod'),
};
}
});
});
readonly isPaginatedContentLoading = this.store.isPaginatedContentLoading;
readonly selectedItem = this.store.selectedItem;
readonly totalPages = this.store.getTotalPages;
@@ -46,6 +85,17 @@ export class CategoryContentViewComponent implements OnInit {
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)
if (!this.isStalker) {
const xtreamStore = this.store as any;
if (xtreamStore.currentPlaylist()?.id) {
xtreamStore.loadAllPositions(xtreamStore.currentPlaylist().id);
}
}
// Clear any previous selectedItem when entering category view
// This ensures the content-header is visible
@@ -83,7 +133,7 @@ export class CategoryContentViewComponent implements OnInit {
// is_series can be "1" (string) or 1 (number)
// ONLY set this for VOD content type - regular series should use the standard series flow
is_series:
this.contentType === 'vod' &&
this.contentType() === 'vod' &&
(item.is_series === '1' || item.is_series === 1),
// Store video_id for season fetching if available
video_id: item.video_id,
@@ -1,5 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { DatabaseService } from 'services';
import { DatabaseService, PlaybackPositionService } from 'services';
import {
XtreamCategory,
XtreamLiveStream,
@@ -24,6 +24,7 @@ import {
@Injectable({ providedIn: 'root' })
export class ElectronXtreamDataSource implements IXtreamDataSource {
private readonly dbService = inject(DatabaseService);
private readonly playbackService = inject(PlaybackPositionService);
private readonly apiService = inject(XtreamApiService);
// =========================================================================
@@ -298,6 +299,65 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
return this.dbService.getContentByXtreamId(xtreamId, playlistId);
}
// =========================================================================
// Playback Position Operations
// =========================================================================
async savePlaybackPosition(
playlistId: string,
data: any
): Promise<void> {
await this.playbackService.savePlaybackPosition(playlistId, data);
}
async getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<any | null> {
return this.playbackService.getPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
}
async getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<any[]> {
return this.playbackService.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
}
async getRecentPlaybackPositions(
playlistId: string,
limit?: number
): Promise<any[]> {
return this.playbackService.getRecentPlaybackPositions(
playlistId,
limit
);
}
async getAllPlaybackPositions(playlistId: string): Promise<any[]> {
return this.playbackService.getAllPlaybackPositions(playlistId);
}
async clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void> {
await this.playbackService.clearPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
}
// =========================================================================
// Cleanup Operations
// =========================================================================
@@ -22,6 +22,7 @@ const STORAGE_KEYS = {
FAVORITES: 'xtream-favorites',
RECENT_ITEMS: 'xtream-recent-items',
PLAYLISTS: 'xtream-playlists',
PLAYBACK_POSITIONS: 'xtream-playback-positions',
};
/**
@@ -72,6 +73,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Also clear favorites and recent items for this playlist
this.clearFavoritesForPlaylist(playlistId);
this.clearRecentItemsForPlaylist(playlistId);
this.clearPlaybackPositionsForPlaylist(playlistId);
// Clear cache
this.clearCacheForPlaylist(playlistId);
@@ -312,6 +314,127 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
this.saveFavoritesToStorage(allFavorites);
}
// =========================================================================
// Playback Position Operations (localStorage)
// =========================================================================
async savePlaybackPosition(playlistId: string, data: any): Promise<void> {
const allPositions = this.getPlaybackPositionsFromStorage();
if (!allPositions[playlistId]) {
allPositions[playlistId] = [];
}
// Remove existing entry if present
allPositions[playlistId] = allPositions[playlistId].filter(
(p: any) =>
!(
p.contentXtreamId === data.contentXtreamId &&
p.contentType === data.contentType
)
);
// Add new entry
allPositions[playlistId].push({
...data,
updatedAt: new Date().toISOString(),
});
this.savePlaybackPositionsToStorage(allPositions);
}
async getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<any | null> {
const allPositions = this.getPlaybackPositionsFromStorage();
const playlistPositions = allPositions[playlistId] || [];
return (
playlistPositions.find(
(p: any) =>
p.contentXtreamId === contentXtreamId &&
p.contentType === contentType
) || null
);
}
async getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<any[]> {
const allPositions = this.getPlaybackPositionsFromStorage();
const playlistPositions = allPositions[playlistId] || [];
return playlistPositions.filter(
(p: any) =>
p.contentType === 'episode' && p.seriesXtreamId === seriesXtreamId
);
}
async getRecentPlaybackPositions(
playlistId: string,
limit?: number
): Promise<any[]> {
const allPositions = this.getPlaybackPositionsFromStorage();
const playlistPositions = allPositions[playlistId] || [];
// Sort by updatedAt descending
playlistPositions.sort(
(a: any, b: any) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
return limit ? playlistPositions.slice(0, limit) : playlistPositions;
}
async getAllPlaybackPositions(playlistId: string): Promise<any[]> {
const allPositions = this.getPlaybackPositionsFromStorage();
return allPositions[playlistId] || [];
}
async clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void> {
const allPositions = this.getPlaybackPositionsFromStorage();
if (allPositions[playlistId]) {
allPositions[playlistId] = allPositions[playlistId].filter(
(p: any) =>
!(
p.contentXtreamId === contentXtreamId &&
p.contentType === contentType
)
);
this.savePlaybackPositionsToStorage(allPositions);
}
}
private getPlaybackPositionsFromStorage(): Record<string, any[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.PLAYBACK_POSITIONS);
return data ? JSON.parse(data) : {};
} catch {
return {};
}
}
private savePlaybackPositionsToStorage(
positions: Record<string, any[]>
): void {
localStorage.setItem(
STORAGE_KEYS.PLAYBACK_POSITIONS,
JSON.stringify(positions)
);
}
private clearPlaybackPositionsForPlaylist(playlistId: string): void {
const allPositions = this.getPlaybackPositionsFromStorage();
delete allPositions[playlistId];
this.savePlaybackPositionsToStorage(allPositions);
}
// =========================================================================
// Recently Viewed Operations (localStorage)
// =========================================================================
@@ -75,6 +75,21 @@ export interface RecentlyViewedItem extends XtreamContentItem {
viewed_at: string;
}
/**
* Playback position data for resume functionality
*/
export interface PlaybackPositionData {
contentXtreamId: number;
contentType: 'vod' | 'episode';
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
positionSeconds: number;
durationSeconds?: number;
playlistId?: string;
updatedAt?: string;
}
/**
* Database category type mapping (differs from API type)
*/
@@ -293,6 +308,57 @@ export interface IXtreamDataSource {
playlistId: string
): Promise<XtreamContentItem | null>;
// =========================================================================
// Playback Position Operations
// =========================================================================
/**
* Save/update playback position for content
*/
savePlaybackPosition(
playlistId: string,
data: PlaybackPositionData
): Promise<void>;
/**
* Get playback position for a specific content item
*/
getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<PlaybackPositionData | null>;
/**
* Get all episode positions for a series (for highlighting watched episodes)
*/
getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<PlaybackPositionData[]>;
/**
* Get recently watched items with positions (for "Continue Watching" section)
*/
getRecentPlaybackPositions(
playlistId: string,
limit?: number
): Promise<PlaybackPositionData[]>;
/**
* Get all playback positions for a playlist (for grid view)
*/
getAllPlaybackPositions(playlistId: string): Promise<PlaybackPositionData[]>;
/**
* Clear playback position (mark as unwatched)
*/
clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void>;
// =========================================================================
// Cleanup Operations
// =========================================================================
@@ -1,6 +1,10 @@
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content class="content">
<app-web-player-view [streamUrl]="streamUrl" />
<app-web-player-view
[streamUrl]="streamUrl"
[startTime]="data.startTime || 0"
(timeUpdate)="handleTimeUpdate($event)"
/>
</mat-dialog-content>
<mat-dialog-actions class="align-actions">
<button
@@ -7,10 +7,13 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { WebPlayerViewComponent } from 'shared-portals';
import { XtreamStore } from '../stores/xtream.store';
export interface PlayerDialogData {
streamUrl: string;
title: string;
contentInfo?: any;
startTime?: number;
}
@Component({
@@ -31,15 +34,33 @@ export class PlayerDialogComponent {
readonly data = inject<PlayerDialogData>(MAT_DIALOG_DATA);
private snackBar = inject(MatSnackBar);
private translateService = inject(TranslateService);
private readonly xtreamStore = inject(XtreamStore);
readonly title: string;
readonly streamUrl: string;
private lastSaveTime = 0;
constructor() {
this.streamUrl = this.data.streamUrl;
this.title = this.data.title;
}
handleTimeUpdate(event: { currentTime: number; duration: number }) {
if (!this.data.contentInfo) return;
const now = Date.now();
// Save every 15 seconds
if (now - this.lastSaveTime > 15000) {
this.lastSaveTime = now;
this.xtreamStore.savePosition(this.data.contentInfo.playlistId, {
...this.data.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
});
}
}
showCopyNotification() {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
@@ -52,6 +52,12 @@
<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) {
@@ -69,6 +75,37 @@
<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 }}
</div>
}
}
<app-watched-badge
[isWatched]="isEpisodeWatched(episode)"
/>
<div class="episode-card__actions">
<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>
@@ -67,7 +67,7 @@
}
.episodes-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
// Season Card
@@ -75,7 +75,7 @@
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
@@ -99,19 +99,63 @@
}
}
&__number {
font-size: 1.75rem !important;
font-weight: 800;
color: rgba(255, 255, 255, 0.05);
line-height: 1;
transition: color 0.3s ease;
&__progress-ring {
position: relative;
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.progress-ring__bg {
fill: none;
stroke: rgba(255, 255, 255, 0.1);
stroke-width: 3;
}
.progress-ring__fill {
fill: none;
stroke: var(--accent-color);
stroke-width: 3;
stroke-linecap: round;
transition: stroke-dasharray 0.5s ease-out;
}
.season-card__number {
position: absolute;
font-size: 1rem !important;
color: var(--text-primary);
font-weight: 700;
opacity: 1;
margin: 0;
padding: 0;
background: transparent;
}
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 0.9375rem !important;
font-weight: 600;
color: var(--text-primary);
flex: 1;
}
&__progress-text {
font-size: 0.75rem;
color: var(--text-muted);
}
&__episodes {
@@ -133,10 +177,16 @@
}
&--has-episodes {
/* background: rgba(var(--accent-color-rgb), 0.1);
border-color: rgba(var(--accent-color-rgb), 0.3); */
.season-card__number {
color: var(--accent-color);
opacity: 0.4; // More subtle
color: var(--text-primary);
}
/* .season-card__label {
color: var(--accent-color);
} */
}
}
@@ -148,6 +198,42 @@
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.05);
position: relative;
// In-progress state
&--in-progress {
border-color: rgba(229, 9, 20, 0.6);
background: rgba(229, 9, 20, 0.05);
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
rgba(229, 9, 20, 0.1) 0%,
transparent 50%
);
pointer-events: none;
z-index: 1;
}
}
// Watched state
&--watched {
opacity: 0.7;
&:hover {
opacity: 1;
}
.episode-card__title::after {
content: ' ✓';
color: #46d369;
font-weight: bold;
margin-left: 4px;
}
}
&:hover {
transform: translateY(-2px);
@@ -176,6 +262,20 @@
}
}
&__position-text {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.75);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
backdrop-filter: blur(4px);
z-index: 2;
}
&__placeholder {
width: 100%;
height: 100%;
@@ -202,6 +302,41 @@
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1;
}
&__actions {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s ease;
.toggle-watched-btn {
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.5);
width: 32px;
height: 32px;
padding: 4px;
&:hover {
color: white;
background: rgba(0, 0, 0, 0.8);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
}
&:hover {
.episode-card__actions {
opacity: 1;
}
}
&__play {
@@ -1,17 +1,35 @@
import { KeyValuePipe } from '@angular/common';
import { Component, EventEmitter, Output, input } from '@angular/core';
import { Component, EventEmitter, Output, input, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces';
import { XtreamStore } from '../stores/xtream.store';
import { ProgressCapsuleComponent } from '../shared/progress-capsule/progress-capsule.component';
import { WatchedBadgeComponent } from '../shared/watched-badge/watched-badge.component';
@Component({
selector: 'app-season-container',
templateUrl: './season-container.component.html',
styleUrls: ['./season-container.component.scss'],
imports: [KeyValuePipe, MatIcon, MatProgressSpinnerModule],
imports: [
KeyValuePipe,
MatButtonModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
ProgressCapsuleComponent,
TranslateModule,
WatchedBadgeComponent,
],
})
export class SeasonContainerComponent {
private readonly xtreamStore = inject(XtreamStore);
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
readonly seriesId = input.required<number>(); // Add seriesId input
readonly isLoading = input<boolean>(false);
@Output() episodeClicked = new EventEmitter<any>();
@@ -32,6 +50,16 @@ export class SeasonContainerComponent {
this.episodeClicked.emit(episode);
}
toggleWatched(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
const playlistId = this.xtreamStore.currentPlaylist().id;
this.xtreamStore.toggleEpisodeWatched(
playlistId,
episode,
this.seriesId()
);
}
/**
* Safely get episode info - returns undefined if info is an empty array
* (Xtream API returns [] when no metadata available instead of null/object)
@@ -44,4 +72,67 @@ export class SeasonContainerComponent {
}
return episode.info;
}
isEpisodeWatched(episode: XtreamSerieEpisode) {
return this.xtreamStore.isWatched(Number(episode.id), 'episode');
}
isEpisodeInProgress(episode: XtreamSerieEpisode) {
return this.xtreamStore.isInProgress(Number(episode.id), 'episode');
}
getEpisodeProgress(episode: XtreamSerieEpisode) {
return this.xtreamStore.getProgressPercent(
Number(episode.id),
'episode'
);
}
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
if (this.isEpisodeWatched(episode)) return null;
const position = this.xtreamStore
.playbackPositions()
.get(`episode_${episode.id}`);
if (!position || !position.positionSeconds) return null;
let seconds = position.positionSeconds;
let suffix = '';
// Calculate remaining time if duration is available
if (position.durationSeconds > 0) {
const remaining = Math.max(
0,
position.durationSeconds - position.positionSeconds
);
if (remaining <= 0) return null;
seconds = remaining;
suffix = ' left';
}
const date = new Date(0);
date.setSeconds(seconds);
const timeString = date.toISOString().substr(11, 8);
const formatted = timeString.startsWith('00:')
? timeString.substr(3)
: timeString;
return `${formatted}${suffix}`;
}
getSeasonWatchedCount(seasonKey: string): number {
const episodes = this.seasons()[seasonKey];
if (!episodes) return 0;
return episodes.filter((e) => this.isEpisodeWatched(e)).length;
}
getSeasonProgressDash(seasonKey: string): string {
const episodes = this.seasons()[seasonKey];
if (!episodes || episodes.length === 0) return '0, 100';
const watched = this.getSeasonWatchedCount(seasonKey);
const percent = (watched / episodes.length) * 100;
return `${percent}, 100`;
}
}
@@ -69,6 +69,7 @@
<!-- Seasons & Episodes -->
<app-season-container
[seasons]="item.episodes"
[seriesId]="item.series_id"
(episodeClicked)="playEpisode($event)"
/>
</app-content-hero>
@@ -39,6 +39,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
serialId,
this.xtreamStore.currentPlaylist().id
);
this.xtreamStore.loadSeriesPositions(
this.xtreamStore.currentPlaylist().id,
Number(serialId)
);
}
ngOnDestroy(): void {
@@ -49,10 +53,25 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
this.addToRecentlyViewed(this.route.snapshot.params.serialId);
const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode);
const contentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
contentXtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: Number(this.selectedItem().series_id),
seasonNumber: Number(episode.season),
episodeNumber: Number(episode.episode_num),
};
const position = this.xtreamStore
.playbackPositions()
.get(`episode_${episode.id}`);
this.xtreamStore.openPlayer(
streamUrl,
episode.title,
this.selectedItem().info.cover
this.selectedItem().info.cover,
position?.positionSeconds,
contentInfo
);
}
@@ -0,0 +1,55 @@
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-progress-capsule',
standalone: true,
template: `
<div
class="progress-capsule"
[class.progress-capsule--watched]="isWatched()"
>
<div
class="progress-capsule__fill"
[style.width.%]="progress()"
></div>
</div>
`,
styles: [
`
.progress-capsule {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 5px;
background: rgba(255, 255, 255, 0.2);
overflow: hidden;
&__fill {
height: 100%;
background: linear-gradient(
90deg,
#e50914 0%,
#ff4d4d 100%
);
transition: width 0.3s ease-out;
border-radius: 0 2px 2px 0;
}
&--watched {
.progress-capsule__fill {
background: linear-gradient(
90deg,
#46d369 0%,
#2ecc71 100%
);
}
}
}
`,
],
})
export class ProgressCapsuleComponent {
readonly progress = input.required<number>(); // 0-100
readonly isWatched = computed(() => this.progress() >= 90);
}
@@ -0,0 +1,44 @@
import { Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
@Component({
selector: 'app-watched-badge',
standalone: true,
imports: [MatIcon],
template: `
@if (isWatched()) {
<div class="watched-badge">
<mat-icon>{{ icon() }}</mat-icon>
</div>
}
`,
styles: [
`
.watched-badge {
position: absolute;
top: 8px;
left: 8px;
width: 24px;
height: 24px;
background: rgba(70, 211, 105, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: white;
}
}
`,
],
})
export class WatchedBadgeComponent {
readonly isWatched = input.required<boolean>();
readonly icon = input<string>('check_circle');
}
@@ -1,5 +1,6 @@
export * from './with-content.feature';
export * from './with-epg.feature';
export * from './with-playback-positions.feature';
export * from './with-player.feature';
export * from './with-portal.feature';
export * from './with-search.feature';
@@ -0,0 +1,394 @@
import { inject } from '@angular/core';
import {
patchState,
signalStoreFeature,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { XtreamSerieEpisode } from 'shared-interfaces';
import {
PlaybackPositionData,
XTREAM_DATA_SOURCE,
} from '../../data-sources/xtream-data-source.interface';
export interface PlaybackPositionsState {
playbackPositions: Map<string, PlaybackPositionData>; // key: `${contentType}_${xtreamId}`
seriesPositions: Map<number, PlaybackPositionData[]>; // key: seriesXtreamId
}
const initialState: PlaybackPositionsState = {
playbackPositions: new Map(),
seriesPositions: new Map(),
};
function parseDuration(duration: string | number): number {
if (typeof duration === 'number') return duration;
if (!duration) return 0;
// Check for "min" format (e.g. "45 min")
const minMatch = duration.match(/(\d+)\s*min/);
if (minMatch) {
return parseInt(minMatch[1], 10) * 60;
}
// Check for "h:m:s" or "m:s" format
if (duration.includes(':')) {
const parts = duration.split(':').map((p) => parseInt(p, 10));
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
}
// Fallback: try parsing as simple number (seconds or minutes? assume minutes if < 1000, seconds otherwise?)
// Xtream usually returns seconds or "min" string
const num = parseInt(duration, 10);
if (!isNaN(num)) {
return num;
}
return 0;
}
export function withPlaybackPositions() {
return signalStoreFeature(
withState(initialState),
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
const getPositionKey = (type: string, id: number) =>
`${type}_${id}`;
return {
/**
* Get progress percentage for display (0-100)
*/
getProgressPercent(
contentXtreamId: number,
contentType: 'vod' | 'episode'
): number {
const key = getPositionKey(contentType, contentXtreamId);
const position = store.playbackPositions().get(key);
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));
},
/**
* Check if content is considered "watched" (>90% complete)
*/
isWatched(
contentXtreamId: number,
contentType: 'vod' | 'episode'
): boolean {
return (
this.getProgressPercent(
contentXtreamId,
contentType
) >= 90
);
},
/**
* Check if content is "in progress" (started but not finished)
*/
isInProgress(
contentXtreamId: number,
contentType: 'vod' | 'episode'
): boolean {
const key = getPositionKey(contentType, contentXtreamId);
const position = store.playbackPositions().get(key);
if (!position) return false;
const percent = this.getProgressPercent(
contentXtreamId,
contentType
);
const inProgress =
position.positionSeconds > 10 && percent < 90;
return inProgress;
},
/**
* Load all playback positions for the playlist (for grid view)
*/
async loadAllPositions(playlistId: string): Promise<void> {
const positions = await dataSource.getAllPlaybackPositions(
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
) {
const existing =
seriesMap.get(pos.seriesXtreamId) || [];
existing.push(pos);
seriesMap.set(pos.seriesXtreamId, existing);
}
});
console.log(
`[withPlaybackPositions] Series map populated: ${seriesMap.size} series`
);
patchState(store, {
playbackPositions: positionsMap,
seriesPositions: seriesMap,
});
},
/**
* Check if a series has any started or watched episodes
*/
hasSeriesProgress(seriesXtreamId: number): boolean {
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;
},
/**
* Load positions for a VOD item
*/
async loadVodPosition(
playlistId: string,
vodXtreamId: number
): Promise<void> {
const position = await dataSource.getPlaybackPosition(
playlistId,
vodXtreamId,
'vod'
);
console.log(
`[withPlaybackPositions] Loaded VOD position for ${vodXtreamId}:`,
position
);
if (position) {
const key = getPositionKey('vod', vodXtreamId);
const updated = new Map(store.playbackPositions());
updated.set(key, position);
patchState(store, { playbackPositions: updated });
}
},
/**
* Load all episode positions for a series
*/
async loadSeriesPositions(
playlistId: string,
seriesXtreamId: number
): Promise<void> {
const positions = await dataSource.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
console.log(
`[withPlaybackPositions] Loaded series positions for ${seriesXtreamId}:`,
positions
);
const updated = new Map(store.seriesPositions());
updated.set(seriesXtreamId, positions);
patchState(store, { seriesPositions: updated });
// Also populate individual positions map
const positionsMap = new Map(store.playbackPositions());
positions.forEach((pos) => {
const key = getPositionKey(
'episode',
pos.contentXtreamId
);
positionsMap.set(key, pos);
});
patchState(store, { playbackPositions: positionsMap });
},
/**
* Save playback position (called from MPV updates)
*/
async savePosition(
playlistId: string,
data: PlaybackPositionData
): Promise<void> {
await dataSource.savePlaybackPosition(playlistId, data);
const key = getPositionKey(
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 });
},
/**
* Toggle watched status for an episode
*/
async toggleEpisodeWatched(
playlistId: string,
episode: XtreamSerieEpisode,
seriesId: number
): Promise<void> {
const id = Number(episode.id);
const isWatched = this.isWatched(id, 'episode');
if (isWatched) {
// Mark as unwatched
await dataSource.clearPlaybackPosition(
playlistId,
id,
'episode'
);
// Update state (remove from map)
const key = getPositionKey('episode', id);
const updated = new Map(store.playbackPositions());
updated.delete(key);
// Update series map
const seriesMap = new Map(store.seriesPositions());
const seriesEpisodes = seriesMap.get(seriesId) || [];
const filteredEpisodes = seriesEpisodes.filter(
(p) => p.contentXtreamId !== id
);
if (filteredEpisodes.length === 0) {
seriesMap.delete(seriesId);
} else {
seriesMap.set(seriesId, filteredEpisodes);
}
patchState(store, {
playbackPositions: updated,
seriesPositions: seriesMap,
});
} else {
// Mark as watched
let duration = 0;
const info = Array.isArray(episode.info)
? null
: episode.info;
if (info?.duration_secs) {
duration = info.duration_secs;
} else if (info?.duration) {
duration = parseDuration(info.duration);
}
if (duration === 0) duration = 1; // Fallback
const data: PlaybackPositionData = {
contentXtreamId: id,
contentType: 'episode',
seriesXtreamId: seriesId,
seasonNumber: Number(episode.season),
episodeNumber: Number(episode.episode_num),
positionSeconds: duration,
durationSeconds: duration,
playlistId,
updatedAt: new Date().toISOString(),
};
// Use existing savePosition to handle state update and persistence
// But we also need to update seriesPositions map which savePosition doesn't do for individual updates
// actually savePosition only updates playbackPositions map.
// We should probably update savePosition to also update series map, or do it here.
// Let's do it here to be safe.
await dataSource.savePlaybackPosition(playlistId, data);
const key = getPositionKey('episode', id);
const updated = new Map(store.playbackPositions());
updated.set(key, data);
const seriesMap = new Map(store.seriesPositions());
const seriesEpisodes = seriesMap.get(seriesId) || [];
// Check if already exists
const existingIdx = seriesEpisodes.findIndex(
(p) => p.contentXtreamId === id
);
if (existingIdx >= 0) {
seriesEpisodes[existingIdx] = data;
} else {
seriesEpisodes.push(data);
}
seriesMap.set(seriesId, seriesEpisodes);
patchState(store, {
playbackPositions: updated,
seriesPositions: seriesMap,
});
}
},
};
}),
withHooks({
onInit(store) {
if (window.electron?.onPlaybackPositionUpdate) {
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
);
}
});
}
},
onDestroy(store) {
if (window.electron?.removePlaybackPositionListener) {
window.electron.removePlaybackPositionListener();
}
},
})
);
}
@@ -136,7 +136,9 @@ export function withPlayer() {
openPlayer(
streamUrl: string,
title: string,
thumbnail: string | null = null
thumbnail: string | null = null,
startTime?: number,
contentInfo?: any
): void {
const playlist = getPlaylistFromStore();
const storeAny = store as any;
@@ -152,7 +154,9 @@ export function withPlayer() {
contentType === 'live',
playlist?.userAgent,
playlist?.referrer,
playlist?.origin
playlist?.origin,
contentInfo,
startTime
);
},
@@ -26,6 +26,7 @@ import {
withSearch,
withEpg,
withPlayer,
withPlaybackPositions,
} from './features';
/**
@@ -42,12 +43,12 @@ import {
* - withPlayer: Stream URL construction and player integration
* - withFavorites: Favorites management
* - withRecentItems: Recently viewed items
* - withPlaybackPositions: Playback position tracking
*
* @see docs/XTREAM_STORE_REFACTORING_PLAN.md
*/
export const XtreamStore = signalStore(
// Note: NOT providedIn: 'root' because shell provides it
// This matches the original behavior
{ providedIn: 'root' },
// Compose all features
withPortal(),
@@ -58,6 +59,7 @@ export const XtreamStore = signalStore(
withPlayer(),
withFavorites(),
withRecentItems(),
withPlaybackPositions(),
// Cross-feature computed properties
withComputed((store) => ({
@@ -105,6 +107,10 @@ export const XtreamStore = signalStore(
await store.fetchPlaylist();
await store.checkPortalStatus();
await store.initializeContent();
const playlist = store.currentPlaylist();
if (playlist) {
store.loadAllPositions(playlist.id);
}
},
/**
@@ -61,10 +61,27 @@
<!-- Action Buttons -->
<ng-container hero-actions>
<button class="play-btn" (click)="playVod(item)">
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
@if (hasPlaybackPosition()) {
<button
class="play-btn play-btn--resume"
(click)="resumeVod(item)"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.RESUME' | translate }} {{ formatPosition() }}
</button>
<button
class="play-btn play-btn--restart"
(click)="playVod(item)"
>
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
</button>
} @else {
<button class="play-btn" (click)="playVod(item)">
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
@@ -1,5 +1,5 @@
import { Location, SlicePipe } from '@angular/common';
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { Component, OnDestroy, OnInit, inject, computed } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute } from '@angular/router';
@@ -33,6 +33,15 @@ export class VodDetailsComponent implements OnInit, OnDestroy {
readonly isFavorite = this.xtreamStore.isFavorite;
readonly selectedItem = this.xtreamStore.selectedItem;
readonly hasPlaybackPosition = computed(() => {
const vodId = this.route.snapshot.params.vodId;
const inProgress = this.xtreamStore.isInProgress(Number(vodId), 'vod');
console.log(
`[VodDetails] hasPlaybackPosition check: vodId=${vodId}, inProgress=${inProgress}`
);
return inProgress;
});
ngOnInit(): void {
const { categoryId, vodId } = this.route.snapshot.params;
this.xtreamStore.fetchVodDetailsWithMetadata({ vodId, categoryId });
@@ -40,6 +49,10 @@ export class VodDetailsComponent implements OnInit, OnDestroy {
vodId,
this.xtreamStore.currentPlaylist().id
);
this.xtreamStore.loadVodPosition(
this.xtreamStore.currentPlaylist().id,
Number(vodId)
);
}
ngOnDestroy() {
@@ -50,13 +63,66 @@ export class VodDetailsComponent implements OnInit, OnDestroy {
this.addToRecentlyViewed();
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
// Use route param vodId if available to match ngOnInit logic, otherwise fallback to item id
const routeVodId = this.route.snapshot.params.vodId;
const id = routeVodId
? Number(routeVodId)
: Number(
vodItem.movie_data?.stream_id || (vodItem as any).stream_id
);
console.log(`[VodDetails] playVod: Resolved ID=${id} for item`, vodItem);
const contentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
contentXtreamId: id,
contentType: 'vod',
};
this.xtreamStore.openPlayer(
streamUrl,
vodItem.info.name ?? vodItem?.movie_data?.name,
vodItem.info.movie_image
vodItem.info.movie_image,
undefined,
contentInfo
);
}
resumeVod(vodItem: XtreamVodDetails) {
this.addToRecentlyViewed();
const vodId = Number(this.route.snapshot.params.vodId);
const position = this.xtreamStore
.playbackPositions()
.get(`vod_${vodId}`);
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
// Use vodId from route (same as above)
const contentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
contentXtreamId: vodId,
contentType: 'vod',
};
this.xtreamStore.openPlayer(
streamUrl,
vodItem.info.name ?? vodItem?.movie_data?.name,
vodItem.info.movie_image,
position?.positionSeconds,
contentInfo
);
}
formatPosition(): string {
const vodId = Number(this.route.snapshot.params.vodId);
const position = this.xtreamStore.playbackPositions().get(`vod_${vodId}`);
if (!position) return '';
const date = new Date(0);
date.setSeconds(position.positionSeconds);
const timeString = date.toISOString().substr(11, 8);
return timeString.startsWith('00:') ? timeString.substr(3) : timeString;
}
toggleFavorite() {
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.vodId,
@@ -18,7 +18,6 @@ import { XtreamStore } from '../stores/xtream.store';
RouterOutlet,
TranslateModule,
],
providers: [XtreamStore],
})
export class XtreamShellComponent {
private readonly route = inject(ActivatedRoute);
+8
View File
@@ -285,6 +285,14 @@
"RATING": "Bewertung",
"AGE": "Alter",
"EPISODE": "Folge",
"RESUME": "Fortsetzen",
"RESTART": "Neu starten",
"CONTINUE_WATCHING": "Weiter schauen",
"WATCHED": "Angesehen",
"IN_PROGRESS": "In Wiedergabe",
"MARK_WATCHED": "Als angesehen markieren",
"MARK_UNWATCHED": "Als nicht angesehen markieren",
"EPISODES_WATCHED": "{{count}} von {{total}} Folgen angesehen",
"YEAR": "Jahr",
"YOUTUBE_TRAILER": "YouTube-Trailer",
"DESCRIPTION": "Beschreibung",
+1
View File
@@ -338,6 +338,7 @@
"CONTINUE_WATCHING": "Continue Watching",
"WATCHED": "Watched",
"IN_PROGRESS": "In Progress",
"MARK_WATCHED": "Mark as Watched",
"MARK_UNWATCHED": "Mark as Unwatched",
"EPISODES_WATCHED": "{{count}} of {{total}} episodes watched",
"ACCOUNT_INFO": {
+8
View File
@@ -224,6 +224,14 @@
"RATING": "Clasificación",
"AGE": "Edad",
"EPISODE": "Episodio",
"RESUME": "Continuar",
"RESTART": "Reiniciar",
"CONTINUE_WATCHING": "Continuar viendo",
"WATCHED": "Visto",
"IN_PROGRESS": "En progreso",
"MARK_WATCHED": "Marcar como visto",
"MARK_UNWATCHED": "Marcar como no visto",
"EPISODES_WATCHED": "{{count}} de {{total}} episodios vistos",
"YEAR": "Año",
"YOUTUBE_TRAILER": "Tráiler de YouTube",
"DESCRIPTION": "Descripción",
+8
View File
@@ -296,6 +296,14 @@
"EPISODE_RUN_TIME": "Длительность эпизода",
"AGE": "Возраст",
"EPISODE": "Эпизод",
"RESUME": "Продолжить",
"RESTART": "С начала",
"CONTINUE_WATCHING": "Продолжить просмотр",
"WATCHED": "Просмотрено",
"IN_PROGRESS": "В процессе",
"MARK_WATCHED": "Отметить как просмотренное",
"MARK_UNWATCHED": "Отметить как непросмотренное",
"EPISODES_WATCHED": "Просмотрено {{count}} из {{total}} эпизодов",
"YEAR": "Год",
"YOUTUBE_TRAILER": "YouTube-трейлер",
"DESCRIPTION": "Описание",
+90
View File
@@ -6,6 +6,96 @@ interface NodeModule {
interface Window {
process: any;
require: any;
electron: {
// Core
getAppVersion: () => Promise<string>;
platform: string;
// Listeners
onChannelChange: (callback: (data: { direction: 'up' | 'down' }) => void) => void;
onPlayerError: (callback: (data: { player: string; error: string; originalError: string }) => void) => void;
onEpgProgress: (callback: (data: { url: string; status: 'loading' | 'complete' | 'error'; stats?: { totalChannels: number; totalPrograms: number }; error?: string }) => void) => void;
onDbSaveContentProgress: (callback: (count: number) => void) => void;
removeDbSaveContentProgress: () => void;
onPlaybackPositionUpdate: (callback: (data: any) => void) => void;
removePlaybackPositionListener: () => void;
// Files & System
saveFileDialog: (defaultPath: string, filters?: { name: string; extensions: string[] }[]) => Promise<any>;
writeFile: (filePath: string, content: string) => Promise<void>;
getLocalIpAddresses: () => Promise<string[]>;
// Playlist
fetchPlaylistByUrl: (url: string, title?: string) => Promise<any>;
updatePlaylistFromFilePath: (filePath: string, title: string) => Promise<any>;
openPlaylistFromFile: () => Promise<any>;
autoUpdatePlaylists: (playlists: any[]) => Promise<any[]>;
dbDeleteAllPlaylists: () => Promise<void>;
// Player
setUserAgent: (userAgent: string, referer?: string) => Promise<void>;
openInMpv: (url: string, title: string, userAgent: string, referer?: string, origin?: string, contentInfo?: any, startTime?: number) => Promise<void>;
openInVlc: (url: string, title: string, userAgent: string, referer?: string, origin?: string, contentInfo?: any, startTime?: number) => Promise<void>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
// EPG
fetchEpg: (urls: string[]) => Promise<{ success: boolean; message?: string; skipped?: string[] }>;
getChannelPrograms: (channelId: string) => Promise<any>;
getEpgChannels: () => Promise<any>;
getEpgChannelsByRange: (skip: number, limit: number) => Promise<any>;
forceFetchEpg: (url: string) => Promise<void>;
clearEpgData: () => Promise<void>;
checkEpgFreshness: (urls: string[], maxAgeHours?: number) => Promise<any>;
searchEpgPrograms: (searchTerm: string, limit?: number) => Promise<any>;
// Settings & Requests
updateSettings: (settings: any) => Promise<void>;
getAiSettings: () => Promise<any>;
stalkerRequest: (payload: any) => Promise<any>;
xtreamRequest: (payload: any) => Promise<any>;
// Database - Playlists & Content
dbCreatePlaylist: (playlist: any) => Promise<void>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
dbUpdatePlaylist: (playlistId: string, updates: any) => Promise<void>;
dbDeletePlaylist: (playlistId: string) => Promise<void>;
dbDeleteXtreamContent: (playlistId: string) => Promise<any>;
dbRestoreXtreamUserData: (playlistId: string, favoritedXtreamIds: number[], recentlyViewedXtreamIds: any[]) => Promise<void>;
// Database - Categories & Content
dbHasCategories: (playlistId: string, type: string) => Promise<boolean>;
dbGetCategories: (playlistId: string, type: string) => Promise<any[]>;
dbSaveCategories: (playlistId: string, categories: any[], type: string, hiddenCategoryXtreamIds?: number[]) => Promise<void>;
dbGetAllCategories: (playlistId: string, type: string) => Promise<any[]>;
dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) => Promise<void>;
dbHasContent: (playlistId: string, type: string) => Promise<boolean>;
dbGetContent: (playlistId: string, type: string) => Promise<any[]>;
dbSaveContent: (playlistId: string, streams: any[], type: string) => Promise<any>;
dbSearchContent: (playlistId: string, searchTerm: string, types: string[]) => Promise<any[]>;
dbGlobalSearch: (searchTerm: string, types: string[]) => Promise<any[]>;
dbGetContentByXtreamId: (xtreamId: number, playlistId: string) => Promise<any>;
// Database - Favorites & Recent
dbGetRecentlyViewed: () => Promise<any[]>;
dbClearRecentlyViewed: () => Promise<void>;
dbAddFavorite: (contentId: number, playlistId: string) => Promise<void>;
dbRemoveFavorite: (contentId: number, playlistId: string) => Promise<void>;
dbIsFavorite: (contentId: number, playlistId: string) => Promise<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
dbAddRecentItem: (contentId: number, playlistId: string) => Promise<void>;
dbClearPlaylistRecentItems: (playlistId: string) => Promise<void>;
dbRemoveRecentItem: (contentId: number, playlistId: string) => Promise<void>;
// Database - Playback Positions
dbSavePlaybackPosition: (playlistId: string, data: any) => Promise<void>;
dbGetPlaybackPosition: (playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode') => Promise<any>;
dbGetSeriesPlaybackPositions: (playlistId: string, seriesXtreamId: number) => Promise<any[]>;
dbGetRecentPlaybackPositions: (playlistId: string, limit?: number) => Promise<any[]>;
dbGetAllPlaybackPositions: (playlistId: string) => Promise<any[]>;
dbClearPlaybackPosition: (playlistId: string, contentXtreamId: number, contentType: 'vod' | 'episode') => Promise<void>;
};
}
/* export interface ParsedPlaylist {