Fix ebook reader text selection and audiobook reader

This commit is contained in:
acx10
2026-02-01 23:49:20 -07:00
parent a402f98e08
commit 17fef505cb
13 changed files with 332 additions and 136 deletions

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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());
}

View File

@@ -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);
}
}

View File

@@ -6,6 +6,5 @@ public record BookFileProgress(
@NotNull Long bookFileId,
String positionData,
String positionHref,
@NotNull Float progressPercent
) {
@NotNull Float progressPercent) {
}

View File

@@ -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"

View File

@@ -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';
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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'}]
: []
});
}

View File

@@ -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}) => {

View File

@@ -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,