From 6c52ee349d987eb19733746c163011ee5de55ea1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 30 Jan 2026 20:36:36 +0100 Subject: [PATCH 01/14] feat: add resizable directive and styles for dynamic element resizing --- libs/ui/components/src/index.ts | 1 + .../src/lib/resizable/resizable.directive.ts | 297 ++++++++++++++++++ .../src/lib/resizable/resizable.scss | 184 +++++++++++ 3 files changed, 482 insertions(+) create mode 100644 libs/ui/components/src/lib/resizable/resizable.directive.ts create mode 100644 libs/ui/components/src/lib/resizable/resizable.scss diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 6426219e9..f990547b6 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -19,3 +19,4 @@ export * from './lib/recent-playlists/recent-playlists.component'; export * from './lib/video-player/sidebar/sidebar.component'; export * from './lib/video-player/toolbar/toolbar.component'; export * from './lib/vjs-player/vjs-player.component'; +export * from './lib/resizable/resizable.directive'; diff --git a/libs/ui/components/src/lib/resizable/resizable.directive.ts b/libs/ui/components/src/lib/resizable/resizable.directive.ts new file mode 100644 index 000000000..058db07f9 --- /dev/null +++ b/libs/ui/components/src/lib/resizable/resizable.directive.ts @@ -0,0 +1,297 @@ +import { + Directive, + ElementRef, + Renderer2, + input, + output, + OnInit, + OnDestroy, + effect, + signal, + AfterViewInit, +} from '@angular/core'; + +/** + * ResizableDirective - Makes an element horizontally resizable by dragging its edge. + * + * Usage: + * ```html + *
+ * Content here + *
+ * ``` + * + * Features: + * - Smooth drag handle with visual feedback + * - Persists width to localStorage + * - Respects min/max width constraints + * - Emits width changes for reactive updates + */ +@Directive({ + selector: '[appResizable]', + standalone: true, +}) +export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy { + /** Minimum width in pixels */ + readonly minWidth = input(200); + + /** Maximum width in pixels */ + readonly maxWidth = input(600); + + /** LocalStorage key for persisting width. If not set, width won't be persisted. */ + readonly storageKey = input(''); + + /** Default width to use when no stored value exists */ + readonly defaultWidth = input(400); + + /** Position of the resize handle: 'right' or 'left' */ + readonly handlePosition = input<'right' | 'left'>('right'); + + /** Emits when width changes during resize */ + readonly widthChange = output(); + + /** Emits when resize starts */ + readonly resizeStart = output(); + + /** Emits when resize ends */ + readonly resizeEnd = output(); + + private handleElement: HTMLElement | null = null; + private isResizing = signal(false); + private startX = 0; + private startWidth = 0; + private currentWidth = signal(0); + + // Event listener references for cleanup + private boundMouseMove: ((e: MouseEvent) => void) | null = null; + private boundMouseUp: ((e: MouseEvent) => void) | null = null; + private boundTouchMove: ((e: TouchEvent) => void) | null = null; + private boundTouchEnd: ((e: TouchEvent) => void) | null = null; + + constructor( + private readonly el: ElementRef, + private readonly renderer: Renderer2 + ) { + // Apply resizing class when dragging + effect(() => { + if (this.isResizing()) { + this.renderer.addClass(document.body, 'resizing-active'); + this.renderer.addClass(this.el.nativeElement, 'is-resizing'); + } else { + this.renderer.removeClass(document.body, 'resizing-active'); + this.renderer.removeClass(this.el.nativeElement, 'is-resizing'); + } + }); + } + + ngOnInit(): void { + // Load persisted width FIRST to avoid flash of default width + this.loadPersistedWidth(); + this.setupStyles(); + } + + ngAfterViewInit(): void { + this.createResizeHandle(); + } + + ngOnDestroy(): void { + this.removeGlobalListeners(); + if (this.handleElement) { + this.handleElement.remove(); + } + } + + private setupStyles(): void { + const el = this.el.nativeElement; + + // Ensure the element has position relative for handle positioning + const computedStyle = window.getComputedStyle(el); + if (computedStyle.position === 'static') { + this.renderer.setStyle(el, 'position', 'relative'); + } + + // Add transition for smooth width changes (only when not dragging) + this.renderer.addClass(el, 'resizable-element'); + } + + private createResizeHandle(): void { + const handle = this.renderer.createElement('div'); + this.handleElement = handle; + + // Set up handle classes based on position + this.renderer.addClass(handle, 'resize-handle'); + this.renderer.addClass( + handle, + `resize-handle--${this.handlePosition()}` + ); + + // Create inner visual elements for the handle + const handleLine = this.renderer.createElement('div'); + this.renderer.addClass(handleLine, 'resize-handle__line'); + this.renderer.appendChild(handle, handleLine); + + // Create grip dots + const gripDots = this.renderer.createElement('div'); + this.renderer.addClass(gripDots, 'resize-handle__grip'); + for (let i = 0; i < 3; i++) { + const dot = this.renderer.createElement('span'); + this.renderer.appendChild(gripDots, dot); + } + this.renderer.appendChild(handle, gripDots); + + // Mouse events + this.renderer.listen(handle, 'mousedown', (e: MouseEvent) => + this.onMouseDown(e) + ); + + // Touch events for mobile support + this.renderer.listen(handle, 'touchstart', (e: TouchEvent) => + this.onTouchStart(e) + ); + + // Append handle to element + this.renderer.appendChild(this.el.nativeElement, handle); + } + + private loadPersistedWidth(): void { + const key = this.storageKey(); + let widthToApply = this.defaultWidth(); + + if (key) { + const stored = localStorage.getItem(key); + if (stored) { + const width = parseInt(stored, 10); + if (!isNaN(width)) { + widthToApply = this.clampWidth(width); + } + } + } + + // Apply width immediately without transition to avoid flash + this.setWidthImmediate(widthToApply); + } + + /** Set width immediately without CSS transition */ + private setWidthImmediate(width: number): void { + this.currentWidth.set(width); + // Temporarily disable transition for immediate application + this.renderer.setStyle(this.el.nativeElement, 'transition', 'none'); + this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`); + + // Re-enable transition after a frame + requestAnimationFrame(() => { + this.renderer.removeStyle(this.el.nativeElement, 'transition'); + }); + } + + private onMouseDown(e: MouseEvent): void { + e.preventDefault(); + e.stopPropagation(); + + this.startResize(e.clientX); + + this.boundMouseMove = (event: MouseEvent) => this.onMouseMove(event); + this.boundMouseUp = (event: MouseEvent) => this.onMouseUp(event); + + document.addEventListener('mousemove', this.boundMouseMove); + document.addEventListener('mouseup', this.boundMouseUp); + } + + private onTouchStart(e: TouchEvent): void { + if (e.touches.length !== 1) return; + + e.preventDefault(); + e.stopPropagation(); + + this.startResize(e.touches[0].clientX); + + this.boundTouchMove = (event: TouchEvent) => this.onTouchMove(event); + this.boundTouchEnd = (event: TouchEvent) => this.onTouchEnd(event); + + document.addEventListener('touchmove', this.boundTouchMove, { + passive: false, + }); + document.addEventListener('touchend', this.boundTouchEnd); + } + + private startResize(clientX: number): void { + this.isResizing.set(true); + this.startX = clientX; + this.startWidth = this.el.nativeElement.offsetWidth; + this.resizeStart.emit(); + } + + private onMouseMove(e: MouseEvent): void { + if (!this.isResizing()) return; + this.resize(e.clientX); + } + + private onTouchMove(e: TouchEvent): void { + if (!this.isResizing() || e.touches.length !== 1) return; + e.preventDefault(); + this.resize(e.touches[0].clientX); + } + + private resize(clientX: number): void { + const delta = + this.handlePosition() === 'right' + ? clientX - this.startX + : this.startX - clientX; + + const newWidth = this.clampWidth(this.startWidth + delta); + this.setWidth(newWidth); + this.widthChange.emit(newWidth); + } + + private onMouseUp(_e: MouseEvent): void { + this.endResize(); + } + + private onTouchEnd(_e: TouchEvent): void { + this.endResize(); + } + + private endResize(): void { + if (!this.isResizing()) return; + + this.isResizing.set(false); + this.removeGlobalListeners(); + this.persistWidth(); + this.resizeEnd.emit(this.currentWidth()); + } + + private removeGlobalListeners(): void { + if (this.boundMouseMove) { + document.removeEventListener('mousemove', this.boundMouseMove); + this.boundMouseMove = null; + } + if (this.boundMouseUp) { + document.removeEventListener('mouseup', this.boundMouseUp); + this.boundMouseUp = null; + } + if (this.boundTouchMove) { + document.removeEventListener('touchmove', this.boundTouchMove); + this.boundTouchMove = null; + } + if (this.boundTouchEnd) { + document.removeEventListener('touchend', this.boundTouchEnd); + this.boundTouchEnd = null; + } + } + + private clampWidth(width: number): number { + return Math.max(this.minWidth(), Math.min(this.maxWidth(), width)); + } + + private setWidth(width: number): void { + this.currentWidth.set(width); + this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`); + } + + private persistWidth(): void { + const key = this.storageKey(); + if (key) { + localStorage.setItem(key, this.currentWidth().toString()); + } + } +} diff --git a/libs/ui/components/src/lib/resizable/resizable.scss b/libs/ui/components/src/lib/resizable/resizable.scss new file mode 100644 index 000000000..1ae5be5a0 --- /dev/null +++ b/libs/ui/components/src/lib/resizable/resizable.scss @@ -0,0 +1,184 @@ +/** + * Resizable Directive Styles + * + * A refined, subtle resize handle that provides clear affordance + * without being visually intrusive. Uses a vertical grip pattern + * that becomes more prominent on hover/active states. + */ + +// Prevent text selection during resize +body.resizing-active { + user-select: none !important; + cursor: col-resize !important; + + * { + cursor: col-resize !important; + } +} + +// Base element styles +.resizable-element { + // Smooth transition when not actively resizing + &:not(.is-resizing) { + transition: width 0.15s ease-out; + } + + &.is-resizing { + transition: none; + } +} + +// Resize handle base styles +.resize-handle { + position: absolute; + top: 0; + bottom: 0; + width: 12px; + z-index: 100; + cursor: col-resize; + display: flex; + align-items: center; + justify-content: center; + + // Visual feedback on hover/active + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 4px; + background: transparent; + transition: background-color 0.2s ease; + border-radius: 2px; + } + + &:hover::before, + &:active::before { + background: rgba(255, 255, 255, 0.08); + } + + &:active::before { + background: rgba(255, 255, 255, 0.12); + } + + // Position variants + &--right { + right: -6px; + + &::before { + left: 50%; + transform: translateX(-50%); + } + } + + &--left { + left: -6px; + + &::before { + right: 50%; + transform: translateX(50%); + } + } + + // The main visible line + &__line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: rgba(255, 255, 255, 0.12); + transition: all 0.2s ease; + } + + &:hover &__line { + width: 2px; + background: rgba(255, 255, 255, 0.25); + box-shadow: 0 0 8px rgba(255, 255, 255, 0.1); + } + + &:active &__line { + width: 2px; + background: var(--mdc-theme-primary, #6366f1); + box-shadow: 0 0 12px rgba(99, 102, 241, 0.3); + } + + // Grip dots indicator + &__grip { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 1; + + span { + width: 4px; + height: 4px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.4); + transition: all 0.15s ease; + } + } + + &:hover &__grip { + opacity: 1; + + span { + background: rgba(255, 255, 255, 0.6); + } + } + + &:active &__grip { + opacity: 1; + + span { + background: var(--mdc-theme-primary, #6366f1); + transform: scale(1.2); + } + } +} + +// Light theme adjustments (if needed) +.light-theme { + .resize-handle { + &::before { + background: transparent; + } + + &:hover::before, + &:active::before { + background: rgba(0, 0, 0, 0.04); + } + + &:active::before { + background: rgba(0, 0, 0, 0.08); + } + + &__line { + background: rgba(0, 0, 0, 0.12); + } + + &:hover &__line { + background: rgba(0, 0, 0, 0.25); + box-shadow: 0 0 8px rgba(0, 0, 0, 0.05); + } + + &:active &__line { + background: var(--mdc-theme-primary, #6366f1); + box-shadow: 0 0 12px rgba(99, 102, 241, 0.2); + } + + &__grip { + span { + background: rgba(0, 0, 0, 0.3); + } + } + + &:hover &__grip { + span { + background: rgba(0, 0, 0, 0.5); + } + } + } +} From 522e18e13d2588c6292d6b781852166956ccb29f Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 30 Jan 2026 20:38:26 +0100 Subject: [PATCH 02/14] refactor: add Stalker VOD and serial details interfaces with normalization functions --- libs/shared/interfaces/src/index.ts | 8 + .../lib/stalker-serial-details.interface.ts | 28 ++++ .../src/lib/stalker-vod-details.interface.ts | 33 ++++ .../src/lib/vod-details-adapters.ts | 158 ++++++++++++++++++ .../src/lib/vod-details-item.interface.ts | 96 +++++++++++ 5 files changed, 323 insertions(+) create mode 100644 libs/shared/interfaces/src/lib/stalker-serial-details.interface.ts create mode 100644 libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts create mode 100644 libs/shared/interfaces/src/lib/vod-details-adapters.ts create mode 100644 libs/shared/interfaces/src/lib/vod-details-item.interface.ts diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 4b1c63f02..6a9dc7c19 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -27,3 +27,11 @@ export * from './lib/xtream-serie-details.interface'; export * from './lib/xtream-serie-item.interface'; export * from './lib/xtream-vod-details.interface'; export * from './lib/xtream-vod-stream.interface'; + +// Stalker interfaces +export * from './lib/stalker-vod-details.interface'; +export * from './lib/stalker-serial-details.interface'; + +// Unified VOD details (discriminated union + adapters) +export * from './lib/vod-details-item.interface'; +export * from './lib/vod-details-adapters'; diff --git a/libs/shared/interfaces/src/lib/stalker-serial-details.interface.ts b/libs/shared/interfaces/src/lib/stalker-serial-details.interface.ts new file mode 100644 index 000000000..76ed1300c --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-serial-details.interface.ts @@ -0,0 +1,28 @@ +/** + * Stalker portal series/serial details response structure. + * Contains series metadata from Stalker/Ministra portals. + */ +export interface StalkerSerialDetails { + /** Series title */ + name: string; + /** Plot description */ + description: string; + /** Director name */ + director: string; + /** Comma-separated list of actors */ + actors: string; + /** Release year */ + year: string; + /** Comma-separated genres */ + genres_str: string; + /** Age rating */ + age: string; + /** IMDB rating as string */ + rating_imdb: string; + /** Kinopoisk rating as string */ + rating_kinopoisk: string; + /** Screenshot/poster URL */ + screenshot_uri: string; + /** Date added to portal */ + added: string; +} diff --git a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts new file mode 100644 index 000000000..bb048d939 --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts @@ -0,0 +1,33 @@ +/** + * Stalker portal VOD details response structure. + * Contains movie metadata from Stalker/Ministra portals. + */ +export interface StalkerVodDetails { + /** Unique identifier for the VOD item */ + id: string; + /** Command string used to play the stream (e.g., "/media/file_123.mpg") */ + cmd: string; + /** VOD metadata */ + info: StalkerVodInfo; +} + +export interface StalkerVodInfo { + /** Poster/cover image URL */ + movie_image: string; + /** Plot description */ + description: string; + /** Movie title */ + name: string; + /** Comma-separated list of actors */ + actors: string; + /** Director name */ + director: string; + /** Release date (format varies) */ + releasedate: string; + /** Genre(s) */ + genre: string; + /** IMDB rating as string */ + rating_imdb: string; + /** Kinopoisk rating as string */ + rating_kinopoisk: string; +} diff --git a/libs/shared/interfaces/src/lib/vod-details-adapters.ts b/libs/shared/interfaces/src/lib/vod-details-adapters.ts new file mode 100644 index 000000000..8a0a9f2d2 --- /dev/null +++ b/libs/shared/interfaces/src/lib/vod-details-adapters.ts @@ -0,0 +1,158 @@ +import { XtreamVodDetails } from './xtream-vod-details.interface'; +import { StalkerVodDetails } from './stalker-vod-details.interface'; +import { + NormalizedVodMeta, + VodDetailsItem, + XtreamVodItem, + StalkerVodItem, +} from './vod-details-item.interface'; + +/** + * Normalizes Xtream VOD details into a display-friendly format. + * Handles the various field name variations in Xtream API responses. + */ +export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta { + const info = item?.info; + const movieData = item?.movie_data; + + return { + title: info?.name || movieData?.name || 'Unknown', + description: info?.description || info?.plot, + posterUrl: info?.movie_image || info?.cover_big, + backdropUrl: info?.backdrop_path?.[0], + year: extractYear(info?.releasedate), + genre: info?.genre, + duration: info?.duration || formatDuration(info?.duration_secs), + country: info?.country, + director: info?.director, + actors: info?.actors || info?.cast, + ratingImdb: info?.rating_imdb, + ratingKinopoisk: info?.rating_kinopoisk, + youtubeTrailer: info?.youtube_trailer, + }; +} + +/** + * Normalizes Stalker VOD details into a display-friendly format. + * Stalker has fewer metadata fields than Xtream. + */ +export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta { + const info = item?.info; + + return { + title: info?.name || 'Unknown', + description: info?.description, + posterUrl: info?.movie_image, + backdropUrl: undefined, // Stalker doesn't provide backdrop + year: extractYear(info?.releasedate), + genre: info?.genre, + duration: undefined, // Stalker doesn't provide duration + country: undefined, // Stalker doesn't provide country + director: info?.director, + actors: info?.actors, + ratingImdb: info?.rating_imdb, + ratingKinopoisk: info?.rating_kinopoisk, + youtubeTrailer: undefined, // Stalker doesn't provide trailers + }; +} + +/** + * Normalizes any VodDetailsItem to display-friendly format. + * Uses discriminated union for type-safe handling. + */ +export function normalizeVodDetails(item: VodDetailsItem): NormalizedVodMeta { + if (item.type === 'xtream') { + return normalizeXtreamVod(item.data); + } + return normalizeStalkerVod(item.data); +} + +/** + * Creates an XtreamVodItem from raw data and context. + */ +export function createXtreamVodItem( + data: XtreamVodDetails, + playlistId: string, + vodId?: number +): XtreamVodItem { + return { + type: 'xtream', + data, + playlistId, + vodId: vodId ?? data.movie_data?.stream_id ?? 0, + }; +} + +/** + * Creates a StalkerVodItem from raw data and context. + */ +export function createStalkerVodItem( + data: StalkerVodDetails, + playlistId: string +): StalkerVodItem { + return { + type: 'stalker', + data, + playlistId, + cmd: data.cmd, + }; +} + +/** + * Gets the unique identifier for a VOD item. + * Returns vodId for Xtream, id for Stalker. + */ +export function getVodId(item: VodDetailsItem): number | string { + if (item.type === 'xtream') { + return item.vodId; + } + return item.data.id; +} + +/** + * Gets the numeric ID for download/favorite tracking. + */ +export function getVodNumericId(item: VodDetailsItem): number { + if (item.type === 'xtream') { + return item.vodId; + } + return Number(item.data.id) || 0; +} + +// ============ Helper Functions ============ + +/** + * Extracts 4-digit year from various date formats. + */ +function extractYear(dateString?: string): string | undefined { + if (!dateString) return undefined; + + // Try to extract 4-digit year from beginning + const yearMatch = dateString.match(/^(\d{4})/); + if (yearMatch) { + return yearMatch[1]; + } + + // Try to parse as date and extract year + const date = new Date(dateString); + if (!isNaN(date.getTime())) { + return date.getFullYear().toString(); + } + + return dateString.slice(0, 4); +} + +/** + * Formats duration from seconds to human-readable string. + */ +function formatDuration(seconds?: number): string | undefined { + if (!seconds || seconds <= 0) return undefined; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} diff --git a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts new file mode 100644 index 000000000..3e6b88373 --- /dev/null +++ b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts @@ -0,0 +1,96 @@ +import { XtreamVodDetails } from './xtream-vod-details.interface'; +import { StalkerVodDetails } from './stalker-vod-details.interface'; + +/** + * Discriminated union for VOD details across portal types. + * Enables type-safe handling without runtime type checks. + * + * @example + * ```typescript + * function handleVod(item: VodDetailsItem) { + * if (item.type === 'xtream') { + * // TypeScript knows item.data is XtreamVodDetails + * console.log(item.data.movie_data.stream_id); + * } else { + * // TypeScript knows item.data is StalkerVodDetails + * console.log(item.data.cmd); + * } + * } + * ``` + */ +export type VodDetailsItem = XtreamVodItem | StalkerVodItem; + +/** + * Xtream Codes VOD item wrapper with context. + */ +export interface XtreamVodItem { + /** Discriminator for Xtream Codes portals */ + readonly type: 'xtream'; + /** Full VOD details from Xtream API */ + data: XtreamVodDetails; + /** Playlist/portal ID for favorites and downloads */ + playlistId: string; + /** Stream ID from Xtream API */ + vodId: number; +} + +/** + * Stalker portal VOD item wrapper with context. + */ +export interface StalkerVodItem { + /** Discriminator for Stalker portals */ + readonly type: 'stalker'; + /** Full VOD details from Stalker API */ + data: StalkerVodDetails; + /** Playlist/portal ID for favorites and downloads */ + playlistId: string; + /** Command string for stream playback */ + cmd: string; +} + +/** + * Normalized metadata for display in templates. + * Provides a consistent interface regardless of portal type. + */ +export interface NormalizedVodMeta { + /** Movie title */ + title: string; + /** Plot description */ + description?: string; + /** Poster/cover image URL */ + posterUrl?: string; + /** Backdrop image URL (Xtream only) */ + backdropUrl?: string; + /** Release year (4 digits) */ + year?: string; + /** Genre(s) */ + genre?: string; + /** Duration string (e.g., "1h 30m") */ + duration?: string; + /** Country of origin (Xtream only) */ + country?: string; + /** Director name */ + director?: string; + /** Actors/cast (comma-separated) */ + actors?: string; + /** IMDB rating */ + ratingImdb?: string; + /** Kinopoisk rating */ + ratingKinopoisk?: string; + /** YouTube trailer ID (Xtream only) */ + youtubeTrailer?: string; +} + +/** + * Type guard to check if item is Xtream VOD. + */ +export function isXtreamVod(item: VodDetailsItem): item is XtreamVodItem { + return item.type === 'xtream'; +} + +/** + * Type guard to check if item is Stalker VOD. + */ +export function isStalkerVod(item: VodDetailsItem): item is StalkerVodItem { + return item.type === 'stalker'; +} From e4be046194031fcb7a1d8e991e91d418115d045d Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 30 Jan 2026 20:38:41 +0100 Subject: [PATCH 03/14] refactor: add SafePipe for sanitizing resource URLs --- libs/ui/pipes/src/index.ts | 1 + .../vod-details => libs/ui/pipes/src/lib}/safe.pipe.ts | 0 2 files changed, 1 insertion(+) rename {apps/web/src/app/xtream-tauri/vod-details => libs/ui/pipes/src/lib}/safe.pipe.ts (100%) diff --git a/libs/ui/pipes/src/index.ts b/libs/ui/pipes/src/index.ts index 17f0f3298..6bcb0bba8 100644 --- a/libs/ui/pipes/src/index.ts +++ b/libs/ui/pipes/src/index.ts @@ -1,3 +1,4 @@ export * from './lib/filter.pipe'; export * from './lib/moment-date.pipe'; +export * from './lib/safe.pipe'; export * from './lib/sort.pipe'; diff --git a/apps/web/src/app/xtream-tauri/vod-details/safe.pipe.ts b/libs/ui/pipes/src/lib/safe.pipe.ts similarity index 100% rename from apps/web/src/app/xtream-tauri/vod-details/safe.pipe.ts rename to libs/ui/pipes/src/lib/safe.pipe.ts From f95ac9c34ec9e39e422c95b2ee277de51b61d7c9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 30 Jan 2026 20:39:18 +0100 Subject: [PATCH 04/14] refactor: refactor existing vod-details component and implement VOD details route component - Added `VodDetailsRouteComponent` to handle VOD details with route parameters. - Refactored existing `VodDetailsComponent` to use a unified structure for Xtream and Stalker portals. - Enhanced VOD details display with normalized metadata and improved playback position handling. - Integrated resizable sidebar functionality in main container and live stream layout components. - Updated routing to use the new VOD details route component. - Improved favorite toggling and download handling in VOD details. - Adjusted styles for responsive sidebar and grid layouts. --- .../database/playback-position.events.ts | 12 + .../video-player/video-player.component.html | 16 +- .../video-player/video-player.component.scss | 15 +- .../video-player/video-player.component.ts | 2 + .../favorites-layout.component.html | 9 +- .../favorites-layout.component.ts | 3 +- .../stalker-serial-details.interface.ts | 18 +- .../models/stalker-vod-details.interface.ts | 23 +- .../recently-viewed.component.html | 43 +- .../recently-viewed.component.ts | 96 ++++- .../stalker-favorites.component.html | 36 +- .../stalker-favorites.component.ts | 66 ++- .../stalker-main-container.component.html | 9 +- .../stalker-main-container.component.ts | 6 +- .../stalker-search.component.html | 21 +- .../stalker-search.component.ts | 46 ++- .../stalker-series-view.component.ts | 149 +++++-- apps/web/src/app/stalker/stalker.store.ts | 26 +- .../category-content-view.component.html | 22 +- .../category-content-view.component.ts | 55 +++ .../downloads/downloads.component.scss | 6 +- .../live-stream-layout.component.html | 11 +- .../live-stream-layout.component.scss | 24 +- .../live-stream-layout.component.ts | 20 +- .../portal-channels-list.component.html | 35 +- .../portal-channels-list.component.scss | 313 ++++++++------ .../portal-channels-list.component.ts | 10 +- .../season-container.component.html | 287 +++++++++---- .../season-container.component.scss | 259 ++++++++++++ .../season-container.component.ts | 130 ++++-- apps/web/src/app/xtream-tauri/sidebar.scss | 5 + ....html => vod-details-route.component.html} | 0 ...nent.ts => vod-details-route.component.ts} | 16 +- .../xtream-main-container.component.html | 9 +- .../xtream-main-container.component.ts | 3 +- .../xtream-shell/xtream-shell.component.ts | 33 +- .../web/src/app/xtream-tauri/xtream.routes.ts | 6 +- .../vod-details/vod-details.component.html | 92 +++-- .../vod-details/vod-details.component.ts | 387 +++++++----------- .../xtream-main-container.component.html | 7 +- .../xtream/xtream-main-container.component.ts | 36 ++ apps/web/src/styles.scss | 1 + .../live-stream-layout.component.html | 9 +- .../live-stream-layout.component.scss | 21 +- .../live-stream-layout.component.ts | 2 + 45 files changed, 1670 insertions(+), 725 deletions(-) rename apps/web/src/app/xtream-tauri/vod-details/{vod-details.component.html => vod-details-route.component.html} (100%) rename apps/web/src/app/xtream-tauri/vod-details/{vod-details.component.ts => vod-details-route.component.ts} (93%) diff --git a/apps/electron-backend/src/app/events/database/playback-position.events.ts b/apps/electron-backend/src/app/events/database/playback-position.events.ts index 423458de0..8e85e2d92 100644 --- a/apps/electron-backend/src/app/events/database/playback-position.events.ts +++ b/apps/electron-backend/src/app/events/database/playback-position.events.ts @@ -117,6 +117,9 @@ ipcMain.handle( 'DB_GET_SERIES_PLAYBACK_POSITIONS', async (event, playlistId: string, seriesXtreamId: number) => { try { + console.log( + `[DatabaseEvents] Getting series playback positions: playlistId=${playlistId}, seriesXtreamId=${seriesXtreamId}` + ); const db = await getDatabase(); const result = await db .select() @@ -132,6 +135,15 @@ ipcMain.handle( ) ); + console.log( + `[DatabaseEvents] Found ${result.length} positions for series ${seriesXtreamId}:`, + result.map((r) => ({ + contentXtreamId: r.contentXtreamId, + seriesXtreamId: r.seriesXtreamId, + position: r.positionSeconds, + })) + ); + return result; } catch (error) { console.error('Error getting series playback positions:', error); diff --git a/apps/web/src/app/home/video-player/video-player.component.html b/apps/web/src/app/home/video-player/video-player.component.html index 5926a7050..66f90c69e 100644 --- a/apps/web/src/app/home/video-player/video-player.component.html +++ b/apps/web/src/app/home/video-player/video-player.component.html @@ -1,6 +1,18 @@ - + - + @defer { } @loading { diff --git a/apps/web/src/app/home/video-player/video-player.component.scss b/apps/web/src/app/home/video-player/video-player.component.scss index c876706e4..4d7ad9be5 100644 --- a/apps/web/src/app/home/video-player/video-player.component.scss +++ b/apps/web/src/app/home/video-player/video-player.component.scss @@ -4,6 +4,7 @@ bottom: 0; left: 0; right: 0; + overflow: hidden; // Prevent horizontal scrollbar from resize handle } .mat-drawer-content { @@ -13,8 +14,18 @@ } .mat-drawer { - width: 400px; - min-width: 400px; + // Width is controlled by appResizable directive + // Default: 350px, min: 200px, max: 600px + overflow-x: hidden !important; // Hide horizontal scrollbar + + .resize-handle--right { + right: 0; + } +} + +// Ensure mat-drawer inner container fills the drawer width +::ng-deep .mat-drawer-inner-container { + width: 100% !important; } .custom-player { diff --git a/apps/web/src/app/home/video-player/video-player.component.ts b/apps/web/src/app/home/video-player/video-player.component.ts index da9cc285e..cc8f40a3c 100644 --- a/apps/web/src/app/home/video-player/video-player.component.ts +++ b/apps/web/src/app/home/video-player/video-player.component.ts @@ -23,6 +23,7 @@ import { HtmlVideoPlayerComponent, InfoOverlayComponent, MultiEpgContainerComponent, + ResizableDirective, SidebarComponent, ToolbarComponent, VjsPlayerComponent, @@ -64,6 +65,7 @@ import { SettingsComponent } from '../../settings/settings.component'; HtmlVideoPlayerComponent, InfoOverlayComponent, MatSidenavModule, + ResizableDirective, RouterLink, SidebarComponent, ToolbarComponent, diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html index 53630e466..fab280587 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html @@ -1,4 +1,11 @@ -