Files
bookhoard/internal/services/reader.go
T
john-okeefe 24ea9d8a38 feat(reader): touch & mobile — tap zones, gesture engine, mobile sheets
Phase 2 of the reader redesign:

- Fixed-layout touch engine (foliate-js e9e61d8): pinch-zoom around
  the midpoint, two-finger pan, single-finger pan while zoomed,
  horizontal swipe page-turn at fit (RTL-aware via next()/prev()),
  and double-tap to zoom 2.5x / reset. Touch events forwarded from
  page iframes with converted coordinates; preventDefault only when
  the engine consumes the gesture, so PDF text selection and native
  taps stay intact. touch-action: none on the host and in comic/pdf
  page documents keeps the browser from fighting the engine.
- Tap zones (Kindle-style) for touch devices: tap the outer margins
  to page, center to toggle chrome. Size configurable (10-50%) via
  the revived tap_zone_size setting; toggle via new tap_zones_enabled
  (Behavior section of the settings drawer). Pointer-based + passive
  so drags/swipes/selection never trigger; attached both to the
  viewport and inside every page document (iframe events don't
  bubble); debounced 280ms so double-tap zoom doesn't also page; no
  zone actions while a fixed-layout page is zoomed.
- Drawers become full-width sheets on screens <= 640px.
2026-08-14 16:00:19 -04:00

420 lines
12 KiB
Go

package services
import (
"bookhoard/internal/database"
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type ReaderService struct {
db *database.Queries
worker *Worker
}
func NewReaderService(db *database.Queries, worker *Worker) *ReaderService {
return &ReaderService{
db: db,
worker: worker,
}
}
// Chapter represents a detected chapter
type Chapter struct {
ID string `json:"id"`
Title string `json:"title"`
StartPage int `json:"start_page"`
PageCount int `json:"page_count"`
Level int `json:"level"`
ParentID *string `json:"parent_id,omitempty"`
}
// Panel represents a detected comic panel
type Panel struct {
ID string `json:"id"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
ReadingOrder int `json:"reading_order"`
}
// DictionaryEntry represents a word definition
type DictionaryEntry struct {
Word string `json:"word"`
Definition string `json:"definition"`
PartOfSpeech string `json:"part_of_speech,omitempty"`
Example string `json:"example,omitempty"`
Etymology string `json:"etymology,omitempty"`
}
// ChapterDetectionResult contains chapter metadata
type ChapterDetectionResult struct {
Chapters []Chapter `json:"chapters"`
Metadata json.RawMessage `json:"metadata,omitempty"`
DetectedAt time.Time `json:"detected_at"`
}
// DetectChapters analyzes a media item to detect chapter structure
func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error) {
// Get media item to determine type
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true})
if err != nil {
return nil, fmt.Errorf("failed to get media item: %w", err)
}
// Check if chapter metadata already exists
if len(item.ChapterMetadata) > 0 {
var existing ChapterDetectionResult
if err := json.Unmarshal(item.ChapterMetadata, &existing); err == nil {
return existing.Chapters, nil
}
}
// Detect chapters based on format
var chapters []Chapter
switch item.FormatGroup {
case "reflowable":
// For ebooks, parse from TOC if available
chapters, err = s.detectEbookChapters(ctx, &item)
case "fixed-layout":
// For comics/manga, detect page breaks as chapters
chapters, err = s.detectComicChapters(ctx, &item)
case "pdf":
// For PDFs, use PDF outline
chapters, err = s.detectPDFChapters(ctx, &item)
default:
chapters = []Chapter{}
}
if err != nil {
return nil, fmt.Errorf("chapter detection failed: %w", err)
}
// Cache the results
result := ChapterDetectionResult{
Chapters: chapters,
Metadata: nil,
DetectedAt: time.Now(),
}
metadataBytes, err := json.Marshal(result)
if err == nil {
_, _ = s.db.UpdateMediaItemChapterMetadata(ctx, database.UpdateMediaItemChapterMetadataParams{
ID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
ChapterMetadata: metadataBytes,
})
}
return chapters, nil
}
func (s *ReaderService) detectEbookChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
// For EPUB files, parse the TOC from the OPF file
// This requires EPUB parsing (see epub-parser.ts)
// For now, return empty structure
return []Chapter{}, nil
}
func (s *ReaderService) detectComicChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
// For comics, treat each page as a potential chapter
// or group pages by story arcs if metadata exists
pageCount := int(item.PageCount.Int32)
if pageCount <= 0 {
return []Chapter{}, nil
}
chapters := make([]Chapter, 0)
chapterSize := 20 // Group pages into chapters of 20 pages each
for i := 0; i < pageCount; i += chapterSize {
endPage := i + chapterSize
if endPage > pageCount {
endPage = pageCount
}
chapters = append(chapters, Chapter{
ID: fmt.Sprintf("chapter-%d", len(chapters)+1),
Title: fmt.Sprintf("Page %d-%d", i+1, endPage),
StartPage: i + 1,
PageCount: endPage - i,
Level: 1,
})
}
return chapters, nil
}
func (s *ReaderService) detectPDFChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
// For PDFs, use pdfcpu to extract outline/bookmarks
// This requires PDF parsing library
return []Chapter{}, nil
}
// DetectPanels analyzes a comic page to detect panel boundaries
func (s *ReaderService) DetectPanels(
ctx context.Context,
mediaItemID uuid.UUID,
pageNumber int,
method string,
) ([]Panel, error) {
// Check if panels already exist in cache
cached, err := s.db.GetPanelData(ctx, database.GetPanelDataParams{
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
PageNumber: int32(pageNumber),
})
if err == nil {
var panels []Panel
if err := json.Unmarshal(cached.Panels, &panels); err == nil {
return panels, nil
}
}
// Detect panels using specified method
var panels []Panel
switch method {
case "grid":
panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
case "ml":
panels, err = s.detectPanelsML(ctx, mediaItemID, pageNumber)
case "manual":
panels, err = s.detectPanelsManual(ctx, mediaItemID, pageNumber)
default:
// Default to grid detection
panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
}
if err != nil {
return nil, fmt.Errorf("panel detection failed: %w", err)
}
// Cache the results
panelsJSON, _ := json.Marshal(panels)
// Insert into panel_data table
_ = panelsJSON
return panels, nil
}
func (s *ReaderService) detectPanelsGrid(
ctx context.Context,
mediaItemID uuid.UUID,
pageNumber int,
) ([]Panel, error) {
// Simple grid-based panel detection
// Divide page into 2x2 or 3x3 grid
// This is a simplified implementation
panels := []Panel{
{
ID: "panel-1",
X: 0,
Y: 0,
Width: 50,
Height: 100,
ReadingOrder: 1,
},
{
ID: "panel-2",
X: 50,
Y: 0,
Width: 50,
Height: 100,
ReadingOrder: 2,
},
}
return panels, nil
}
func (s *ReaderService) detectPanelsML(
ctx context.Context,
mediaItemID uuid.UUID,
pageNumber int,
) ([]Panel, error) {
// ML-based panel detection
// This would require a trained model
// For now, fall back to grid detection
return s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
}
func (s *ReaderService) detectPanelsManual(
ctx context.Context,
mediaItemID uuid.UUID,
pageNumber int,
) ([]Panel, error) {
// Manual panel detection returns existing manually-set panels
// These would be stored in the panel_data table
return []Panel{}, nil
}
// CalculateReadingSpeed updates reading speed statistics
func (s *ReaderService) CalculateReadingSpeed(
ctx context.Context,
userID uuid.UUID,
mediaItemID uuid.UUID,
pagesRead int,
minutes float32,
) error {
if minutes <= 0 {
return fmt.Errorf("invalid time: must be positive")
}
pagesPerMinute := float32(pagesRead) / minutes
// Get or create reading speed record
_, err := s.db.GetReadingSpeed(ctx, database.GetReadingSpeedParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
})
if err != nil {
// Create new record
_, err = s.db.CreateReadingSpeed(ctx, database.CreateReadingSpeedParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
PagesPerMinute: pgtype.Float4{Float32: pagesPerMinute, Valid: true},
PagesRead: pgtype.Int4{Int32: int32(pagesRead), Valid: true},
TotalReadingMinutes: pgtype.Float4{Float32: minutes, Valid: true},
LastReadAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
})
} else {
// Update existing record with moving average
// This would require an UpdateReadingSpeed query
_ = pagesPerMinute
}
return err
}
// LookupWord retrieves dictionary entry for a word
func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error) {
// Check cache first
cached, err := s.db.GetDictionaryEntry(ctx, word)
if err == nil {
return &DictionaryEntry{
Word: cached.Word,
Definition: cached.Definition,
PartOfSpeech: cached.PartOfSpeech.String,
Example: cached.Example.String,
Etymology: cached.Etymology.String,
}, nil
}
// Not in cache, fetch from dictionary API
entry, err := s.fetchDictionaryEntry(ctx, word)
if err != nil {
return nil, err
}
// Cache the entry
_, _ = s.db.CreateDictionaryEntry(ctx, database.CreateDictionaryEntryParams{
Word: entry.Word,
Definition: entry.Definition,
PartOfSpeech: pgtype.Text{String: entry.PartOfSpeech, Valid: entry.PartOfSpeech != ""},
Example: pgtype.Text{String: entry.Example, Valid: entry.Example != ""},
Etymology: pgtype.Text{String: entry.Etymology, Valid: entry.Etymology != ""},
})
return entry, nil
}
func (s *ReaderService) fetchDictionaryEntry(ctx context.Context, word string) (*DictionaryEntry, error) {
// Fetch from external dictionary API
// For now, return a placeholder
return &DictionaryEntry{
Word: word,
Definition: fmt.Sprintf("Definition for %s", word),
}, nil
}
// GetSettings retrieves reader settings for a user
func (s *ReaderService) GetSettings(
ctx context.Context,
userID uuid.UUID,
) (map[string]interface{}, error) {
// Get settings from database
settings, err := s.db.GetReaderSettings(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
// Return default settings
return s.getDefaultSettings(), nil
}
var result map[string]interface{}
if err := json.Unmarshal(settings, &result); err != nil {
return s.getDefaultSettings(), nil
}
return result, nil
}
// UpdateSettings updates reader settings for a user
func (s *ReaderService) UpdateSettings(
ctx context.Context,
userID uuid.UUID,
settings map[string]interface{},
) error {
// Merge with existing settings
existing, err := s.GetSettings(ctx, userID)
if err != nil {
existing = s.getDefaultSettings()
}
// Merge settings (partial update)
for key, value := range settings {
existing[key] = value
}
// Serialize and save
settingsJSON, err := json.Marshal(existing)
if err != nil {
return fmt.Errorf("failed to serialize settings: %w", err)
}
// Update in database
_, err = s.db.UpsertReaderSettings(ctx, database.UpsertReaderSettingsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
SettingValue: settingsJSON,
})
return err
}
func (s *ReaderService) getDefaultSettings() map[string]interface{} {
return map[string]interface{}{
"chrome_behavior": "auto-hide",
"progress_mode": "pages",
"chrome_theme": "tokyo-night",
"reading_theme": "dark",
"reading_font": "literata",
"font_size": 16,
"line_height": 1.6,
"margin_width": 20,
"tap_zone_size": 30,
"auto_scroll": false,
"panel_zoom_enabled": true,
"double_page_spread": true,
"pdf_interaction_mode": "select",
"tap_zones_enabled": true,
// Dockable panel defaults
"panel_layout": map[string]interface{}{
"toc": map[string]interface{}{"side": "left", "visible": true, "collapsed": false, "width_px": 320, "order": 1, "locked": false, "last_valid_side": "left"},
"settings": map[string]interface{}{"side": "left", "visible": false, "collapsed": true, "width_px": 380, "order": 2, "locked": false, "last_valid_side": "left"},
"navigator": map[string]interface{}{"side": "right", "visible": true, "collapsed": false, "width_px": 200, "order": 1, "locked": false, "last_valid_side": "right"},
"bookmarks": map[string]interface{}{"side": "right", "visible": false, "collapsed": true, "width_px": 280, "order": 2, "locked": false, "last_valid_side": "right"},
"mobile_nav_visible": false,
},
}
}