Reading profile fixes and feedback (#3853)

This commit is contained in:
Fesaa 2025-06-14 19:06:05 +02:00 committed by GitHub
parent fc968f0044
commit c6d157c863
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3973 additions and 58 deletions

View file

@ -64,6 +64,9 @@ public sealed record UserReadingProfileDto
/// <inheritdoc cref="AppUserReadingProfile.WidthOverride"/> /// <inheritdoc cref="AppUserReadingProfile.WidthOverride"/>
public int? WidthOverride { get; set; } public int? WidthOverride { get; set; }
/// <inheritdoc cref="AppUserReadingProfile.DisableWidthOverride"/>
public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never;
#endregion #endregion
#region EpubReader #region EpubReader

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class AppUserReadingProfileDisableWidthOverrideBreakPoint : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DisableWidthOverride",
table: "AppUserReadingProfiles",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DisableWidthOverride",
table: "AppUserReadingProfiles");
}
}
}

View file

@ -665,6 +665,9 @@ namespace API.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasDefaultValue("Dark"); .HasDefaultValue("Dark");
b.Property<int>("DisableWidthOverride")
.HasColumnType("INTEGER");
b.Property<bool>("EmulateBook") b.Property<bool>("EmulateBook")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -704,7 +707,7 @@ namespace API.Data.Migrations
b.Property<int>("ScalingOption") b.Property<int>("ScalingOption")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.PrimitiveCollection<string>("SeriesIds") b.Property<string>("SeriesIds")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("ShowScreenHints") b.Property<bool>("ShowScreenHints")

View file

@ -1,9 +1,22 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Enums.UserPreferences; using API.Entities.Enums.UserPreferences;
namespace API.Entities; namespace API.Entities;
public enum BreakPoint
{
[Description("Never")]
Never = 0,
[Description("Mobile")]
Mobile = 1,
[Description("Tablet")]
Tablet = 2,
[Description("Desktop")]
Desktop = 3,
}
public class AppUserReadingProfile public class AppUserReadingProfile
{ {
public int Id { get; set; } public int Id { get; set; }
@ -72,6 +85,10 @@ public class AppUserReadingProfile
/// Manga Reader Option: Optional fixed width override /// Manga Reader Option: Optional fixed width override
/// </summary> /// </summary>
public int? WidthOverride { get; set; } = null; public int? WidthOverride { get; set; } = null;
/// <summary>
/// Manga Reader Option: Disable the width override if the screen is past the breakpoint
/// </summary>
public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never;
#endregion #endregion

View file

@ -432,6 +432,7 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService
existingProfile.SwipeToPaginate = dto.SwipeToPaginate; existingProfile.SwipeToPaginate = dto.SwipeToPaginate;
existingProfile.AllowAutomaticWebtoonReaderDetection = dto.AllowAutomaticWebtoonReaderDetection; existingProfile.AllowAutomaticWebtoonReaderDetection = dto.AllowAutomaticWebtoonReaderDetection;
existingProfile.WidthOverride = dto.WidthOverride; existingProfile.WidthOverride = dto.WidthOverride;
existingProfile.DisableWidthOverride = dto.DisableWidthOverride;
// Book Reader // Book Reader
existingProfile.BookReaderMargin = dto.BookReaderMargin; existingProfile.BookReaderMargin = dto.BookReaderMargin;

View file

@ -17,7 +17,7 @@ public static class Configuration
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
? "https://plus.kavitareader.com" : "https://plus.kavitareader.com"; ? "http://localhost:5020" : "https://plus.kavitareader.com";
public static readonly string StatsApiUrl = "https://stats.kavitareader.com"; public static readonly string StatsApiUrl = "https://stats.kavitareader.com";
public static int Port public static int Port

View file

@ -12,6 +12,7 @@ import {PdfLayoutMode} from "./pdf-layout-mode";
import {PdfSpreadMode} from "./pdf-spread-mode"; import {PdfSpreadMode} from "./pdf-spread-mode";
import {Series} from "../series"; import {Series} from "../series";
import {Library} from "../library/library"; import {Library} from "../library/library";
import {UserBreakpoint} from "../../shared/_services/utility.service";
export enum ReadingProfileKind { export enum ReadingProfileKind {
Default = 0, Default = 0,
@ -39,6 +40,7 @@ export interface ReadingProfile {
swipeToPaginate: boolean; swipeToPaginate: boolean;
allowAutomaticWebtoonReaderDetection: boolean; allowAutomaticWebtoonReaderDetection: boolean;
widthOverride?: number; widthOverride?: number;
disableWidthOverride: UserBreakpoint;
// Book Reader // Book Reader
bookReaderMargin: number; bookReaderMargin: number;
@ -75,3 +77,4 @@ export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multi
export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}]; export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}];
export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}]; export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}];
export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}]; export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}];
export const breakPoints = [UserBreakpoint.Never, UserBreakpoint.Mobile, UserBreakpoint.Tablet, UserBreakpoint.Desktop]

View file

@ -20,5 +20,6 @@ export enum WikiLink {
UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native', UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native',
UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker', UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker',
OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients', OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients',
Guides = 'https://wiki.kavitareader.com/guides' Guides = 'https://wiki.kavitareader.com/guides',
ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/",
} }

View file

@ -0,0 +1,25 @@
import {Pipe, PipeTransform} from '@angular/core';
import {translate} from "@jsverse/transloco";
import {UserBreakpoint} from "../shared/_services/utility.service";
@Pipe({
name: 'breakpoint'
})
export class BreakpointPipe implements PipeTransform {
transform(value: UserBreakpoint): string {
const v = parseInt(value + '', 10) as UserBreakpoint;
switch (v) {
case UserBreakpoint.Never:
return translate('breakpoint-pipe.never');
case UserBreakpoint.Mobile:
return translate('breakpoint-pipe.mobile');
case UserBreakpoint.Tablet:
return translate('breakpoint-pipe.tablet');
case UserBreakpoint.Desktop:
return translate('breakpoint-pipe.desktop');
}
throw new Error("unknown breakpoint value: " + value);
}
}

View file

@ -107,6 +107,7 @@ export class AppComponent implements OnInit {
const vh = window.innerHeight * 0.01; const vh = window.innerHeight * 0.01;
this.document.documentElement.style.setProperty('--vh', `${vh}px`); this.document.documentElement.style.setProperty('--vh', `${vh}px`);
this.utilityService.activeBreakpointSource.next(this.utilityService.getActiveBreakpoint()); this.utilityService.activeBreakpointSource.next(this.utilityService.getActiveBreakpoint());
this.utilityService.updateUserBreakpoint();
} }
ngOnInit(): void { ngOnInit(): void {

View file

@ -33,8 +33,9 @@
<div infinite-scroll [infiniteScrollDistance]="1" [infiniteScrollThrottle]="50"> <div infinite-scroll [infiniteScrollDistance]="1" [infiniteScrollThrottle]="50">
@for(item of webtoonImages | async; let index = $index; track item.src) { @for(item of webtoonImages | async; let index = $index; track item.src) {
<img src="{{item.src}}" style="display: block;" [ngStyle]="{'width': widthOverride$ | async}" <img src="{{item.src}}" style="display: block;"
[style.filter]="(darkness$ | async) ?? '' | safeStyle" [style.filter]="(darkness$ | async) ?? '' | safeStyle"
[style.width]="widthOverride()"
class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}} {{areImagesWiderThanWindow ? 'full-width' : ''}}" class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}} {{areImagesWiderThanWindow ? 'full-width' : ''}}"
rel="nofollow" rel="nofollow"
alt="image" alt="image"

View file

@ -1,20 +1,20 @@
import {AsyncPipe, DOCUMENT, NgStyle} from '@angular/common'; import {AsyncPipe, DOCUMENT} from '@angular/common';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component, computed,
DestroyRef, DestroyRef, effect,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
inject, inject,
Inject, Inject, Injector,
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit, OnInit,
Output, Output,
Renderer2, Renderer2, Signal,
SimpleChanges, SimpleChanges,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
@ -25,11 +25,13 @@ import {ReaderService} from '../../../_services/reader.service';
import {PAGING_DIRECTION} from '../../_models/reader-enums'; import {PAGING_DIRECTION} from '../../_models/reader-enums';
import {WebtoonImage} from '../../_models/webtoon-image'; import {WebtoonImage} from '../../_models/webtoon-image';
import {MangaReaderService} from '../../_service/manga-reader.service'; import {MangaReaderService} from '../../_service/manga-reader.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {InfiniteScrollModule} from "ngx-infinite-scroll"; import {InfiniteScrollModule} from "ngx-infinite-scroll";
import {ReaderSetting} from "../../_models/reader-setting"; import {ReaderSetting} from "../../_models/reader-setting";
import {SafeStylePipe} from "../../../_pipes/safe-style.pipe"; import {SafeStylePipe} from "../../../_pipes/safe-style.pipe";
import {UtilityService} from "../../../shared/_services/utility.service";
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
/** /**
* How much additional space should pass, past the original bottom of the document height before we trigger the next chapter load * How much additional space should pass, past the original bottom of the document height before we trigger the next chapter load
@ -63,7 +65,7 @@ const enum DEBUG_MODES {
templateUrl: './infinite-scroller.component.html', templateUrl: './infinite-scroller.component.html',
styleUrls: ['./infinite-scroller.component.scss'], styleUrls: ['./infinite-scroller.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, TranslocoDirective, InfiniteScrollModule, SafeStylePipe, NgStyle] imports: [AsyncPipe, TranslocoDirective, InfiniteScrollModule, SafeStylePipe]
}) })
export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit { export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit {
@ -71,6 +73,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
private readonly readerService = inject(ReaderService); private readonly readerService = inject(ReaderService);
private readonly renderer = inject(Renderer2); private readonly renderer = inject(Renderer2);
private readonly scrollService = inject(ScrollService); private readonly scrollService = inject(ScrollService);
private readonly utilityService = inject(UtilityService);
private readonly injector = inject(Injector);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@ -91,6 +95,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
*/ */
@Input({required: true}) urlProvider!: (page: number) => string; @Input({required: true}) urlProvider!: (page: number) => string;
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>; @Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
@Input({required: true}) readingProfile!: ReadingProfile;
@Output() pageNumberChange: EventEmitter<number> = new EventEmitter<number>(); @Output() pageNumberChange: EventEmitter<number> = new EventEmitter<number>();
@Output() loadNextChapter: EventEmitter<void> = new EventEmitter<void>(); @Output() loadNextChapter: EventEmitter<void> = new EventEmitter<void>();
@Output() loadPrevChapter: EventEmitter<void> = new EventEmitter<void>(); @Output() loadPrevChapter: EventEmitter<void> = new EventEmitter<void>();
@ -174,13 +179,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
*/ */
debugLogFilter: Array<string> = ['[PREFETCH]', '[Intersection]', '[Visibility]', '[Image Load]']; debugLogFilter: Array<string> = ['[PREFETCH]', '[Intersection]', '[Visibility]', '[Image Load]'];
/** readerSettings!: Signal<ReaderSetting>;
* Width override for manual width control widthOverride!: Signal<string>;
* 2 observables needed to avoid flickering, probably due to data races, when changing the width
* this allows to precisely define execution order
*/
widthOverride$ : Observable<string> = new Observable<string>();
widthSliderValue$ : Observable<string> = new Observable<string>();
get minPageLoaded() { get minPageLoaded() {
return Math.min(...Object.values(this.imagesLoaded)); return Math.min(...Object.values(this.imagesLoaded));
@ -240,30 +240,37 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
); );
// We need the injector as toSignal is only allowed in injection context
// https://angular.dev/guide/signals#injection-context
this.readerSettings = toSignal(this.readerSettings$, {injector: this.injector, requireSync: true});
this.widthSliderValue$ = this.readerSettings$.pipe( // Automatically updates when the breakpoint changes, or when reader settings changes
map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'), this.widthOverride = computed(() => {
takeUntilDestroyed(this.destroyRef) const breakpoint = this.utilityService.activeUserBreakpoint();
); const value = this.readerSettings().widthSlider;
this.widthOverride$ = this.widthSliderValue$; if (breakpoint <= this.readingProfile.disableWidthOverride) {
return '';
}
return (parseInt(value) <= 0) ? '' : value + '%';
});
//perform jump so the page stays in view //perform jump so the page stays in view
this.widthSliderValue$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { effect(() => {
const width = this.widthOverride(); // needs to be at the top for effect to work
this.currentPageElem = this.document.querySelector('img#page-' + this.pageNum); this.currentPageElem = this.document.querySelector('img#page-' + this.pageNum);
if(!this.currentPageElem) if(!this.currentPageElem)
return; return;
let images = Array.from(document.querySelectorAll('img[id^="page-"]')) as HTMLImageElement[]; let images = Array.from(document.querySelectorAll('img[id^="page-"]')) as HTMLImageElement[];
images.forEach((img) => { images.forEach((img) => {
this.renderer.setStyle(img, "width", val); this.renderer.setStyle(img, "width", width);
}); });
this.widthOverride$ = this.widthSliderValue$;
this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top; this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top;
this.currentPageElem.scrollIntoView(); this.currentPageElem.scrollIntoView();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); }, {injector: this.injector});
if (this.goToPage) { if (this.goToPage) {
this.goToPage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(page => { this.goToPage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(page => {

View file

@ -93,7 +93,8 @@
[readerSettings$]="readerSettings$" [readerSettings$]="readerSettings$"
[bookmark$]="showBookmarkEffect$" [bookmark$]="showBookmarkEffect$"
[pageNum$]="pageNum$" [pageNum$]="pageNum$"
[showClickOverlay$]="showClickOverlay$"> [showClickOverlay$]="showClickOverlay$"
[readingProfile]="readingProfile">
</app-single-renderer> </app-single-renderer>
<app-double-renderer [image$]="currentImage$" <app-double-renderer [image$]="currentImage$"
@ -133,7 +134,8 @@
(loadPrevChapter)="loadPrevChapter()" (loadPrevChapter)="loadPrevChapter()"
[bookmarkPage]="showBookmarkEffectEvent" [bookmarkPage]="showBookmarkEffectEvent"
[fullscreenToggled]="fullscreenEvent" [fullscreenToggled]="fullscreenEvent"
[readerSettings$]="readerSettings$"> [readerSettings$]="readerSettings$"
[readingProfile]="readingProfile">
</app-infinite-scroller> </app-infinite-scroller>
</div> </div>
} }

View file

@ -1283,8 +1283,19 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
} }
/**
* Calls the correct prev- or nextPage method based on the direction, readingDirection, and readerMode
*
* readingDirection is ignored when readerMode is Webtoon or UpDown
*
* KeyDirection.Right: right or bottom click
* KeyDirection.Left: left or top click
* @param event
* @param direction
*/
handlePageChange(event: any, direction: KeyDirection) { handlePageChange(event: any, direction: KeyDirection) {
if (this.readerMode === ReaderMode.Webtoon) { // Webtoons and UpDown reading mode should not take ReadingDirection into account
if (this.readerMode === ReaderMode.Webtoon || this.readerMode === ReaderMode.UpDown) {
if (direction === KeyDirection.Right) { if (direction === KeyDirection.Right) {
this.nextPage(event); this.nextPage(event);
} else { } else {
@ -1292,6 +1303,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
return; return;
} }
if (direction === KeyDirection.Right) { if (direction === KeyDirection.Right) {
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage(event) : this.prevPage(event); this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage(event) : this.prevPage(event);
} else if (direction === KeyDirection.Left) { } else if (direction === KeyDirection.Left) {

View file

@ -3,7 +3,7 @@
[style.filter]="(darkness$ | async) ?? '' | safeStyle" [style.height]="(imageContainerHeight$ | async) ?? '' | safeStyle"> [style.filter]="(darkness$ | async) ?? '' | safeStyle" [style.height]="(imageContainerHeight$ | async) ?? '' | safeStyle">
@if(currentImage) { @if(currentImage) {
<img alt=" " <img alt=" "
style="width: {{widthOverride$ | async}}" [style.width]="widthOverride()"
#image #image
[src]="currentImage.src" [src]="currentImage.src"
id="image-1" id="image-1"

View file

@ -2,15 +2,15 @@ import { DOCUMENT, NgIf, AsyncPipe } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component, computed, DestroyRef, effect,
EventEmitter, EventEmitter,
inject, inject,
Inject, Inject, Injector,
Input, Input,
OnInit, OnInit,
Output Output, signal, Signal, WritableSignal
} from '@angular/core'; } from '@angular/core';
import {combineLatest, filter, map, Observable, of, shareReplay, switchMap, tap} from 'rxjs'; import {combineLatest, combineLatestWith, filter, map, Observable, of, shareReplay, switchMap, tap} from 'rxjs';
import { PageSplitOption } from 'src/app/_models/preferences/page-split-option'; import { PageSplitOption } from 'src/app/_models/preferences/page-split-option';
import { ReaderMode } from 'src/app/_models/preferences/reader-mode'; import { ReaderMode } from 'src/app/_models/preferences/reader-mode';
import { LayoutMode } from '../../_models/layout-mode'; import { LayoutMode } from '../../_models/layout-mode';
@ -18,8 +18,10 @@ import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums';
import { ReaderSetting } from '../../_models/reader-setting'; import { ReaderSetting } from '../../_models/reader-setting';
import { ImageRenderer } from '../../_models/renderer'; import { ImageRenderer } from '../../_models/renderer';
import { MangaReaderService } from '../../_service/manga-reader.service'; import { MangaReaderService } from '../../_service/manga-reader.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed, toObservable, toSignal} from "@angular/core/rxjs-interop";
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
import {UtilityService} from "../../../shared/_services/utility.service";
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
@Component({ @Component({
selector: 'app-single-renderer', selector: 'app-single-renderer',
@ -30,7 +32,11 @@ import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
}) })
export class SingleRendererComponent implements OnInit, ImageRenderer { export class SingleRendererComponent implements OnInit, ImageRenderer {
private readonly utilityService = inject(UtilityService);
private readonly injector = inject(Injector);
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>; @Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
@Input({required: true}) readingProfile!: ReadingProfile;
@Input({required: true}) image$!: Observable<HTMLImageElement | null>; @Input({required: true}) image$!: Observable<HTMLImageElement | null>;
@Input({required: true}) bookmark$!: Observable<number>; @Input({required: true}) bookmark$!: Observable<number>;
@Input({required: true}) showClickOverlay$!: Observable<boolean>; @Input({required: true}) showClickOverlay$!: Observable<boolean>;
@ -52,10 +58,8 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
pageNum: number = 0; pageNum: number = 0;
maxPages: number = 1; maxPages: number = 1;
/** readerSettings!: Signal<ReaderSetting>;
* Width override for maunal width control widthOverride!: Signal<string>;
*/
widthOverride$ : Observable<string> = new Observable<string>();
get ReaderMode() {return ReaderMode;} get ReaderMode() {return ReaderMode;}
get LayoutMode() {return LayoutMode;} get LayoutMode() {return LayoutMode;}
@ -71,12 +75,16 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
); );
//handle manual width this.readerSettings = toSignal(this.readerSettings$, {injector: this.injector, requireSync: true});
this.widthOverride$ = this.readerSettings$.pipe( this.widthOverride = computed(() => {
map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'), const breakpoint = this.utilityService.activeUserBreakpoint();
takeUntilDestroyed(this.destroyRef) const value = this.readerSettings().widthSlider;
);
if (breakpoint <= this.readingProfile.disableWidthOverride) {
return '';
}
return (parseInt(value) <= 0) ? '' : value + '%';
});
this.emulateBookClass$ = this.readerSettings$.pipe( this.emulateBookClass$ = this.readerSettings$.pipe(
map(data => data.emulateBook), map(data => data.emulateBook),

View file

@ -1,5 +1,5 @@
import {HttpParams} from '@angular/common/http'; import {HttpParams} from '@angular/common/http';
import {Injectable} from '@angular/core'; import {Inject, Injectable, signal, Signal} from '@angular/core';
import {Chapter} from 'src/app/_models/chapter'; import {Chapter} from 'src/app/_models/chapter';
import {LibraryType} from 'src/app/_models/library/library'; import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format'; import {MangaFormat} from 'src/app/_models/manga-format';
@ -8,6 +8,8 @@ import {Series} from 'src/app/_models/series';
import {Volume} from 'src/app/_models/volume'; import {Volume} from 'src/app/_models/volume';
import {translate} from "@jsverse/transloco"; import {translate} from "@jsverse/transloco";
import {debounceTime, ReplaySubject, shareReplay} from "rxjs"; import {debounceTime, ReplaySubject, shareReplay} from "rxjs";
import {DOCUMENT} from "@angular/common";
import getComputedStyle from "@popperjs/core/lib/dom-utils/getComputedStyle";
export enum KEY_CODES { export enum KEY_CODES {
RIGHT_ARROW = 'ArrowRight', RIGHT_ARROW = 'ArrowRight',
@ -27,12 +29,37 @@ export enum KEY_CODES {
SHIFT = 'Shift' SHIFT = 'Shift'
} }
/**
* Use {@link UserBreakpoint} and {@link UtilityService.activeUserBreakpoint} for breakpoint that should depend on user settings
*/
export enum Breakpoint { export enum Breakpoint {
Mobile = 768, Mobile = 768,
Tablet = 1280, Tablet = 1280,
Desktop = 1440 Desktop = 1440
} }
/*
Breakpoints, but they're derived from css vars in the theme
*/
export enum UserBreakpoint {
/**
* This is to be used in the UI/as value to disable the functionality with breakpoint, will not actually be set as a breakpoint
*/
Never = 0,
/**
* --mobile-breakpoint
*/
Mobile = 1,
/**
* --tablet-breakpoint
*/
Tablet = 2,
/**
* --desktop-breakpoint, does not actually matter as everything that's not mobile or tablet will be desktop
*/
Desktop = 3,
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -42,11 +69,19 @@ export class UtilityService {
public readonly activeBreakpointSource = new ReplaySubject<Breakpoint>(1); public readonly activeBreakpointSource = new ReplaySubject<Breakpoint>(1);
public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true})); public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true}));
/**
* The currently active breakpoint, is {@link UserBreakpoint.Never} until the app has loaded
*/
public readonly activeUserBreakpoint = signal<UserBreakpoint>(UserBreakpoint.Never);
// TODO: I need an isPhone/Tablet so that I can easily trigger different views // TODO: I need an isPhone/Tablet so that I can easily trigger different views
mangaFormatKeys: string[] = []; mangaFormatKeys: string[] = [];
constructor(@Inject(DOCUMENT) private document: Document) {
}
sortChapters = (a: Chapter, b: Chapter) => { sortChapters = (a: Chapter, b: Chapter) => {
return a.minNumber - b.minNumber; return a.minNumber - b.minNumber;
@ -132,6 +167,34 @@ export class UtilityService {
return Breakpoint.Desktop; return Breakpoint.Desktop;
} }
updateUserBreakpoint(): void {
this.activeUserBreakpoint.set(this.getActiveUserBreakpoint());
}
private getActiveUserBreakpoint(): UserBreakpoint {
const style = getComputedStyle(this.document.body)
const mobileBreakPoint = this.parseOrDefault<number>(style.getPropertyValue('--setting-mobile-breakpoint'), Breakpoint.Mobile);
const tabletBreakPoint = this.parseOrDefault<number>(style.getPropertyValue('--setting-tablet-breakpoint'), Breakpoint.Tablet);
//const desktopBreakPoint = this.parseOrDefault<number>(style.getPropertyValue('--setting-desktop-breakpoint'), Breakpoint.Desktop);
if (window.innerWidth <= mobileBreakPoint) {
return UserBreakpoint.Mobile;
} else if (window.innerWidth <= tabletBreakPoint) {
return UserBreakpoint.Tablet;
}
// Fallback to desktop
return UserBreakpoint.Desktop;
}
private parseOrDefault<T>(s: string, def: T): T {
const ret = parseInt(s, 10);
if (isNaN(ret)) {
return def;
}
return ret as T;
}
isInViewport(element: Element, additionalTopOffset: number = 0) { isInViewport(element: Element, additionalTopOffset: number = 0) {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
return ( return (

View file

@ -10,13 +10,13 @@
</div> </div>
<p class="ps-2">{{t('description')}}</p> <p class="ps-2">{{t('description')}}</p>
<p class="ps-2 text-muted">{{t('extra-tip')}}</p> <p class="ps-2 text-muted">{{t('extra-tip')}} <a target="_blank" rel="noopener noreferrer" [href]="WikiLink.ReadingProfiles">{{t('wiki-title')}}</a></p>
<div class="row g-0 "> <div class="row g-0 ">
<div class="col-lg-3 col-md-5 col-sm-7 col-xs-7 scroller"> <div class="col-lg-3 col-md-5 col-sm-7 col-xs-7 scroller mb-2 mb-sm-0">
<div class="pe-2"> <div class="pe-sm-2">
@if (readingProfiles.length < virtualScrollerBreakPoint) { @if (readingProfiles.length < virtualScrollerBreakPoint) {
@for (readingProfile of readingProfiles; track readingProfile.id) { @for (readingProfile of readingProfiles; track readingProfile.id) {
@ -32,7 +32,7 @@
</div> </div>
</div> </div>
<div class="col-lg-9 col-md-7 col-sm-4 col-xs-4 ps-3"> <div class="col-lg-9 col-md-7 col-sm-4 col-xs-4 ps-0 ps-sm-3">
<div class="card p-3"> <div class="card p-3">
@if (selectedProfile === null) { @if (selectedProfile === null) {
<p class="ps-2">{{t('no-selected')}}</p> <p class="ps-2">{{t('no-selected')}}</p>
@ -46,7 +46,9 @@
<div class="mb-2 d-flex justify-content-between align-items-center"> <div class="mb-2 d-flex justify-content-between align-items-center">
<app-setting-item [title]="''" [showEdit]="false" [canEdit]="selectedProfile.kind !== ReadingProfileKind.Default"> <app-setting-item [title]="''" [showEdit]="false" [canEdit]="selectedProfile.kind !== ReadingProfileKind.Default">
<ng-template #view> <ng-template #view>
<span [class.clickable]="selectedProfile.kind !== ReadingProfileKind.Default">
{{readingProfileForm.get('name')!.value}} {{readingProfileForm.get('name')!.value}}
</span>
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
<input class="form-control" type="text" formControlName="name" [disabled]="selectedProfile.kind === ReadingProfileKind.Default"> <input class="form-control" type="text" formControlName="name" [disabled]="selectedProfile.kind === ReadingProfileKind.Default">
@ -250,6 +252,22 @@
</div> </div>
} }
<div class="row g-0 mt-4 mb-4">
<app-setting-item [title]="t('disable-width-override-label')" [subtitle]="t('disable-width-override-tooltip')">
<ng-template #view>
{{readingProfileForm.get('disableWidthOverride')!.value | breakpoint}}
</ng-template>
<ng-template #edit>
<select class="form-select" aria-describedby="image-reader-heading"
formControlName="disableWidthOverride">
@for (opt of breakPoints; track opt) {
<option [value]="opt">{{opt | breakpoint}}</option>
}
</select>
</ng-template>
</app-setting-item>
</div>
</div> </div>
} }
</ng-template> </ng-template>

View file

@ -1,7 +1,5 @@
@use '../../../series-detail-common'; @use '../../../series-detail-common';
.group-item { .group-item {
background-color: transparent; background-color: transparent;

View file

@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnIni
import {ReadingProfileService} from "../../_services/reading-profile.service"; import {ReadingProfileService} from "../../_services/reading-profile.service";
import { import {
bookLayoutModes, bookLayoutModes,
bookWritingStyles, bookWritingStyles, breakPoints,
layoutModes, layoutModes,
pageSplitOptions, pageSplitOptions,
pdfScrollModes, pdfScrollModes,
@ -47,6 +47,8 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {LoadingComponent} from "../../shared/loading/loading.component"; import {LoadingComponent} from "../../shared/loading/loading.component";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {ConfirmService} from "../../shared/confirm.service"; import {ConfirmService} from "../../shared/confirm.service";
import {WikiLink} from "../../_models/wiki";
import {BreakpointPipe} from "../../_pipes/breakpoint.pipe";
enum TabId { enum TabId {
ImageReader = "image-reader", ImageReader = "image-reader",
@ -85,6 +87,7 @@ enum TabId {
NgbNavOutlet, NgbNavOutlet,
LoadingComponent, LoadingComponent,
NgbTooltip, NgbTooltip,
BreakpointPipe,
], ],
templateUrl: './manage-reading-profiles.component.html', templateUrl: './manage-reading-profiles.component.html',
styleUrl: './manage-reading-profiles.component.scss', styleUrl: './manage-reading-profiles.component.scss',
@ -193,6 +196,7 @@ export class ManageReadingProfilesComponent implements OnInit {
this.readingProfileForm.addControl('backgroundColor', new FormControl(this.selectedProfile.backgroundColor, [])); this.readingProfileForm.addControl('backgroundColor', new FormControl(this.selectedProfile.backgroundColor, []));
this.readingProfileForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.selectedProfile.allowAutomaticWebtoonReaderDetection, [])); this.readingProfileForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.selectedProfile.allowAutomaticWebtoonReaderDetection, []));
this.readingProfileForm.addControl('widthOverride', new FormControl(this.selectedProfile.widthOverride, [Validators.min(0), Validators.max(100)])); this.readingProfileForm.addControl('widthOverride', new FormControl(this.selectedProfile.widthOverride, [Validators.min(0), Validators.max(100)]));
this.readingProfileForm.addControl('disableWidthOverride', new FormControl(this.selectedProfile.disableWidthOverride, []))
// Epub reader // Epub reader
this.readingProfileForm.addControl('bookReaderFontFamily', new FormControl(this.selectedProfile.bookReaderFontFamily, [])); this.readingProfileForm.addControl('bookReaderFontFamily', new FormControl(this.selectedProfile.bookReaderFontFamily, []));
@ -237,10 +241,10 @@ export class ManageReadingProfilesComponent implements OnInit {
} else { } else {
const profile = this.packData(); const profile = this.packData();
this.readingProfileService.updateProfile(profile).subscribe({ this.readingProfileService.updateProfile(profile).subscribe({
next: _ => { next: newProfile => {
this.readingProfiles = this.readingProfiles.map(p => { this.readingProfiles = this.readingProfiles.map(p => {
if (p.id !== profile.id) return p; if (p.id !== profile.id) return p;
return profile; return newProfile;
}); });
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}, },
@ -260,6 +264,7 @@ export class ManageReadingProfilesComponent implements OnInit {
data.pageSplitOption = parseInt(data.pageSplitOption as unknown as string); data.pageSplitOption = parseInt(data.pageSplitOption as unknown as string);
data.readerMode = parseInt(data.readerMode as unknown as string); data.readerMode = parseInt(data.readerMode as unknown as string);
data.layoutMode = parseInt(data.layoutMode as unknown as string); data.layoutMode = parseInt(data.layoutMode as unknown as string);
data.disableWidthOverride = parseInt(data.disableWidthOverride as unknown as string);
data.bookReaderReadingDirection = parseInt(data.bookReaderReadingDirection as unknown as string); data.bookReaderReadingDirection = parseInt(data.bookReaderReadingDirection as unknown as string);
data.bookReaderWritingStyle = parseInt(data.bookReaderWritingStyle as unknown as string); data.bookReaderWritingStyle = parseInt(data.bookReaderWritingStyle as unknown as string);
@ -316,4 +321,6 @@ export class ManageReadingProfilesComponent implements OnInit {
protected readonly pdfScrollModes = pdfScrollModes; protected readonly pdfScrollModes = pdfScrollModes;
protected readonly TabId = TabId; protected readonly TabId = TabId;
protected readonly ReadingProfileKind = ReadingProfileKind; protected readonly ReadingProfileKind = ReadingProfileKind;
protected readonly WikiLink = WikiLink;
protected readonly breakPoints = breakPoints;
} }

View file

@ -2882,10 +2882,17 @@
"pdf-light": "Light", "pdf-light": "Light",
"pdf-dark": "Dark" "pdf-dark": "Dark"
}, },
"breakpoint-pipe": {
"never": "Never",
"mobile": "Mobile",
"tablet": "Tablet",
"desktop": "Desktop"
},
"manage-reading-profiles": { "manage-reading-profiles": {
"description": "Not all your series may be read in the same way, set up distinct reading profiles per library or series to make getting back in your series as seamless as possible.", "description": "Not all your series may be read in the same way, set up distinct reading profiles per library or series to make getting back in your series as seamless as possible.",
"extra-tip": "Assign reading profiles via the action menu on series and libraries, or in bulk. When changing settings in a reader, a hidden profile is created that remembers your choices for that series (not for pdfs). This profile is removed when you assign or update one of your own reading profiles to the series.", "extra-tip": "Assign reading profiles via the action menu on series and libraries, or in bulk. When changing settings in a reader, a hidden profile is created that remembers your choices for that series (not for pdfs). This profile is removed when you assign one of your own reading profiles to the series. More information can be found on the",
"wiki-title": "wiki",
"profiles-title": "Your reading profiles", "profiles-title": "Your reading profiles",
"default-profile": "Default", "default-profile": "Default",
"add": "{{common.add}}", "add": "{{common.add}}",
@ -2920,6 +2927,8 @@
"allow-auto-webtoon-reader-tooltip": "Switch into Webtoon Reader mode if pages look like a webtoon. Some false positives may occur.", "allow-auto-webtoon-reader-tooltip": "Switch into Webtoon Reader mode if pages look like a webtoon. Some false positives may occur.",
"width-override-label": "{{manga-reader.width-override-label}}", "width-override-label": "{{manga-reader.width-override-label}}",
"width-override-tooltip": "Override width of images in the reader", "width-override-tooltip": "Override width of images in the reader",
"disable-width-override-label": "Disable width override",
"disable-width-override-tooltip": "Prevent the width override from taking effect when your screen is at least the configured breakpoint or smaller",
"reset": "{{common.reset}}", "reset": "{{common.reset}}",
"book-reader-settings-title": "Book Reader", "book-reader-settings-title": "Book Reader",

View file

@ -442,4 +442,10 @@
/** Search **/ /** Search **/
--input-hint-border-color: #aeaeae; --input-hint-border-color: #aeaeae;
--input-hint-text-color: lightgrey; --input-hint-text-color: lightgrey;
/** Breakpoint **/
--setting-mobile-breakpoint: 768;
--setting-tablet-breakpoint: 1280;
--setting-desktop-breakpoint: 1440;
} }