mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
feat: playback position persistence for all players and grid indicators
This commit is contained in:
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);
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
+3
-3
@@ -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()"
|
||||
|
||||
+53
-3
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Описание",
|
||||
|
||||
Vendored
+90
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user