Compare commits
1 commit
develop
...
feature/ne
Author | SHA1 | Date | |
---|---|---|---|
![]() |
28c968ac4d |
10 changed files with 322 additions and 15 deletions
|
@ -344,4 +344,22 @@ public class ImageController : BaseApiController
|
|||
|
||||
return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cover image for a random Series
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("random-series-cover")]
|
||||
public async Task<ActionResult> 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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -116,6 +116,7 @@ public interface ISeriesRepository
|
|||
/// <returns></returns>
|
||||
Task AddSeriesModifiers(int userId, IList<SeriesDto> series);
|
||||
Task<string?> GetSeriesCoverImageAsync(int seriesId);
|
||||
Task<string?> GetRandomSeriesCoverImageAsync();
|
||||
Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
|
||||
Task<PagedList<SeriesDto>> 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<AppUser> userManager)
|
||||
{
|
||||
_context = context;
|
||||
|
@ -780,6 +783,19 @@ public class SeriesRepository : ISeriesRepository
|
|||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> 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();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of Series that were added, ordered by Created desc
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
<div class="masonry-container" [class.no-images]="noImagesFound">
|
||||
<div class="masonry-grid">
|
||||
<div *ngFor="let image of coverImages" class="masonry-item">
|
||||
<img [ngSrc]="image" width="250" height="357" alt="Series cover" priority (error)="imageService.updateErroredWebLinkImage($event)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="masonry-grid duplicate">
|
||||
<div *ngFor="let image of duplicateImages" class="masonry-item">
|
||||
<img [ngSrc]="image" width="250" height="357" alt="Series cover" priority (error)="imageService.updateErroredWebLinkImage($event)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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<boolean>();
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,8 @@
|
|||
<div class="mx-auto container login text-center"
|
||||
[ngStyle]="{'height': (navService.navbarVisible$ | async) ? 'calc(var(--vh, 1vh) * 100 - var(--nav-offset))' : 'calc(var(--vh, 1vh) * 100)'}">
|
||||
<div class="mx-auto login text-center"
|
||||
[ngStyle]="{'height': (navService.navbarVisible$ | async) ? 'calc(var(--vh, 1vh) * 100 - var(--nav-offset))' : 'calc(var(--vh, 1vh) * 100)'}"
|
||||
[class.no-images]="hasValidCoverImages">
|
||||
|
||||
<app-cover-masonry (hasValidImages)="onCoverImagesValid($event)"></app-cover-masonry>
|
||||
|
||||
<div class="row align-items-center row-cols-1 logo-container mb-3 justify-content-center">
|
||||
<div class="col col-md-4 col-sm-12 col-xs-12 align-self-center p-0">
|
||||
|
|
|
@ -3,29 +3,52 @@
|
|||
}
|
||||
|
||||
.login {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
height: calc(var(--vh, 1vh) * 100 - 57px);
|
||||
min-height: 289px;
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
background: var(--login-background-color);
|
||||
overflow: hidden;
|
||||
background-color: var(--bs-body-bg);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&::before {
|
||||
&.no-images::before {
|
||||
content: "";
|
||||
background-image: var(--login-background-url);
|
||||
background-size: var(--login-background-size);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: var(--login-background-opacity);
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.no-cover-images {
|
||||
background-color: var(--login-background-color);
|
||||
|
||||
&::before {
|
||||
opacity: var(--login-background-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: "Spartan", sans-serif;
|
||||
font-size: 1.5rem;
|
||||
|
@ -60,6 +83,8 @@
|
|||
border-width: var(--login-card-border-width);
|
||||
border-style: var(--login-card-border-style);
|
||||
border-color: var(--login-card-border-color);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&:focus {
|
||||
border: 2px solid white;
|
||||
|
|
|
@ -1,17 +1,31 @@
|
|||
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, Component, inject, OnInit} from '@angular/core';
|
||||
import {AsyncPipe, NgStyle} from "@angular/common";
|
||||
import {NavService} from "../../../_services/nav.service";
|
||||
import {CoverMasonryComponent} from "../cover-masonry/cover-masonry.component";
|
||||
import {ThemeService} from "../../../_services/theme.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-splash-container',
|
||||
templateUrl: './splash-container.component.html',
|
||||
styleUrls: ['./splash-container.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgStyle,
|
||||
AsyncPipe
|
||||
AsyncPipe,
|
||||
CoverMasonryComponent
|
||||
]
|
||||
})
|
||||
export class SplashContainerComponent {
|
||||
export class SplashContainerComponent implements OnInit {
|
||||
protected readonly navService = inject(NavService);
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
hasValidCoverImages = false;
|
||||
|
||||
ngOnInit() {
|
||||
this.themeService.getThemes().subscribe();
|
||||
}
|
||||
|
||||
onCoverImagesValid(hasImages: boolean) {
|
||||
this.hasValidCoverImages = !hasImages;
|
||||
}
|
||||
}
|
||||
|
|
15
openapi.json
15
openapi.json
|
@ -2950,6 +2950,21 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/Image/random-series-cover": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Image"
|
||||
],
|
||||
"summary": "Returns cover image for a random Series",
|
||||
"parameters": [
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Library/create": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue