diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 87e0542d1..2443eceb9 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -344,4 +344,22 @@ public class ImageController : BaseApiController return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); } + + /// + /// Returns cover image for a random Series + /// + /// + [HttpGet("random-series-cover")] + public async Task GetRandomSeriesCoverImage() + { + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.SeriesRepository.GetRandomSeriesCoverImageAsync()); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest("No cover image found"); + var format = _directoryService.FileSystem.Path.GetExtension(path); + + Response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate"); + Response.Headers.Add("Pragma", "no-cache"); + Response.Headers.Add("Expires", "0"); + + return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); + } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 31ddc22f1..f739141f5 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -116,6 +116,7 @@ public interface ISeriesRepository /// Task AddSeriesModifiers(int userId, IList series); Task GetSeriesCoverImageAsync(int seriesId); + Task GetRandomSeriesCoverImageAsync(); Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter); Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); Task> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter); @@ -183,6 +184,8 @@ public class SeriesRepository : ISeriesRepository private readonly Regex _yearRegex = new Regex(@"\d{4}", RegexOptions.Compiled, Services.Tasks.Scanner.Parser.Parser.RegexTimeout); + private static readonly Random _random = new Random(); + public SeriesRepository(DataContext context, IMapper mapper, UserManager userManager) { _context = context; @@ -780,6 +783,19 @@ public class SeriesRepository : ISeriesRepository .SingleOrDefaultAsync(); } + public async Task GetRandomSeriesCoverImageAsync() + { + var count = await _context.Series.CountAsync(); + if (count == 0) return null; + + var skip = _random.Next(0, count); + + return await _context.Series + .Skip(skip) + .Select(s => s.CoverImage) + .FirstOrDefaultAsync(); + } + /// /// Returns a list of Series that were added, ordered by Created desc diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 86aa8872a..f8124cb9c 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -109,6 +109,13 @@ export class ImageService { return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`; } + getRandomSeriesCoverImage() { + const timestamp = new Date().getTime(); + const random = Math.random().toString(36).substring(7); + return `${this.baseUrl}image/random-series-cover?t=${timestamp}&r=${random}`; + } + + updateErroredWebLinkImage(event: any) { event.target.src = this.errorWebLinkImage; } diff --git a/UI/Web/src/app/registration/_components/cover-masonry/cover-masonry.component.html b/UI/Web/src/app/registration/_components/cover-masonry/cover-masonry.component.html new file mode 100644 index 000000000..80e85cccb --- /dev/null +++ b/UI/Web/src/app/registration/_components/cover-masonry/cover-masonry.component.html @@ -0,0 +1,12 @@ +
+
+
+ Series cover +
+
+
+
+ Series cover +
+
+
\ No newline at end of file diff --git a/UI/Web/src/app/registration/_components/cover-masonry/cover-masonry.component.scss b/UI/Web/src/app/registration/_components/cover-masonry/cover-masonry.component.scss new file mode 100644 index 000000000..310910ca5 --- /dev/null +++ b/UI/Web/src/app/registration/_components/cover-masonry/cover-masonry.component.scss @@ -0,0 +1,108 @@ +.masonry-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100vh; + overflow: hidden; + z-index: 0; + background-color: var(--login-background-color); + + &.no-images { + display: none; + } +} + +.masonry-grid { + display: grid; + grid-template-columns: repeat(auto-fit, 250px); + grid-auto-rows: minmax(0, 370px); + grid-gap: 10px; + padding: 2px; + width: 100%; + animation: scrollMasonry 90s linear infinite; + animation-play-state: running; + will-change: transform; + backface-visibility: hidden; + transform: translateZ(0); + justify-content: space-evenly; + + &.duplicate { + position: absolute; + top: 0; + left: 0; + animation: scrollMasonryDuplicate 90s linear infinite; + } + + @media (max-width: 1600px) { + grid-template-columns: repeat(auto-fit, 250px); + } + + @media (max-width: 1200px) { + grid-template-columns: repeat(auto-fit, 250px); + } + + @media (max-width: 900px) { + grid-template-columns: repeat(auto-fit, 250px); + } + + @media (max-width: 600px) { + grid-template-columns: repeat(auto-fit, 250px); + } +} + +.masonry-item { + position: relative; + overflow: hidden; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease; + opacity: 0; + animation: fadeIn 0.5s ease forwards; + background-color: var(--elevation-layer11-dark); + height: auto; + aspect-ratio: 0.70; + margin-bottom: 10px; + + &:hover { + transform: scale(1.02); + animation-play-state: paused; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + mix-blend-mode: overlay; + } +} + +@keyframes scrollMasonry { + 0% { + transform: translate3d(0, 0, 0); + } + 100% { + transform: translate3d(0, -100%, 0); + } +} + +@keyframes scrollMasonryDuplicate { + 0% { + transform: translate3d(0, 100%, 0); + } + 100% { + transform: translate3d(0, 0, 0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/UI/Web/src/app/registration/_components/cover-masonry/cover-masonry.component.ts b/UI/Web/src/app/registration/_components/cover-masonry/cover-masonry.component.ts new file mode 100644 index 000000000..200965163 --- /dev/null +++ b/UI/Web/src/app/registration/_components/cover-masonry/cover-masonry.component.ts @@ -0,0 +1,89 @@ +import { ChangeDetectionStrategy, Component, OnInit, OnDestroy, HostListener, Output, EventEmitter } from '@angular/core'; +import { CommonModule, NgOptimizedImage } from '@angular/common'; +import { ImageService } from 'src/app/_services/image.service'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'app-cover-masonry', + templateUrl: './cover-masonry.component.html', + styleUrls: ['./cover-masonry.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, NgOptimizedImage] +}) +export class CoverMasonryComponent implements OnInit, OnDestroy { + @Output() hasValidImages = new EventEmitter(); + coverImages: string[] = []; + duplicateImages: string[] = []; + noImagesFound = false; + private readonly batchSize = 40; + private loading = false; + private scrollContainer: HTMLElement | null = null; + private loadedImages = 0; + + constructor(public imageService: ImageService) {} + + ngOnInit() { + this.loadRandomCovers(); + this.scrollContainer = document.querySelector('.masonry-container'); + } + + ngOnDestroy() { + this.scrollContainer = null; + } + + @HostListener('window:scroll', ['$event']) + onScroll() { + if (this.loading || !this.scrollContainer) return; + + const container = this.scrollContainer; + const scrollPosition = window.scrollY + window.innerHeight; + const containerBottom = container.offsetTop + container.offsetHeight; + + // Load more when we're within 200px of the bottom + if (scrollPosition > containerBottom - 200) { + this.loadRandomCovers(); + } + } + + private loadRandomCovers() { + if (this.loading) return; + + this.loading = true; + this.loadedImages = 0; + const newImages: string[] = []; + + for (let i = 0; i < this.batchSize; i++) { + const url = this.imageService.getRandomSeriesCoverImage(); + if (url) { + // Add a random query parameter to prevent caching + const uniqueUrl = `${url}?t=${Date.now()}`; + newImages.push(uniqueUrl); + } + } + + // Check if images load successfully + newImages.forEach(url => { + const img = new Image(); + img.onload = () => { + this.loadedImages++; + if (this.loadedImages === newImages.length) { + this.coverImages = [...this.coverImages, ...newImages]; + this.duplicateImages = [...this.coverImages]; + this.noImagesFound = this.coverImages.length === 0; + this.hasValidImages.emit(true); + } + }; + img.onerror = () => { + this.loadedImages++; + if (this.loadedImages === newImages.length) { + this.noImagesFound = this.coverImages.length === 0; + this.hasValidImages.emit(this.coverImages.length > 0); + } + }; + img.src = url; + }); + + this.loading = false; + } +} \ No newline at end of file diff --git a/UI/Web/src/app/registration/_components/splash-container/splash-container.component.html b/UI/Web/src/app/registration/_components/splash-container/splash-container.component.html index 566616a91..2cb0d59dc 100644 --- a/UI/Web/src/app/registration/_components/splash-container/splash-container.component.html +++ b/UI/Web/src/app/registration/_components/splash-container/splash-container.component.html @@ -1,5 +1,8 @@ -