mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-02-18 03:07:40 +01:00
Fix ebook reader text selection and audiobook reader
This commit is contained in:
@@ -32,7 +32,7 @@ public class MobileBookController {
|
||||
@GetMapping
|
||||
public ResponseEntity<MobilePageResponse<MobileBookSummary>> getBooks(
|
||||
@Parameter(description = "Page number (0-indexed)") @RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@Parameter(description = "Page size (max 50)") @RequestParam(required = false, defaultValue = "20") Integer size,
|
||||
@Parameter(description = "Page size (max 50)") @RequestParam(required = false, defaultValue = "50") Integer size,
|
||||
@Parameter(description = "Sort field (title, addedOn, lastReadTime, seriesName)") @RequestParam(required = false, defaultValue = "addedOn") String sort,
|
||||
@Parameter(description = "Sort direction (asc, desc)") @RequestParam(required = false, defaultValue = "desc") String dir,
|
||||
@Parameter(description = "Filter by library ID") @RequestParam(required = false) Long libraryId,
|
||||
@@ -140,4 +140,19 @@ public class MobileBookController {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.getBooksByMagicShelf(magicShelfId, page, size));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get random books",
|
||||
description = "Retrieve a paginated list of random books from accessible libraries.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Random books retrieved successfully"),
|
||||
@ApiResponse(responseCode = "403", description = "Access denied")
|
||||
})
|
||||
@GetMapping("/random")
|
||||
public ResponseEntity<MobilePageResponse<MobileBookSummary>> getRandomBooks(
|
||||
@Parameter(description = "Page number (0-indexed)") @RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@Parameter(description = "Page size (max 50)") @RequestParam(required = false, defaultValue = "20") Integer size,
|
||||
@Parameter(description = "Filter by library ID") @RequestParam(required = false) Long libraryId) {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.getRandomBooks(page, size, libraryId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,21 @@ import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class MobileBookFile {
|
||||
private Long id;
|
||||
private String bookType;
|
||||
private Long fileSizeKb;
|
||||
private Long bookId;
|
||||
private String fileName;
|
||||
private boolean isBook;
|
||||
private boolean folderBased;
|
||||
private String bookType;
|
||||
private String archiveType;
|
||||
private Long fileSizeKb;
|
||||
private String extension;
|
||||
private Instant addedOn;
|
||||
private boolean isPrimary;
|
||||
}
|
||||
|
||||
@@ -234,13 +234,32 @@ public interface MobileBookMapper {
|
||||
|
||||
return book.getBookFiles().stream()
|
||||
.filter(bf -> bf.getBookType() != null && bf.isBook())
|
||||
.map(bf -> MobileBookFile.builder()
|
||||
.id(bf.getId())
|
||||
.bookType(bf.getBookType().name())
|
||||
.fileSizeKb(bf.getFileSizeKb())
|
||||
.fileName(bf.getFileName())
|
||||
.isPrimary(bf.getId().equals(primaryId))
|
||||
.build())
|
||||
.map(bf -> {
|
||||
String extension = null;
|
||||
try {
|
||||
String fileName = bf.getFileName();
|
||||
int lastDot = fileName.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
extension = fileName.substring(lastDot + 1);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Handle case where extension cannot be extracted
|
||||
}
|
||||
|
||||
return MobileBookFile.builder()
|
||||
.id(bf.getId())
|
||||
.bookId(bf.getBook() != null ? bf.getBook().getId() : null)
|
||||
.fileName(bf.getFileName())
|
||||
.isBook(bf.isBook())
|
||||
.folderBased(bf.isFolderBased())
|
||||
.bookType(bf.getBookType().name())
|
||||
.archiveType(bf.getArchiveType() != null ? bf.getArchiveType().name() : null)
|
||||
.fileSizeKb(bf.getFileSizeKb())
|
||||
.extension(extension)
|
||||
.addedOn(bf.getAddedOn())
|
||||
.isPrimary(bf.getId().equals(primaryId))
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,20 @@ package com.adityachandel.booklore.mobile.service;
|
||||
|
||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.mobile.dto.*;
|
||||
import com.adityachandel.booklore.mobile.dto.MobileBookDetail;
|
||||
import com.adityachandel.booklore.mobile.dto.MobileBookSummary;
|
||||
import com.adityachandel.booklore.mobile.dto.MobilePageResponse;
|
||||
import com.adityachandel.booklore.mobile.mapper.MobileBookMapper;
|
||||
import com.adityachandel.booklore.mobile.specification.MobileBookSpecification;
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.Library;
|
||||
import com.adityachandel.booklore.model.entity.*;
|
||||
import com.adityachandel.booklore.model.enums.ReadStatus;
|
||||
import com.adityachandel.booklore.repository.*;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.ShelfRepository;
|
||||
import com.adityachandel.booklore.repository.UserBookFileProgressRepository;
|
||||
import com.adityachandel.booklore.repository.UserBookProgressRepository;
|
||||
import com.adityachandel.booklore.service.opds.MagicShelfBookService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -116,8 +122,8 @@ public class MobileBookService {
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int pageNum = page != null && page >= 0 ? page : 0;
|
||||
int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
||||
int pageNum = validatePageNumber(page);
|
||||
int pageSize = validatePageSize(size);
|
||||
|
||||
Pageable pageable = PageRequest.of(pageNum, pageSize, Sort.by(Sort.Direction.DESC, "addedOn"));
|
||||
|
||||
@@ -129,17 +135,7 @@ public class MobileBookService {
|
||||
);
|
||||
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
|
||||
Set<Long> bookIds = bookPage.getContent().stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMap(userId, bookIds);
|
||||
|
||||
List<MobileBookSummary> summaries = bookPage.getContent().stream()
|
||||
.map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId())))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return MobilePageResponse.of(summaries, pageNum, pageSize, bookPage.getTotalElements());
|
||||
return buildPageResponse(bookPage, userId, pageNum, pageSize);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -148,7 +144,7 @@ public class MobileBookService {
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int maxItems = limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : 10;
|
||||
int maxItems = validateLimit(limit, 10);
|
||||
|
||||
Specification<BookEntity> spec = MobileBookSpecification.combine(
|
||||
MobileBookSpecification.notDeleted(),
|
||||
@@ -158,11 +154,7 @@ public class MobileBookService {
|
||||
);
|
||||
|
||||
List<BookEntity> books = bookRepository.findAll(spec);
|
||||
|
||||
Set<Long> bookIds = books.stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMap(userId, bookIds);
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMapForBooks(userId, books);
|
||||
|
||||
return books.stream()
|
||||
.filter(book -> progressMap.containsKey(book.getId()))
|
||||
@@ -185,7 +177,7 @@ public class MobileBookService {
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int maxItems = limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : 10;
|
||||
int maxItems = validateLimit(limit, 10);
|
||||
|
||||
Specification<BookEntity> spec = MobileBookSpecification.combine(
|
||||
MobileBookSpecification.notDeleted(),
|
||||
@@ -196,17 +188,43 @@ public class MobileBookService {
|
||||
|
||||
Pageable pageable = PageRequest.of(0, maxItems, Sort.by(Sort.Direction.DESC, "addedOn"));
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
|
||||
Set<Long> bookIds = bookPage.getContent().stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMap(userId, bookIds);
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMapForBooks(userId, bookPage.getContent());
|
||||
|
||||
return bookPage.getContent().stream()
|
||||
.map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MobilePageResponse<MobileBookSummary> getRandomBooks(
|
||||
Integer page,
|
||||
Integer size,
|
||||
Long libraryId) {
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int pageNum = validatePageNumber(page);
|
||||
int pageSize = validatePageSize(size);
|
||||
|
||||
Specification<BookEntity> spec = buildBaseSpecification(accessibleLibraryIds, libraryId);
|
||||
|
||||
long totalElements = bookRepository.count(spec);
|
||||
|
||||
if (totalElements == 0) {
|
||||
return MobilePageResponse.of(Collections.emptyList(), pageNum, pageSize, 0L);
|
||||
}
|
||||
|
||||
long maxOffset = Math.max(0, totalElements - pageSize);
|
||||
int randomOffset = (int) (Math.random() * (maxOffset + 1));
|
||||
|
||||
Pageable pageable = PageRequest.of(randomOffset / pageSize, pageSize);
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
|
||||
return buildPageResponse(bookPage, userId, pageNum, pageSize);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MobilePageResponse<MobileBookSummary> getBooksByMagicShelf(
|
||||
Long magicShelfId,
|
||||
@@ -216,28 +234,25 @@ public class MobileBookService {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
|
||||
int pageNum = page != null && page >= 0 ? page : 0;
|
||||
int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
||||
int pageNum = validatePageNumber(page);
|
||||
int pageSize = validatePageSize(size);
|
||||
|
||||
var booksPage = magicShelfBookService.getBooksByMagicShelfId(userId, magicShelfId, pageNum, pageSize);
|
||||
|
||||
Set<Long> bookIds = booksPage.getContent().stream()
|
||||
.map(book -> book.getId())
|
||||
.map(Book::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMap(userId, bookIds);
|
||||
|
||||
List<MobileBookSummary> summaries = booksPage.getContent().stream()
|
||||
.map(book -> {
|
||||
BookEntity bookEntity = bookRepository.findById(book.getId()).orElse(null);
|
||||
if (bookEntity == null) {
|
||||
return null;
|
||||
}
|
||||
if (bookEntity.getIsPhysical() != null && bookEntity.getIsPhysical()) {
|
||||
return null;
|
||||
}
|
||||
return mobileBookMapper.toSummary(bookEntity, progressMap.get(book.getId()));
|
||||
})
|
||||
.filter(summary -> summary != null)
|
||||
if (bookIds.isEmpty()) {
|
||||
return MobilePageResponse.of(Collections.emptyList(), pageNum, pageSize, 0L);
|
||||
}
|
||||
|
||||
List<BookEntity> bookEntities = bookRepository.findAllById(bookIds);
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMapForBooks(userId, bookEntities);
|
||||
|
||||
List<MobileBookSummary> summaries = bookEntities.stream()
|
||||
.filter(bookEntity -> bookEntity.getIsPhysical() == null || !bookEntity.getIsPhysical())
|
||||
.map(bookEntity -> mobileBookMapper.toSummary(bookEntity, progressMap.get(bookEntity.getId())))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return MobilePageResponse.of(summaries, pageNum, pageSize, booksPage.getTotalElements());
|
||||
@@ -245,20 +260,7 @@ public class MobileBookService {
|
||||
|
||||
@Transactional
|
||||
public void updateReadStatus(Long bookId, ReadStatus status) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
BookEntity book = bookRepository.findById(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
if (!accessibleLibraryIds.contains(book.getLibrary().getId())) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to this book");
|
||||
}
|
||||
|
||||
UserBookProgressEntity progress = userBookProgressRepository
|
||||
.findByUserIdAndBookId(userId, bookId)
|
||||
.orElseGet(() -> createNewProgress(userId, book));
|
||||
UserBookProgressEntity progress = validateAccessAndGetProgress(bookId);
|
||||
|
||||
progress.setReadStatus(status);
|
||||
progress.setReadStatusModifiedTime(Instant.now());
|
||||
@@ -272,6 +274,13 @@ public class MobileBookService {
|
||||
|
||||
@Transactional
|
||||
public void updatePersonalRating(Long bookId, Integer rating) {
|
||||
UserBookProgressEntity progress = validateAccessAndGetProgress(bookId);
|
||||
|
||||
progress.setPersonalRating(rating);
|
||||
userBookProgressRepository.save(progress);
|
||||
}
|
||||
|
||||
private UserBookProgressEntity validateAccessAndGetProgress(Long bookId) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
@@ -279,16 +288,17 @@ public class MobileBookService {
|
||||
BookEntity book = bookRepository.findById(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
if (!accessibleLibraryIds.contains(book.getLibrary().getId())) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to this book");
|
||||
}
|
||||
validateLibraryAccess(accessibleLibraryIds, book.getLibrary().getId());
|
||||
|
||||
UserBookProgressEntity progress = userBookProgressRepository
|
||||
return userBookProgressRepository
|
||||
.findByUserIdAndBookId(userId, bookId)
|
||||
.orElseGet(() -> createNewProgress(userId, book));
|
||||
}
|
||||
|
||||
progress.setPersonalRating(rating);
|
||||
userBookProgressRepository.save(progress);
|
||||
private void validateLibraryAccess(Set<Long> accessibleLibraryIds, Long libraryId) {
|
||||
if (accessibleLibraryIds != null && !accessibleLibraryIds.contains(libraryId)) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to this book");
|
||||
}
|
||||
}
|
||||
|
||||
private UserBookProgressEntity createNewProgress(Long userId, BookEntity book) {
|
||||
@@ -379,4 +389,60 @@ public class MobileBookService {
|
||||
|
||||
return Sort.by(direction, field);
|
||||
}
|
||||
|
||||
private int validatePageNumber(Integer page) {
|
||||
return page != null && page >= 0 ? page : 0;
|
||||
}
|
||||
|
||||
private int validatePageSize(Integer size) {
|
||||
return size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
private int validateLimit(Integer limit, int defaultValue) {
|
||||
return limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : defaultValue;
|
||||
}
|
||||
|
||||
private Specification<BookEntity> buildBaseSpecification(Set<Long> accessibleLibraryIds, Long libraryId) {
|
||||
List<Specification<BookEntity>> specs = new ArrayList<>();
|
||||
specs.add(MobileBookSpecification.notDeleted());
|
||||
specs.add(MobileBookSpecification.hasDigitalFile());
|
||||
|
||||
if (accessibleLibraryIds != null) {
|
||||
if (libraryId != null && !accessibleLibraryIds.contains(libraryId)) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to library " + libraryId);
|
||||
}
|
||||
specs.add(libraryId != null
|
||||
? MobileBookSpecification.inLibrary(libraryId)
|
||||
: MobileBookSpecification.inLibraries(accessibleLibraryIds));
|
||||
} else if (libraryId != null) {
|
||||
specs.add(MobileBookSpecification.inLibrary(libraryId));
|
||||
}
|
||||
|
||||
return MobileBookSpecification.combine(specs.toArray(new Specification[0]));
|
||||
}
|
||||
|
||||
private MobilePageResponse<MobileBookSummary> buildPageResponse(
|
||||
Page<BookEntity> bookPage,
|
||||
Long userId,
|
||||
int pageNum,
|
||||
int pageSize) {
|
||||
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMapForBooks(userId, bookPage.getContent());
|
||||
|
||||
List<MobileBookSummary> summaries = bookPage.getContent().stream()
|
||||
.map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId())))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return MobilePageResponse.of(summaries, pageNum, pageSize, bookPage.getTotalElements());
|
||||
}
|
||||
|
||||
private Map<Long, UserBookProgressEntity> getProgressMapForBooks(Long userId, List<BookEntity> books) {
|
||||
if (books.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Set<Long> bookIds = books.stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
return getProgressMap(userId, bookIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,5 @@ public record BookFileProgress(
|
||||
@NotNull Long bookFileId,
|
||||
String positionData,
|
||||
String positionHref,
|
||||
@NotNull Float progressPercent
|
||||
) {
|
||||
@NotNull Float progressPercent) {
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
@for (book of scroll.viewPortItems; let i = $index; track book.id) {
|
||||
<div
|
||||
class="virtual-scroller-item"
|
||||
[ngStyle]="{width: currentCardSize.width + 'px', height: currentCardSize.height + 'px'}">
|
||||
[ngStyle]="{width: currentCardSize.width + 'px', height: getCardHeight(book) + 'px'}">
|
||||
<app-book-card
|
||||
[index]="books.indexOf(book)"
|
||||
[book]="book"
|
||||
|
||||
@@ -205,6 +205,17 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return this.coverScalePreferenceService.gridColumnMinWidth;
|
||||
}
|
||||
|
||||
getCardHeight(book: Book): number {
|
||||
if (this.isMobile) {
|
||||
const isAudiobook = book.primaryFile?.bookType === 'AUDIOBOOK';
|
||||
if (isAudiobook) {
|
||||
return this.mobileCardSize.width + this.MOBILE_TITLE_BAR_HEIGHT;
|
||||
}
|
||||
return this.mobileCardSize.height;
|
||||
}
|
||||
return this.coverScalePreferenceService.getCardHeight(book);
|
||||
}
|
||||
|
||||
get viewIcon(): string {
|
||||
return this.currentViewMode === VIEW_MODES.GRID ? 'pi pi-objects-column' : 'pi pi-table';
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cover-container.audiobook-cover {
|
||||
aspect-ratio: 1/1;
|
||||
background-color: var(--surface-ground);
|
||||
}
|
||||
|
||||
.book-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -37,10 +42,6 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.cover-container.audiobook-cover {
|
||||
background-color: var(--surface-ground);
|
||||
}
|
||||
|
||||
.cover-container::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {Subject} from 'rxjs';
|
||||
import {debounceTime} from 'rxjs/operators';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {LocalStorageService} from '../../../../shared/service/local-storage.service';
|
||||
import {Book} from '../../model/book.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -11,6 +12,7 @@ export class CoverScalePreferenceService {
|
||||
|
||||
private readonly BASE_WIDTH = 135;
|
||||
private readonly BASE_HEIGHT = 220;
|
||||
private readonly TITLE_BAR_HEIGHT = 31;
|
||||
private readonly DEBOUNCE_MS = 1000;
|
||||
private readonly STORAGE_KEY = 'coverScalePreference';
|
||||
|
||||
@@ -50,6 +52,14 @@ export class CoverScalePreferenceService {
|
||||
return `${this.currentCardSize.width}px`;
|
||||
}
|
||||
|
||||
getCardHeight(book: Book): number {
|
||||
const isAudiobook = book.primaryFile?.bookType === 'AUDIOBOOK';
|
||||
if (isAudiobook) {
|
||||
return Math.round((this.BASE_WIDTH + this.TITLE_BAR_HEIGHT) * this.scaleFactor);
|
||||
}
|
||||
return this.currentCardSize.height;
|
||||
}
|
||||
|
||||
private saveScalePreference(scale: number): void {
|
||||
try {
|
||||
this.localStorageService.set(this.STORAGE_KEY, scale);
|
||||
|
||||
@@ -144,6 +144,25 @@
|
||||
transition: all 0.4s ease;
|
||||
filter: blur(6px);
|
||||
}
|
||||
|
||||
// Audiobook covers should fill the container height
|
||||
::ng-deep {
|
||||
app-book-card {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.book-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cover-container.audiobook-cover {
|
||||
aspect-ratio: unset;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.magic-shelf-icon {
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import { Component, ElementRef, inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { CommonModule, Location } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import {Component, ElementRef, inject, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||
import {CommonModule, Location} from '@angular/common';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
|
||||
import { Button } from 'primeng/button';
|
||||
import { Slider, SliderChangeEvent } from 'primeng/slider';
|
||||
import { ProgressSpinner } from 'primeng/progressspinner';
|
||||
import { Tooltip } from 'primeng/tooltip';
|
||||
import { MessageService } from 'primeng/api';
|
||||
import { SelectButton } from 'primeng/selectbutton';
|
||||
import { Menu } from 'primeng/menu';
|
||||
import { MenuItem } from 'primeng/api';
|
||||
import {Button} from 'primeng/button';
|
||||
import {Slider, SliderChangeEvent} from 'primeng/slider';
|
||||
import {ProgressSpinner} from 'primeng/progressspinner';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {MenuItem, MessageService} from 'primeng/api';
|
||||
import {SelectButton} from 'primeng/selectbutton';
|
||||
import {Menu} from 'primeng/menu';
|
||||
|
||||
import { AudiobookService } from './audiobook.service';
|
||||
import { AudiobookInfo, AudiobookChapter, AudiobookTrack, AudiobookProgress } from './audiobook.model';
|
||||
import { BookService } from '../../book/service/book.service';
|
||||
import { BookMarkService, BookMark, CreateBookMarkRequest } from '../../../shared/service/book-mark.service';
|
||||
import { AudiobookSessionService } from '../../../shared/service/audiobook-session.service';
|
||||
import { PageTitleService } from '../../../shared/service/page-title.service';
|
||||
import { AuthService } from '../../../shared/service/auth.service';
|
||||
import { API_CONFIG } from '../../../core/config/api-config';
|
||||
import {AudiobookService} from './audiobook.service';
|
||||
import {AudiobookChapter, AudiobookInfo, AudiobookProgress, AudiobookTrack} from './audiobook.model';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {BookMark, BookMarkService, CreateBookMarkRequest} from '../../../shared/service/book-mark.service';
|
||||
import {AudiobookSessionService} from '../../../shared/service/audiobook-session.service';
|
||||
import {PageTitleService} from '../../../shared/service/page-title.service';
|
||||
import {AuthService} from '../../../shared/service/auth.service';
|
||||
import {API_CONFIG} from '../../../core/config/api-config';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audiobook-reader',
|
||||
@@ -48,7 +47,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
|
||||
private bookMarkService = inject(BookMarkService);
|
||||
private authService = inject(AuthService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private location = inject(Location);
|
||||
private messageService = inject(MessageService);
|
||||
private audiobookSessionService = inject(AudiobookSessionService);
|
||||
@@ -87,24 +85,24 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
|
||||
private originalVolume = 1;
|
||||
|
||||
sleepTimerOptions: MenuItem[] = [
|
||||
{ label: '15 minutes', command: () => this.setSleepTimer(15) },
|
||||
{ label: '30 minutes', command: () => this.setSleepTimer(30) },
|
||||
{ label: '45 minutes', command: () => this.setSleepTimer(45) },
|
||||
{ label: '60 minutes', command: () => this.setSleepTimer(60) },
|
||||
{ label: 'End of chapter', command: () => this.setSleepTimerEndOfChapter() },
|
||||
{ separator: true },
|
||||
{ label: 'Cancel timer', command: () => this.cancelSleepTimer(), visible: false }
|
||||
{label: '15 minutes', command: () => this.setSleepTimer(15)},
|
||||
{label: '30 minutes', command: () => this.setSleepTimer(30)},
|
||||
{label: '45 minutes', command: () => this.setSleepTimer(45)},
|
||||
{label: '60 minutes', command: () => this.setSleepTimer(60)},
|
||||
{label: 'End of chapter', command: () => this.setSleepTimerEndOfChapter()},
|
||||
{separator: true},
|
||||
{label: 'Cancel timer', command: () => this.cancelSleepTimer(), visible: false}
|
||||
];
|
||||
|
||||
bookmarks: BookMark[] = [];
|
||||
|
||||
playbackRates = [
|
||||
{ label: '0.5x', value: 0.5 },
|
||||
{ label: '0.75x', value: 0.75 },
|
||||
{ label: '1x', value: 1 },
|
||||
{ label: '1.25x', value: 1.25 },
|
||||
{ label: '1.5x', value: 1.5 },
|
||||
{ label: '2x', value: 2 }
|
||||
{label: '0.5x', value: 0.5},
|
||||
{label: '0.75x', value: 0.75},
|
||||
{label: '1x', value: 1},
|
||||
{label: '1.25x', value: 1.25},
|
||||
{label: '1.5x', value: 1.5},
|
||||
{label: '2x', value: 2}
|
||||
];
|
||||
|
||||
private progressSaveInterval?: ReturnType<typeof setInterval>;
|
||||
@@ -394,7 +392,7 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
|
||||
artist: this.audiobookInfo.author || 'Unknown Author',
|
||||
album: this.audiobookInfo.title,
|
||||
artwork: this.coverUrl
|
||||
? [{ src: this.coverUrl, sizes: '512x512', type: 'image/png' }]
|
||||
? [{src: this.coverUrl, sizes: '512x512', type: 'image/png'}]
|
||||
: []
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Component, CUSTOM_ELEMENTS_SCHEMA, HostListener, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {forkJoin, Observable, of, Subject, throwError} from 'rxjs';
|
||||
import {Observable, of, Subject, throwError} from 'rxjs';
|
||||
import {catchError, map, switchMap, takeUntil, tap} from 'rxjs/operators';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {ReaderLoaderService} from './core/loader.service';
|
||||
@@ -27,8 +27,8 @@ import {ReaderQuickSettingsComponent} from './layout/header/quick-settings.compo
|
||||
import {ReaderBookMetadataDialogComponent} from './dialogs/metadata-dialog.component';
|
||||
import {ReaderHeaderFooterVisibilityManager} from './shared/visibility.util';
|
||||
import {EpubCustomFontService} from './features/fonts/custom-font.service';
|
||||
import {TextSelectionPopupComponent, TextSelectionAction} from './shared/selection-popup.component';
|
||||
import {ReaderNoteDialogComponent, NoteDialogData, NoteDialogResult} from './dialogs/note-dialog.component';
|
||||
import {TextSelectionAction, TextSelectionPopupComponent} from './shared/selection-popup.component';
|
||||
import {NoteDialogData, NoteDialogResult, ReaderNoteDialogComponent} from './dialogs/note-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ebook-reader',
|
||||
@@ -102,7 +102,7 @@ export class EbookReaderComponent implements OnInit, OnDestroy {
|
||||
sectionFractions: number[] = [];
|
||||
|
||||
showSelectionPopup = false;
|
||||
popupPosition = { x: 0, y: 0 };
|
||||
popupPosition = {x: 0, y: 0};
|
||||
showPopupBelow = false;
|
||||
overlappingAnnotationId: number | null = null;
|
||||
selectedText = '';
|
||||
@@ -229,7 +229,7 @@ export class EbookReaderComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
return this.stateService.initializeState(this.bookId, bookFileId!).pipe(
|
||||
map(() => ({ book, bookType, bookFileId }))
|
||||
map(() => ({book, bookType, bookFileId}))
|
||||
);
|
||||
}),
|
||||
switchMap(({book, bookType, bookFileId}) => {
|
||||
|
||||
@@ -208,22 +208,20 @@ export class ReaderSelectionService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getBasePath = (cfi: string): string => {
|
||||
const inner = cfi.replace(/^epubcfi\(/, '').replace(/\)$/, '');
|
||||
const commaIndex = inner.indexOf(',');
|
||||
if (commaIndex > 0) {
|
||||
return inner.substring(0, commaIndex).replace(/:\d+$/, '');
|
||||
}
|
||||
return inner.replace(/:\d+$/, '');
|
||||
};
|
||||
|
||||
const selectionBasePath = getBasePath(selectionCfi);
|
||||
const selectionRange = this.parseCfiRange(selectionCfi);
|
||||
if (!selectionRange) return null;
|
||||
|
||||
for (const annotation of this.annotations) {
|
||||
const annotationBasePath = getBasePath(annotation.cfi);
|
||||
if (selectionBasePath.startsWith(annotationBasePath) ||
|
||||
annotationBasePath.startsWith(selectionBasePath) ||
|
||||
selectionBasePath === annotationBasePath) {
|
||||
const annotationRange = this.parseCfiRange(annotation.cfi);
|
||||
if (!annotationRange) continue;
|
||||
|
||||
// Check if base paths match (same text node/element)
|
||||
if (selectionRange.basePath !== annotationRange.basePath) continue;
|
||||
|
||||
// Check if character ranges actually overlap
|
||||
// Two ranges [a, b] and [c, d] overlap if a < d AND c < b
|
||||
if (selectionRange.startOffset < annotationRange.endOffset &&
|
||||
annotationRange.startOffset < selectionRange.endOffset) {
|
||||
return annotation.id;
|
||||
}
|
||||
}
|
||||
@@ -231,6 +229,58 @@ export class ReaderSelectionService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a CFI range and extracts the base path and character offsets.
|
||||
* CFI range format: epubcfi(parent_path,relative_start,relative_end)
|
||||
* Example: epubcfi(/6/4!/4/2/1:0,/4/2/1:15)
|
||||
*/
|
||||
private parseCfiRange(cfi: string): { basePath: string; startOffset: number; endOffset: number } | null {
|
||||
// Remove epubcfi() wrapper
|
||||
const inner = cfi.replace(/^epubcfi\(/, '').replace(/\)$/, '');
|
||||
|
||||
// Find the comma that separates parent path from relative offsets
|
||||
const commaIndex = inner.indexOf(',');
|
||||
if (commaIndex <= 0) {
|
||||
// Not a range CFI, might be a point CFI - treat as zero-width range
|
||||
const offsetMatch = inner.match(/:(\d+)$/);
|
||||
if (offsetMatch) {
|
||||
const basePath = inner.replace(/:\d+$/, '');
|
||||
const offset = parseInt(offsetMatch[1], 10);
|
||||
return { basePath, startOffset: offset, endOffset: offset };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract parent path (before first comma)
|
||||
const parentPath = inner.substring(0, commaIndex);
|
||||
|
||||
// Extract relative start and end parts (after commas)
|
||||
const relativePartsStr = inner.substring(commaIndex + 1);
|
||||
const relativeParts = relativePartsStr.split(',');
|
||||
if (relativeParts.length !== 2) return null;
|
||||
|
||||
const relativeStart = relativeParts[0];
|
||||
const relativeEnd = relativeParts[1];
|
||||
|
||||
// Extract character offsets from relative parts
|
||||
const startOffsetMatch = relativeStart.match(/:(\d+)$/);
|
||||
const endOffsetMatch = relativeEnd.match(/:(\d+)$/);
|
||||
|
||||
if (!startOffsetMatch || !endOffsetMatch) return null;
|
||||
|
||||
const startOffset = parseInt(startOffsetMatch[1], 10);
|
||||
const endOffset = parseInt(endOffsetMatch[1], 10);
|
||||
|
||||
// Build the full base path by combining parent with the common path structure
|
||||
// Strip character offsets from the path to get the node path
|
||||
const startNodePath = relativeStart.replace(/:\d+$/, '');
|
||||
|
||||
// Use parent + start node path as base (start and end should be in same node for highlights)
|
||||
const basePath = `${parentPath}${startNodePath}`;
|
||||
|
||||
return { basePath, startOffset, endOffset };
|
||||
}
|
||||
|
||||
private emitState(): void {
|
||||
this.stateSubject.next({
|
||||
visible: this._visible,
|
||||
|
||||
Reference in New Issue
Block a user