From a56e3594b402a962d011a53134d65bd43158fd8a Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jan 2025 08:55:21 +0100 Subject: [PATCH] feat(recently-viewed): enhance UI with playlist grouping and global search support --- .../recently-viewed.component.html | 152 ++++++++++++----- .../recently-viewed.component.scss | 55 ++++++ .../recently-viewed.component.ts | 79 +++++++-- .../search-results.component.html | 161 ++++++++++++++---- .../search-results.component.scss | 152 ++++++++++++++++- .../search-results.component.ts | 123 ++++++++++--- 6 files changed, 616 insertions(+), 106 deletions(-) diff --git a/src/app/xtream-tauri/recently-viewed/recently-viewed.component.html b/src/app/xtream-tauri/recently-viewed/recently-viewed.component.html index f5ccc4e63..4e41bf804 100644 --- a/src/app/xtream-tauri/recently-viewed/recently-viewed.component.html +++ b/src/app/xtream-tauri/recently-viewed/recently-viewed.component.html @@ -2,49 +2,119 @@

Recently Viewed

- @if (items.length) { - - } -
- - @if (items.length) { -
- @for (item of items; track $index) { - - - -
-

{{ item.title }}

-

- {{ item.type | titlecase }} -

-

- {{ item.viewed_at | date: 'd MMMM, y, hh:mm' }} -

-
-
+
+ @if (recentItems()?.length) { + + } + @if (isGlobal) { + }
+
+ + @if (recentItems()?.length) { + @if (isGlobal) { +
+

+ Found {{ recentItems().length }} items across + {{ (getGroupedItems() | keyvalue).length }} playlists +

+ @for (group of getGroupedItems() | keyvalue; track group.key) { +
+

+ playlist_play + {{ group.key }} + ({{ $any(group).value.length }} items) +

+
+ @for (item of $any(group).value; track item.id) { + + + +
+

{{ item.title }}

+

+ {{ item.type | titlecase }} +

+

+ {{ + item.viewed_at + | date: 'd MMMM, y, HH:mm' + }} +

+
+
+ } +
+
+ } +
+ } @else { +
+ @for (item of recentItems(); track item.id) { + + + +
+

{{ item.title }}

+

+ {{ item.type | titlecase }} +

+

+ {{ item.viewed_at | date: 'd MMMM, y, hh:mm' }} +

+
+
+ } +
+ } } @else {
history diff --git a/src/app/xtream-tauri/recently-viewed/recently-viewed.component.scss b/src/app/xtream-tauri/recently-viewed/recently-viewed.component.scss index 9d247ff1e..20eac9cf4 100644 --- a/src/app/xtream-tauri/recently-viewed/recently-viewed.component.scss +++ b/src/app/xtream-tauri/recently-viewed/recently-viewed.component.scss @@ -15,6 +15,12 @@ justify-content: space-between; align-items: center; margin-bottom: 20px; + + .header-actions { + display: flex; + align-items: center; + gap: 8px; + } } .items-grid { @@ -105,4 +111,53 @@ background: #43a047; color: white; } +} + +.playlist-section { + .section-title { + margin: 0 0 1.5rem; + font-size: 1.2rem; + font-weight: normal; + color: var(--text-color-secondary); + padding: 0 8px; + } +} + +.playlist-group { + margin-bottom: 2rem; + + .playlist-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 1rem 0; + padding: 8px; + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + font-size: 1.1rem; + font-weight: 500; + + mat-icon { + opacity: 0.7; + } + + .item-count { + margin-left: auto; + font-size: 0.9rem; + opacity: 0.7; + } + } +} + +:host-context(.global-search-overlay) { + .recent-container { + background: var(--background); + height: 100vh; + } + + .playlist-group { + .playlist-title { + background: var(--surface); + } + } } \ No newline at end of file diff --git a/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts b/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts index 1d4ceb61a..362399418 100644 --- a/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts +++ b/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts @@ -1,15 +1,24 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; +import { CommonModule, KeyValuePipe } from '@angular/common'; +import { Component, computed, inject, Optional } from '@angular/core'; +import { MatButtonModule, MatIconButton } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { ActivatedRoute, Router } from '@angular/router'; +import groupBy from 'lodash/groupBy'; import { XtreamStore } from '../xtream.store'; @Component({ selector: 'app-recently-viewed', standalone: true, - imports: [CommonModule, MatCardModule, MatButtonModule, MatIconModule], + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + KeyValuePipe, + MatIconButton, + ], templateUrl: './recently-viewed.component.html', styleUrl: './recently-viewed.component.scss', }) @@ -17,32 +26,78 @@ export class RecentlyViewedComponent { private xtreamStore = inject(XtreamStore); private activatedRoute = inject(ActivatedRoute); private router = inject(Router); + private dialogData = inject(MAT_DIALOG_DATA, { optional: true }); - readonly recentItems = this.xtreamStore.recentItems; + readonly isGlobal = this.dialogData?.isGlobal ?? false; + readonly recentItems = computed(() => + this.isGlobal + ? this.xtreamStore.globalRecentItems() + : this.xtreamStore.recentItems() + ); readonly currentPlaylist = this.xtreamStore.currentPlaylist; - constructor() { - this.xtreamStore.loadRecentItems(this.currentPlaylist); + constructor( + @Optional() public dialogRef?: MatDialogRef + ) { + if (this.isGlobal) { + this.loadGlobalItems(); + } else if (this.currentPlaylist()) { + this.xtreamStore.loadRecentItems(this.currentPlaylist); + } + } + + private async loadGlobalItems() { + try { + await this.xtreamStore.loadGlobalRecentItems(); + } catch (error) { + console.error('Error loading global items:', error); + } } clearHistory() { - this.xtreamStore.clearRecentItems(this.xtreamStore.currentPlaylist); + if (this.isGlobal) { + this.xtreamStore.clearGlobalRecentlyViewed(); + } else { + this.xtreamStore.clearRecentItems(this.xtreamStore.currentPlaylist); + } } openItem(item: any) { const type = item.type === 'movie' ? 'vod' : item.type; this.xtreamStore.setSelectedContentType(type); - this.router.navigate(['..', type, item.category_id, item.xtream_id], { - relativeTo: this.activatedRoute, - }); + if (this.isGlobal) { + this.dialogRef?.close(); + + this.router.navigate([ + '/xtreams', + item.playlist_id, + type, + item.category_id, + item.xtream_id, + ]); + } else { + this.router.navigate( + ['..', type, item.category_id, item.xtream_id], + { + relativeTo: this.activatedRoute, + } + ); + } } removeItem(event: Event, itemId: number) { event.stopPropagation(); this.xtreamStore.removeRecentItem({ itemId, - playlistId: this.currentPlaylist().id + playlistId: this.currentPlaylist().id, }); } + + getGroupedItems() { + const items = this.recentItems(); + if (!this.isGlobal) return { default: items }; + const grouped = groupBy(items, 'playlist_name'); + return grouped; + } } diff --git a/src/app/xtream-tauri/search-results/search-results.component.html b/src/app/xtream-tauri/search-results/search-results.component.html index 690a55124..43bc34153 100644 --- a/src/app/xtream-tauri/search-results/search-results.component.html +++ b/src/app/xtream-tauri/search-results/search-results.component.html @@ -1,6 +1,11 @@

Search

+ @if (isGlobalSearch) { + + }
@@ -37,7 +42,7 @@
- @if (xtreamStore.searchResults().length > 0) { + @if (!isGlobalSearch && xtreamStore.searchResults().length > 0) {
Found {{ xtreamStore.searchResults().length }} results
@@ -46,36 +51,132 @@
@if (xtreamStore.searchResults().length > 0) { -
- @for (item of xtreamStore.searchResults(); track item.id) { - - - @if (item.poster_url) { - - } - @if (!item.poster_url) { -
- movie -
- } -

{{ item.title }}

-

{{ item.description }}

-
- {{ item.type }} + @if (isGlobalSearch) { + +
+

+ Found {{ xtreamStore.searchResults().length }} items + across + {{ (getGroupedResults() | keyvalue).length }} playlists +

+ @for ( + group of getGroupedResults() | keyvalue; + track group.key + ) { +
+

+ playlist_play + {{ group.key }} + ({{ + $any(group).value.length + }} + items) +

+
+ @for ( + item of $any(group).value; + track item.id + ) { + + + @if (item.poster_url) { + + } + @if (!item.poster_url) { +
+ movie +
+ } +

{{ item.title }}

+

{{ item.description }}

+
+ {{ item.type }} +
+ @if ( + item.playlist_id && + isGlobalSearch + ) { +
+ {{ item.playlist_name }} +
+ } + @if ( + isGlobalSearch && + item.playlist_name + ) { +
+ playlist_play + {{ + item.playlist_name + }} +
+ } +
+
+ }
- - - } -
- } @else if (searchTerm) { +
+ } +
+ } @else { + +
+ @for (item of xtreamStore.searchResults(); track item.id) { + + + @if (item.poster_url) { + + } + @if (!item.poster_url) { +
+ movie +
+ } +

{{ item.title }}

+

{{ item.description }}

+
+ {{ item.type }} +
+ @if (item.playlist_id && isGlobalSearch) { +
+ {{ item.playlist_name }} +
+ } + @if (isGlobalSearch && item.playlist_name) { +
+ playlist_play + {{ item.playlist_name }} +
+ } +
+
+ } +
+ } + } @else if (searchTerm && searchTerm.length >= 3) {
search_off

No results found for "{{ searchTerm }}"

diff --git a/src/app/xtream-tauri/search-results/search-results.component.scss b/src/app/xtream-tauri/search-results/search-results.component.scss index cef58105f..13d6fc1b6 100644 --- a/src/app/xtream-tauri/search-results/search-results.component.scss +++ b/src/app/xtream-tauri/search-results/search-results.component.scss @@ -25,14 +25,12 @@ } .type-filters { - margin-top: 1rem; mat-checkbox { margin-right: 1rem; } } .results-count { - margin-top: 1rem; font-size: 0.9rem; } } @@ -40,7 +38,6 @@ .results-container { flex: 1; overflow-y: auto; - padding: 0 1rem; .results-grid { display: grid; @@ -125,4 +122,153 @@ font-size: 1.1rem; margin: 0; } +} + +:host-context(.global-search-overlay) { + .search-page { + background: var(--background); + height: 100vh; + } + + .results-grid { + mat-card { + position: relative; + + .poster, + .poster-placeholder { + margin-top: 24px; // Add space for the playlist badge + } + + .playlist-badge { + position: absolute; + top: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.7); + padding: 6px 8px; + font-size: 12px; + color: white; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-top-left-radius: inherit; + border-top-right-radius: inherit; + backdrop-filter: blur(5px); + display: flex; + align-items: center; + gap: 4px; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + flex-shrink: 0; + } + } + } + } +} + +.playlist-group { + margin-bottom: 2rem; + + .playlist-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 1rem 0; + padding: 8px; + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + font-size: 1.1rem; + font-weight: 500; + + mat-icon { + opacity: 0.7; + } + + .item-count { + margin-left: auto; + font-size: 0.9rem; + opacity: 0.7; + } + } + + .results-grid { + margin-bottom: 0; + } +} + +:host-context(.global-search-overlay) { + .search-page { + background: var(--background); + height: 100vh; + } + + .results-grid { + mat-card { + position: relative; + + .poster, + .poster-placeholder { + margin-top: 24px; + } + + .playlist-badge { + position: absolute; + top: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.7); + padding: 6px 8px; + font-size: 12px; + color: white; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-top-left-radius: inherit; + border-top-right-radius: inherit; + backdrop-filter: blur(5px); + display: flex; + align-items: center; + gap: 4px; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + flex-shrink: 0; + } + } + } + } + + .playlist-group { + .playlist-title { + background: var(--surface); + } + } + + .results-grid { + mat-card { + .playlist-badge { + display: none; + } + + .poster, + .poster-placeholder { + margin-top: 0; + } + } + } +} + +.playlist-section { + .section-title { + margin: 0 0 1.5rem; + font-size: 1.2rem; + font-weight: normal; + color: var(--text-color-secondary); + padding: 0 8px; + } } \ No newline at end of file diff --git a/src/app/xtream-tauri/search-results/search-results.component.ts b/src/app/xtream-tauri/search-results/search-results.component.ts index 4af0a22fa..af088f0cd 100644 --- a/src/app/xtream-tauri/search-results/search-results.component.ts +++ b/src/app/xtream-tauri/search-results/search-results.component.ts @@ -1,45 +1,83 @@ +import { KeyValuePipe } from '@angular/common'; import { AfterViewInit, Component, ElementRef, inject, + Inject, + OnDestroy, + Optional, ViewChild, } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { MatIconButton } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; +import { MatIcon } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { ActivatedRoute, Router } from '@angular/router'; +import groupBy from 'lodash/groupBy'; +import { debounceTime, Subject } from 'rxjs'; import { XtreamItem } from '../../../../shared/xtream-item.interface'; +import { DatabaseService } from '../../services/database.service'; import { XtreamStore } from '../xtream.store'; +interface SearchResultsData { + isGlobalSearch: boolean; +} + @Component({ selector: 'app-search-results', standalone: true, imports: [ + MatIconButton, MatCardModule, MatFormFieldModule, MatInputModule, - MatIconModule, + MatIcon, MatCheckboxModule, FormsModule, + MatDialogModule, + KeyValuePipe, ], + providers: [], templateUrl: './search-results.component.html', styleUrls: ['./search-results.component.scss'], }) -export class SearchResultsComponent implements AfterViewInit { +export class SearchResultsComponent implements AfterViewInit, OnDestroy { @ViewChild('searchInput') searchInput!: ElementRef; readonly xtreamStore = inject(XtreamStore); readonly router = inject(Router); readonly activatedRoute = inject(ActivatedRoute); + readonly databaseService = inject(DatabaseService); searchTerm = ''; filters = { live: true, movie: true, series: true, }; + isGlobalSearch = false; + private searchSubject = new Subject(); + private readonly DEBOUNCE_TIME = 300; + + constructor( + @Optional() @Inject(MAT_DIALOG_DATA) private data: SearchResultsData, + @Optional() public dialogRef: MatDialogRef + ) { + this.isGlobalSearch = data?.isGlobalSearch || false; + + this.searchSubject + .pipe(debounceTime(this.DEBOUNCE_TIME)) + .subscribe(() => { + this.executeSearch(); + }); + } ngAfterViewInit() { this.xtreamStore.setSelectedContentType(undefined); @@ -50,30 +88,75 @@ export class SearchResultsComponent implements AfterViewInit { onSearch() { if (this.searchTerm.length >= 3) { - const types = Object.entries(this.filters) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .filter(([_, enabled]) => enabled) - .map(([type]) => type); - this.xtreamStore.searchContent({ term: this.searchTerm, types }); + this.searchSubject.next(this.searchTerm); + } else { + this.xtreamStore.resetSearchResults(); } } - selectItem(item: XtreamItem) { - const type = item.type === 'movie' ? 'vod' : item.type; - this.xtreamStore.resetSearchResults(); - this.xtreamStore.setSelectedContentType(type); + private executeSearch() { + const types = Object.entries(this.filters) + .filter(([_, enabled]) => enabled) + .map(([type]) => type); - if (item.type === 'live') { - this.router.navigate(['..', type, item.category_id], { - relativeTo: this.activatedRoute, - }); + if (this.isGlobalSearch) { + this.searchGlobal(this.searchTerm, types); } else { + this.xtreamStore.searchContent({ + term: this.searchTerm, + types, + }); + } + } + + async searchGlobal(term: string, types: string[]) { + try { + const results = await this.databaseService.globalSearchContent( + term, + types + ); + if (results && Array.isArray(results)) { + this.xtreamStore.setGlobalSearchResults(results); + } + } catch (error) { + console.error('Error in global search:', error); + this.xtreamStore.resetSearchResults(); + } + } + + ngOnDestroy() { + this.searchSubject.complete(); + } + + selectItem( + item: XtreamItem & { playlist_id?: string; playlist_name?: string } + ) { + if (this.isGlobalSearch && item.playlist_id) { + this.dialogRef?.close(); + const type = item.type === 'movie' ? 'vod' : item.type; + this.router.navigate([ + '/xtreams', + item.playlist_id, + type, + item.category_id, + item.xtream_id, + ]); + } else { + const type = item.type === 'movie' ? 'vod' : item.type; + this.xtreamStore.resetSearchResults(); + this.xtreamStore.setSelectedContentType(type); + this.router.navigate( - ['..', type, item.category_id, item.xtream_id], - { - relativeTo: this.activatedRoute, - } + item.type === 'live' + ? ['..', type, item.category_id] + : ['..', type, item.category_id, item.xtream_id], + { relativeTo: this.activatedRoute } ); } } + getGroupedResults() { + const results = this.xtreamStore.searchResults(); + if (!this.isGlobalSearch) return { default: results }; + return groupBy(results, 'playlist_name'); + } }