edit_codex

This commit is contained in:
Курнат Андрей
2026-03-08 22:07:04 +03:00
parent f50c918f10
commit 7393230696
28 changed files with 1686 additions and 1117 deletions

1
.gitignore vendored
View File

@@ -361,3 +361,4 @@ MigrationBackup/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
.dotnet-cli/

View File

@@ -1,16 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:BookReader.Views" xmlns:views="clr-namespace:BookReader.Views"
x:Class="BookReader.AppShell" x:Class="BookReader.AppShell"
Shell.FlyoutBehavior="Disabled" Shell.FlyoutBehavior="Disabled"
Shell.BackgroundColor="#3E2723" Shell.NavBarHasShadow="False"
Shell.BackgroundColor="{StaticResource TabBarColor}"
Shell.ForegroundColor="White" Shell.ForegroundColor="White"
Shell.TitleColor="White"> Shell.TitleColor="White"
Shell.TabBarBackgroundColor="{StaticResource TabBarColor}"
Shell.TabBarForegroundColor="#D9C3B4"
Shell.TabBarTitleColor="#D9C3B4"
Shell.TabBarUnselectedColor="#8B7264">
<ShellContent <TabBar>
Title="Library" <Tab Title="Библиотека" Icon="library_tab.svg">
ContentTemplate="{DataTemplate views:BookshelfPage}" <ShellContent ContentTemplate="{DataTemplate views:BookshelfPage}"
Route="bookshelf" /> Route="bookshelf" />
</Tab>
<Tab Title="Calibre" Icon="cloud_tab.svg">
<ShellContent ContentTemplate="{DataTemplate views:CalibreLibraryPage}"
Route="calibre" />
</Tab>
<Tab Title="Настройки" Icon="settings_tab.svg">
<ShellContent ContentTemplate="{DataTemplate views:SettingsPage}"
Route="settings" />
</Tab>
</TabBar>
</Shell> </Shell>

View File

@@ -9,7 +9,5 @@ public partial class AppShell : Shell
InitializeComponent(); InitializeComponent();
Routing.RegisterRoute("reader", typeof(ReaderPage)); Routing.RegisterRoute("reader", typeof(ReaderPage));
Routing.RegisterRoute("settings", typeof(SettingsPage));
Routing.RegisterRoute("calibre", typeof(CalibreLibraryPage));
} }
} }

View File

@@ -37,13 +37,7 @@
<ItemGroup> <ItemGroup>
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" /> <MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ItemGroup>
<AndroidResource Remove="Platforms\Android\Resources\xml\network_security_config.xml" />
</ItemGroup>
<ItemGroup>
<MauiXaml Update="Resources\Styles\AppStyles.xaml"> <MauiXaml Update="Resources\Styles\AppStyles.xaml">
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
</MauiXaml> </MauiXaml>
@@ -56,3 +50,4 @@
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#3E2723" BaseSize="128,128" /> <MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#3E2723" BaseSize="128,128" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,8 +1,7 @@
namespace BookReader; namespace BookReader;
public static class Constants public static class Constants
{ {
// UI Constants
public static class UI public static class UI
{ {
public const int ProgressBarMaxWidth = 120; public const int ProgressBarMaxWidth = 120;
@@ -12,29 +11,33 @@ public static class Constants
public const int VerticalItemSpacing = 15; public const int VerticalItemSpacing = 15;
} }
// Reader Constants
public static class Reader public static class Reader
{ {
public const int DefaultFontSize = 18; public const int DefaultFontSize = 18;
public const int MinFontSize = 12; public const int MinFontSize = 12;
public const int MaxFontSize = 40; public const int MaxFontSize = 40;
public const string DefaultTheme = "sepia";
public const double DefaultBrightness = 100;
public const double MinBrightness = 70;
public const double MaxBrightness = 120;
public const int Base64ChunkSize = 400_000; public const int Base64ChunkSize = 400_000;
public const int Base64RawChunkSize = 120_000;
public const int TouchZoneLeftPercent = 30; public const int TouchZoneLeftPercent = 30;
public const int TouchZoneRightPercent = 30; public const int TouchZoneRightPercent = 30;
public const int TouchZoneCenterPercent = 40; public const int TouchZoneCenterPercent = 40;
public const int SwipeMinDistance = 50; public const int SwipeMinDistance = 50;
public const int SwipeMaxDurationMs = 500; public const int SwipeMaxDurationMs = 500;
public const int ProgressSaveThrottleSeconds = 2;
} }
// Database Constants
public static class Database public static class Database
{ {
public const string DatabaseFileName = "bookreader.db3"; public const string DatabaseFileName = "bookreader.db3";
public const int RetryCount = 3; public const int RetryCount = 3;
public const int RetryBaseDelayMs = 100; public const int RetryBaseDelayMs = 100;
public const int MaxReadingHistoryEntriesPerBook = 200;
} }
// File Constants
public static class Files public static class Files
{ {
public const string BooksFolder = "Books"; public const string BooksFolder = "Books";
@@ -44,7 +47,6 @@ public static class Constants
public const string Fb2ZipExtension = ".fb2.zip"; public const string Fb2ZipExtension = ".fb2.zip";
} }
// Network Constants
public static class Network public static class Network
{ {
public const int HttpClientTimeoutSeconds = 30; public const int HttpClientTimeoutSeconds = 30;
@@ -52,7 +54,6 @@ public static class Constants
public const int CalibrePageSize = 20; public const int CalibrePageSize = 20;
} }
// Storage Keys
public static class StorageKeys public static class StorageKeys
{ {
public const string CalibreUrl = "CalibreUrl"; public const string CalibreUrl = "CalibreUrl";

View File

@@ -4,6 +4,7 @@
android:allowBackup="true" android:allowBackup="true"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Maui.Main" android:theme="@style/Maui.Main"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
</application> </application>

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#A65436" d="M6 17.5A5.5 5.5 0 0 1 6 6.5 6.5 6.5 0 0 1 18.5 8 4.5 4.5 0 1 1 18 17.5z"/>
<path fill="#F7E8D9" d="M9 16.5h6.2A3.3 3.3 0 0 0 15 10a4.8 4.8 0 0 0-9.3 1.5A4 4 0 0 0 9 16.5z"/>
<path fill="#6F311D" d="M11.25 9.5h1.5v4.2h-1.5z"/>
<path fill="#6F311D" d="m9.25 11.7 2.75 2.8 2.75-2.8 1.05 1.05L12 16.55l-3.8-3.8z"/>
</svg>

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#A65436" d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15.5A2.5 2.5 0 0 0 17.5 16H6.5A2.5 2.5 0 0 0 4 18.5z"/>
<path fill="#F7E8D9" d="M6.5 4.5H18.5V14.5H6.5A4 4 0 0 0 4.75 14.9V5.5A1.75 1.75 0 0 1 6.5 3.75z"/>
<path fill="#6F311D" d="M7.5 7h8v1.5h-8zm0 3h6.5v1.5H7.5z"/>
<path fill="#27160E" d="M6.5 16.75h11A1.75 1.75 0 0 1 19.25 18.5V20H6.5a1.5 1.5 0 0 1 0-3z"/>
</svg>

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#A65436" d="M12 2.5 14 5l3-.2.9 2.8L20.5 9 19 12l1.5 3-2.6 1.4-.9 2.8-3-.2-2 2.5-2-2.5-3 .2L5 16.4 2.5 15 4 12 2.5 9 5 7.6l.9-2.8 3 .2z"/>
<circle cx="12" cy="12" r="3.2" fill="#F7E8D9"/>
<circle cx="12" cy="12" r="1.5" fill="#27160E"/>
</svg>

View File

@@ -1,35 +1,35 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Book Reader</title> <title>Book Reader</title>
<style> <style>
* { /* Сброс отступов и установка box-sizing для всех элементов */ * { /* РЎР±СЂРѕСЃ отступов Рё установка box-sizing для всех элементов */
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
html, body { /* Растягиваем страницу на весь экран, отключаем прокрутку и выделение текста */ html, body { /* Растягиваем страницу РЅР° весь экран, отключаем прокрутку Рё выделение текста */
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background-color: #faf8ef; /* Цвет "сепия" для комфортного чтения */ background-color: #faf8ef; /* Цвет "сепия" для комфортного чтения */
font-family: serif; font-family: serif;
-webkit-touch-callout: none; /* Запрет контекстного меню при долгом нажатии (iOS) */ -webkit-touch-callout: none; /* Запрет контекстного меню РїСЂРё долгом нажатии (iOS) */
-webkit-user-select: none; /* Запрет выделения текста */ -webkit-user-select: none; /* Запрет выделения текста */
user-select: none; user-select: none;
} }
#reader-container { /* Основной контейнер для книги */ #reader-container { /* РћСЃРЅРѕРІРЅРѕР№ контейнер для РєРЅРёРіРё */
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
#book-content, #fb2-content { /* Контейнеры для EPUB и FB2 соответственно */ #book-content, #fb2-content { /* Контейнеры для EPUB Рё FB2 соответственно */
width: 100%; width: 100%;
height: 100%; height: 100%;
position: absolute; position: absolute;
@@ -37,7 +37,7 @@
left: 0; left: 0;
} }
#fb2-content { /* Специфичные настройки для FB2: отступы и скрытие по умолчанию */ #fb2-content { /* Специфичные настройки для FB2: отступы Рё скрытие РїРѕ умолчанию */
overflow: hidden; overflow: hidden;
padding: 20px; padding: 20px;
font-size: 18px; font-size: 18px;
@@ -45,7 +45,7 @@
display: none; display: none;
} }
#loading { /* Центрированный экран загрузки */ #loading { /* Центрированный экран загрузки */
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -56,7 +56,7 @@
gap: 15px; gap: 15px;
} }
.spinner { /* Анимация крутящегося индикатора загрузки */ .spinner { /* Анимация крутящегося индикатора загрузки */
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 4px solid #ddd; border: 4px solid #ddd;
@@ -65,13 +65,13 @@
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { /* Описание вращения спиннера */ @keyframes spin { /* Описание вращения спиннера */
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
#error-display { /* Контейнер для вывода ошибок */ #error-display { /* Контейнер для вывода ошибок */
display: none; display: none;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -84,7 +84,7 @@
gap: 10px; gap: 10px;
} }
/* Прозрачные зоны для управления тапами (назад, меню, вперед) */ /* Прозрачные Р·РѕРЅС‹ для управления тапами (назад, меню, вперед) */
.touch-zone { .touch-zone {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -96,17 +96,17 @@
left: 0; left: 0;
width: 30%; width: 30%;
} }
/* Левая треть экрана — назад */ /* Левая треть экрана — назад */
#touch-right { #touch-right {
right: 0; right: 0;
width: 30%; width: 30%;
} }
/* Правая треть — вперед */ /* Правая треть — вперед */
#touch-center { #touch-center {
left: 30%; left: 30%;
width: 40%; width: 40%;
} }
/* Центр — открыть меню */ /* Центр — открыть меню */
</style> </style>
</head> </head>
<body> <body>
@@ -116,7 +116,7 @@
<span id="loading-text">Initializing...</span> <span id="loading-text">Initializing...</span>
</div> </div>
<div id="error-display"> <div id="error-display">
<span style="font-size:40px">⚠️</span> <span style="font-size:40px">вљ пёЏ</span>
<span id="error-text"></span> <span id="error-text"></span>
</div> </div>
</div> </div>
@@ -126,20 +126,14 @@
<script src="_framework/hybridwebview.js"></script> <script src="_framework/hybridwebview.js"></script>
<script src="js/jszip.min.js"></script> <script src="js/jszip.min.js"></script>
<script src="js/epub.min.js"></script> ``` <script src="js/epub.min.js"></script>
---
### JavaScript Логика
```javascript
<script> <script>
(function () { // Самовызывающаяся функция для изоляции переменных (function () { // Самовызывающаяся функция для изоляции переменных
'use strict'; 'use strict';
// ========== КЭШИРОВАННЫЕ DOM-ЭЛЕМЕНТЫ ========== // ========== КЭШИРОВАННЫЕ DOM-ЭЛЕМЕНТЫ ==========
const $ = id => document.getElementById(id); // Короткий псевдоним для поиска элементов const $ = id => document.getElementById(id); // Короткий псевдоним для РїРѕРёСЃРєР° элементов
const els = { // Объект со ссылками на элементы для быстрого доступа const els = { // Объект СЃРѕ ссылками РЅР° элементы для быстрого доступа
loading: $('loading'), loading: $('loading'),
loadingText: $('loading-text'), loadingText: $('loading-text'),
errorDisplay: $('error-display'), errorDisplay: $('error-display'),
@@ -148,83 +142,88 @@
fb2Content: $('fb2-content'), fb2Content: $('fb2-content'),
}; };
// ========== СОСТОЯНИЕ (STATE) ========== // ========== СОСТОЯНИЕ (STATE) ==========
const state = { // Глобальный объект состояния текущей книги const state = { // Глобальный объект состояния текущей РєРЅРёРіРё
book: null, // Объект книги epub.js book: null, // Объект РєРЅРёРіРё epub.js
rendition: null, // Объект отображения epub.js rendition: null, // Объект отображения epub.js
currentCfi: null, // Текущая позиция (идентификатор) в EPUB currentCfi: null, // Текущая позиция (идентификатор) РІ EPUB
totalPages: 0, // Всего страниц totalPages: 0, // Всего страниц
bookFormat: '', // epub или fb2 bookFormat: '', // epub или fb2
isBookLoaded: false, isBookLoaded: false,
fb2CurrentPage: 0, fb2CurrentPage: 0,
fb2TotalPages: 1, fb2TotalPages: 1,
toc: [], // Оглавление toc: [], // Оглавление
lastCfi: null // Последний CFI lastCfi: null,
currentPage: 0,
currentFontSize: 18,
currentFontFamily: 'serif',
currentTheme: 'sepia',
brightness: 100
}; };
// ========== УТИЛИТЫ ========== // ========== УТИЛИТЫ ==========
function debugLog(msg) { // Логирование в консоль с префиксом function debugLog(msg) { // Логирование РІ консоль СЃ префиксом
console.log('[Reader] ' + msg); console.log('[Reader] ' + msg);
} }
function showError(msg) { // Показ экрана ошибки пользователю function showError(msg) { // Показ экрана ошибки пользователю
els.loading.style.display = 'none'; els.loading.style.display = 'none';
els.errorDisplay.style.display = 'flex'; els.errorDisplay.style.display = 'flex';
els.errorText.textContent = msg; els.errorText.textContent = msg;
debugLog('ERROR: ' + msg); debugLog('ERROR: ' + msg);
} }
function setLoadingText(msg) { // Обновление текста на экране загрузки function setLoadingText(msg) { // Обновление текста РЅР° экране загрузки
if (els.loadingText) els.loadingText.textContent = msg; if (els.loadingText) els.loadingText.textContent = msg;
debugLog(msg); debugLog(msg);
} }
const _escDiv = document.createElement('div'); // Буфер для очистки HTML const _escDiv = document.createElement('div'); // Буфер для очистки HTML
function escapeHtml(text) { // Защита от XSS: превращает < в &lt; и т.д. function escapeHtml(text) { // Защита РѕС XSS: превращает < РІ &lt; Рё С.Рґ.
_escDiv.textContent = text; _escDiv.textContent = text;
return _escDiv.innerHTML; return _escDiv.innerHTML;
} }
function base64ToArrayBuffer(base64) { // Конвертация данных из строки (Base64) в бинарный массив function base64ToArrayBuffer(base64) { // Конвертация данных РёР· строки (Base64) РІ бинарный массив
const bin = atob(base64); // Декодирование base64 const bin = atob(base64); // Декодирование base64
const len = bin.length; const len = bin.length;
const buf = new ArrayBuffer(len); const buf = new ArrayBuffer(len);
const view = new Uint8Array(buf); const view = new Uint8Array(buf);
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
view[i] = bin.charCodeAt(i); // Заполнение массива байтами view[i] = bin.charCodeAt(i); // Заполнение массива байтами
} }
return buf; return buf;
} }
function calculateOptimalLocationSize() { function calculateOptimalLocationSize() {
// 1. Получаем размеры видимой области // 1. Получаем размеры РІРёРґРёРјРѕР№ области
const width = window.innerWidth; const width = window.innerWidth;
const height = window.innerHeight; const height = window.innerHeight;
// 2. Получаем текущий размер шрифта (из состояния или напрямую из настроек) // 2. Получаем текущий размер шрифта (РёР· состояния или напрямую РёР· настроек)
// Если размер шрифта еще не задан, используем 18px по умолчанию // Если размер шрифта еще РЅРµ задан, используем 18px РїРѕ умолчанию
const fontSize = state.currentFontSize || 18; const fontSize = state.currentFontSize || 18;
// 3. Эмпирический коэффициент: // 3. Эмпирический коэффициент:
// На 1 квадратный пиксель при 18-м шрифте приходится примерно 0.005 - 0.007 символа. // РќР° 1 квадратный пиксель РїСЂРё 18-Рј шрифте приходится примерно 0.005 - 0.007 символа.
// Мы рассчитываем "площадь" одного символа. // РњС‹ рассчитываем "площадь" РѕРґРЅРѕРіРѕ символа.
// Чем больше шрифт, тем больше места занимает символ (квадратичная зависимость). // Чем больше шрифт, тем больше места занимает СЃРёРјРІРѕР» (квадратичная зависимость).
const charArea = (fontSize * fontSize) * 0.55; const charArea = (fontSize * fontSize) * 0.55;
// 4. Рассчитываем общую вместимость экрана в символах // 4. Рассчитываем общую вместимость экрана РІ символах
const screenArea = width * height; const screenArea = width * height;
let charactersPerScreen = Math.floor(screenArea / charArea); let charactersPerScreen = Math.floor(screenArea / charArea);
// 5. Ограничиваем значения для стабильности epub.js // 5. Ограничиваем значения для стабильности epub.js
// Минимум 400 (чтобы не плодить тысячи локаций на маленьких текстах) // РњРёРЅРёРјСѓРј 400 (чтобы РЅРµ плодить тысячи локаций РЅР° маленьких текстах)
// Максимум 1500 (чтобы избежать "залипания" на больших экранах) // Максимум 1500 (чтобы избежать "залипания" РЅР° больших экранах)
const finalSize = Math.max(400, Math.min(1000, charactersPerScreen)); const finalSize = Math.max(400, Math.min(1000, charactersPerScreen));
debugLog(`Расчет локации: Экран ${width}x${height}, Шрифт ${fontSize}px => Размер локации: ${finalSize}`); debugLog(`Расчет локации: Экран ${width}x${height}, Шрифт ${fontSize}px => Размер локации: ${finalSize}`);
return finalSize; return finalSize;
} }
// ========== MESSAGE BRIDGE (Связь с приложением) ========== // ========== MESSAGE BRIDGE (РЎРІСЏР·СЊ СЃ приложением) ==========
function sendMessage(action, data) { // Отправка данных в нативный код (C# / Swift / Kotlin) function sendMessage(action, data) { // Отправка данных РІ нативный РєРѕРґ (C# / Swift / Kotlin)
const message = JSON.stringify({ action, data: data || {} }); const message = JSON.stringify({ action, data: data || {} });
try { try {
if (window.HybridWebView && typeof window.HybridWebView.SendRawMessage === 'function') { if (window.HybridWebView && typeof window.HybridWebView.SendRawMessage === 'function') {
@@ -235,39 +234,39 @@
} }
} }
// ========== TOUCH / SWIPE (Жесты) ========== // ========== TOUCH / SWIPE (Жесты) ==========
let swipeStartX = 0, swipeStartY = 0, swipeStartTime = 0; let swipeStartX = 0, swipeStartY = 0, swipeStartTime = 0;
const touchActions = { // Действия при клике на зоны const touchActions = { // Действия РїСЂРё клике РЅР° Р·РѕРЅС‹
'touch-left': () => window.prevPage(), 'touch-left': () => window.prevPage(),
'touch-right': () => window.nextPage(), 'touch-right': () => window.nextPage(),
'touch-center': () => sendMessage('toggleMenu', {}), // Показать меню приложения 'touch-center': () => sendMessage('toggleMenu', {}), // Показать меню приложения
}; };
function initTouchZones() { // Настройка слушателей событий на зоны тача function initTouchZones() { // Настройка слушателей событий РЅР° Р·РѕРЅС‹ тача
Object.keys(touchActions).forEach(id => { Object.keys(touchActions).forEach(id => {
const el = $(id); const el = $(id);
if (!el) return; if (!el) return;
el.addEventListener('click', e => { // Обработка обычного клика el.addEventListener('click', e => { // Обработка обычного клика
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
touchActions[id](); touchActions[id]();
}); });
el.addEventListener('touchstart', e => { // Начало касания для свайпа el.addEventListener('touchstart', e => { // Начало касания для свайпа
const t = e.touches[0]; const t = e.touches[0];
swipeStartX = t.clientX; swipeStartX = t.clientX;
swipeStartY = t.clientY; swipeStartY = t.clientY;
swipeStartTime = Date.now(); swipeStartTime = Date.now();
}, { passive: true }); }, { passive: true });
el.addEventListener('touchend', e => { // Конец касания: расчет свайпа el.addEventListener('touchend', e => { // Конец касания: расчет свайпа
const t = e.changedTouches[0]; const t = e.changedTouches[0];
const dx = t.clientX - swipeStartX; // Смещение по горизонтали const dx = t.clientX - swipeStartX; // Смещение РїРѕ горизонтали
const dy = t.clientY - swipeStartY; // Смещение по вертикали const dy = t.clientY - swipeStartY; // Смещение РїРѕ вертикали
const dt = Date.now() - swipeStartTime; // Время касания const dt = Date.now() - swipeStartTime; // Время касания
// Если быстро (меньше 0.5с), горизонтально и длиннее 50px // Если быстро (меньше 0.5СЃ), горизонтально Рё длиннее 50px
if (dt < 500 && Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) { if (dt < 500 && Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) {
dx < 0 ? window.nextPage() : window.prevPage(); dx < 0 ? window.nextPage() : window.prevPage();
} }
@@ -283,20 +282,20 @@
if (typeof JSZip === 'undefined') { showError('JSZip not loaded!'); return; } if (typeof JSZip === 'undefined') { showError('JSZip not loaded!'); return; }
if (typeof ePub === 'undefined') { showError('epub.js not loaded!'); return; } if (typeof ePub === 'undefined') { showError('epub.js not loaded!'); return; }
state.book = ePub(arrayBuffer); // Создание объекта книги из данных state.book = ePub(arrayBuffer); // Создание объекта РєРЅРёРіРё РёР· данных
state.lastCfi = null; state.lastCfi = null;
els.fb2Content.style.display = 'none'; els.fb2Content.style.display = 'none';
els.bookContent.style.display = 'block'; els.bookContent.style.display = 'block';
// Рендеринг (отрисовка) книги в контейнер // Рендеринг (отрисовка) РєРЅРёРіРё РІ контейнер
state.rendition = state.book.renderTo('book-content', { state.rendition = state.book.renderTo('book-content', {
width: '100%', width: '100%',
height: '100%', height: '100%',
spread: 'none', // Без двухстраничного режима spread: 'none', // Без двухстраничного режима
flow: 'paginated' // Режим постраничного отображения flow: 'paginated' // Режим постраничного отображения
}); });
// Настройка стилей внутри фрейма книги // Настройка стилей внутри фрейма РєРЅРёРіРё
state.rendition.themes.default({ state.rendition.themes.default({
'body': { 'body': {
'font-family': 'serif !important', 'font-family': 'serif !important',
@@ -310,32 +309,32 @@
}); });
state.book.ready.then(() => { state.book.ready.then(() => {
// Убираем экран загрузки СРАЗУ, как только книга готова к отрисовке первой страницы // Убираем экран загрузки РЎР РђР—РЈ, как только РєРЅРёРіР° готова Рє отрисовке первой страницы
els.loading.style.display = 'none'; els.loading.style.display = 'none';
// Обработка оглавления // Обработка оглавления
const toc = state.book.navigation.toc || []; const toc = state.book.navigation.toc || [];
state.toc = toc.map(ch => ({ label: ch.label.trim(), href: ch.href })); state.toc = toc.map(ch => ({ label: ch.label.trim(), href: ch.href }));
sendMessage('chaptersLoaded', { chapters: state.toc }); sendMessage('chaptersLoaded', { chapters: state.toc });
// ПРОВЕРКА КЭША: Если мы уже передали сохраненные локации // ПРОВЕРКА РљР­РЁРђ: Если РјС СѓР¶Рµ передали сохраненные локации
if (cachedLocations) { if (cachedLocations) {
debugLog("Загрузка из кэша..."); debugLog("Загрузка РёР· кэша...");
state.book.locations.load(cachedLocations); state.book.locations.load(cachedLocations);
state.totalPages = state.book.locations.length(); state.totalPages = state.book.locations.length();
state.lastCfi = null; state.lastCfi = null;
sendMessage('bookReady', { totalPages: state.totalPages }); sendMessage('bookReady', { totalPages: state.totalPages });
} else { } else {
// Используем setTimeout, чтобы не блокировать поток отрисовки // Используем setTimeout, чтобы РЅРµ блокировать поток отрисовки
const dynamicSize = calculateOptimalLocationSize(); const dynamicSize = calculateOptimalLocationSize();
// Запускаем генерацию с динамическим размером // Запускаем генерацию СЃ динамическим размером
setTimeout(() => { setTimeout(() => {
state.book.locations.generate(1000).then(() => { state.book.locations.generate(1000).then(() => {
state.totalPages = state.book.locations.length(); state.totalPages = state.book.locations.length();
const locationsToSave = state.book.locations.save(); const locationsToSave = state.book.locations.save();
state.lastCfi = null; state.lastCfi = null;
// Отправляем в C#, чтобы сохранить на будущее // Отправляем РІ C#, чтобы сохранить РЅР° будущее
sendMessage('saveLocations', { locations: locationsToSave }); sendMessage('saveLocations', { locations: locationsToSave });
sendMessage('bookReady', { totalPages: state.totalPages }); sendMessage('bookReady', { totalPages: state.totalPages });
}); });
@@ -343,32 +342,32 @@
} }
}); });
// Событие при смене страницы // Событие РїСЂРё смене страницы
state.rendition.on('relocated', location => { state.rendition.on('relocated', location => {
if (!location || !location.start) return; if (!location || !location.start) return;
const newCfi = location.start.cfi; const newCfi = location.start.cfi;
// Получаем процент прогресса // Получаем процент прогресса
const progress = state.book.locations.percentageFromCfi(newCfi) || 0; const progress = state.book.locations.percentageFromCfi(newCfi) || 0;
// Обновляем lastCfi // Обновляем lastCfi
state.lastCfi = newCfi; state.lastCfi = newCfi;
const chapterPage = location.start.displayed ? location.start.displayed.page : 1; const chapterPage = location.start.displayed ? location.start.displayed.page : 1;
const chapterTotal = location.start.displayed ? location.start.displayed.total : 1; const chapterTotal = location.start.displayed ? location.start.displayed.total : 1;
const currentHref = location.start.href || ''; const currentHref = location.start.href || '';
// Поиск человеческого названия текущей главы // РџРѕРёСЃРє человеческого названия текущей главы
let chapterName = currentHref; let chapterName = currentHref;
const foundChapter = state.toc.find(ch => currentHref.includes(ch.href)); const foundChapter = state.toc.find(ch => currentHref.includes(ch.href));
if (foundChapter) chapterName = foundChapter.label; if (foundChapter) chapterName = foundChapter.label;
// Отправка прогресса в приложение // Отправка прогресса РІ приложение
// currentPage теперь показывает процент (округлённый до целого) // currentPage теперь показывает процент (округлённый РґРѕ целого)
sendMessage('progressUpdate', { sendMessage('progressUpdate', {
progress: progress, progress: progress,
cfi: newCfi, cfi: newCfi,
currentPage: Math.round(progress * 100), // Процент вместо номера страницы currentPage: Math.round(progress * 100), // Процент вместо номера страницы
totalPages: 100, // 100% totalPages: 100, // 100%
chapterCurrentPage: chapterPage, chapterCurrentPage: chapterPage,
chapterTotalPages: chapterTotal, chapterTotalPages: chapterTotal,
@@ -376,7 +375,7 @@
}); });
}); });
// Отображение книги на сохраненной позиции или в начале // Отображение РєРЅРёРіРё РЅР° сохраненной позиции или РІ начале
const displayTarget = (lastCfi && lastCfi !== 'null' && lastCfi !== 'undefined') ? lastCfi : undefined; const displayTarget = (lastCfi && lastCfi !== 'null' && lastCfi !== 'undefined') ? lastCfi : undefined;
state.rendition.display(displayTarget); state.rendition.display(displayTarget);
@@ -391,7 +390,7 @@
const bytes = new Uint8Array(arrayBuffer); const bytes = new Uint8Array(arrayBuffer);
let xmlText = new TextDecoder('utf-8').decode(bytes); let xmlText = new TextDecoder('utf-8').decode(bytes);
// Проверка кодировки в заголовке XML (если не UTF-8, перекодируем) // Проверка РєРѕРґРёСЂРѕРІРєРё РІ заголовке XML (если РЅРµ UTF-8, перекодируем)
const encMatch = xmlText.match(/encoding=["\']([^"\']+)["\']/i); const encMatch = xmlText.match(/encoding=["\']([^"\']+)["\']/i);
if (encMatch && encMatch[1].toLowerCase() !== 'utf-8') { if (encMatch && encMatch[1].toLowerCase() !== 'utf-8') {
xmlText = new TextDecoder(encMatch[1]).decode(bytes); xmlText = new TextDecoder(encMatch[1]).decode(bytes);
@@ -401,18 +400,18 @@
els.bookContent.style.display = 'none'; els.bookContent.style.display = 'none';
els.fb2Content.style.display = 'block'; els.fb2Content.style.display = 'block';
// Парсинг строки в XML-документ // Парсинг строки РІ XML-документ
const doc = new DOMParser().parseFromString(xmlText, 'text/xml'); const doc = new DOMParser().parseFromString(xmlText, 'text/xml');
if (doc.querySelector('parsererror')) { showError('FB2 XML parse error'); return; } if (doc.querySelector('parsererror')) { showError('FB2 XML parse error'); return; }
const fb2Html = parseFb2Document(doc); // Преобразование FB2 XML в HTML const fb2Html = parseFb2Document(doc); // Преобразование FB2 XML РІ HTML
els.fb2Content.innerHTML = fb2Html.html; els.fb2Content.innerHTML = fb2Html.html;
els.loading.style.display = 'none'; els.loading.style.display = 'none';
// Использование анимационных кадров для замера размеров после вставки в DOM // Использование анимационных кадров для замера размеров после вставки РІ DOM
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
setupFb2Pagination(); // Нарезка на колонки setupFb2Pagination(); // Нарезка РЅР° колонки
sendMessage('chaptersLoaded', { chapters: fb2Html.chapters }); sendMessage('chaptersLoaded', { chapters: fb2Html.chapters });
sendMessage('bookReady', { totalPages: state.totalPages }); sendMessage('bookReady', { totalPages: state.totalPages });
state.isBookLoaded = true; state.isBookLoaded = true;
@@ -424,20 +423,20 @@
} catch (e) { showError('FB2 error: ' + e.message); } } catch (e) { showError('FB2 error: ' + e.message); }
} }
// ========== FB2 PARSER (Преобразование тегов) ========== // ========== FB2 PARSER (Преобразование тегов) ==========
function localTag(el) { // Утилита для получения имени тега без пространств имен (типа fb2:) function localTag(el) { // Утилита для получения имени тега без пространств имен (типа fb2:)
return el.tagName ? el.tagName.toLowerCase().replace(/.*:/, '') : ''; return el.tagName ? el.tagName.toLowerCase().replace(/.*:/, '') : '';
} }
function parseFb2Document(doc) { function parseFb2Document(doc) {
const chapters = []; const chapters = [];
const parts = ['<div id="fb2-inner" style="font-size:18px;font-family:serif;line-height:1.6;">']; const parts = ['<div id="fb2-inner" style="font-size:18px;font-family:serif;line-height:1.6;">'];
let bodies = doc.querySelectorAll('body'); // FB2 может иметь несколько body (основной текст, сноски) let bodies = doc.querySelectorAll('body'); // FB2 может иметь несколько body (РѕСЃРЅРѕРІРЅРѕР№ текст, СЃРЅРѕСЃРєРё)
let chapterIndex = 0; let chapterIndex = 0;
for (const body of bodies) { for (const body of bodies) {
for (const child of body.children) { for (const child of body.children) {
if (localTag(child) === 'section') { // Секции — это главы if (localTag(child) === 'section') { // Секции — это главы
const result = parseFb2Section(child, chapterIndex); const result = parseFb2Section(child, chapterIndex);
parts.push(result.html); parts.push(result.html);
chapters.push(...result.chapters); chapters.push(...result.chapters);
@@ -449,14 +448,14 @@
return { html: parts.join(''), chapters }; return { html: parts.join(''), chapters };
} }
// Обработчики конкретных тегов FB2 // Обработчики конкретных тегов FB2
const sectionTagHandlers = { const sectionTagHandlers = {
title(child, idx, chapters) { // Заголовки глав title(child, idx, chapters) { // Заголовки глав
const text = (child.textContent || '').trim(); const text = (child.textContent || '').trim();
chapters.push({ label: text, href: idx.toString() }); chapters.push({ label: text, href: idx.toString() });
return `<h2 class="fb2-title" data-chapter="${idx}" style="text-align:center;margin:1em 0 .5em">${escapeHtml(text)}</h2>`; return `<h2 class="fb2-title" data-chapter="${idx}" style="text-align:center;margin:1em 0 .5em">${escapeHtml(text)}</h2>`;
}, },
p(child) { // Абзацы p(child) { // Абзацы
return `<p style="text-indent:1.5em;margin-bottom:.3em">${getInlineHtml(child)}</p>`; return `<p style="text-indent:1.5em;margin-bottom:.3em">${getInlineHtml(child)}</p>`;
}, },
'empty-line'() { return '<br/>'; }, 'empty-line'() { return '<br/>'; },
@@ -471,7 +470,7 @@
const parts = [`<div class="fb2-section" data-section="${startIndex}">`]; const parts = [`<div class="fb2-section" data-section="${startIndex}">`];
for (const child of section.children) { for (const child of section.children) {
const tag = localTag(child); const tag = localTag(child);
if (tag === 'section') { // Рекурсия для вложенных секций if (tag === 'section') { // Рекурсия для вложенных секций
const sub = parseFb2Section(child, startIndex + chapters.length); const sub = parseFb2Section(child, startIndex + chapters.length);
parts.push(sub.html); parts.push(sub.html);
chapters.push(...sub.chapters); chapters.push(...sub.chapters);
@@ -483,11 +482,11 @@
return { html: parts.join(''), chapters }; return { html: parts.join(''), chapters };
} }
function getInlineHtml(el) { // Обработка форматирования внутри строки (жирный, курсив) function getInlineHtml(el) { // Обработка форматирования внутри строки (жирный, РєСѓСЂСЃРёРІ)
const parts = []; const parts = [];
for (const node of el.childNodes) { for (const node of el.childNodes) {
if (node.nodeType === 3) parts.push(escapeHtml(node.textContent)); // Текст if (node.nodeType === 3) parts.push(escapeHtml(node.textContent)); // Текст
else if (node.nodeType === 1) { // Тег else if (node.nodeType === 1) { // Тег
const tag = localTag(node); const tag = localTag(node);
const inner = getInlineHtml(node); const inner = getInlineHtml(node);
if (tag === 'strong' || tag === 'bold') parts.push(`<strong>${inner}</strong>`); if (tag === 'strong' || tag === 'bold') parts.push(`<strong>${inner}</strong>`);
@@ -499,17 +498,17 @@
return parts.join(''); return parts.join('');
} }
function parseInnerParagraphs(el) { // Парсинг группы абзацев (для цитат/эпиграфов) function parseInnerParagraphs(el) { // Парсинг РіСЂСѓРїРїС‹ абзацев (для цитат/эпиграфов)
const parts = []; const parts = [];
for (const child of el.children) { for (const child of el.children) {
const tag = localTag(child); const tag = localTag(child);
if (tag === 'p') parts.push(`<p>${getInlineHtml(child)}</p>`); if (tag === 'p') parts.push(`<p>${getInlineHtml(child)}</p>`);
else if (tag === 'text-author') parts.push(`<p style="text-align:right;font-style:italic"> ${escapeHtml(child.textContent || '')}</p>`); else if (tag === 'text-author') parts.push(`<p style="text-align:right;font-style:italic">— ${escapeHtml(child.textContent || '')}</p>`);
} }
return parts.join(''); return parts.join('');
} }
function parsePoem(el) { // Парсинг стихов (строфы и строки) function parsePoem(el) { // Парсинг стихов (строфы Рё строки)
const parts = []; const parts = [];
for (const child of el.children) { for (const child of el.children) {
const tag = localTag(child); const tag = localTag(child);
@@ -526,7 +525,7 @@
return parts.join(''); return parts.join('');
} }
// ========== FB2 PAGINATION (Имитация страниц через CSS Columns) ========== // ========== FB2 PAGINATION (Имитация страниц через CSS Columns) ==========
function setupFb2Pagination() { function setupFb2Pagination() {
const container = els.fb2Content; const container = els.fb2Content;
const inner = $('fb2-inner'); const inner = $('fb2-inner');
@@ -535,17 +534,17 @@
const w = container.clientWidth; const w = container.clientWidth;
const h = container.clientHeight; const h = container.clientHeight;
// Основная магия: CSS превращает длинный текст в ряд колонок шириной с экран // Основная магия: CSS превращает длинный текст РІ СЂСЏРґ колонок шириной СЃ экран
Object.assign(inner.style, { Object.assign(inner.style, {
columnWidth: w + 'px', columnWidth: w + 'px',
columnGap: '40px', // Зазор между колонками columnGap: '40px', // Зазор между колонками
columnFill: 'auto', columnFill: 'auto',
height: h + 'px', height: h + 'px',
overflow: 'hidden', overflow: 'hidden',
}); });
requestAnimationFrame(() => { requestAnimationFrame(() => {
// Общая ширина контента делить на ширину экрана = количество страниц // Общая ширина контента делить РЅР° ширину экрана = количество страниц
state.fb2TotalPages = Math.max(1, Math.ceil(inner.scrollWidth / w)); state.fb2TotalPages = Math.max(1, Math.ceil(inner.scrollWidth / w));
state.totalPages = state.fb2TotalPages; state.totalPages = state.fb2TotalPages;
state.fb2CurrentPage = 0; state.fb2CurrentPage = 0;
@@ -554,17 +553,17 @@
}); });
} }
function showFb2Page(idx) { // Переход на конкретную страницу FB2 function showFb2Page(idx) { // Переход РЅР° конкретную страницу FB2
idx = Math.max(0, Math.min(idx, state.fb2TotalPages - 1)); idx = Math.max(0, Math.min(idx, state.fb2TotalPages - 1));
state.fb2CurrentPage = idx; state.fb2CurrentPage = idx;
const inner = $('fb2-inner'); const inner = $('fb2-inner');
if (inner) { if (inner) {
// Сдвигаем контент влево, чтобы показать нужную "колонку" // Сдвигаем контент влево, чтобы показать РЅСѓР¶РЅСѓСЋ "колонку"
inner.style.transform = `translateX(-${idx * els.fb2Content.clientWidth}px)`; inner.style.transform = `translateX(-${idx * els.fb2Content.clientWidth}px)`;
} }
} }
function updateFb2Progress() { // Оповещение приложения о прогрессе в FB2 function updateFb2Progress() { // Оповещение приложения Рѕ прогрессе РІ FB2
const total = state.fb2TotalPages; const total = state.fb2TotalPages;
const progress = total > 1 ? state.fb2CurrentPage / (total - 1) : 0; const progress = total > 1 ? state.fb2CurrentPage / (total - 1) : 0;
sendMessage('progressUpdate', { sendMessage('progressUpdate', {
@@ -576,7 +575,7 @@
}); });
} }
function getCurrentFb2Chapter() { // Поиск заголовка, который сейчас виден на экране function getCurrentFb2Chapter() { // РџРѕРёСЃРє заголовка, который сейчас виден РЅР° экране
const inner = $('fb2-inner'); const inner = $('fb2-inner');
const container = els.fb2Content; const container = els.fb2Content;
if (!inner || !container) return ''; if (!inner || !container) return '';
@@ -590,15 +589,15 @@
return chapter; return chapter;
} }
function goToFb2Position(progress) { // Переход по проценту (0.0 - 1.0) function goToFb2Position(progress) { // Переход РїРѕ проценту (0.0 - 1.0)
showFb2Page(Math.round(progress * (state.fb2TotalPages - 1))); showFb2Page(Math.round(progress * (state.fb2TotalPages - 1)));
} }
// ========== PUBLIC API (Методы, доступные извне, например из C#) ========== // ========== PUBLIC API (Методы, доступные РёР·РІРЅРµ, например РёР· C#) ==========
window.loadBookFromBase64 = function (base64Data, format, lastPosition, cachedLocations) { window.loadBookFromBase64 = function (base64Data, format, lastPosition, cachedLocations) {
state.bookFormat = format; state.bookFormat = format;
if (format === 'epub') { if (format === 'epub') {
// Передаем кэш в основную функцию загрузки // Передаем РєСЌС€ РІ РѕСЃРЅРѕРІРЅСѓСЋ функцию загрузки
loadEpubFromBase64(base64Data, lastPosition, cachedLocations); loadEpubFromBase64(base64Data, lastPosition, cachedLocations);
} }
else if (format === 'fb2') { else if (format === 'fb2') {
@@ -607,7 +606,7 @@
else showError('Unsupported format: ' + format); else showError('Unsupported format: ' + format);
}; };
window.nextPage = function () { // Листать вперед window.nextPage = function () { // Листать вперед
if (state.bookFormat === 'epub' && state.rendition) { if (state.bookFormat === 'epub' && state.rendition) {
state.rendition.next(); state.rendition.next();
} }
@@ -617,7 +616,7 @@
} }
}; };
window.prevPage = function () { // Листать назад window.prevPage = function () { // Листать назад
if (state.bookFormat === 'epub' && state.rendition) { if (state.bookFormat === 'epub' && state.rendition) {
state.rendition.prev(); state.rendition.prev();
} }
@@ -628,17 +627,19 @@
}; };
window.setFontSize = function (size) { // Изменение размера шрифта window.setFontSize = function (size) { // Изменение размера шрифта
state.currentFontSize = size;
if (state.bookFormat === 'epub' && state.rendition) state.rendition.themes.fontSize(size + 'px'); if (state.bookFormat === 'epub' && state.rendition) state.rendition.themes.fontSize(size + 'px');
else if (state.bookFormat === 'fb2') { else if (state.bookFormat === 'fb2') {
const inner = $('fb2-inner'); const inner = $('fb2-inner');
if (inner) { if (inner) {
inner.style.fontSize = size + 'px'; inner.style.fontSize = size + 'px';
requestAnimationFrame(setupFb2Pagination); // Пересчитать страницы! requestAnimationFrame(setupFb2Pagination);
} }
} }
}; };
window.setFontFamily = function (family) { // Изменение гарнитуры шрифта window.setFontFamily = function (family) { // Изменение гарнитуры шрифта
state.currentFontFamily = family;
if (state.bookFormat === 'epub' && state.rendition) state.rendition.themes.font(family); if (state.bookFormat === 'epub' && state.rendition) state.rendition.themes.font(family);
else if (state.bookFormat === 'fb2') { else if (state.bookFormat === 'fb2') {
const inner = $('fb2-inner'); const inner = $('fb2-inner');
@@ -649,7 +650,63 @@
} }
}; };
window.goToChapter = function (href) { // Переход к главе из оглавления function getThemePalette(theme) {
switch (theme) {
case 'dark':
return { background: '#181411', color: '#F4E7D8' };
case 'light':
return { background: '#FFFDF8', color: '#2B1B15' };
default:
return { background: '#FAF1E2', color: '#33231B' };
}
}
function applyReaderTheme() {
const palette = getThemePalette(state.currentTheme);
document.body.style.backgroundColor = palette.background;
els.bookContent.style.backgroundColor = palette.background;
els.fb2Content.style.backgroundColor = palette.background;
if (state.rendition) {
state.rendition.themes.default({
'body': {
'font-family': state.currentFontFamily + ' !important',
'font-size': state.currentFontSize + 'px !important',
'line-height': '1.6 !important',
'padding': '15px !important',
'background-color': palette.background + ' !important',
'color': palette.color + ' !important'
},
'p': { 'text-indent': '1.5em', 'margin-bottom': '0.5em' }
});
state.rendition.themes.fontSize(state.currentFontSize + 'px');
state.rendition.themes.font(state.currentFontFamily);
}
const inner = $('fb2-inner');
if (inner) {
inner.style.backgroundColor = palette.background;
inner.style.color = palette.color;
}
}
function applyReaderBrightness() {
const brightness = Math.max(0.7, Math.min(1.2, state.brightness / 100));
els.bookContent.style.filter = `brightness(${brightness})`;
els.fb2Content.style.filter = `brightness(${brightness})`;
}
window.setReaderTheme = function (theme) {
state.currentTheme = theme || 'sepia';
applyReaderTheme();
};
window.setBrightness = function (value) {
state.brightness = value || 100;
applyReaderBrightness();
};
window.goToChapter = function (href) { // Переход к главе из оглавления
if (state.bookFormat === 'epub' && state.rendition) state.rendition.display(href); if (state.bookFormat === 'epub' && state.rendition) state.rendition.display(href);
else if (state.bookFormat === 'fb2') { else if (state.bookFormat === 'fb2') {
const el = $('fb2-inner').querySelector(`[data-chapter="${href}"]`); const el = $('fb2-inner').querySelector(`[data-chapter="${href}"]`);
@@ -660,7 +717,7 @@
} }
}; };
window.getProgress = function () { // Запрос текущего состояния прогресса (в JSON) window.getProgress = function () { // Запрос текущего состояния прогресса (РІ JSON)
if (state.bookFormat === 'epub' && state.book && state.currentCfi) { if (state.bookFormat === 'epub' && state.book && state.currentCfi) {
try { try {
return JSON.stringify({ return JSON.stringify({
@@ -680,12 +737,14 @@
return '{}'; return '{}';
}; };
// ========== ИНИЦИАЛИЗАЦИЯ ========== // ========== ИНИЦИАЛИЗАЦИЯ ==========
initTouchZones(); // Включаем зоны кликов initTouchZones(); // Включаем Р·РѕРЅС‹ кликов
setLoadingText('Waiting for book...'); // Сообщаем, что готовы принимать файл setLoadingText('Waiting for book...'); // Сообщаем, что РіРѕСРѕРІС РїСЂРёРЅРёРјР°С‚СЊ файл
sendMessage('readerReady', {}); // Уведомляем нативное приложение: "Я загрузился!" sendMessage('readerReady', {}); // Уведомляем нативное приложение: "РЇ загрузился!"
})(); })();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -4,33 +4,141 @@
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit" xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:converters="clr-namespace:BookReader.Converters"> xmlns:converters="clr-namespace:BookReader.Converters">
<!-- Converters from CommunityToolkit -->
<toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" /> <toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" />
<toolkit:IsStringNotNullOrEmptyConverter x:Key="IsNotNullOrEmptyConverter" /> <toolkit:IsStringNotNullOrEmptyConverter x:Key="IsNotNullOrEmptyConverter" />
<!-- Custom Converters -->
<converters:ProgressToWidthConverter x:Key="ProgressToWidthConverter" /> <converters:ProgressToWidthConverter x:Key="ProgressToWidthConverter" />
<converters:ByteArrayToImageSourceConverter x:Key="ByteArrayToImageConverter" /> <converters:ByteArrayToImageSourceConverter x:Key="ByteArrayToImageConverter" />
<!-- Colors --> <Color x:Key="AppBackground">#F5E8D6</Color>
<Color x:Key="PrimaryBrown">#5D4037</Color> <Color x:Key="AppBackgroundDeep">#EBC9AE</Color>
<Color x:Key="DarkBrown">#3E2723</Color> <Color x:Key="SurfaceColor">#FFF8F0</Color>
<Color x:Key="LightBrown">#8D6E63</Color> <Color x:Key="SurfaceMuted">#F7E8D9</Color>
<Color x:Key="ShelfColor">#6D4C41</Color> <Color x:Key="SurfaceStrong">#EED7C0</Color>
<Color x:Key="AccentGreen">#4CAF50</Color> <Color x:Key="InkColor">#27160E</Color>
<Color x:Key="TextPrimary">#EFEBE9</Color> <Color x:Key="InkSoftColor">#6E5648</Color>
<Color x:Key="TextSecondary">#A1887F</Color> <Color x:Key="BorderColor">#DEC2AA</Color>
<Color x:Key="AccentColor">#A65436</Color>
<Color x:Key="AccentDarkColor">#6F311D</Color>
<Color x:Key="AccentSoftColor">#DFA98D</Color>
<Color x:Key="SuccessColor">#2F7D5A</Color>
<Color x:Key="WarningColor">#C47A3E</Color>
<Color x:Key="DangerColor">#B64932</Color>
<Color x:Key="TabBarColor">#2B1B15</Color>
<LinearGradientBrush x:Key="AppBackgroundBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="{StaticResource AppBackground}" Offset="0.0" />
<GradientStop Color="{StaticResource AppBackgroundDeep}" Offset="1.0" />
</LinearGradientBrush>
<!-- Styles -->
<Style TargetType="NavigationPage"> <Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="{StaticResource DarkBrown}" /> <Setter Property="BarBackgroundColor" Value="{StaticResource TabBarColor}" />
<Setter Property="BarTextColor" Value="White" /> <Setter Property="BarTextColor" Value="White" />
</Style> </Style>
<Style TargetType="Shell"> <Style TargetType="Shell">
<Setter Property="Shell.BackgroundColor" Value="{StaticResource DarkBrown}" /> <Setter Property="Shell.BackgroundColor" Value="{StaticResource TabBarColor}" />
<Setter Property="Shell.ForegroundColor" Value="White" /> <Setter Property="Shell.ForegroundColor" Value="White" />
<Setter Property="Shell.TitleColor" Value="White" /> <Setter Property="Shell.TitleColor" Value="White" />
<Setter Property="Shell.TabBarBackgroundColor" Value="{StaticResource TabBarColor}" />
<Setter Property="Shell.TabBarForegroundColor" Value="#C9B4A6" />
<Setter Property="Shell.TabBarTitleColor" Value="#C9B4A6" />
<Setter Property="Shell.TabBarUnselectedColor" Value="#9E8474" />
</Style> </Style>
<Style x:Key="PageTitleStyle" TargetType="Label">
<Setter Property="FontSize" Value="28" />
<Setter Property="FontFamily" Value="OpenSansSemibold" />
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
<Setter Property="LineBreakMode" Value="WordWrap" />
</Style>
<Style x:Key="PageSubtitleStyle" TargetType="Label">
<Setter Property="FontSize" Value="14" />
<Setter Property="TextColor" Value="{StaticResource InkSoftColor}" />
<Setter Property="LineBreakMode" Value="WordWrap" />
</Style>
<Style x:Key="SectionTitleStyle" TargetType="Label">
<Setter Property="FontSize" Value="18" />
<Setter Property="FontFamily" Value="OpenSansSemibold" />
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
</Style>
<Style x:Key="CaptionStyle" TargetType="Label">
<Setter Property="FontSize" Value="12" />
<Setter Property="TextColor" Value="{StaticResource InkSoftColor}" />
</Style>
<Style x:Key="CardBorderStyle" TargetType="Border">
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceColor}" />
<Setter Property="Stroke" Value="{StaticResource BorderColor}" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="StrokeShape" Value="RoundRectangle 24" />
<Setter Property="Padding" Value="18" />
</Style>
<Style x:Key="MutedCardBorderStyle" TargetType="Border">
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceMuted}" />
<Setter Property="Stroke" Value="{StaticResource BorderColor}" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="StrokeShape" Value="RoundRectangle 24" />
<Setter Property="Padding" Value="18" />
</Style>
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="BackgroundColor" Value="{StaticResource AccentColor}" />
<Setter Property="TextColor" Value="White" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Padding" Value="18,12" />
<Setter Property="FontFamily" Value="OpenSansSemibold" />
<Setter Property="FontSize" Value="14" />
</Style>
<Style x:Key="SecondaryButtonStyle" TargetType="Button">
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceStrong}" />
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Padding" Value="18,12" />
<Setter Property="FontFamily" Value="OpenSansSemibold" />
<Setter Property="FontSize" Value="14" />
<Setter Property="BorderColor" Value="{StaticResource BorderColor}" />
<Setter Property="BorderWidth" Value="1" />
</Style>
<Style x:Key="TonalButtonStyle" TargetType="Button">
<Setter Property="BackgroundColor" Value="#E9D7C3" />
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="FontSize" Value="13" />
</Style>
<Style x:Key="AppSearchBarStyle" TargetType="SearchBar">
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceColor}" />
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
<Setter Property="PlaceholderColor" Value="{StaticResource InkSoftColor}" />
<Setter Property="CancelButtonColor" Value="{StaticResource AccentColor}" />
<Setter Property="HeightRequest" Value="52" />
</Style>
<Style x:Key="AppEntryStyle" TargetType="Entry">
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceColor}" />
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
<Setter Property="PlaceholderColor" Value="{StaticResource InkSoftColor}" />
<Setter Property="HeightRequest" Value="52" />
</Style>
<Style x:Key="AppPickerStyle" TargetType="Picker">
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceColor}" />
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
<Setter Property="HeightRequest" Value="52" />
<Setter Property="TitleColor" Value="{StaticResource InkSoftColor}" />
</Style>
<Style x:Key="ReaderSliderStyle" TargetType="Slider">
<Setter Property="MinimumTrackColor" Value="{StaticResource AccentColor}" />
<Setter Property="MaximumTrackColor" Value="{StaticResource SurfaceStrong}" />
<Setter Property="ThumbColor" Value="{StaticResource AccentDarkColor}" />
</Style>
</ResourceDictionary> </ResourceDictionary>

View File

@@ -10,8 +10,6 @@ public class CalibreWebService : ICalibreWebService
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ICoverCacheService _coverCacheService; private readonly ICoverCacheService _coverCacheService;
private string _baseUrl = string.Empty; private string _baseUrl = string.Empty;
private string _username = string.Empty;
private string _password = string.Empty;
public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService) public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService)
{ {
@@ -22,16 +20,18 @@ public class CalibreWebService : ICalibreWebService
public void Configure(string url, string username, string password) public void Configure(string url, string username, string password)
{ {
_baseUrl = url.TrimEnd('/'); _baseUrl = url.Trim().TrimEnd('/');
_username = username;
_password = password;
if (!string.IsNullOrEmpty(_username)) if (!string.IsNullOrWhiteSpace(username))
{ {
var authBytes = Encoding.ASCII.GetBytes($"{_username}:{_password}"); var authBytes = Encoding.ASCII.GetBytes($"{username}:{password}");
_httpClient.DefaultRequestHeaders.Authorization = _httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", Convert.ToBase64String(authBytes)); new AuthenticationHeaderValue("Basic", Convert.ToBase64String(authBytes));
} }
else
{
_httpClient.DefaultRequestHeaders.Authorization = null;
}
} }
public async Task<bool> TestConnectionAsync(string url, string username, string password) public async Task<bool> TestConnectionAsync(string url, string username, string password)
@@ -48,23 +48,26 @@ public class CalibreWebService : ICalibreWebService
} }
} }
public async Task<List<CalibreBook>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = Constants.Network.CalibrePageSize) public async Task<Result<List<CalibreBook>>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = Constants.Network.CalibrePageSize)
{ {
if (string.IsNullOrWhiteSpace(_baseUrl))
{
return Result<List<CalibreBook>>.Failure("Сначала укажите адрес сервера Calibre в настройках.");
}
var books = new List<CalibreBook>(); var books = new List<CalibreBook>();
try try
{ {
var offset = page * pageSize; var offset = page * pageSize;
var query = string.IsNullOrEmpty(searchQuery) ? "" : Uri.EscapeDataString(searchQuery); var query = string.IsNullOrWhiteSpace(searchQuery) ? string.Empty : Uri.EscapeDataString(searchQuery.Trim());
var url = $"{_baseUrl}/ajax/search?query={query}&num={pageSize}&offset={offset}&sort=timestamp&sort_order=desc"; var url = $"{_baseUrl}/ajax/search?query={query}&num={pageSize}&offset={offset}&sort=timestamp&sort_order=desc";
var response = await _httpClient.GetStringAsync(url); var response = await _httpClient.GetStringAsync(url);
var json = JObject.Parse(response); var json = JObject.Parse(response);
var bookIds = json["book_ids"]?.ToObject<List<int>>() ?? new List<int>(); var bookIds = json["book_ids"]?.ToObject<List<int>>() ?? new List<int>();
// Параллельная загрузка данных книг с ограничением var semaphore = new SemaphoreSlim(4);
var semaphore = new SemaphoreSlim(4); // Максимум 4 параллельных запроса
var tasks = bookIds.Select(async bookId => var tasks = bookIds.Select(async bookId =>
{ {
await semaphore.WaitAsync(); await semaphore.WaitAsync();
@@ -79,14 +82,22 @@ public class CalibreWebService : ICalibreWebService
}); });
var results = await Task.WhenAll(tasks); var results = await Task.WhenAll(tasks);
books.AddRange(results.Where(b => b != null)!); books.AddRange(results.Where(book => book != null)!);
return Result<List<CalibreBook>>.Success(books);
}
catch (TaskCanceledException ex)
{
return Result<List<CalibreBook>>.Failure(new TimeoutException("Сервер Calibre отвечает слишком долго.", ex));
}
catch (HttpRequestException ex)
{
return Result<List<CalibreBook>>.Failure(new HttpRequestException("Не удалось подключиться к серверу Calibre.", ex));
} }
catch (Exception ex) catch (Exception ex)
{ {
System.Diagnostics.Debug.WriteLine($"Error fetching Calibre books: {ex.Message}"); return Result<List<CalibreBook>>.Failure(new Exception("Не удалось загрузить каталог Calibre.", ex));
} }
return books;
} }
private async Task<CalibreBook?> LoadBookDataAsync(int bookId) private async Task<CalibreBook?> LoadBookDataAsync(int bookId)
@@ -98,25 +109,27 @@ public class CalibreWebService : ICalibreWebService
var bookJson = JObject.Parse(bookResponse); var bookJson = JObject.Parse(bookResponse);
var formats = bookJson["formats"]?.ToObject<List<string>>() ?? new List<string>(); var formats = bookJson["formats"]?.ToObject<List<string>>() ?? new List<string>();
var supportedFormat = formats.FirstOrDefault(f => var supportedFormat = formats.FirstOrDefault(format =>
f.Equals("EPUB", StringComparison.OrdinalIgnoreCase) || format.Equals("EPUB", StringComparison.OrdinalIgnoreCase) ||
f.Equals("FB2", StringComparison.OrdinalIgnoreCase)); format.Equals("FB2", StringComparison.OrdinalIgnoreCase));
if (supportedFormat == null) return null; if (supportedFormat == null)
{
return null;
}
var authors = bookJson["authors"]?.ToObject<List<string>>() ?? new List<string>(); var authors = bookJson["authors"]?.ToObject<List<string>>() ?? new List<string>();
var calibreBook = new CalibreBook var calibreBook = new CalibreBook
{ {
Id = bookId.ToString(), Id = bookId.ToString(),
Title = bookJson["title"]?.ToString() ?? "Unknown", Title = bookJson["title"]?.ToString() ?? "Без названия",
Author = string.Join(", ", authors), Author = string.Join(", ", authors),
Format = supportedFormat.ToLowerInvariant(), Format = supportedFormat.ToLowerInvariant(),
CoverUrl = $"{_baseUrl}/get/cover/{bookId}", CoverUrl = $"{_baseUrl}/get/cover/{bookId}",
DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}" DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}"
}; };
// Try to load cover from cache first
var cacheKey = $"cover_{bookId}"; var cacheKey = $"cover_{bookId}";
calibreBook.CoverImage = await _coverCacheService.GetCoverAsync(cacheKey); calibreBook.CoverImage = await _coverCacheService.GetCoverAsync(cacheKey);
@@ -127,7 +140,9 @@ public class CalibreWebService : ICalibreWebService
calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl); calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl);
await _coverCacheService.SetCoverAsync(cacheKey, calibreBook.CoverImage); await _coverCacheService.SetCoverAsync(cacheKey, calibreBook.CoverImage);
} }
catch { } catch
{
}
} }
return calibreBook; return calibreBook;
@@ -140,7 +155,7 @@ public class CalibreWebService : ICalibreWebService
public async Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null) public async Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null)
{ {
var booksDir = Path.Combine(FileSystem.AppDataDirectory, "Books"); var booksDir = Path.Combine(FileSystem.AppDataDirectory, Constants.Files.BooksFolder);
Directory.CreateDirectory(booksDir); Directory.CreateDirectory(booksDir);
var fileName = $"{Guid.NewGuid()}.{book.Format}"; var fileName = $"{Guid.NewGuid()}.{book.Format}";
@@ -164,8 +179,10 @@ public class CalibreWebService : ICalibreWebService
bytesRead += read; bytesRead += read;
if (totalBytes > 0) if (totalBytes > 0)
{
progress?.Report((double)bytesRead / totalBytes); progress?.Report((double)bytesRead / totalBytes);
} }
}
return filePath; return filePath;
} }

View File

@@ -13,16 +13,16 @@ public class DatabaseService : IDatabaseService
public DatabaseService() public DatabaseService()
{ {
_dbPath = Path.Combine(FileSystem.AppDataDirectory, "bookreader.db3"); _dbPath = Path.Combine(FileSystem.AppDataDirectory, Constants.Database.DatabaseFileName);
// Polly 8.x retry pipeline: 3 попытки с экспоненциальной задержкой
_retryPipeline = new ResiliencePipelineBuilder() _retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions .AddRetry(new RetryStrategyOptions
{ {
ShouldHandle = new PredicateBuilder().Handle<SQLiteException>() ShouldHandle = new PredicateBuilder().Handle<SQLiteException>()
.Handle<Exception>(ex => ex.Message.Contains("database is locked") || ex.Message.Contains("busy")), .Handle<Exception>(ex => ex.Message.Contains("database is locked") || ex.Message.Contains("busy")),
MaxRetryAttempts = 3, MaxRetryAttempts = Constants.Database.RetryCount,
DelayGenerator = context => new ValueTask<TimeSpan?>(TimeSpan.FromMilliseconds(100 * Math.Pow(2, context.AttemptNumber))), DelayGenerator = context => new ValueTask<TimeSpan?>(
TimeSpan.FromMilliseconds(Constants.Database.RetryBaseDelayMs * Math.Pow(2, context.AttemptNumber))),
OnRetry = context => OnRetry = context =>
{ {
System.Diagnostics.Debug.WriteLine($"[Database] Retry {context.AttemptNumber + 1} after {context.RetryDelay.TotalMilliseconds}ms: {context.Outcome.Exception?.Message}"); System.Diagnostics.Debug.WriteLine($"[Database] Retry {context.AttemptNumber + 1} after {context.RetryDelay.TotalMilliseconds}ms: {context.Outcome.Exception?.Message}");
@@ -34,11 +34,14 @@ public class DatabaseService : IDatabaseService
public async Task InitializeAsync(CancellationToken ct = default) public async Task InitializeAsync(CancellationToken ct = default)
{ {
if (_database != null) return; if (_database != null)
{
return;
}
_database = new SQLiteAsyncConnection(_dbPath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache); _database = new SQLiteAsyncConnection(_dbPath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
await _retryPipeline.ExecuteAsync(async (token) => await _retryPipeline.ExecuteAsync(async _ =>
{ {
await _database!.CreateTableAsync<Book>(); await _database!.CreateTableAsync<Book>();
await _database!.CreateTableAsync<AppSettings>(); await _database!.CreateTableAsync<AppSettings>();
@@ -49,31 +52,35 @@ public class DatabaseService : IDatabaseService
private async Task EnsureInitializedAsync() private async Task EnsureInitializedAsync()
{ {
if (_database == null) if (_database == null)
{
await InitializeAsync(); await InitializeAsync();
} }
}
// Books
public async Task<List<Book>> GetAllBooksAsync(CancellationToken ct = default) public async Task<List<Book>> GetAllBooksAsync(CancellationToken ct = default)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) => return await _retryPipeline.ExecuteAsync(async _ =>
await _database!.Table<Book>().OrderByDescending(b => b.LastRead).ToListAsync(), ct); await _database!.Table<Book>().OrderByDescending(book => book.LastRead).ToListAsync(), ct);
} }
public async Task<Book?> GetBookByIdAsync(int id, CancellationToken ct = default) public async Task<Book?> GetBookByIdAsync(int id, CancellationToken ct = default)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) => return await _retryPipeline.ExecuteAsync(async _ =>
await _database!.Table<Book>().Where(b => b.Id == id).FirstOrDefaultAsync(), ct); await _database!.Table<Book>().Where(book => book.Id == id).FirstOrDefaultAsync(), ct);
} }
public async Task<int> SaveBookAsync(Book book, CancellationToken ct = default) public async Task<int> SaveBookAsync(Book book, CancellationToken ct = default)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) => return await _retryPipeline.ExecuteAsync(async _ =>
{ {
if (book.Id != 0) if (book.Id != 0)
{
return await _database!.UpdateAsync(book); return await _database!.UpdateAsync(book);
}
return await _database!.InsertAsync(book); return await _database!.InsertAsync(book);
}, ct); }, ct);
} }
@@ -81,35 +88,36 @@ public class DatabaseService : IDatabaseService
public async Task<int> UpdateBookAsync(Book book, CancellationToken ct = default) public async Task<int> UpdateBookAsync(Book book, CancellationToken ct = default)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) => return await _retryPipeline.ExecuteAsync(async _ => await _database!.UpdateAsync(book), ct);
await _database!.UpdateAsync(book), ct);
} }
public async Task<int> DeleteBookAsync(Book book, CancellationToken ct = default) public async Task<int> DeleteBookAsync(Book book, CancellationToken ct = default)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
// Delete associated file
if (File.Exists(book.FilePath)) if (File.Exists(book.FilePath))
{ {
try { File.Delete(book.FilePath); } catch { } try
{
File.Delete(book.FilePath);
}
catch
{
}
} }
// Delete progress records await _retryPipeline.ExecuteAsync(async _ =>
await _retryPipeline.ExecuteAsync(async (token) => await _database!.Table<ReadingProgress>().DeleteAsync(progress => progress.BookId == book.Id), ct);
await _database!.Table<ReadingProgress>().DeleteAsync(p => p.BookId == book.Id), ct);
return await _retryPipeline.ExecuteAsync(async (token) => return await _retryPipeline.ExecuteAsync(async _ => await _database!.DeleteAsync(book), ct);
await _database!.DeleteAsync(book), ct);
} }
// Settings
public async Task<string?> GetSettingAsync(string key, CancellationToken ct = default) public async Task<string?> GetSettingAsync(string key, CancellationToken ct = default)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) => return await _retryPipeline.ExecuteAsync(async _ =>
{ {
var setting = await _database!.Table<AppSettings>().Where(s => s.Key == key).FirstOrDefaultAsync(); var setting = await _database!.Table<AppSettings>().Where(item => item.Key == key).FirstOrDefaultAsync();
return setting?.Value; return setting?.Value;
}, ct); }, ct);
} }
@@ -117,9 +125,9 @@ public class DatabaseService : IDatabaseService
public async Task SetSettingAsync(string key, string value, CancellationToken ct = default) public async Task SetSettingAsync(string key, string value, CancellationToken ct = default)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
await _retryPipeline.ExecuteAsync(async (token) => await _retryPipeline.ExecuteAsync(async _ =>
{ {
var existing = await _database!.Table<AppSettings>().Where(s => s.Key == key).FirstOrDefaultAsync(); var existing = await _database!.Table<AppSettings>().Where(item => item.Key == key).FirstOrDefaultAsync();
if (existing != null) if (existing != null)
{ {
existing.Value = value; existing.Value = value;
@@ -135,31 +143,42 @@ public class DatabaseService : IDatabaseService
public async Task<Dictionary<string, string>> GetAllSettingsAsync(CancellationToken ct = default) public async Task<Dictionary<string, string>> GetAllSettingsAsync(CancellationToken ct = default)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) => return await _retryPipeline.ExecuteAsync(async _ =>
{ {
var settings = await _database!.Table<AppSettings>().ToListAsync(); var settings = await _database!.Table<AppSettings>().ToListAsync();
return settings.ToDictionary(s => s.Key, s => s.Value); return settings.ToDictionary(setting => setting.Key, setting => setting.Value);
}, ct); }, ct);
} }
// Reading Progress
public async Task SaveProgressAsync(ReadingProgress progress, CancellationToken ct = default) public async Task SaveProgressAsync(ReadingProgress progress, CancellationToken ct = default)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
await _retryPipeline.ExecuteAsync(async (token) => await _retryPipeline.ExecuteAsync(async _ =>
{ {
progress.Timestamp = DateTime.UtcNow; progress.Timestamp = DateTime.UtcNow;
await _database!.InsertAsync(progress); await _database!.InsertAsync(progress);
await _database.ExecuteAsync(
@"DELETE FROM ReadingProgress
WHERE BookId = ?
AND Id NOT IN (
SELECT Id FROM ReadingProgress
WHERE BookId = ?
ORDER BY Timestamp DESC
LIMIT ?
)",
progress.BookId,
progress.BookId,
Constants.Database.MaxReadingHistoryEntriesPerBook);
}, ct); }, ct);
} }
public async Task<ReadingProgress?> GetLatestProgressAsync(int bookId, CancellationToken ct = default) public async Task<ReadingProgress?> GetLatestProgressAsync(int bookId, CancellationToken ct = default)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) => return await _retryPipeline.ExecuteAsync(async _ =>
await _database!.Table<ReadingProgress>() await _database!.Table<ReadingProgress>()
.Where(p => p.BookId == bookId) .Where(progress => progress.BookId == bookId)
.OrderByDescending(p => p.Timestamp) .OrderByDescending(progress => progress.Timestamp)
.FirstOrDefaultAsync(), ct); .FirstOrDefaultAsync(), ct);
} }
} }

View File

@@ -5,7 +5,7 @@ namespace BookReader.Services;
public interface ICalibreWebService public interface ICalibreWebService
{ {
Task<bool> TestConnectionAsync(string url, string username, string password); Task<bool> TestConnectionAsync(string url, string username, string password);
Task<List<CalibreBook>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = 20); Task<Result<List<CalibreBook>>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = 20);
Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null); Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null);
void Configure(string url, string username, string password); void Configure(string url, string username, string password);
} }

View File

@@ -6,9 +6,10 @@ public interface ISettingsService
Task SetAsync(string key, string value); Task SetAsync(string key, string value);
Task<int> GetIntAsync(string key, int defaultValue = 0); Task<int> GetIntAsync(string key, int defaultValue = 0);
Task SetIntAsync(string key, int value); Task SetIntAsync(string key, int value);
Task<double> GetDoubleAsync(string key, double defaultValue = 0);
Task SetDoubleAsync(string key, double value);
Task<Dictionary<string, string>> GetAllAsync(); Task<Dictionary<string, string>> GetAllAsync();
// Secure storage for sensitive data
Task SetSecurePasswordAsync(string password); Task SetSecurePasswordAsync(string password);
Task<string> GetSecurePasswordAsync(); Task<string> GetSecurePasswordAsync();
Task ClearSecurePasswordAsync(); Task ClearSecurePasswordAsync();

View File

@@ -1,4 +1,5 @@
using BookReader.Models; using BookReader.Models;
using System.Globalization;
namespace BookReader.Services; namespace BookReader.Services;
@@ -26,13 +27,32 @@ public class SettingsService : ISettingsService
{ {
var value = await _databaseService.GetSettingAsync(key); var value = await _databaseService.GetSettingAsync(key);
if (int.TryParse(value, out var result)) if (int.TryParse(value, out var result))
{
return result; return result;
}
return defaultValue; return defaultValue;
} }
public async Task SetIntAsync(string key, int value) public async Task SetIntAsync(string key, int value)
{ {
await _databaseService.SetSettingAsync(key, value.ToString()); await _databaseService.SetSettingAsync(key, value.ToString(CultureInfo.InvariantCulture));
}
public async Task<double> GetDoubleAsync(string key, double defaultValue = 0)
{
var value = await _databaseService.GetSettingAsync(key);
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result))
{
return result;
}
return defaultValue;
}
public async Task SetDoubleAsync(string key, double value)
{
await _databaseService.SetSettingAsync(key, value.ToString(CultureInfo.InvariantCulture));
} }
public async Task<Dictionary<string, string>> GetAllAsync() public async Task<Dictionary<string, string>> GetAllAsync()
@@ -47,7 +67,7 @@ public class SettingsService : ISettingsService
public async Task<string> GetSecurePasswordAsync() public async Task<string> GetSecurePasswordAsync()
{ {
return await SecureStorage.Default.GetAsync(SecureStorageKeys.CalibrePassword); return await SecureStorage.Default.GetAsync(SecureStorageKeys.CalibrePassword) ?? string.Empty;
} }
public async Task ClearSecurePasswordAsync() public async Task ClearSecurePasswordAsync()
@@ -56,3 +76,4 @@ public class SettingsService : ISettingsService
await Task.CompletedTask; await Task.CompletedTask;
} }
} }

View File

@@ -10,9 +10,7 @@ public partial class BookshelfViewModel : BaseViewModel
{ {
private readonly IDatabaseService _databaseService; private readonly IDatabaseService _databaseService;
private readonly IBookParserService _bookParserService; private readonly IBookParserService _bookParserService;
private readonly ISettingsService _settingsService;
private readonly INavigationService _navigationService; private readonly INavigationService _navigationService;
private readonly ICachedImageLoadingService _imageLoadingService;
public ObservableCollection<Book> Books { get; } = new(); public ObservableCollection<Book> Books { get; } = new();
@@ -22,29 +20,40 @@ public partial class BookshelfViewModel : BaseViewModel
[ObservableProperty] [ObservableProperty]
private string _searchText = string.Empty; private string _searchText = string.Empty;
[ObservableProperty]
private int _booksInProgress;
[ObservableProperty]
private int _completedBooks;
[ObservableProperty]
private Book? _continueReadingBook;
public bool HasContinueReading => ContinueReadingBook != null;
public string LibrarySummary => $"{Books.Count} книг в библиотеке";
partial void OnSearchTextChanged(string value) partial void OnSearchTextChanged(string value)
{ {
// Автоматически выполняем поиск при изменении текста
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
{ {
// Если поле пустое - загружаем все книги
LoadBooksCommand.Execute(null); LoadBooksCommand.Execute(null);
} }
} }
partial void OnContinueReadingBookChanged(Book? value)
{
OnPropertyChanged(nameof(HasContinueReading));
}
public BookshelfViewModel( public BookshelfViewModel(
IDatabaseService databaseService, IDatabaseService databaseService,
IBookParserService bookParserService, IBookParserService bookParserService,
ISettingsService settingsService, INavigationService navigationService)
INavigationService navigationService,
ICachedImageLoadingService imageLoadingService)
{ {
_databaseService = databaseService; _databaseService = databaseService;
_bookParserService = bookParserService; _bookParserService = bookParserService;
_settingsService = settingsService;
_navigationService = navigationService; _navigationService = navigationService;
_imageLoadingService = imageLoadingService; Title = "Библиотека";
Title = "My Library";
} }
[RelayCommand] [RelayCommand]
@@ -56,14 +65,13 @@ public partial class BookshelfViewModel : BaseViewModel
{ {
var books = await _databaseService.GetAllBooksAsync(); var books = await _databaseService.GetAllBooksAsync();
// Применяем фильтр поиска если есть
if (!string.IsNullOrWhiteSpace(SearchText)) if (!string.IsNullOrWhiteSpace(SearchText))
{ {
var searchLower = SearchText.ToLowerInvariant(); var searchLower = SearchText.Trim().ToLowerInvariant();
books = books.Where(b => books = books.Where(book =>
b.Title.ToLowerInvariant().Contains(searchLower) || book.Title.ToLowerInvariant().Contains(searchLower) ||
b.Author.ToLowerInvariant().Contains(searchLower) book.Author.ToLowerInvariant().Contains(searchLower))
).ToList(); .ToList();
} }
Books.Clear(); Books.Clear();
@@ -71,11 +79,12 @@ public partial class BookshelfViewModel : BaseViewModel
{ {
Books.Add(book); Books.Add(book);
} }
IsEmpty = Books.Count == 0;
RefreshLibraryState();
} }
catch (Exception ex) catch (Exception ex)
{ {
System.Diagnostics.Debug.WriteLine($"Error loading books: {ex.Message}"); await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось загрузить библиотеку: {ex.Message}", "OK");
} }
finally finally
{ {
@@ -86,15 +95,8 @@ public partial class BookshelfViewModel : BaseViewModel
[RelayCommand] [RelayCommand]
public async Task SearchAsync(object? parameter) public async Task SearchAsync(object? parameter)
{ {
// Если параметр пустой или null, используем текущий SearchText var requestedText = parameter?.ToString() ?? SearchText;
var searchText = parameter?.ToString() ?? SearchText; SearchText = requestedText ?? string.Empty;
if (string.IsNullOrWhiteSpace(searchText))
{
// Очищаем поиск и загружаем все книги
SearchText = string.Empty;
}
await LoadBooksAsync(); await LoadBooksAsync();
} }
@@ -110,42 +112,47 @@ public partial class BookshelfViewModel : BaseViewModel
var result = await FilePicker.Default.PickAsync(new PickOptions var result = await FilePicker.Default.PickAsync(new PickOptions
{ {
PickerTitle = "Select a book", PickerTitle = "Выберите книгу",
FileTypes = customFileTypes FileTypes = customFileTypes
}); });
if (result == null) return; if (result == null)
{
return;
}
var extension = Path.GetExtension(result.FileName).ToLowerInvariant(); var extension = Path.GetExtension(result.FileName).ToLowerInvariant();
if (extension != ".epub" && extension != ".fb2") if (extension != Constants.Files.EpubExtension && extension != Constants.Files.Fb2Extension)
{ {
await _navigationService.DisplayAlertAsync("Error", "Only EPUB and FB2 formats are supported.", "OK"); await _navigationService.DisplayAlertAsync("Формат не поддерживается", "Сейчас можно добавить только EPUB и FB2.", "OK");
return; return;
} }
IsBusy = true; IsBusy = true;
StatusMessage = "Adding book..."; StatusMessage = "Добавляю книгу...";
// Copy to temp if needed and parse using var sourceStream = await result.OpenReadAsync();
string filePath;
using var stream = await result.OpenReadAsync();
var tempPath = Path.Combine(FileSystem.CacheDirectory, result.FileName); var tempPath = Path.Combine(FileSystem.CacheDirectory, result.FileName);
using (var fileStream = File.Create(tempPath)) using (var tempFileStream = File.Create(tempPath))
{ {
await stream.CopyToAsync(fileStream); await sourceStream.CopyToAsync(tempFileStream);
} }
filePath = tempPath;
var book = await _bookParserService.ParseAndStoreBookAsync(filePath, result.FileName); var book = await _bookParserService.ParseAndStoreBookAsync(tempPath, result.FileName);
Books.Insert(0, book); Books.Insert(0, book);
IsEmpty = false; RefreshLibraryState();
// Clean temp try
try { File.Delete(tempPath); } catch { } {
File.Delete(tempPath);
}
catch
{
}
} }
catch (Exception ex) catch (Exception ex)
{ {
await _navigationService.DisplayAlertAsync("Error", $"Failed to add book: {ex.Message}", "OK"); await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось добавить книгу: {ex.Message}", "OK");
} }
finally finally
{ {
@@ -157,29 +164,41 @@ public partial class BookshelfViewModel : BaseViewModel
[RelayCommand] [RelayCommand]
public async Task DeleteBookAsync(Book book) public async Task DeleteBookAsync(Book book)
{ {
if (book == null) return; if (book == null)
{
return;
}
var confirm = await _navigationService.DisplayAlertAsync("Delete Book", var confirmed = await _navigationService.DisplayAlertAsync(
$"Are you sure you want to delete \"{book.Title}\"?", "Delete", "Cancel"); "Удалить книгу",
$"Удалить \"{book.Title}\" из библиотеки?",
"Удалить",
"Отмена");
if (!confirm) return; if (!confirmed)
{
return;
}
try try
{ {
await _databaseService.DeleteBookAsync(book); await _databaseService.DeleteBookAsync(book);
Books.Remove(book); Books.Remove(book);
IsEmpty = Books.Count == 0; RefreshLibraryState();
} }
catch (Exception ex) catch (Exception ex)
{ {
await _navigationService.DisplayAlertAsync("Error", $"Failed to delete book: {ex.Message}", "OK"); await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось удалить книгу: {ex.Message}", "OK");
} }
} }
[RelayCommand] [RelayCommand]
public async Task OpenBookAsync(Book book) public async Task OpenBookAsync(Book book)
{ {
if (book == null) return; if (book == null)
{
return;
}
var navigationParameter = new Dictionary<string, object> var navigationParameter = new Dictionary<string, object>
{ {
@@ -190,14 +209,20 @@ public partial class BookshelfViewModel : BaseViewModel
} }
[RelayCommand] [RelayCommand]
public async Task OpenSettingsAsync() public Task OpenSettingsAsync() => _navigationService.GoToAsync("//settings");
{
await _navigationService.GoToAsync("settings");
}
[RelayCommand] [RelayCommand]
public async Task OpenCalibreLibraryAsync() public Task OpenCalibreLibraryAsync() => _navigationService.GoToAsync("//calibre");
private void RefreshLibraryState()
{ {
await _navigationService.GoToAsync("calibre"); IsEmpty = Books.Count == 0;
BooksInProgress = Books.Count(book => book.ReadingProgress > 0 && book.ReadingProgress < 1);
CompletedBooks = Books.Count(book => book.ReadingProgress >= 1);
ContinueReadingBook = Books
.OrderByDescending(book => book.LastRead)
.FirstOrDefault(book => book.ReadingProgress > 0 && book.ReadingProgress < 1);
OnPropertyChanged(nameof(LibrarySummary));
} }
} }

View File

@@ -13,7 +13,6 @@ public partial class CalibreLibraryViewModel : BaseViewModel
private readonly IDatabaseService _databaseService; private readonly IDatabaseService _databaseService;
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly INavigationService _navigationService; private readonly INavigationService _navigationService;
private readonly ICachedImageLoadingService _imageLoadingService;
public ObservableCollection<CalibreBook> Books { get; } = new(); public ObservableCollection<CalibreBook> Books { get; } = new();
@@ -34,21 +33,27 @@ public partial class CalibreLibraryViewModel : BaseViewModel
private int _currentPage; private int _currentPage;
public bool HasConnectionError => !string.IsNullOrWhiteSpace(ConnectionErrorMessage);
public bool HasBooks => Books.Count > 0;
partial void OnConnectionErrorMessageChanged(string value)
{
OnPropertyChanged(nameof(HasConnectionError));
}
public CalibreLibraryViewModel( public CalibreLibraryViewModel(
ICalibreWebService calibreWebService, ICalibreWebService calibreWebService,
IBookParserService bookParserService, IBookParserService bookParserService,
IDatabaseService databaseService, IDatabaseService databaseService,
ISettingsService settingsService, ISettingsService settingsService,
INavigationService navigationService, INavigationService navigationService)
ICachedImageLoadingService imageLoadingService)
{ {
_calibreWebService = calibreWebService; _calibreWebService = calibreWebService;
_bookParserService = bookParserService; _bookParserService = bookParserService;
_databaseService = databaseService; _databaseService = databaseService;
_settingsService = settingsService; _settingsService = settingsService;
_navigationService = navigationService; _navigationService = navigationService;
_imageLoadingService = imageLoadingService; Title = "Calibre";
Title = "Calibre Library";
} }
[RelayCommand] [RelayCommand]
@@ -59,35 +64,42 @@ public partial class CalibreLibraryViewModel : BaseViewModel
var password = await _settingsService.GetSecurePasswordAsync(); var password = await _settingsService.GetSecurePasswordAsync();
IsConfigured = !string.IsNullOrWhiteSpace(url); IsConfigured = !string.IsNullOrWhiteSpace(url);
ConnectionErrorMessage = string.Empty;
if (IsConfigured) if (IsConfigured)
{ {
_calibreWebService.Configure(url, username, password); _calibreWebService.Configure(url, username, password);
await LoadBooksAsync(); await LoadBooksAsync();
} }
else
{
Books.Clear();
}
} }
[RelayCommand] [RelayCommand]
public async Task LoadBooksAsync() public async Task LoadBooksAsync()
{ {
if (IsBusy || !IsConfigured) return; if (IsBusy || !IsConfigured)
{
return;
}
IsBusy = true; IsBusy = true;
_currentPage = 0; _currentPage = 0;
ConnectionErrorMessage = string.Empty; ConnectionErrorMessage = string.Empty;
try try
{ {
var books = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage); var result = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
if (!result.IsSuccess)
{
Books.Clear(); Books.Clear();
foreach (var book in books) ConnectionErrorMessage = result.ErrorMessage ?? "Не удалось загрузить каталог Calibre.";
{ return;
Books.Add(book);
} }
}
catch (Exception ex) ReplaceBooks(result.Value ?? new List<CalibreBook>());
{
ConnectionErrorMessage = "No connection to Calibre server";
System.Diagnostics.Debug.WriteLine($"Error loading Calibre library: {ex.Message}");
} }
finally finally
{ {
@@ -98,7 +110,11 @@ public partial class CalibreLibraryViewModel : BaseViewModel
[RelayCommand] [RelayCommand]
public async Task RefreshBooksAsync() public async Task RefreshBooksAsync()
{ {
if (IsRefreshing || !IsConfigured) return; if (IsRefreshing || !IsConfigured)
{
return;
}
IsRefreshing = true; IsRefreshing = true;
try try
@@ -114,19 +130,32 @@ public partial class CalibreLibraryViewModel : BaseViewModel
[RelayCommand] [RelayCommand]
public async Task LoadMoreBooksAsync() public async Task LoadMoreBooksAsync()
{ {
if (IsBusy || !IsConfigured) return; if (IsBusy || !IsConfigured || HasConnectionError)
{
return;
}
IsBusy = true; IsBusy = true;
_currentPage++; _currentPage++;
try try
{ {
var books = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage); var result = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
foreach (var book in books) if (!result.IsSuccess)
{
DownloadStatus = result.ErrorMessage ?? "Не удалось подгрузить ещё книги.";
return;
}
var existingIds = Books.Select(book => book.Id).ToHashSet();
foreach (var book in result.Value ?? new List<CalibreBook>())
{
if (existingIds.Add(book.Id))
{ {
Books.Add(book); Books.Add(book);
} }
} }
catch { } }
finally finally
{ {
IsBusy = false; IsBusy = false;
@@ -142,16 +171,19 @@ public partial class CalibreLibraryViewModel : BaseViewModel
[RelayCommand] [RelayCommand]
public async Task DownloadBookAsync(CalibreBook calibreBook) public async Task DownloadBookAsync(CalibreBook calibreBook)
{ {
if (calibreBook == null) return; if (calibreBook == null)
{
return;
}
IsBusy = true; IsBusy = true;
DownloadStatus = $"Downloading {calibreBook.Title}..."; DownloadStatus = $"Загрузка: {calibreBook.Title}";
try try
{ {
var progress = new Progress<double>(p => var progress = new Progress<double>(value =>
{ {
DownloadStatus = $"Downloading... {p * 100:F0}%"; DownloadStatus = $"Загрузка: {value * 100:F0}%";
}); });
var filePath = await _calibreWebService.DownloadBookAsync(calibreBook, progress); var filePath = await _calibreWebService.DownloadBookAsync(calibreBook, progress);
@@ -161,27 +193,36 @@ public partial class CalibreLibraryViewModel : BaseViewModel
book.CalibreId = calibreBook.Id; book.CalibreId = calibreBook.Id;
if (calibreBook.CoverImage != null) if (calibreBook.CoverImage != null)
{
book.CoverImage = calibreBook.CoverImage; book.CoverImage = calibreBook.CoverImage;
}
await _databaseService.UpdateBookAsync(book); await _databaseService.UpdateBookAsync(book);
DownloadStatus = "Download complete!"; DownloadStatus = "Книга добавлена в библиотеку";
await _navigationService.DisplayAlertAsync("Success", $"\"{calibreBook.Title}\" has been added to your library.", "OK"); await _navigationService.DisplayAlertAsync("Готово", $"\"{calibreBook.Title}\" добавлена в библиотеку.", "OK");
} }
catch (Exception ex) catch (Exception ex)
{ {
await _navigationService.DisplayAlertAsync("Error", $"Failed to download: {ex.Message}", "OK"); await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось скачать книгу: {ex.Message}", "OK");
} }
finally finally
{ {
IsBusy = false; IsBusy = false;
DownloadStatus = string.Empty;
} }
} }
[RelayCommand] [RelayCommand]
public async Task OpenSettingsAsync() public Task OpenSettingsAsync() => _navigationService.GoToAsync("//settings");
private void ReplaceBooks(IEnumerable<CalibreBook> books)
{ {
await _navigationService.GoToAsync("settings"); Books.Clear();
foreach (var book in books)
{
Books.Add(book);
}
OnPropertyChanged(nameof(HasBooks));
} }
} }

View File

@@ -1,5 +1,4 @@
using Android.Graphics.Fonts; using BookReader.Models;
using BookReader.Models;
using BookReader.Services; using BookReader.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -11,7 +10,13 @@ public partial class ReaderViewModel : BaseViewModel
{ {
private readonly IDatabaseService _databaseService; private readonly IDatabaseService _databaseService;
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly INavigationService _navigationService;
private double _lastPersistedProgress = -1;
private string _lastPersistedCfi = string.Empty;
private string _lastPersistedChapter = string.Empty;
private int _lastPersistedCurrentPage = -1;
private int _lastPersistedTotalPages = -1;
private DateTime _lastPersistedAt = DateTime.MinValue;
[ObservableProperty] [ObservableProperty]
private Book? _book; private Book? _book;
@@ -28,6 +33,12 @@ public partial class ReaderViewModel : BaseViewModel
[ObservableProperty] [ObservableProperty]
private string _fontFamily = "serif"; private string _fontFamily = "serif";
[ObservableProperty]
private string _readerTheme = Constants.Reader.DefaultTheme;
[ObservableProperty]
private double _brightness = Constants.Reader.DefaultBrightness;
[ObservableProperty] [ObservableProperty]
private List<string> _chapters = new(); private List<string> _chapters = new();
@@ -44,19 +55,18 @@ public partial class ReaderViewModel : BaseViewModel
private int _currentPage = 1; private int _currentPage = 1;
[ObservableProperty] [ObservableProperty]
private int _totalPages = 1; private int _totalPages = 100;
// Это свойство будет обновляться автоматически при изменении любого из полей выше public string ChapterProgressText => ChapterTotalPages > 1
public string ChapterProgressText => $"{ChapterCurrentPage} из {ChapterTotalPages}"; ? $"Глава: {ChapterCurrentPage} из {ChapterTotalPages}"
: "Позиция внутри главы появится после перелистывания";
// Это свойство показывает процент прогресса public string ProgressText => TotalPages == 100
public string ProgressText => $"{CurrentPage}%"; ? $"{CurrentPage}%"
: $"Стр. {CurrentPage} из {TotalPages}";
// Чтобы ChapterProgressText уведомлял интерфейс, добавим частичные методы (особенность Toolkit)
partial void OnChapterCurrentPageChanged(int value) => OnPropertyChanged(nameof(ChapterProgressText)); partial void OnChapterCurrentPageChanged(int value) => OnPropertyChanged(nameof(ChapterProgressText));
partial void OnChapterTotalPagesChanged(int value) => OnPropertyChanged(nameof(ChapterProgressText)); partial void OnChapterTotalPagesChanged(int value) => OnPropertyChanged(nameof(ChapterProgressText));
// Чтобы ProgressText уведомлял интерфейс, добавим частичные методы (особенность Toolkit)
partial void OnCurrentPageChanged(int value) => OnPropertyChanged(nameof(ProgressText)); partial void OnCurrentPageChanged(int value) => OnPropertyChanged(nameof(ProgressText));
partial void OnTotalPagesChanged(int value) => OnPropertyChanged(nameof(ProgressText)); partial void OnTotalPagesChanged(int value) => OnPropertyChanged(nameof(ProgressText));
@@ -78,42 +88,40 @@ public partial class ReaderViewModel : BaseViewModel
12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40
}; };
// Events for the view to subscribe to
public event Action<string>? OnJavaScriptRequested; public event Action<string>? OnJavaScriptRequested;
public event Action? OnBookReady;
public ReaderViewModel( public ReaderViewModel(
IDatabaseService databaseService, IDatabaseService databaseService,
ISettingsService settingsService, ISettingsService settingsService)
INavigationService navigationService)
{ {
_databaseService = databaseService; _databaseService = databaseService;
_settingsService = settingsService; _settingsService = settingsService;
_navigationService = navigationService;
_fontSize = Constants.Reader.DefaultFontSize; _fontSize = Constants.Reader.DefaultFontSize;
} }
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
// Валидация: книга должна быть загружена
if (Book == null) if (Book == null)
{ {
System.Diagnostics.Debug.WriteLine("[ReaderViewModel] Book is null, cannot initialize"); System.Diagnostics.Debug.WriteLine("[ReaderViewModel] Book is null, cannot initialize");
return; return;
} }
// Валидация: файл книги должен существовать
if (!File.Exists(Book.FilePath)) if (!File.Exists(Book.FilePath))
{ {
System.Diagnostics.Debug.WriteLine($"[ReaderViewModel] Book file not found: {Book.FilePath}"); System.Diagnostics.Debug.WriteLine($"[ReaderViewModel] Book file not found: {Book.FilePath}");
return; return;
} }
var savedFontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, Constants.Reader.DefaultFontSize); FontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, Constants.Reader.DefaultFontSize);
var savedFontFamily = await _settingsService.GetAsync(SettingsKeys.DefaultFontFamily, "serif"); FontFamily = await _settingsService.GetAsync(SettingsKeys.DefaultFontFamily, "serif");
ReaderTheme = await _settingsService.GetAsync(SettingsKeys.Theme, Constants.Reader.DefaultTheme);
Brightness = await _settingsService.GetDoubleAsync(SettingsKeys.Brightness, Constants.Reader.DefaultBrightness);
FontSize = savedFontSize; CurrentPage = Book.CurrentPage > 0 ? Book.CurrentPage : 1;
FontFamily = savedFontFamily; TotalPages = Book.TotalPages > 0 ? Book.TotalPages : 100;
RememberPersistedProgress(Book.ReadingProgress, Book.LastCfi, Book.LastChapter, Book.CurrentPage, Book.TotalPages);
} }
[RelayCommand] [RelayCommand]
@@ -121,8 +129,10 @@ public partial class ReaderViewModel : BaseViewModel
{ {
IsMenuVisible = !IsMenuVisible; IsMenuVisible = !IsMenuVisible;
if (!IsMenuVisible) if (!IsMenuVisible)
{
IsChapterListVisible = false; IsChapterListVisible = false;
} }
}
[RelayCommand] [RelayCommand]
public void HideMenu() public void HideMenu()
@@ -149,14 +159,34 @@ public partial class ReaderViewModel : BaseViewModel
public void ChangeFontFamily(string family) public void ChangeFontFamily(string family)
{ {
FontFamily = family; FontFamily = family;
OnJavaScriptRequested?.Invoke($"setFontFamily('{family}')"); OnJavaScriptRequested?.Invoke($"setFontFamily('{EscapeJs(family)}')");
_ = _settingsService.SetAsync(SettingsKeys.DefaultFontFamily, family); _ = _settingsService.SetAsync(SettingsKeys.DefaultFontFamily, family);
} }
[RelayCommand]
public void ChangeReaderTheme(string theme)
{
ReaderTheme = theme;
OnJavaScriptRequested?.Invoke($"setReaderTheme('{EscapeJs(theme)}')");
_ = _settingsService.SetAsync(SettingsKeys.Theme, theme);
}
[RelayCommand]
public void ChangeBrightness(double brightness)
{
Brightness = Math.Clamp(brightness, Constants.Reader.MinBrightness, Constants.Reader.MaxBrightness);
OnJavaScriptRequested?.Invoke($"setBrightness({Brightness.ToString(System.Globalization.CultureInfo.InvariantCulture)})");
_ = _settingsService.SetDoubleAsync(SettingsKeys.Brightness, Brightness);
}
[RelayCommand] [RelayCommand]
public void GoToChapter(string chapter) public void GoToChapter(string chapter)
{ {
if (string.IsNullOrEmpty(chapter)) return; if (string.IsNullOrEmpty(chapter))
{
return;
}
OnJavaScriptRequested?.Invoke($"goToChapter('{EscapeJs(chapter)}')"); OnJavaScriptRequested?.Invoke($"goToChapter('{EscapeJs(chapter)}')");
IsChapterListVisible = false; IsChapterListVisible = false;
IsMenuVisible = false; IsMenuVisible = false;
@@ -169,11 +199,12 @@ public partial class ReaderViewModel : BaseViewModel
System.Diagnostics.Debug.WriteLine("[ReaderViewModel] Cannot save locations: Book is null"); System.Diagnostics.Debug.WriteLine("[ReaderViewModel] Cannot save locations: Book is null");
return; return;
} }
Book.Locations = locations; Book.Locations = locations;
await _databaseService.UpdateBookAsync(Book); await _databaseService.UpdateBookAsync(Book);
} }
public async Task SaveProgressAsync(double progress, string? cfi, string? chapter, int currentPage, int totalPages) public async Task SaveProgressAsync(double progress, string? cfi, string? chapter, int currentPage, int totalPages, bool force = false)
{ {
if (Book == null) if (Book == null)
{ {
@@ -181,8 +212,13 @@ public partial class ReaderViewModel : BaseViewModel
return; return;
} }
// Важно: если CFI пустой, не перезаписываем старый прогресс (защита от багов JS) if (string.IsNullOrEmpty(cfi) && progress <= 0)
if (string.IsNullOrEmpty(cfi) && progress <= 0) return; {
return;
}
CurrentPage = currentPage > 0 ? currentPage : CurrentPage;
TotalPages = totalPages > 0 ? totalPages : TotalPages;
Book.ReadingProgress = progress; Book.ReadingProgress = progress;
Book.LastCfi = cfi; Book.LastCfi = cfi;
@@ -191,6 +227,23 @@ public partial class ReaderViewModel : BaseViewModel
Book.TotalPages = totalPages; Book.TotalPages = totalPages;
Book.LastRead = DateTime.UtcNow; Book.LastRead = DateTime.UtcNow;
var hasMeaningfulChange = HasMeaningfulProgressChange(progress, cfi, chapter, currentPage, totalPages);
if (!force)
{
var throttle = TimeSpan.FromSeconds(Constants.Reader.ProgressSaveThrottleSeconds);
var shouldPersistNow = hasMeaningfulChange &&
(DateTime.UtcNow - _lastPersistedAt >= throttle || Math.Abs(progress - _lastPersistedProgress) >= 0.02 || currentPage != _lastPersistedCurrentPage);
if (!shouldPersistNow)
{
return;
}
}
else if (!hasMeaningfulChange)
{
return;
}
await _databaseService.UpdateBookAsync(Book); await _databaseService.UpdateBookAsync(Book);
await _databaseService.SaveProgressAsync(new ReadingProgress await _databaseService.SaveProgressAsync(new ReadingProgress
@@ -201,26 +254,31 @@ public partial class ReaderViewModel : BaseViewModel
CurrentPage = currentPage, CurrentPage = currentPage,
ChapterTitle = chapter ChapterTitle = chapter
}); });
RememberPersistedProgress(progress, cfi, chapter, currentPage, totalPages);
} }
public string GetBookFilePath() public string? GetLastCfi() => Book?.LastCfi;
public string? GetLocations() => Book?.Locations;
private bool HasMeaningfulProgressChange(double progress, string? cfi, string? chapter, int currentPage, int totalPages)
{ {
return Book?.FilePath ?? string.Empty; return Math.Abs(progress - _lastPersistedProgress) >= 0.005 ||
string.Equals(cfi ?? string.Empty, _lastPersistedCfi, StringComparison.Ordinal) == false ||
string.Equals(chapter ?? string.Empty, _lastPersistedChapter, StringComparison.Ordinal) == false ||
currentPage != _lastPersistedCurrentPage ||
totalPages != _lastPersistedTotalPages;
} }
public string GetBookFormat() private void RememberPersistedProgress(double progress, string? cfi, string? chapter, int currentPage, int totalPages)
{ {
return Book?.Format ?? "epub"; _lastPersistedProgress = progress;
} _lastPersistedCfi = cfi ?? string.Empty;
_lastPersistedChapter = chapter ?? string.Empty;
public string? GetLastCfi() _lastPersistedCurrentPage = currentPage;
{ _lastPersistedTotalPages = totalPages;
return Book?.LastCfi; _lastPersistedAt = DateTime.UtcNow;
}
public string? GetLocations()
{
return Book?.Locations;
} }
private static string EscapeJs(string value) private static string EscapeJs(string value)

View File

@@ -2,11 +2,17 @@
using BookReader.Services; using BookReader.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Maui.Graphics;
namespace BookReader.ViewModels; namespace BookReader.ViewModels;
public partial class SettingsViewModel : BaseViewModel public partial class SettingsViewModel : BaseViewModel
{ {
private static readonly Color SuccessTone = Color.FromArgb("#2F7D5A");
private static readonly Color WarningTone = Color.FromArgb("#C47A3E");
private static readonly Color DangerTone = Color.FromArgb("#B64932");
private static readonly Color NeutralTone = Color.FromArgb("#6E5648");
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly ICalibreWebService _calibreWebService; private readonly ICalibreWebService _calibreWebService;
private readonly INavigationService _navigationService; private readonly INavigationService _navigationService;
@@ -26,9 +32,24 @@ public partial class SettingsViewModel : BaseViewModel
[ObservableProperty] [ObservableProperty]
private string _defaultFontFamily = "serif"; private string _defaultFontFamily = "serif";
[ObservableProperty]
private string _defaultReaderTheme = "Тёплая";
[ObservableProperty]
private double _defaultBrightness = Constants.Reader.DefaultBrightness;
[ObservableProperty] [ObservableProperty]
private string _connectionStatus = string.Empty; private string _connectionStatus = string.Empty;
[ObservableProperty]
private Color _connectionStatusColor = NeutralTone;
[ObservableProperty]
private string _connectionSecurityHint = string.Empty;
[ObservableProperty]
private Color _connectionSecurityColor = NeutralTone;
[ObservableProperty] [ObservableProperty]
private bool _isConnectionTesting; private bool _isConnectionTesting;
@@ -43,6 +64,18 @@ public partial class SettingsViewModel : BaseViewModel
12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40
}; };
public List<string> AvailableReaderThemes { get; } = new()
{
"Тёплая",
"Светлая",
"Тёмная"
};
partial void OnCalibreUrlChanged(string value)
{
UpdateConnectionSecurityHint();
}
public SettingsViewModel( public SettingsViewModel(
ISettingsService settingsService, ISettingsService settingsService,
ICalibreWebService calibreWebService, ICalibreWebService calibreWebService,
@@ -51,7 +84,7 @@ public partial class SettingsViewModel : BaseViewModel
_settingsService = settingsService; _settingsService = settingsService;
_calibreWebService = calibreWebService; _calibreWebService = calibreWebService;
_navigationService = navigationService; _navigationService = navigationService;
Title = "Settings"; Title = "Настройки";
} }
[RelayCommand] [RelayCommand]
@@ -62,49 +95,144 @@ public partial class SettingsViewModel : BaseViewModel
CalibrePassword = await _settingsService.GetSecurePasswordAsync(); CalibrePassword = await _settingsService.GetSecurePasswordAsync();
DefaultFontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, Constants.Reader.DefaultFontSize); DefaultFontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, Constants.Reader.DefaultFontSize);
DefaultFontFamily = await _settingsService.GetAsync(SettingsKeys.DefaultFontFamily, "serif"); DefaultFontFamily = await _settingsService.GetAsync(SettingsKeys.DefaultFontFamily, "serif");
DefaultReaderTheme = ToDisplayTheme(await _settingsService.GetAsync(SettingsKeys.Theme, Constants.Reader.DefaultTheme));
DefaultBrightness = await _settingsService.GetDoubleAsync(SettingsKeys.Brightness, Constants.Reader.DefaultBrightness);
UpdateConnectionSecurityHint();
} }
[RelayCommand] [RelayCommand]
public async Task SaveSettingsAsync() public async Task SaveSettingsAsync()
{ {
await _settingsService.SetAsync(SettingsKeys.CalibreUrl, CalibreUrl); await PersistSettingsAsync(showNotification: true);
await _settingsService.SetAsync(SettingsKeys.CalibreUsername, CalibreUsername);
await _settingsService.SetSecurePasswordAsync(CalibrePassword);
await _settingsService.SetIntAsync(SettingsKeys.DefaultFontSize, DefaultFontSize);
await _settingsService.SetAsync(SettingsKeys.DefaultFontFamily, DefaultFontFamily);
if (!string.IsNullOrEmpty(CalibreUrl))
{
_calibreWebService.Configure(CalibreUrl, CalibreUsername, CalibrePassword);
} }
await _navigationService.DisplayAlertAsync("Settings", "Settings saved successfully.", "OK"); public async Task SaveSilentlyAsync()
{
await PersistSettingsAsync(showNotification: false);
} }
[RelayCommand] [RelayCommand]
public async Task TestConnectionAsync() public async Task TestConnectionAsync()
{ {
if (string.IsNullOrWhiteSpace(CalibreUrl)) if (!TryValidateCalibreUrl(out var validationMessage))
{ {
ConnectionStatus = "Please enter a URL"; ConnectionStatus = validationMessage;
ConnectionStatusColor = DangerTone;
return; return;
} }
IsConnectionTesting = true; IsConnectionTesting = true;
ConnectionStatus = "Testing connection..."; ConnectionStatus = "Проверяю соединение...";
ConnectionStatusColor = NeutralTone;
try try
{ {
var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword); var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword);
ConnectionStatus = success ? "✅ Connection successful!" : "❌ Connection failed"; ConnectionStatus = success ? "Соединение установлено." : "Сервер ответил ошибкой или недоступен.";
ConnectionStatusColor = success ? SuccessTone : DangerTone;
} }
catch (Exception ex) catch (Exception ex)
{ {
ConnectionStatus = $"❌ Error: {ex.Message}"; ConnectionStatus = $"Ошибка проверки: {ex.Message}";
ConnectionStatusColor = DangerTone;
} }
finally finally
{ {
IsConnectionTesting = false; IsConnectionTesting = false;
} }
} }
private async Task PersistSettingsAsync(bool showNotification)
{
await _settingsService.SetAsync(SettingsKeys.CalibreUrl, CalibreUrl);
await _settingsService.SetAsync(SettingsKeys.CalibreUsername, CalibreUsername);
await _settingsService.SetSecurePasswordAsync(CalibrePassword);
await _settingsService.SetIntAsync(SettingsKeys.DefaultFontSize, DefaultFontSize);
await _settingsService.SetAsync(SettingsKeys.DefaultFontFamily, DefaultFontFamily);
await _settingsService.SetAsync(SettingsKeys.Theme, ToStoredTheme(DefaultReaderTheme));
await _settingsService.SetDoubleAsync(SettingsKeys.Brightness, DefaultBrightness);
if (!string.IsNullOrWhiteSpace(CalibreUrl))
{
_calibreWebService.Configure(CalibreUrl, CalibreUsername, CalibrePassword);
}
if (showNotification)
{
await _navigationService.DisplayAlertAsync("Настройки", "Изменения сохранены.", "OK");
}
}
private bool TryValidateCalibreUrl(out string message)
{
if (string.IsNullOrWhiteSpace(CalibreUrl))
{
message = "Введите адрес сервера Calibre.";
return false;
}
if (!Uri.TryCreate(CalibreUrl, UriKind.Absolute, out var uri))
{
message = "Адрес сервера должен быть полным URL, например https://server.example.";
return false;
}
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
{
message = "Поддерживаются только адреса с http:// или https://.";
return false;
}
message = string.Empty;
return true;
}
private void UpdateConnectionSecurityHint()
{
if (string.IsNullOrWhiteSpace(CalibreUrl))
{
ConnectionSecurityHint = "Если Calibre доступен из интернета, используйте HTTPS. Для локальной сети HTTP допустим, но менее безопасен.";
ConnectionSecurityColor = NeutralTone;
return;
}
if (!Uri.TryCreate(CalibreUrl, UriKind.Absolute, out var uri))
{
ConnectionSecurityHint = "Проверьте адрес сервера. Нужен полный URL с http:// или https://.";
ConnectionSecurityColor = DangerTone;
return;
}
if (uri.Scheme == Uri.UriSchemeHttps)
{
ConnectionSecurityHint = "HTTPS включён: логин и пароль передаются по зашифрованному каналу.";
ConnectionSecurityColor = SuccessTone;
return;
}
if (uri.Scheme == Uri.UriSchemeHttp)
{
ConnectionSecurityHint = "HTTP подходит для домашней сети, но трафик и пароль не шифруются.";
ConnectionSecurityColor = WarningTone;
return;
}
ConnectionSecurityHint = "Поддерживаются только схемы http:// и https://.";
ConnectionSecurityColor = DangerTone;
}
private static string ToDisplayTheme(string storedTheme) => storedTheme switch
{
"light" => "Светлая",
"dark" => "Тёмная",
_ => "Тёплая"
};
private static string ToStoredTheme(string displayTheme) => displayTheme switch
{
"Светлая" => "light",
"Тёмная" => "dark",
_ => Constants.Reader.DefaultTheme
};
} }

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:BookReader.ViewModels" xmlns:vm="clr-namespace:BookReader.ViewModels"
@@ -6,75 +6,154 @@
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit" xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:Class="BookReader.Views.BookshelfPage" x:Class="BookReader.Views.BookshelfPage"
x:DataType="vm:BookshelfViewModel" x:DataType="vm:BookshelfViewModel"
Title="{Binding Title}" Title="Библиотека"
Shell.NavBarIsVisible="True"> Background="{StaticResource AppBackgroundBrush}">
<ContentPage.Resources> <ContentPage.Resources>
<toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" /> <toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" />
</ContentPage.Resources> </ContentPage.Resources>
<Shell.TitleView> <Grid RowDefinitions="Auto,*">
<Grid ColumnDefinitions="*,Auto" Padding="0,0,5,0"> <VerticalStackLayout Grid.Row="0"
<Label Grid.Column="0" Padding="20,24,20,14"
Text="📚 Моя книжная полка" Spacing="16">
FontSize="20" <VerticalStackLayout Spacing="6">
FontAttributes="Bold" <Label Text="Личная полка"
VerticalOptions="Center" Style="{StaticResource PageTitleStyle}" />
TextColor="White" /> <Label Text="Соберите библиотеку, возвращайтесь к начатому и держите Calibre под рукой."
<ImageButton Grid.Column="1" Style="{StaticResource PageSubtitleStyle}" />
Source="dots_vertical.png"
WidthRequest="30"
HeightRequest="30"
VerticalOptions="Center"
Clicked="OnMenuClicked" />
</Grid>
</Shell.TitleView>
<Grid RowDefinitions="Auto,*,Auto" BackgroundColor="#3E2723">
<!-- Search Bar -->
<SearchBar Grid.Row="0"
Text="{Binding SearchText, Mode=TwoWay}"
SearchCommand="{Binding SearchCommand}"
SearchCommandParameter="{Binding Text, Source={RelativeSource Self}}"
Placeholder="Search by title or author..."
PlaceholderColor="#A1887F"
TextColor="White"
BackgroundColor="#4E342E"
Margin="10,5" />
<!-- Bookshelf Background -->
<Grid Grid.Row="1">
<!-- Empty state -->
<VerticalStackLayout IsVisible="{Binding IsEmpty}"
VerticalOptions="Center"
HorizontalOptions="Center"
Spacing="20"
Padding="40">
<Label Text="📖"
FontSize="80"
HorizontalOptions="Center" />
<Label Text="Ваша книжная полка пуста"
FontSize="20"
TextColor="#D7CCC8"
HorizontalOptions="Center" />
<Label Text="Добавьте книгу из Вашего устроиства или библиотеки Calibre"
FontSize="14"
TextColor="#A1887F"
HorizontalOptions="Center"
HorizontalTextAlignment="Center" />
</VerticalStackLayout> </VerticalStackLayout>
<!-- Book Collection --> <Border Style="{StaticResource CardBorderStyle}">
<CollectionView ItemsSource="{Binding Books}" <SearchBar Text="{Binding SearchText, Mode=TwoWay}"
SearchCommand="{Binding SearchCommand}"
SearchCommandParameter="{Binding Text, Source={RelativeSource Self}}"
Placeholder="Название или автор"
Style="{StaticResource AppSearchBarStyle}" />
</Border>
</VerticalStackLayout>
<Grid Grid.Row="1">
<VerticalStackLayout IsVisible="{Binding IsEmpty}"
Padding="20,0,20,24"
VerticalOptions="Center"
HorizontalOptions="Fill"
Spacing="16">
<Border Style="{StaticResource CardBorderStyle}">
<VerticalStackLayout Spacing="14">
<Label Text="Библиотека пока пуста"
Style="{StaticResource SectionTitleStyle}" />
<Label Text="Добавьте EPUB или FB2 с устройства либо подключитесь к Calibre, чтобы собрать первую полку."
Style="{StaticResource PageSubtitleStyle}" />
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
<Button Grid.Column="0"
Text="Добавить файл"
Style="{StaticResource PrimaryButtonStyle}"
Command="{Binding AddBookFromFileCommand}" />
<Button Grid.Column="1"
Text="Открыть Calibre"
Style="{StaticResource SecondaryButtonStyle}"
Command="{Binding OpenCalibreLibraryCommand}" />
</Grid>
</VerticalStackLayout>
</Border>
</VerticalStackLayout>
<CollectionView x:Name="BooksCollectionView"
IsVisible="{Binding IsEmpty, Converter={StaticResource InvertedBoolConverter}}"
ItemsSource="{Binding Books}"
SelectionMode="None" SelectionMode="None"
Margin="10"> Margin="0,0,0,24">
<CollectionView.Header>
<VerticalStackLayout Padding="20,0,20,18"
Spacing="16">
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
<Border Style="{StaticResource CardBorderStyle}">
<VerticalStackLayout Spacing="6">
<Label Text="Библиотека"
Style="{StaticResource CaptionStyle}" />
<Label Text="{Binding LibrarySummary}"
Style="{StaticResource SectionTitleStyle}" />
<Label Text="{Binding BooksInProgress, StringFormat='В процессе: {0}'}"
Style="{StaticResource CaptionStyle}" />
</VerticalStackLayout>
</Border>
<Border Style="{StaticResource MutedCardBorderStyle}">
<VerticalStackLayout Spacing="6">
<Label Text="Прогресс"
Style="{StaticResource CaptionStyle}" />
<Label Text="{Binding CompletedBooks, StringFormat='{0} завершено'}"
Style="{StaticResource SectionTitleStyle}" />
<Label Text="{Binding BooksInProgress, StringFormat='{0} ещё читаете'}"
Style="{StaticResource CaptionStyle}" />
</VerticalStackLayout>
</Border>
</Grid>
<Border Style="{StaticResource CardBorderStyle}"
IsVisible="{Binding HasContinueReading}">
<Grid ColumnDefinitions="76,*,Auto"
ColumnSpacing="14">
<Border Grid.Column="0"
BackgroundColor="{StaticResource SurfaceStrong}"
StrokeThickness="0"
StrokeShape="RoundRectangle 16"
HeightRequest="108"
WidthRequest="76"
Padding="0">
<Image Source="{Binding ContinueReadingBook.CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
Aspect="AspectFill" />
</Border>
<VerticalStackLayout Grid.Column="1"
VerticalOptions="Center"
Spacing="4">
<Label Text="Продолжить чтение"
Style="{StaticResource CaptionStyle}" />
<Label Text="{Binding ContinueReadingBook.Title}"
Style="{StaticResource SectionTitleStyle}"
MaxLines="2"
LineBreakMode="TailTruncation" />
<Label Text="{Binding ContinueReadingBook.Author}"
Style="{StaticResource CaptionStyle}"
MaxLines="1"
LineBreakMode="TailTruncation" />
<Label Text="{Binding ContinueReadingBook.ProgressText}"
FontSize="13"
TextColor="{StaticResource SuccessColor}" />
</VerticalStackLayout>
<Button Grid.Column="2"
Text="Открыть"
Style="{StaticResource PrimaryButtonStyle}"
Padding="16,10"
VerticalOptions="Center"
Command="{Binding OpenBookCommand}"
CommandParameter="{Binding ContinueReadingBook}" />
</Grid>
</Border>
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
<Button Grid.Column="0"
Text="Добавить файл"
Style="{StaticResource PrimaryButtonStyle}"
Command="{Binding AddBookFromFileCommand}" />
<Button Grid.Column="1"
Text="Каталог Calibre"
Style="{StaticResource SecondaryButtonStyle}"
Command="{Binding OpenCalibreLibraryCommand}" />
</Grid>
</VerticalStackLayout>
</CollectionView.Header>
<CollectionView.ItemsLayout> <CollectionView.ItemsLayout>
<GridItemsLayout Orientation="Vertical" <GridItemsLayout x:Name="BooksGridLayout"
Span="3" Orientation="Vertical"
HorizontalItemSpacing="10" Span="2"
VerticalItemSpacing="15" /> HorizontalItemSpacing="14"
VerticalItemSpacing="14" />
</CollectionView.ItemsLayout> </CollectionView.ItemsLayout>
<CollectionView.ItemTemplate> <CollectionView.ItemTemplate>
@@ -82,116 +161,68 @@
<SwipeView> <SwipeView>
<SwipeView.RightItems> <SwipeView.RightItems>
<SwipeItems> <SwipeItems>
<SwipeItem Text="Delete" <SwipeItem Text="Удалить"
BackgroundColor="#D32F2F" BackgroundColor="{StaticResource DangerColor}"
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=DeleteBookCommand}" Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=DeleteBookCommand}"
CommandParameter="{Binding .}" /> CommandParameter="{Binding .}" />
</SwipeItems> </SwipeItems>
</SwipeView.RightItems> </SwipeView.RightItems>
<Frame Padding="0" <Border BackgroundColor="{StaticResource SurfaceColor}"
CornerRadius="8" Stroke="{StaticResource BorderColor}"
BackgroundColor="#5D4037" StrokeThickness="1"
HasShadow="True" StrokeShape="RoundRectangle 22">
BorderColor="Transparent"> <Border.GestureRecognizers>
<Frame.GestureRecognizers> <TapGestureRecognizer Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=OpenBookCommand}"
<TapGestureRecognizer
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=OpenBookCommand}"
CommandParameter="{Binding .}" /> CommandParameter="{Binding .}" />
</Frame.GestureRecognizers> </Border.GestureRecognizers>
<Grid RowDefinitions="150,Auto,Auto,Auto" Padding="0"> <Grid RowDefinitions="168,Auto,Auto,Auto"
<!-- Cover Image --> Padding="0">
<Frame Grid.Row="0" <Border Grid.Row="0"
Padding="0" StrokeThickness="0"
IsClippedToBounds="True" StrokeShape="RoundRectangle 22,22,0,0"
HasShadow="False" BackgroundColor="{StaticResource SurfaceStrong}">
BorderColor="Transparent">
<Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}" <Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
Aspect="AspectFill" Aspect="AspectFill"
HeightRequest="150" /> HeightRequest="168" />
</Frame> </Border>
<!-- Progress Bar --> <ProgressBar Grid.Row="0"
<Grid Grid.Row="0" Progress="{Binding ReadingProgress}"
ProgressColor="{StaticResource SuccessColor}"
BackgroundColor="#33FFFFFF"
VerticalOptions="End" VerticalOptions="End"
HeightRequest="4" HeightRequest="6" />
BackgroundColor="#44000000">
<BoxView BackgroundColor="#4CAF50"
HorizontalOptions="Start"
WidthRequest="{Binding ReadingProgress, Converter={StaticResource ProgressToWidthConverter}}" />
</Grid>
<!-- Title -->
<Label Grid.Row="1" <Label Grid.Row="1"
Margin="12,12,12,0"
Text="{Binding Title}" Text="{Binding Title}"
FontSize="11" FontFamily="OpenSansSemibold"
FontAttributes="Bold" FontSize="14"
TextColor="#EFEBE9" TextColor="{StaticResource InkColor}"
LineBreakMode="TailTruncation"
MaxLines="2" MaxLines="2"
Padding="5,5,5,0" /> LineBreakMode="TailTruncation" />
<!-- Author -->
<Label Grid.Row="2" <Label Grid.Row="2"
Margin="12,4,12,0"
Text="{Binding Author}" Text="{Binding Author}"
FontSize="9" FontSize="12"
TextColor="#A1887F" TextColor="{StaticResource InkSoftColor}"
LineBreakMode="TailTruncation"
MaxLines="1" MaxLines="1"
Padding="5,0,5,2" /> LineBreakMode="TailTruncation" />
<!-- Progress Text -->
<Label Grid.Row="3" <Label Grid.Row="3"
Margin="12,6,12,12"
Text="{Binding ProgressText}" Text="{Binding ProgressText}"
FontSize="9" FontSize="12"
TextColor="#81C784" TextColor="{StaticResource AccentDarkColor}" />
Padding="5,0,5,5" />
</Grid> </Grid>
</Frame> </Border>
</SwipeView> </SwipeView>
</DataTemplate> </DataTemplate>
</CollectionView.ItemTemplate> </CollectionView.ItemTemplate>
</CollectionView> </CollectionView>
</Grid> </Grid>
<!-- Bottom Shelf / Action Bar -->
<Grid Grid.Row="2"
BackgroundColor="#4E342E"
Padding="15,10"
ColumnDefinitions="*,Auto,Auto">
<!-- Shelf decoration -->
<BoxView Grid.ColumnSpan="3"
BackgroundColor="#6D4C41"
HeightRequest="3"
VerticalOptions="Start"
Margin="0,-10,0,0" />
<Label Grid.Column="0"
Text="{Binding Books.Count, StringFormat='{0} books'}"
TextColor="#A1887F"
FontSize="12"
VerticalOptions="Center" />
<Button Grid.Column="1"
Text="📁 Add File"
BackgroundColor="#6D4C41"
TextColor="White"
FontSize="12"
CornerRadius="20"
Padding="15,8"
Margin="5,0"
Command="{Binding AddBookFromFileCommand}" />
<Button Grid.Column="2"
Text="☁️ Calibre"
BackgroundColor="#6D4C41"
TextColor="White"
FontSize="12"
CornerRadius="20"
Padding="15,8"
Command="{Binding OpenCalibreLibraryCommand}" />
</Grid>
</Grid> </Grid>
</ContentPage> </ContentPage>

View File

@@ -1,4 +1,4 @@
using BookReader.Services; using BookReader.Services;
using BookReader.ViewModels; using BookReader.ViewModels;
namespace BookReader.Views; namespace BookReader.Views;
@@ -6,20 +6,20 @@ namespace BookReader.Views;
public partial class BookshelfPage : ContentPage public partial class BookshelfPage : ContentPage
{ {
private readonly BookshelfViewModel _viewModel; private readonly BookshelfViewModel _viewModel;
private readonly INavigationService _navigationService;
public BookshelfPage(BookshelfViewModel viewModel, INavigationService navigationService, ICachedImageLoadingService imageLoadingService) public BookshelfPage(BookshelfViewModel viewModel)
{ {
InitializeComponent(); InitializeComponent();
_viewModel = viewModel; _viewModel = viewModel;
_navigationService = navigationService;
BindingContext = viewModel; BindingContext = viewModel;
SizeChanged += OnPageSizeChanged;
} }
protected override void OnAppearing() protected override void OnAppearing()
{ {
base.OnAppearing(); base.OnAppearing();
// Загружаем книги только если коллекция пуста UpdateGridSpan(Width);
if (_viewModel.Books.Count == 0) if (_viewModel.Books.Count == 0)
{ {
_ = _viewModel.LoadBooksCommand.ExecuteAsync(null); _ = _viewModel.LoadBooksCommand.ExecuteAsync(null);
@@ -30,30 +30,31 @@ public partial class BookshelfPage : ContentPage
{ {
base.OnNavigatedTo(args); base.OnNavigatedTo(args);
// Если вернулись на главную страницу и книги уже загружены - обновляем прогресс
// (например, после чтения)
if (_viewModel.Books.Count > 0 && !_viewModel.IsBusy) if (_viewModel.Books.Count > 0 && !_viewModel.IsBusy)
{ {
await _viewModel.LoadBooksCommand.ExecuteAsync(null); await _viewModel.LoadBooksCommand.ExecuteAsync(null);
} }
} }
private async void OnMenuClicked(object? sender, EventArgs e) private void OnPageSizeChanged(object? sender, EventArgs e)
{ {
var action = await _navigationService.DisplayActionSheetAsync("Menu", "Cancel", UpdateGridSpan(Width);
"⚙️ Settings", "☁️ Calibre Library", " About");
switch (action)
{
case "⚙️ Settings":
await _viewModel.OpenSettingsCommand.ExecuteAsync(null);
break;
case "☁️ Calibre Library":
await _viewModel.OpenCalibreLibraryCommand.ExecuteAsync(null);
break;
case " About":
await _navigationService.DisplayAlertAsync("About", "BookReader v1.0\nEPUB & FB2 Reader", "OK");
break;
} }
private void UpdateGridSpan(double availableWidth)
{
if (availableWidth <= 0)
{
return;
}
var span = availableWidth switch
{
< 520 => 2,
< 920 => 3,
_ => 4
};
BooksGridLayout.Span = span;
} }
} }

View File

@@ -1,163 +1,169 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:BookReader.ViewModels" xmlns:vm="clr-namespace:BookReader.ViewModels"
xmlns:models="clr-namespace:BookReader.Models" xmlns:models="clr-namespace:BookReader.Models"
xmlns:converters="clr-namespace:BookReader.Converters"
x:Class="BookReader.Views.CalibreLibraryPage" x:Class="BookReader.Views.CalibreLibraryPage"
x:DataType="vm:CalibreLibraryViewModel" x:DataType="vm:CalibreLibraryViewModel"
Title="{Binding Title}" Title="Calibre"
BackgroundColor="#1E1E1E"> Background="{StaticResource AppBackgroundBrush}">
<ContentPage.Resources> <Grid RowDefinitions="Auto,*">
<converters:ByteArrayToImageSourceConverter x:Key="ByteArrayToImageConverter" />
</ContentPage.Resources>
<Grid RowDefinitions="Auto,Auto,*,Auto">
<!-- Not Configured Message -->
<VerticalStackLayout Grid.Row="0" <VerticalStackLayout Grid.Row="0"
IsVisible="{Binding IsConfigured, Converter={StaticResource InvertedBoolConverter}}" Padding="20,24,20,14"
Padding="30" Spacing="16">
Spacing="15" <VerticalStackLayout Spacing="6">
VerticalOptions="Center"> <Label Text="Каталог Calibre"
<Label Text="☁️ Calibre-Web not configured" Style="{StaticResource PageTitleStyle}" />
FontSize="20" <Label Text="Ищите книги на сервере, скачивайте их в одно касание и держите онлайн-каталог рядом с локальной полкой."
TextColor="White" Style="{StaticResource PageSubtitleStyle}" />
HorizontalOptions="Center" />
<Label Text="Please configure your Calibre-Web server in Settings"
FontSize="14"
TextColor="#B0B0B0"
HorizontalOptions="Center"
HorizontalTextAlignment="Center" />
<Button Text="Open Settings"
BackgroundColor="#5D4037"
TextColor="White"
CornerRadius="8"
Command="{Binding OpenSettingsCommand}"
HorizontalOptions="Center" />
</VerticalStackLayout> </VerticalStackLayout>
<!-- Connection Error Message (Offline Mode) --> <Border Style="{StaticResource CardBorderStyle}"
<VerticalStackLayout Grid.Row="0" IsVisible="{Binding IsConfigured}">
IsVisible="{Binding IsConfigured}" <SearchBar Text="{Binding SearchQuery}"
Padding="30" Placeholder="Поиск по Calibre"
Spacing="15" SearchCommand="{Binding SearchCommand}"
VerticalOptions="Center"> Style="{StaticResource AppSearchBarStyle}" />
<Label Text="⚠️ Connection failed" </Border>
FontSize="20" </VerticalStackLayout>
TextColor="#FF7043"
HorizontalOptions="Center" <Grid Grid.Row="1">
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource IsNotNullOrEmptyConverter}}" /> <VerticalStackLayout IsVisible="{Binding IsConfigured, Converter={StaticResource InvertedBoolConverter}}"
Padding="20,0,20,24"
VerticalOptions="Center"
Spacing="16">
<Border Style="{StaticResource CardBorderStyle}">
<VerticalStackLayout Spacing="12">
<Label Text="Calibre ещё не настроен"
Style="{StaticResource SectionTitleStyle}" />
<Label Text="Укажите адрес сервера, логин и пароль в настройках, после чего каталог появится здесь."
Style="{StaticResource PageSubtitleStyle}" />
<Button Text="Открыть настройки"
Style="{StaticResource PrimaryButtonStyle}"
Command="{Binding OpenSettingsCommand}" />
</VerticalStackLayout>
</Border>
</VerticalStackLayout>
<VerticalStackLayout IsVisible="{Binding HasConnectionError}"
Padding="20,0,20,24"
VerticalOptions="Center"
Spacing="16">
<Border Style="{StaticResource CardBorderStyle}">
<VerticalStackLayout Spacing="12">
<Label Text="Подключение не удалось"
Style="{StaticResource SectionTitleStyle}" />
<Label Text="{Binding ConnectionErrorMessage}" <Label Text="{Binding ConnectionErrorMessage}"
FontSize="14" Style="{StaticResource PageSubtitleStyle}" />
TextColor="#B0B0B0" <Grid ColumnDefinitions="*,*" ColumnSpacing="12">
HorizontalOptions="Center" <Button Grid.Column="0"
HorizontalTextAlignment="Center" Text="Повторить"
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource IsNotNullOrEmptyConverter}}" /> Style="{StaticResource PrimaryButtonStyle}"
<Button Text="🔄 Retry" Command="{Binding RefreshBooksCommand}" />
BackgroundColor="#5D4037" <Button Grid.Column="1"
TextColor="White" Text="Настройки"
CornerRadius="8" Style="{StaticResource SecondaryButtonStyle}"
Command="{Binding RefreshBooksCommand}" Command="{Binding OpenSettingsCommand}" />
HorizontalOptions="Center" </Grid>
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource IsNotNullOrEmptyConverter}}" /> </VerticalStackLayout>
</Border>
</VerticalStackLayout> </VerticalStackLayout>
<!-- Search Bar --> <RefreshView IsVisible="{Binding IsConfigured}"
<SearchBar Grid.Row="1" IsEnabled="{Binding HasConnectionError, Converter={StaticResource InvertedBoolConverter}}"
IsVisible="{Binding IsConfigured}"
Text="{Binding SearchQuery}"
Placeholder="Search books..."
PlaceholderColor="#666"
TextColor="White"
BackgroundColor="#2C2C2C"
SearchCommand="{Binding SearchCommand}" />
<!-- Book List -->
<RefreshView Grid.Row="2"
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource InvertedBoolConverter}}"
Command="{Binding RefreshBooksCommand}" Command="{Binding RefreshBooksCommand}"
IsRefreshing="{Binding IsRefreshing}"> IsRefreshing="{Binding IsRefreshing}"
Margin="0,0,0,24">
<CollectionView ItemsSource="{Binding Books}" <CollectionView ItemsSource="{Binding Books}"
SelectionMode="None" SelectionMode="None"
RemainingItemsThreshold="5" RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreBooksCommand}"> RemainingItemsThresholdReachedCommand="{Binding LoadMoreBooksCommand}">
<CollectionView.EmptyView>
<VerticalStackLayout Padding="20"
Spacing="10">
<Border Style="{StaticResource CardBorderStyle}">
<VerticalStackLayout Spacing="8">
<Label Text="Ничего не найдено"
Style="{StaticResource SectionTitleStyle}" />
<Label Text="Измените запрос или обновите каталог, если на сервере появились новые книги."
Style="{StaticResource PageSubtitleStyle}" />
</VerticalStackLayout>
</Border>
</VerticalStackLayout>
</CollectionView.EmptyView>
<CollectionView.Header>
<VerticalStackLayout Padding="20,0,20,16"
Spacing="12">
<Border Style="{StaticResource MutedCardBorderStyle}"
IsVisible="{Binding DownloadStatus, Converter={StaticResource IsNotNullOrEmptyConverter}}">
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="12">
<Label Text="{Binding DownloadStatus}"
Style="{StaticResource PageSubtitleStyle}"
VerticalOptions="Center" />
<ActivityIndicator Grid.Column="1"
IsRunning="{Binding IsBusy}"
IsVisible="{Binding IsBusy}"
Color="{StaticResource AccentColor}"
HeightRequest="22"
WidthRequest="22" />
</Grid>
</Border>
</VerticalStackLayout>
</CollectionView.Header>
<CollectionView.ItemTemplate> <CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:CalibreBook"> <DataTemplate x:DataType="models:CalibreBook">
<Frame Margin="10,5" <Border BackgroundColor="{StaticResource SurfaceColor}"
Padding="10" Stroke="{StaticResource BorderColor}"
BackgroundColor="#2C2C2C" StrokeThickness="1"
CornerRadius="10" StrokeShape="RoundRectangle 24"
HasShadow="True" Margin="20,0,20,14"
BorderColor="Transparent"> Padding="14">
<Grid ColumnDefinitions="80,*,Auto" ColumnSpacing="12"> <Grid ColumnDefinitions="84,*,Auto"
<!-- Cover --> ColumnSpacing="14">
<Frame Grid.Column="0" <Border Grid.Column="0"
Padding="0" BackgroundColor="{StaticResource SurfaceStrong}"
CornerRadius="6" StrokeThickness="0"
IsClippedToBounds="True" StrokeShape="RoundRectangle 16"
HasShadow="False" HeightRequest="118"
BorderColor="Transparent" WidthRequest="84"
HeightRequest="110" Padding="0">
WidthRequest="80">
<Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}" <Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
Aspect="AspectFill" /> Aspect="AspectFill" />
</Frame> </Border>
<!-- Info -->
<VerticalStackLayout Grid.Column="1" <VerticalStackLayout Grid.Column="1"
VerticalOptions="Center" Spacing="4"
Spacing="4"> VerticalOptions="Center">
<Label Text="{Binding Title}" <Label Text="{Binding Title}"
FontSize="15" Style="{StaticResource SectionTitleStyle}"
FontAttributes="Bold"
TextColor="White"
MaxLines="2" MaxLines="2"
LineBreakMode="TailTruncation" /> LineBreakMode="TailTruncation" />
<Label Text="{Binding Author}" <Label Text="{Binding Author}"
Style="{StaticResource PageSubtitleStyle}"
MaxLines="2"
LineBreakMode="TailTruncation" />
<Label Text="{Binding Format, StringFormat='Формат: {0}'}"
FontSize="12" FontSize="12"
TextColor="#A1887F" /> TextColor="{StaticResource SuccessColor}" />
<Label Text="{Binding Format, StringFormat='Format: {0}'}"
FontSize="11"
TextColor="#81C784" />
</VerticalStackLayout> </VerticalStackLayout>
<!-- Download Button -->
<Button Grid.Column="2" <Button Grid.Column="2"
Text="⬇️" Text="Скачать"
FontSize="20" Style="{StaticResource PrimaryButtonStyle}"
BackgroundColor="#4CAF50" Padding="16,10"
TextColor="White"
CornerRadius="25"
WidthRequest="50"
HeightRequest="50"
VerticalOptions="Center" VerticalOptions="Center"
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:CalibreLibraryViewModel}}, Path=DownloadBookCommand}" Command="{Binding Source={RelativeSource AncestorType={x:Type vm:CalibreLibraryViewModel}}, Path=DownloadBookCommand}"
CommandParameter="{Binding .}" /> CommandParameter="{Binding .}" />
</Grid> </Grid>
</Frame> </Border>
</DataTemplate> </DataTemplate>
</CollectionView.ItemTemplate> </CollectionView.ItemTemplate>
</CollectionView> </CollectionView>
</RefreshView> </RefreshView>
<!-- Status Bar -->
<Grid Grid.Row="3"
BackgroundColor="#2C2C2C"
Padding="15,8"
IsVisible="{Binding IsConfigured}">
<Label Text="{Binding DownloadStatus}"
TextColor="#81C784"
FontSize="12"
VerticalOptions="Center" />
<ActivityIndicator IsRunning="{Binding IsBusy}"
IsVisible="{Binding IsBusy}"
Color="#FF8A65"
HorizontalOptions="End"
HeightRequest="20"
WidthRequest="20" />
</Grid> </Grid>
</Grid> </Grid>
</ContentPage> </ContentPage>

View File

@@ -1,5 +1,4 @@
using BookReader.Services; using BookReader.ViewModels;
using BookReader.ViewModels;
namespace BookReader.Views; namespace BookReader.Views;
@@ -7,7 +6,7 @@ public partial class CalibreLibraryPage : ContentPage
{ {
private readonly CalibreLibraryViewModel _viewModel; private readonly CalibreLibraryViewModel _viewModel;
public CalibreLibraryPage(CalibreLibraryViewModel viewModel, ICachedImageLoadingService imageLoadingService) public CalibreLibraryPage(CalibreLibraryViewModel viewModel)
{ {
InitializeComponent(); InitializeComponent();
_viewModel = viewModel; _viewModel = viewModel;

View File

@@ -1,165 +1,194 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:BookReader.ViewModels" xmlns:vm="clr-namespace:BookReader.ViewModels"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit" xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:converters="clr-namespace:BookReader.Converters"
x:Class="BookReader.Views.ReaderPage" x:Class="BookReader.Views.ReaderPage"
x:DataType="vm:ReaderViewModel" x:DataType="vm:ReaderViewModel"
Shell.NavBarIsVisible="False" Shell.NavBarIsVisible="False"
NavigationPage.HasNavigationBar="False"> NavigationPage.HasNavigationBar="False"
BackgroundColor="#1B130F">
<ContentPage.Resources> <ContentPage.Resources>
<toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" /> <toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" />
</ContentPage.Resources> </ContentPage.Resources>
<Grid> <Grid>
<!-- Рендер книги -->
<HybridWebView x:Name="ReaderWebView" <HybridWebView x:Name="ReaderWebView"
RawMessageReceived="OnRawMessageReceived" RawMessageReceived="OnRawMessageReceived"
DefaultFile="index.html" DefaultFile="index.html"
HorizontalOptions="Fill" HorizontalOptions="Fill"
VerticalOptions="Fill" /> VerticalOptions="Fill" />
<!-- Всплывающее меню -->
<Grid IsVisible="{Binding IsMenuVisible}" <Grid IsVisible="{Binding IsMenuVisible}"
BackgroundColor="Transparent" BackgroundColor="#73000000"
InputTransparent="False"> InputTransparent="False">
<Grid.GestureRecognizers> <Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding HideMenuCommand}" /> <TapGestureRecognizer Command="{Binding HideMenuCommand}" />
</Grid.GestureRecognizers> </Grid.GestureRecognizers>
<!-- Верхняя всплывающая панель -->
<Frame BackgroundColor="#2C2C2C" <Grid RowDefinitions="Auto,*,Auto"
VerticalOptions="Start" Padding="16,24,16,20">
HorizontalOptions="FillAndExpand" <Border Grid.Row="0"
Padding="20" BackgroundColor="#F7E8D9"
BorderColor="Transparent"> StrokeThickness="0"
<VerticalStackLayout> StrokeShape="RoundRectangle 24"
<Label Text="{Binding ProgressText}" Padding="16">
TextColor="White" <Grid ColumnDefinitions="Auto,*,Auto"
FontSize="12" ColumnSpacing="12">
HorizontalTextAlignment="Center" /> <Button Grid.Column="0"
<!--Back Button--> Text="Назад"
<Button Text="← Back to Library" Style="{StaticResource SecondaryButtonStyle}"
BackgroundColor="#D32F2F" Padding="16,10"
TextColor="White"
FontSize="14"
CornerRadius="8"
HeightRequest="45"
Clicked="OnBackToLibrary" /> Clicked="OnBackToLibrary" />
<VerticalStackLayout Grid.Column="1"
VerticalOptions="Center"
Spacing="2">
<Label Text="{Binding Book.Title}"
FontFamily="OpenSansSemibold"
FontSize="17"
TextColor="{StaticResource InkColor}"
MaxLines="2"
LineBreakMode="TailTruncation" />
<Label Text="{Binding ProgressText}"
FontSize="13"
TextColor="{StaticResource AccentDarkColor}" />
<Label Text="{Binding ChapterProgressText}"
FontSize="12"
TextColor="{StaticResource InkSoftColor}"
MaxLines="1"
LineBreakMode="TailTruncation" />
</VerticalStackLayout> </VerticalStackLayout>
</Frame>
<!--Нижняя панель--> <Button Grid.Column="2"
<Frame VerticalOptions="End" Text="Скрыть"
HorizontalOptions="FillAndExpand" Style="{StaticResource TonalButtonStyle}"
BackgroundColor="#2C2C2C" Command="{Binding HideMenuCommand}" />
Padding="20" </Grid>
BorderColor="Transparent"> </Border>
<Frame.GestureRecognizers>
<Border Grid.Row="2"
BackgroundColor="#FFF8F0"
StrokeThickness="0"
StrokeShape="RoundRectangle 28"
Padding="18"
VerticalOptions="End">
<Border.GestureRecognizers>
<TapGestureRecognizer Tapped="OnMenuPanelTapped" /> <TapGestureRecognizer Tapped="OnMenuPanelTapped" />
</Frame.GestureRecognizers> </Border.GestureRecognizers>
<VerticalStackLayout Spacing="15"> <ScrollView HeightRequest="360">
<!--Title--> <VerticalStackLayout Spacing="16">
<Label Text="Reading Settings" <Label Text="Параметры чтения"
FontSize="18" FontFamily="OpenSansSemibold"
FontAttributes="Bold" FontSize="20"
TextColor="White" TextColor="{StaticResource InkColor}" />
HorizontalOptions="Center" />
<!--Font Size--> <Grid ColumnDefinitions="*,*,*"
<VerticalStackLayout Spacing="5"> ColumnSpacing="10">
<Label Text="Font Size" <Button Grid.Column="0"
FontSize="14" Text="Тёплая"
TextColor="#B0B0B0" /> Style="{StaticResource TonalButtonStyle}"
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="10"> Clicked="OnSepiaThemeClicked" />
<Button Grid.Column="1"
Text="Светлая"
Style="{StaticResource TonalButtonStyle}"
Clicked="OnLightThemeClicked" />
<Button Grid.Column="2"
Text="Тёмная"
Style="{StaticResource TonalButtonStyle}"
Clicked="OnDarkThemeClicked" />
</Grid>
<VerticalStackLayout Spacing="8">
<Label Text="Размер шрифта"
Style="{StaticResource CaptionStyle}" />
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="12">
<Button Grid.Column="0" <Button Grid.Column="0"
Text="A-" Text="A-"
FontSize="14" Style="{StaticResource SecondaryButtonStyle}"
WidthRequest="45" WidthRequest="56"
HeightRequest="40"
BackgroundColor="#444"
TextColor="White"
CornerRadius="8"
Clicked="OnDecreaseFontSize" /> Clicked="OnDecreaseFontSize" />
<Label Grid.Column="1" <Label Grid.Column="1"
Text="{Binding FontSize, StringFormat='{0}px'}" Text="{Binding FontSize, StringFormat='Текущее значение: {0}px'}"
FontSize="16" FontSize="14"
TextColor="White" TextColor="{StaticResource InkColor}"
HorizontalOptions="Center" HorizontalOptions="Center"
VerticalOptions="Center" /> VerticalOptions="Center" />
<Button Grid.Column="2" <Button Grid.Column="2"
Text="A+" Text="A+"
FontSize="14" Style="{StaticResource SecondaryButtonStyle}"
WidthRequest="45" WidthRequest="56"
HeightRequest="40"
BackgroundColor="#444"
TextColor="White"
CornerRadius="8"
Clicked="OnIncreaseFontSize" /> Clicked="OnIncreaseFontSize" />
</Grid> </Grid>
</VerticalStackLayout> </VerticalStackLayout>
<!--Font Family--> <VerticalStackLayout Spacing="8">
<VerticalStackLayout Spacing="5"> <Label Text="{Binding Brightness, StringFormat='Яркость: {0:F0}%'}"
<Label Text="Font Family" Style="{StaticResource CaptionStyle}" />
FontSize="14" <Slider Minimum="70"
TextColor="#B0B0B0" /> Maximum="120"
Value="{Binding Brightness}"
ValueChanged="OnBrightnessChanged"
Style="{StaticResource ReaderSliderStyle}" />
</VerticalStackLayout>
<VerticalStackLayout Spacing="8">
<Label Text="Гарнитура"
Style="{StaticResource CaptionStyle}" />
<Picker x:Name="FontFamilyPicker" <Picker x:Name="FontFamilyPicker"
ItemsSource="{Binding AvailableFonts}" ItemsSource="{Binding AvailableFonts}"
SelectedItem="{Binding FontFamily}" SelectedItem="{Binding FontFamily}"
TextColor="White" Style="{StaticResource AppPickerStyle}"
BackgroundColor="#444"
FontSize="14"
SelectedIndexChanged="OnFontFamilyChanged" /> SelectedIndexChanged="OnFontFamilyChanged" />
</VerticalStackLayout> </VerticalStackLayout>
<!--Chapters Button--> <Button Text="Оглавление"
<Button Text="📑 Chapters" Style="{StaticResource PrimaryButtonStyle}"
BackgroundColor="#5D4037"
TextColor="White"
FontSize="14"
CornerRadius="8"
HeightRequest="45"
Command="{Binding ToggleChapterListCommand}" /> Command="{Binding ToggleChapterListCommand}" />
<!--Chapter List-->
<CollectionView ItemsSource="{Binding Chapters}" <CollectionView ItemsSource="{Binding Chapters}"
IsVisible="{Binding IsChapterListVisible}" IsVisible="{Binding IsChapterListVisible}"
MaximumHeightRequest="200" MaximumHeightRequest="190"
SelectionMode="Single" SelectionMode="Single"
SelectionChanged="OnChapterSelected"> SelectionChanged="OnChapterSelected">
<CollectionView.ItemTemplate> <CollectionView.ItemTemplate>
<DataTemplate x:DataType="x:String"> <DataTemplate x:DataType="x:String">
<Grid Padding="10,8" BackgroundColor="Transparent"> <Border BackgroundColor="{StaticResource SurfaceMuted}"
Stroke="{StaticResource BorderColor}"
StrokeThickness="1"
StrokeShape="RoundRectangle 16"
Margin="0,0,0,10"
Padding="12,10">
<Label Text="{Binding .}" <Label Text="{Binding .}"
TextColor="#E0E0E0"
FontSize="13" FontSize="13"
TextColor="{StaticResource InkColor}"
LineBreakMode="TailTruncation" /> LineBreakMode="TailTruncation" />
</Grid> </Border>
</DataTemplate> </DataTemplate>
</CollectionView.ItemTemplate> </CollectionView.ItemTemplate>
</CollectionView> </CollectionView>
</VerticalStackLayout> </VerticalStackLayout>
</Frame> </ScrollView>
</Border>
</Grid>
</Grid> </Grid>
<!-- Нижняя постоянная панель отображает прогресс чтения -->
<VerticalStackLayout VerticalOptions="End" <VerticalStackLayout VerticalOptions="End"
HorizontalOptions="Center" HorizontalOptions="Center"
Padding="10" Padding="12,0,12,16"
InputTransparent="True"> InputTransparent="True">
<Border BackgroundColor="#B31F1612"
<Frame BackgroundColor="#AA000000" StrokeThickness="0"
CornerRadius="15" StrokeShape="RoundRectangle 18"
Padding="10,2" Padding="12,6">
BorderColor="Transparent"
HasShadow="False">
<Label Text="{Binding ProgressText}" <Label Text="{Binding ProgressText}"
TextColor="White" TextColor="White"
FontSize="12" FontSize="12"
HorizontalTextAlignment="Center" /> HorizontalTextAlignment="Center" />
</Frame> </Border>
</VerticalStackLayout> </VerticalStackLayout>
</Grid> </Grid>
</ContentPage> </ContentPage>

View File

@@ -1,6 +1,7 @@
using BookReader.Services; using BookReader.Services;
using BookReader.ViewModels; using BookReader.ViewModels;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using System.Globalization;
namespace BookReader.Views; namespace BookReader.Views;
@@ -8,9 +9,10 @@ public partial class ReaderPage : ContentPage
{ {
private readonly ReaderViewModel _viewModel; private readonly ReaderViewModel _viewModel;
private readonly INavigationService _navigationService; private readonly INavigationService _navigationService;
private bool _isBookLoaded;
private readonly List<JObject> _chapterData = new(); private readonly List<JObject> _chapterData = new();
private bool _isActive; private bool _isActive;
private bool _isBookLoaded;
private bool _isSubscribedToJavaScriptRequests;
public ReaderPage(ReaderViewModel viewModel, INavigationService navigationService) public ReaderPage(ReaderViewModel viewModel, INavigationService navigationService)
{ {
@@ -18,19 +20,18 @@ public partial class ReaderPage : ContentPage
_viewModel = viewModel; _viewModel = viewModel;
_navigationService = navigationService; _navigationService = navigationService;
BindingContext = viewModel; BindingContext = viewModel;
_viewModel.OnJavaScriptRequested += OnJavaScriptRequested;
} }
protected override async void OnAppearing() protected override async void OnAppearing()
{ {
base.OnAppearing(); base.OnAppearing();
_isActive = true; _isActive = true;
EnsureSubscribed();
try try
{ {
await _viewModel.InitializeAsync(); await _viewModel.InitializeAsync();
System.Diagnostics.Debug.WriteLine("[Reader] ViewModel initialized"); System.Diagnostics.Debug.WriteLine("[Reader] ViewModel initialized");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -41,7 +42,7 @@ public partial class ReaderPage : ContentPage
protected override async void OnDisappearing() protected override async void OnDisappearing()
{ {
_isActive = false; _isActive = false;
_viewModel.OnJavaScriptRequested -= OnJavaScriptRequested; EnsureUnsubscribed();
base.OnDisappearing(); base.OnDisappearing();
await SaveCurrentProgress(); await SaveCurrentProgress();
} }
@@ -50,107 +51,84 @@ public partial class ReaderPage : ContentPage
{ {
_isActive = false; _isActive = false;
base.OnNavigatedFrom(args); base.OnNavigatedFrom(args);
// Сохраняем немедленно при любом уходе со страницы
await SaveCurrentProgress(); await SaveCurrentProgress();
} }
// ========== ЗАГРУЗКА КНИГИ ==========
private async Task LoadBookIntoWebView() private async Task LoadBookIntoWebView()
{ {
try try
{ {
var book = _viewModel.Book; var book = _viewModel.Book;
if (book == null) if (book == null || _isBookLoaded)
{
return;
}
if (_isBookLoaded)
{ {
return; return;
} }
if (!File.Exists(book.FilePath)) if (!File.Exists(book.FilePath))
{ {
await _navigationService.DisplayAlertAsync("Error", "Book file not found", "OK"); await _navigationService.DisplayAlertAsync("Ошибка", "Файл книги не найден.", "OK");
return; return;
} }
// Читаем файл и конвертируем в Base64
var fileBytes = await File.ReadAllBytesAsync(book.FilePath);
var base64 = Convert.ToBase64String(fileBytes);
var format = book.Format.ToLowerInvariant(); var format = book.Format.ToLowerInvariant();
var lastCfi = _viewModel.GetLastCfi() ?? ""; var lastCfi = _viewModel.GetLastCfi() ?? string.Empty;
var locations=_viewModel.GetLocations() ?? ""; var locations = _viewModel.GetLocations() ?? string.Empty;
// Отправляем данные чанками чтобы не превысить лимит JS строки
const int chunkSize = 400_000;
if (base64.Length > chunkSize)
{
var chunks = SplitString(base64, chunkSize);
await EvalJsAsync("window._bkChunks = [];"); await EvalJsAsync("window._bkChunks = [];");
for (int i = 0; i < chunks.Count; i++) using (var fileStream = File.OpenRead(book.FilePath))
{ {
await EvalJsAsync($"window._bkChunks.push('{chunks[i]}');"); var buffer = new byte[Constants.Reader.Base64RawChunkSize];
int bytesRead;
while ((bytesRead = await fileStream.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0)
{
var base64Chunk = Convert.ToBase64String(buffer, 0, bytesRead);
await EvalJsAsync($"window._bkChunks.push('{base64Chunk}');");
}
} }
await EvalJsAsync( await EvalJsAsync(
$"window.loadBookFromBase64(window._bkChunks.join(''), '{format}', '{EscapeJs(lastCfi)}','{EscapeJs(locations)}');" $"window.loadBookFromBase64(window._bkChunks.join(''), '{format}', '{EscapeJs(lastCfi)}', '{EscapeJs(locations)}');");
);
await EvalJsAsync("delete window._bkChunks;"); await EvalJsAsync("delete window._bkChunks;");
}
else
{
await EvalJsAsync(
$"window.loadBookFromBase64('{base64}', '{format}', '{EscapeJs(lastCfi)}','{EscapeJs(locations)}');"
);
}
_isBookLoaded = true; _isBookLoaded = true;
System.Diagnostics.Debug.WriteLine("[Reader] Book load command sent");
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize.ToString(CultureInfo.InvariantCulture)})");
// Применяем настройки шрифта сразу
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')"); await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
await EvalJsAsync($"window.setReaderTheme('{EscapeJs(_viewModel.ReaderTheme)}')");
System.Diagnostics.Debug.WriteLine("[Reader] Book fully loaded"); await EvalJsAsync($"window.setBrightness({_viewModel.Brightness.ToString(CultureInfo.InvariantCulture)})");
} }
catch (Exception ex) catch (Exception ex)
{ {
System.Diagnostics.Debug.WriteLine($"[Reader] Load error: {ex.Message}\n{ex.StackTrace}"); System.Diagnostics.Debug.WriteLine($"[Reader] Load error: {ex.Message}\n{ex.StackTrace}");
await _navigationService.DisplayAlertAsync("Error", $"Failed to load book: {ex.Message}", "OK"); await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось загрузить книгу: {ex.Message}", "OK");
} }
} }
// ========== СОХРАНЕНИЕ ПРОГРЕССА ==========
private async Task SaveCurrentProgress() private async Task SaveCurrentProgress()
{ {
if (!_isBookLoaded) return; if (!_isBookLoaded)
{
return;
}
try try
{ {
var result = await EvalJsWithResultAsync("window.getProgress()"); var result = await EvalJsWithResultAsync("window.getProgress()");
if (string.IsNullOrEmpty(result) || result == "null" || result == "{}" || result == "undefined") if (string.IsNullOrEmpty(result) || result is "null" or "{}" or "undefined")
{
return; return;
}
result = UnescapeJsResult(result); result = UnescapeJsResult(result);
var data = JObject.Parse(result); var data = JObject.Parse(result);
var progress = data["progress"]?.Value<double>() ?? 0; var progress = data["progress"]?.Value<double>() ?? 0;
var cfi = data["cfi"]?.ToString(); var cfi = data["cfi"]?.ToString();
var currentPage = data["currentPage"]?.Value<int>() ?? 0; var currentPage = data["currentPage"]?.Value<int>() ?? 0;
var totalPages = data["totalPages"]?.Value<int>() ?? 0; var totalPages = data["totalPages"]?.Value<int>() ?? 0;
await _viewModel.SaveProgressAsync(progress, cfi, null, currentPage, totalPages); await _viewModel.SaveProgressAsync(progress, cfi, _viewModel.Book?.LastChapter, currentPage, totalPages, force: true);
System.Diagnostics.Debug.WriteLine($"[Reader] Saved progress: {progress:P0}");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -158,16 +136,16 @@ public partial class ReaderPage : ContentPage
} }
} }
// ========== ОБРАБОТКА СООБЩЕНИЙ ОТ JS ==========
private async void OnRawMessageReceived(object? sender, HybridWebViewRawMessageReceivedEventArgs e) private async void OnRawMessageReceived(object? sender, HybridWebViewRawMessageReceivedEventArgs e)
{ {
try try
{ {
var message = e.Message; var message = e.Message;
if (string.IsNullOrEmpty(message)) return; if (string.IsNullOrEmpty(message))
{
return;
}
// ... (оставляем логику логирования и парсинга JSON) ...
var json = JObject.Parse(message); var json = JObject.Parse(message);
var action = json["action"]?.ToString(); var action = json["action"]?.ToString();
var data = json["data"] as JObject; var data = json["data"] as JObject;
@@ -175,16 +153,11 @@ public partial class ReaderPage : ContentPage
switch (action) switch (action)
{ {
case "readerReady": case "readerReady":
// Вызываем загрузку книги ТОЛЬКО после того, как JS подтвердил готовность
_ = MainThread.InvokeOnMainThreadAsync(LoadBookIntoWebView); _ = MainThread.InvokeOnMainThreadAsync(LoadBookIntoWebView);
break; break;
case "toggleMenu": case "toggleMenu":
MainThread.BeginInvokeOnMainThread(() => MainThread.BeginInvokeOnMainThread(() => _viewModel.ToggleMenuCommand.Execute(null));
{
_viewModel.ToggleMenuCommand.Execute(null);
});
break; break;
case "progressUpdate": case "progressUpdate":
@@ -195,17 +168,17 @@ public partial class ReaderPage : ContentPage
var chapter = data["chapter"]?.ToString(); var chapter = data["chapter"]?.ToString();
var currentPage = data["currentPage"]?.Value<int>() ?? 0; var currentPage = data["currentPage"]?.Value<int>() ?? 0;
var totalPages = data["totalPages"]?.Value<int>() ?? 0; var totalPages = data["totalPages"]?.Value<int>() ?? 0;
// Ловим новые данные по главе
var chapterPage = data["chapterCurrentPage"]?.Value<int>() ?? 1; var chapterPage = data["chapterCurrentPage"]?.Value<int>() ?? 1;
var chapterTotal = data["chapterTotalPages"]?.Value<int>() ?? 1; var chapterTotal = data["chapterTotalPages"]?.Value<int>() ?? 1;
// Обновляем ViewModel на главном потоке
MainThread.BeginInvokeOnMainThread(() => { MainThread.BeginInvokeOnMainThread(() =>
{
_viewModel.ChapterCurrentPage = chapterPage; _viewModel.ChapterCurrentPage = chapterPage;
_viewModel.ChapterTotalPages = chapterTotal; _viewModel.ChapterTotalPages = chapterTotal;
_viewModel.TotalPages = totalPages; _viewModel.TotalPages = totalPages;
_viewModel.CurrentPage = currentPage; _viewModel.CurrentPage = currentPage;
}); });
await _viewModel.SaveProgressAsync(progress, cfi, chapter, currentPage, totalPages); await _viewModel.SaveProgressAsync(progress, cfi, chapter, currentPage, totalPages);
} }
break; break;
@@ -213,35 +186,35 @@ public partial class ReaderPage : ContentPage
case "chaptersLoaded": case "chaptersLoaded":
if (data != null) if (data != null)
{ {
var chapters = data["chapters"]?.ToObject<List<JObject>>() ?? new(); var chapters = data["chapters"]?.ToObject<List<JObject>>() ?? new List<JObject>();
_chapterData.Clear(); _chapterData.Clear();
_chapterData.AddRange(chapters); _chapterData.AddRange(chapters);
MainThread.BeginInvokeOnMainThread(() => MainThread.BeginInvokeOnMainThread(() =>
{ {
_viewModel.Chapters = chapters _viewModel.Chapters = chapters
.Select(c => c["label"]?.ToString() ?? "") .Select(chapter => chapter["label"]?.ToString() ?? string.Empty)
.Where(l => !string.IsNullOrWhiteSpace(l)) .Where(label => !string.IsNullOrWhiteSpace(label))
.ToList(); .ToList();
}); });
} }
break; break;
case "bookReady": case "bookReady":
if (data != null)
{
MainThread.BeginInvokeOnMainThread(async () => MainThread.BeginInvokeOnMainThread(async () =>
{ {
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})"); await EvalJsAsync($"window.setFontSize({_viewModel.FontSize.ToString(CultureInfo.InvariantCulture)})");
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')"); await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
await EvalJsAsync($"window.setReaderTheme('{EscapeJs(_viewModel.ReaderTheme)}')");
await EvalJsAsync($"window.setBrightness({_viewModel.Brightness.ToString(CultureInfo.InvariantCulture)})");
}); });
}
break; break;
case "saveLocations": case "saveLocations":
// Извлекаем строку локаций из данных if (data != null)
string locations = data["locations"]?.ToString(); {
// Сохраняем в базу данных var locations = data["locations"]?.ToString() ?? string.Empty;
await _viewModel.SaveLocationsAsync(locations); await _viewModel.SaveLocationsAsync(locations);
}
break; break;
} }
} }
@@ -251,34 +224,54 @@ public partial class ReaderPage : ContentPage
} }
} }
// ========== ОБРАБОТКА ЗАПРОСОВ JS ОТ VIEWMODEL ==========
private async void OnJavaScriptRequested(string script) private async void OnJavaScriptRequested(string script)
{ {
if (!_isActive) return; if (!_isActive)
{
return;
}
await EvalJsAsync(script); await EvalJsAsync(script);
} }
// ========== UI EVENTS ==========
private void OnDecreaseFontSize(object? sender, EventArgs e) private void OnDecreaseFontSize(object? sender, EventArgs e)
{ {
var idx = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize); var index = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
if (idx > 0) if (index > 0)
{ {
_viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[idx - 1]); _viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[index - 1]);
} }
} }
private void OnIncreaseFontSize(object? sender, EventArgs e) private void OnIncreaseFontSize(object? sender, EventArgs e)
{ {
var idx = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize); var index = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
if (idx < _viewModel.AvailableFontSizes.Count - 1) if (index < _viewModel.AvailableFontSizes.Count - 1)
{ {
_viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[idx + 1]); _viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[index + 1]);
} }
} }
private void OnSepiaThemeClicked(object? sender, EventArgs e)
{
_viewModel.ChangeReaderThemeCommand.Execute("sepia");
}
private void OnLightThemeClicked(object? sender, EventArgs e)
{
_viewModel.ChangeReaderThemeCommand.Execute("light");
}
private void OnDarkThemeClicked(object? sender, EventArgs e)
{
_viewModel.ChangeReaderThemeCommand.Execute("dark");
}
private void OnBrightnessChanged(object? sender, ValueChangedEventArgs e)
{
_viewModel.ChangeBrightnessCommand.Execute(e.NewValue);
}
private void OnFontFamilyChanged(object? sender, EventArgs e) private void OnFontFamilyChanged(object? sender, EventArgs e)
{ {
if (FontFamilyPicker.SelectedItem is string family) if (FontFamilyPicker.SelectedItem is string family)
@@ -291,15 +284,14 @@ public partial class ReaderPage : ContentPage
{ {
if (e.CurrentSelection.FirstOrDefault() is string chapterLabel) if (e.CurrentSelection.FirstOrDefault() is string chapterLabel)
{ {
var chapterObj = _chapterData.FirstOrDefault(c => c["label"]?.ToString() == chapterLabel); var chapterObject = _chapterData.FirstOrDefault(chapter => chapter["label"]?.ToString() == chapterLabel);
var href = chapterObj?["href"]?.ToString() ?? chapterLabel; var href = chapterObject?["href"]?.ToString() ?? chapterLabel;
_viewModel.GoToChapterCommand.Execute(href); _viewModel.GoToChapterCommand.Execute(href);
} }
} }
private void OnMenuPanelTapped(object? sender, TappedEventArgs e) private void OnMenuPanelTapped(object? sender, TappedEventArgs e)
{ {
// Предотвращаем всплытие тапа на оверлей
} }
private async void OnBackToLibrary(object? sender, EventArgs e) private async void OnBackToLibrary(object? sender, EventArgs e)
@@ -308,11 +300,28 @@ public partial class ReaderPage : ContentPage
await _navigationService.GoBackAsync(); await _navigationService.GoBackAsync();
} }
// ========== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ========== private void EnsureSubscribed()
{
if (_isSubscribedToJavaScriptRequests)
{
return;
}
_viewModel.OnJavaScriptRequested += OnJavaScriptRequested;
_isSubscribedToJavaScriptRequests = true;
}
private void EnsureUnsubscribed()
{
if (!_isSubscribedToJavaScriptRequests)
{
return;
}
_viewModel.OnJavaScriptRequested -= OnJavaScriptRequested;
_isSubscribedToJavaScriptRequests = false;
}
/// <summary>
/// Выполняет JavaScript без ожидания результата
/// </summary>
private async Task EvalJsAsync(string script) private async Task EvalJsAsync(string script)
{ {
try try
@@ -335,9 +344,6 @@ public partial class ReaderPage : ContentPage
} }
} }
/// <summary>
/// Выполняет JavaScript и возвращает результат
/// </summary>
private async Task<string?> EvalJsWithResultAsync(string script) private async Task<string?> EvalJsWithResultAsync(string script)
{ {
string? result = null; string? result = null;
@@ -359,29 +365,16 @@ public partial class ReaderPage : ContentPage
{ {
System.Diagnostics.Debug.WriteLine($"[Reader] JS result dispatch error: {ex.Message}"); System.Diagnostics.Debug.WriteLine($"[Reader] JS result dispatch error: {ex.Message}");
} }
return result; return result;
} }
/// <summary>
/// Разбивает строку на чанки заданного размера
/// </summary>
private static List<string> SplitString(string str, int chunkSize)
{
var chunks = new List<string>();
for (int i = 0; i < str.Length; i += chunkSize)
{
chunks.Add(str.Substring(i, Math.Min(chunkSize, str.Length - i)));
}
return chunks;
}
/// <summary>
/// Экранирует строку для вставки в JS код (внутри одинарных кавычек)
/// </summary>
private static string EscapeJs(string value) private static string EscapeJs(string value)
{ {
if (string.IsNullOrEmpty(value)) if (string.IsNullOrEmpty(value))
{
return string.Empty; return string.Empty;
}
return value return value
.Replace("\\", "\\\\") .Replace("\\", "\\\\")
@@ -392,30 +385,25 @@ public partial class ReaderPage : ContentPage
.Replace("\t", "\\t"); .Replace("\t", "\\t");
} }
/// <summary>
/// Убирает экранирование из результата EvaluateJavaScriptAsync.
/// Android WebView оборачивает результат в кавычки и экранирует.
/// </summary>
private static string UnescapeJsResult(string result) private static string UnescapeJsResult(string result)
{ {
if (string.IsNullOrEmpty(result)) if (string.IsNullOrEmpty(result))
{
return result; return result;
}
// Убираем обрамляющие кавычки если есть
if (result.StartsWith("\"") && result.EndsWith("\"")) if (result.StartsWith("\"") && result.EndsWith("\""))
{ {
result = result.Substring(1, result.Length - 2); result = result.Substring(1, result.Length - 2);
} }
// Убираем экранирование return result
result = result
.Replace("\\\"", "\"") .Replace("\\\"", "\"")
.Replace("\\\\", "\\") .Replace("\\\\", "\\")
.Replace("\\/", "/") .Replace("\\/", "/")
.Replace("\\n", "\n") .Replace("\\n", "\n")
.Replace("\\r", "\r") .Replace("\\r", "\r")
.Replace("\\t", "\t"); .Replace("\\t", "\t");
return result;
} }
} }

View File

@@ -1,120 +1,130 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:BookReader.ViewModels" xmlns:vm="clr-namespace:BookReader.ViewModels"
x:Class="BookReader.Views.SettingsPage" x:Class="BookReader.Views.SettingsPage"
x:DataType="vm:SettingsViewModel" x:DataType="vm:SettingsViewModel"
Title="{Binding Title}" Title="Настройки"
BackgroundColor="#1E1E1E"> Background="{StaticResource AppBackgroundBrush}">
<ScrollView> <Grid RowDefinitions="Auto,*">
<VerticalStackLayout Spacing="20" Padding="20"> <VerticalStackLayout Grid.Row="0"
Padding="20,24,20,14"
Spacing="6">
<Label Text="Настройки"
Style="{StaticResource PageTitleStyle}" />
<Label Text="Подключите Calibre и задайте параметры чтения, которые будут применяться ко всем новым книгам."
Style="{StaticResource PageSubtitleStyle}" />
</VerticalStackLayout>
<!-- Calibre-Web Settings --> <ScrollView Grid.Row="1"
<Frame BackgroundColor="#2C2C2C" Margin="0,0,0,24">
CornerRadius="12" <VerticalStackLayout Padding="20,0,20,0"
Padding="15" Spacing="16">
HasShadow="True" <Border Style="{StaticResource CardBorderStyle}">
BorderColor="Transparent"> <VerticalStackLayout Spacing="14">
<VerticalStackLayout Spacing="12"> <Label Text="Подключение к Calibre"
<Label Text="☁️ Calibre-Web Connection" Style="{StaticResource SectionTitleStyle}" />
FontSize="18"
FontAttributes="Bold"
TextColor="White" />
<VerticalStackLayout Spacing="5"> <VerticalStackLayout Spacing="6">
<Label Text="Server URL" <Label Text="Адрес сервера"
FontSize="12" Style="{StaticResource CaptionStyle}" />
TextColor="#B0B0B0" />
<Entry Text="{Binding CalibreUrl}" <Entry Text="{Binding CalibreUrl}"
Placeholder="https://your-calibre-server.com" Placeholder="https://your-calibre-server.example"
PlaceholderColor="#666" Style="{StaticResource AppEntryStyle}"
TextColor="White"
BackgroundColor="#3C3C3C"
Keyboard="Url" /> Keyboard="Url" />
</VerticalStackLayout> </VerticalStackLayout>
<VerticalStackLayout Spacing="5"> <VerticalStackLayout Spacing="6">
<Label Text="Username" <Label Text="Логин"
FontSize="12" Style="{StaticResource CaptionStyle}" />
TextColor="#B0B0B0" />
<Entry Text="{Binding CalibreUsername}" <Entry Text="{Binding CalibreUsername}"
Placeholder="Username" Placeholder="Имя пользователя"
PlaceholderColor="#666" Style="{StaticResource AppEntryStyle}" />
TextColor="White"
BackgroundColor="#3C3C3C" />
</VerticalStackLayout> </VerticalStackLayout>
<VerticalStackLayout Spacing="5"> <VerticalStackLayout Spacing="6">
<Label Text="Password" <Label Text="Пароль"
FontSize="12" Style="{StaticResource CaptionStyle}" />
TextColor="#B0B0B0" />
<Entry Text="{Binding CalibrePassword}" <Entry Text="{Binding CalibrePassword}"
Placeholder="Password" Placeholder="Пароль"
PlaceholderColor="#666" Style="{StaticResource AppEntryStyle}"
TextColor="White"
BackgroundColor="#3C3C3C"
IsPassword="True" /> IsPassword="True" />
</VerticalStackLayout> </VerticalStackLayout>
<Button Text="Test Connection" <Border Style="{StaticResource MutedCardBorderStyle}">
BackgroundColor="#5D4037" <Label Text="{Binding ConnectionSecurityHint}"
TextColor="White" TextColor="{Binding ConnectionSecurityColor}"
CornerRadius="8" FontSize="13"
LineBreakMode="WordWrap" />
</Border>
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
<Button Grid.Column="0"
Text="Проверить"
Style="{StaticResource SecondaryButtonStyle}"
Command="{Binding TestConnectionCommand}" Command="{Binding TestConnectionCommand}"
IsEnabled="{Binding IsConnectionTesting, Converter={StaticResource InvertedBoolConverter}}" /> IsEnabled="{Binding IsConnectionTesting, Converter={StaticResource InvertedBoolConverter}}" />
<Button Grid.Column="1"
Text="Сохранить"
Style="{StaticResource PrimaryButtonStyle}"
Command="{Binding SaveSettingsCommand}" />
</Grid>
<Grid ColumnDefinitions="*,Auto"
IsVisible="{Binding ConnectionStatus, Converter={StaticResource IsNotNullOrEmptyConverter}}">
<Label Text="{Binding ConnectionStatus}" <Label Text="{Binding ConnectionStatus}"
TextColor="{Binding ConnectionStatusColor}"
FontSize="13" FontSize="13"
TextColor="#81C784" VerticalOptions="Center" />
IsVisible="{Binding ConnectionStatus, Converter={StaticResource IsNotNullOrEmptyConverter}}" /> <ActivityIndicator Grid.Column="1"
IsRunning="{Binding IsConnectionTesting}"
IsVisible="{Binding IsConnectionTesting}"
Color="{StaticResource AccentColor}" />
</Grid>
</VerticalStackLayout> </VerticalStackLayout>
</Frame> </Border>
<!-- Reading Settings --> <Border Style="{StaticResource CardBorderStyle}">
<Frame BackgroundColor="#2C2C2C" <VerticalStackLayout Spacing="14">
CornerRadius="12" <Label Text="Чтение по умолчанию"
Padding="15" Style="{StaticResource SectionTitleStyle}" />
HasShadow="True"
BorderColor="Transparent">
<VerticalStackLayout Spacing="12">
<Label Text="📖 Reading Defaults"
FontSize="18"
FontAttributes="Bold"
TextColor="White" />
<VerticalStackLayout Spacing="5"> <VerticalStackLayout Spacing="6">
<Label Text="Default Font Size" <Label Text="Размер шрифта"
FontSize="12" Style="{StaticResource CaptionStyle}" />
TextColor="#B0B0B0" />
<Picker ItemsSource="{Binding AvailableFontSizes}" <Picker ItemsSource="{Binding AvailableFontSizes}"
SelectedItem="{Binding DefaultFontSize}" SelectedItem="{Binding DefaultFontSize}"
TextColor="White" Style="{StaticResource AppPickerStyle}" />
BackgroundColor="#3C3C3C" />
</VerticalStackLayout> </VerticalStackLayout>
<VerticalStackLayout Spacing="5"> <VerticalStackLayout Spacing="6">
<Label Text="Default Font Family" <Label Text="Гарнитура"
FontSize="12" Style="{StaticResource CaptionStyle}" />
TextColor="#B0B0B0" />
<Picker ItemsSource="{Binding AvailableFonts}" <Picker ItemsSource="{Binding AvailableFonts}"
SelectedItem="{Binding DefaultFontFamily}" SelectedItem="{Binding DefaultFontFamily}"
TextColor="White" Style="{StaticResource AppPickerStyle}" />
BackgroundColor="#3C3C3C" /> </VerticalStackLayout>
<VerticalStackLayout Spacing="6">
<Label Text="Тема ридера"
Style="{StaticResource CaptionStyle}" />
<Picker ItemsSource="{Binding AvailableReaderThemes}"
SelectedItem="{Binding DefaultReaderTheme}"
Style="{StaticResource AppPickerStyle}" />
</VerticalStackLayout>
<VerticalStackLayout Spacing="8">
<Label Text="{Binding DefaultBrightness, StringFormat='Яркость страницы: {0:F0}%'}"
Style="{StaticResource CaptionStyle}" />
<Slider Minimum="70"
Maximum="120"
Value="{Binding DefaultBrightness}"
Style="{StaticResource ReaderSliderStyle}" />
</VerticalStackLayout> </VerticalStackLayout>
</VerticalStackLayout> </VerticalStackLayout>
</Frame> </Border>
<!-- Save Button -->
<Button Text="💾 Save Settings"
BackgroundColor="#4CAF50"
TextColor="White"
FontSize="16"
FontAttributes="Bold"
CornerRadius="12"
HeightRequest="50"
Command="{Binding SaveSettingsCommand}" />
</VerticalStackLayout> </VerticalStackLayout>
</ScrollView> </ScrollView>
</Grid>
</ContentPage> </ContentPage>

View File

@@ -1,4 +1,4 @@
using BookReader.ViewModels; using BookReader.ViewModels;
namespace BookReader.Views; namespace BookReader.Views;
@@ -31,10 +31,9 @@ public partial class SettingsPage : ContentPage
{ {
base.OnDisappearing(); base.OnDisappearing();
// Автосохранение при выходе со страницы настроек
try try
{ {
await _viewModel.SaveSettingsCommand.ExecuteAsync(null); await _viewModel.SaveSilentlyAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {