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

3
.gitignore vendored
View File

@@ -360,4 +360,5 @@ MigrationBackup/
.ionide/
# 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"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:BookReader.Views"
x:Class="BookReader.AppShell"
Shell.FlyoutBehavior="Disabled"
Shell.BackgroundColor="#3E2723"
Shell.NavBarHasShadow="False"
Shell.BackgroundColor="{StaticResource TabBarColor}"
Shell.ForegroundColor="White"
Shell.TitleColor="White">
Shell.TitleColor="White"
Shell.TabBarBackgroundColor="{StaticResource TabBarColor}"
Shell.TabBarForegroundColor="#D9C3B4"
Shell.TabBarTitleColor="#D9C3B4"
Shell.TabBarUnselectedColor="#8B7264">
<ShellContent
Title="Library"
ContentTemplate="{DataTemplate views:BookshelfPage}"
Route="bookshelf" />
<TabBar>
<Tab Title="Библиотека" Icon="library_tab.svg">
<ShellContent ContentTemplate="{DataTemplate views:BookshelfPage}"
Route="bookshelf" />
</Tab>
</Shell>
<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>

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@
android:allowBackup="true"
android:supportsRtl="true"
android:theme="@style/Maui.Main"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
</application>
@@ -21,4 +22,4 @@
<data android:scheme="content" />
</intent>
</queries>
</manifest>
</manifest>

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

View File

@@ -4,33 +4,141 @@
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:converters="clr-namespace:BookReader.Converters">
<!-- Converters from CommunityToolkit -->
<toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" />
<toolkit:IsStringNotNullOrEmptyConverter x:Key="IsNotNullOrEmptyConverter" />
<!-- Custom Converters -->
<converters:ProgressToWidthConverter x:Key="ProgressToWidthConverter" />
<converters:ByteArrayToImageSourceConverter x:Key="ByteArrayToImageConverter" />
<!-- Colors -->
<Color x:Key="PrimaryBrown">#5D4037</Color>
<Color x:Key="DarkBrown">#3E2723</Color>
<Color x:Key="LightBrown">#8D6E63</Color>
<Color x:Key="ShelfColor">#6D4C41</Color>
<Color x:Key="AccentGreen">#4CAF50</Color>
<Color x:Key="TextPrimary">#EFEBE9</Color>
<Color x:Key="TextSecondary">#A1887F</Color>
<Color x:Key="AppBackground">#F5E8D6</Color>
<Color x:Key="AppBackgroundDeep">#EBC9AE</Color>
<Color x:Key="SurfaceColor">#FFF8F0</Color>
<Color x:Key="SurfaceMuted">#F7E8D9</Color>
<Color x:Key="SurfaceStrong">#EED7C0</Color>
<Color x:Key="InkColor">#27160E</Color>
<Color x:Key="InkSoftColor">#6E5648</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">
<Setter Property="BarBackgroundColor" Value="{StaticResource DarkBrown}" />
<Setter Property="BarBackgroundColor" Value="{StaticResource TabBarColor}" />
<Setter Property="BarTextColor" Value="White" />
</Style>
<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.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>
</ResourceDictionary>
<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>

View File

@@ -10,8 +10,6 @@ public class CalibreWebService : ICalibreWebService
private readonly HttpClient _httpClient;
private readonly ICoverCacheService _coverCacheService;
private string _baseUrl = string.Empty;
private string _username = string.Empty;
private string _password = string.Empty;
public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService)
{
@@ -22,16 +20,18 @@ public class CalibreWebService : ICalibreWebService
public void Configure(string url, string username, string password)
{
_baseUrl = url.TrimEnd('/');
_username = username;
_password = password;
_baseUrl = url.Trim().TrimEnd('/');
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 =
new AuthenticationHeaderValue("Basic", Convert.ToBase64String(authBytes));
}
else
{
_httpClient.DefaultRequestHeaders.Authorization = null;
}
}
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>();
try
{
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 response = await _httpClient.GetStringAsync(url);
var json = JObject.Parse(response);
var bookIds = json["book_ids"]?.ToObject<List<int>>() ?? new List<int>();
// Параллельная загрузка данных книг с ограничением
var semaphore = new SemaphoreSlim(4); // Максимум 4 параллельных запроса
var semaphore = new SemaphoreSlim(4);
var tasks = bookIds.Select(async bookId =>
{
await semaphore.WaitAsync();
@@ -79,14 +82,22 @@ public class CalibreWebService : ICalibreWebService
});
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)
{
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)
@@ -98,25 +109,27 @@ public class CalibreWebService : ICalibreWebService
var bookJson = JObject.Parse(bookResponse);
var formats = bookJson["formats"]?.ToObject<List<string>>() ?? new List<string>();
var supportedFormat = formats.FirstOrDefault(f =>
f.Equals("EPUB", StringComparison.OrdinalIgnoreCase) ||
f.Equals("FB2", StringComparison.OrdinalIgnoreCase));
var supportedFormat = formats.FirstOrDefault(format =>
format.Equals("EPUB", 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 calibreBook = new CalibreBook
{
Id = bookId.ToString(),
Title = bookJson["title"]?.ToString() ?? "Unknown",
Title = bookJson["title"]?.ToString() ?? "Без названия",
Author = string.Join(", ", authors),
Format = supportedFormat.ToLowerInvariant(),
CoverUrl = $"{_baseUrl}/get/cover/{bookId}",
DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}"
};
// Try to load cover from cache first
var cacheKey = $"cover_{bookId}";
calibreBook.CoverImage = await _coverCacheService.GetCoverAsync(cacheKey);
@@ -127,7 +140,9 @@ public class CalibreWebService : ICalibreWebService
calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl);
await _coverCacheService.SetCoverAsync(cacheKey, calibreBook.CoverImage);
}
catch { }
catch
{
}
}
return calibreBook;
@@ -140,7 +155,7 @@ public class CalibreWebService : ICalibreWebService
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);
var fileName = $"{Guid.NewGuid()}.{book.Format}";
@@ -164,9 +179,11 @@ public class CalibreWebService : ICalibreWebService
bytesRead += read;
if (totalBytes > 0)
{
progress?.Report((double)bytesRead / totalBytes);
}
}
return filePath;
}
}
}

View File

@@ -13,16 +13,16 @@ public class DatabaseService : IDatabaseService
public DatabaseService()
{
_dbPath = Path.Combine(FileSystem.AppDataDirectory, "bookreader.db3");
// Polly 8.x retry pipeline: 3 попытки с экспоненциальной задержкой
_dbPath = Path.Combine(FileSystem.AppDataDirectory, Constants.Database.DatabaseFileName);
_retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<SQLiteException>()
.Handle<Exception>(ex => ex.Message.Contains("database is locked") || ex.Message.Contains("busy")),
MaxRetryAttempts = 3,
DelayGenerator = context => new ValueTask<TimeSpan?>(TimeSpan.FromMilliseconds(100 * Math.Pow(2, context.AttemptNumber))),
MaxRetryAttempts = Constants.Database.RetryCount,
DelayGenerator = context => new ValueTask<TimeSpan?>(
TimeSpan.FromMilliseconds(Constants.Database.RetryBaseDelayMs * Math.Pow(2, context.AttemptNumber))),
OnRetry = context =>
{
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)
{
if (_database != null) return;
if (_database != null)
{
return;
}
_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<AppSettings>();
@@ -49,31 +52,35 @@ public class DatabaseService : IDatabaseService
private async Task EnsureInitializedAsync()
{
if (_database == null)
{
await InitializeAsync();
}
}
// Books
public async Task<List<Book>> GetAllBooksAsync(CancellationToken ct = default)
{
await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) =>
await _database!.Table<Book>().OrderByDescending(b => b.LastRead).ToListAsync(), ct);
return await _retryPipeline.ExecuteAsync(async _ =>
await _database!.Table<Book>().OrderByDescending(book => book.LastRead).ToListAsync(), ct);
}
public async Task<Book?> GetBookByIdAsync(int id, CancellationToken ct = default)
{
await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) =>
await _database!.Table<Book>().Where(b => b.Id == id).FirstOrDefaultAsync(), ct);
return await _retryPipeline.ExecuteAsync(async _ =>
await _database!.Table<Book>().Where(book => book.Id == id).FirstOrDefaultAsync(), ct);
}
public async Task<int> SaveBookAsync(Book book, CancellationToken ct = default)
{
await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) =>
return await _retryPipeline.ExecuteAsync(async _ =>
{
if (book.Id != 0)
{
return await _database!.UpdateAsync(book);
}
return await _database!.InsertAsync(book);
}, ct);
}
@@ -81,35 +88,36 @@ public class DatabaseService : IDatabaseService
public async Task<int> UpdateBookAsync(Book book, CancellationToken ct = default)
{
await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) =>
await _database!.UpdateAsync(book), ct);
return await _retryPipeline.ExecuteAsync(async _ => await _database!.UpdateAsync(book), ct);
}
public async Task<int> DeleteBookAsync(Book book, CancellationToken ct = default)
{
await EnsureInitializedAsync();
// Delete associated file
if (File.Exists(book.FilePath))
{
try { File.Delete(book.FilePath); } catch { }
try
{
File.Delete(book.FilePath);
}
catch
{
}
}
// Delete progress records
await _retryPipeline.ExecuteAsync(async (token) =>
await _database!.Table<ReadingProgress>().DeleteAsync(p => p.BookId == book.Id), ct);
await _retryPipeline.ExecuteAsync(async _ =>
await _database!.Table<ReadingProgress>().DeleteAsync(progress => progress.BookId == book.Id), ct);
return await _retryPipeline.ExecuteAsync(async (token) =>
await _database!.DeleteAsync(book), ct);
return await _retryPipeline.ExecuteAsync(async _ => await _database!.DeleteAsync(book), ct);
}
// Settings
public async Task<string?> GetSettingAsync(string key, CancellationToken ct = default)
{
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;
}, ct);
}
@@ -117,9 +125,9 @@ public class DatabaseService : IDatabaseService
public async Task SetSettingAsync(string key, string value, CancellationToken ct = default)
{
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)
{
existing.Value = value;
@@ -135,31 +143,42 @@ public class DatabaseService : IDatabaseService
public async Task<Dictionary<string, string>> GetAllSettingsAsync(CancellationToken ct = default)
{
await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) =>
return await _retryPipeline.ExecuteAsync(async _ =>
{
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);
}
// Reading Progress
public async Task SaveProgressAsync(ReadingProgress progress, CancellationToken ct = default)
{
await EnsureInitializedAsync();
await _retryPipeline.ExecuteAsync(async (token) =>
await _retryPipeline.ExecuteAsync(async _ =>
{
progress.Timestamp = DateTime.UtcNow;
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);
}
public async Task<ReadingProgress?> GetLatestProgressAsync(int bookId, CancellationToken ct = default)
{
await EnsureInitializedAsync();
return await _retryPipeline.ExecuteAsync(async (token) =>
return await _retryPipeline.ExecuteAsync(async _ =>
await _database!.Table<ReadingProgress>()
.Where(p => p.BookId == bookId)
.OrderByDescending(p => p.Timestamp)
.Where(progress => progress.BookId == bookId)
.OrderByDescending(progress => progress.Timestamp)
.FirstOrDefaultAsync(), ct);
}
}
}

View File

@@ -5,7 +5,7 @@ namespace BookReader.Services;
public interface ICalibreWebService
{
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);
void Configure(string url, string username, string password);
}
}

View File

@@ -6,10 +6,11 @@ public interface ISettingsService
Task SetAsync(string key, string value);
Task<int> GetIntAsync(string key, int defaultValue = 0);
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();
// Secure storage for sensitive data
Task SetSecurePasswordAsync(string password);
Task<string> GetSecurePasswordAsync();
Task ClearSecurePasswordAsync();
}
}

View File

@@ -1,4 +1,5 @@
using BookReader.Models;
using System.Globalization;
namespace BookReader.Services;
@@ -26,13 +27,32 @@ public class SettingsService : ISettingsService
{
var value = await _databaseService.GetSettingAsync(key);
if (int.TryParse(value, out var result))
{
return result;
}
return defaultValue;
}
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()
@@ -47,7 +67,7 @@ public class SettingsService : ISettingsService
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()
@@ -55,4 +75,5 @@ public class SettingsService : ISettingsService
SecureStorage.Default.Remove(SecureStorageKeys.CalibrePassword);
await Task.CompletedTask;
}
}
}

View File

@@ -10,9 +10,7 @@ public partial class BookshelfViewModel : BaseViewModel
{
private readonly IDatabaseService _databaseService;
private readonly IBookParserService _bookParserService;
private readonly ISettingsService _settingsService;
private readonly INavigationService _navigationService;
private readonly ICachedImageLoadingService _imageLoadingService;
public ObservableCollection<Book> Books { get; } = new();
@@ -22,29 +20,40 @@ public partial class BookshelfViewModel : BaseViewModel
[ObservableProperty]
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)
{
// Автоматически выполняем поиск при изменении текста
if (string.IsNullOrWhiteSpace(value))
{
// Если поле пустое - загружаем все книги
LoadBooksCommand.Execute(null);
}
}
partial void OnContinueReadingBookChanged(Book? value)
{
OnPropertyChanged(nameof(HasContinueReading));
}
public BookshelfViewModel(
IDatabaseService databaseService,
IBookParserService bookParserService,
ISettingsService settingsService,
INavigationService navigationService,
ICachedImageLoadingService imageLoadingService)
INavigationService navigationService)
{
_databaseService = databaseService;
_bookParserService = bookParserService;
_settingsService = settingsService;
_navigationService = navigationService;
_imageLoadingService = imageLoadingService;
Title = "My Library";
Title = "Библиотека";
}
[RelayCommand]
@@ -56,14 +65,13 @@ public partial class BookshelfViewModel : BaseViewModel
{
var books = await _databaseService.GetAllBooksAsync();
// Применяем фильтр поиска если есть
if (!string.IsNullOrWhiteSpace(SearchText))
{
var searchLower = SearchText.ToLowerInvariant();
books = books.Where(b =>
b.Title.ToLowerInvariant().Contains(searchLower) ||
b.Author.ToLowerInvariant().Contains(searchLower)
).ToList();
var searchLower = SearchText.Trim().ToLowerInvariant();
books = books.Where(book =>
book.Title.ToLowerInvariant().Contains(searchLower) ||
book.Author.ToLowerInvariant().Contains(searchLower))
.ToList();
}
Books.Clear();
@@ -71,11 +79,12 @@ public partial class BookshelfViewModel : BaseViewModel
{
Books.Add(book);
}
IsEmpty = Books.Count == 0;
RefreshLibraryState();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error loading books: {ex.Message}");
await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось загрузить библиотеку: {ex.Message}", "OK");
}
finally
{
@@ -86,15 +95,8 @@ public partial class BookshelfViewModel : BaseViewModel
[RelayCommand]
public async Task SearchAsync(object? parameter)
{
// Если параметр пустой или null, используем текущий SearchText
var searchText = parameter?.ToString() ?? SearchText;
if (string.IsNullOrWhiteSpace(searchText))
{
// Очищаем поиск и загружаем все книги
SearchText = string.Empty;
}
var requestedText = parameter?.ToString() ?? SearchText;
SearchText = requestedText ?? string.Empty;
await LoadBooksAsync();
}
@@ -110,42 +112,47 @@ public partial class BookshelfViewModel : BaseViewModel
var result = await FilePicker.Default.PickAsync(new PickOptions
{
PickerTitle = "Select a book",
PickerTitle = "Выберите книгу",
FileTypes = customFileTypes
});
if (result == null) return;
if (result == null)
{
return;
}
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;
}
IsBusy = true;
StatusMessage = "Adding book...";
StatusMessage = "Добавляю книгу...";
// Copy to temp if needed and parse
string filePath;
using var stream = await result.OpenReadAsync();
using var sourceStream = await result.OpenReadAsync();
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);
IsEmpty = false;
RefreshLibraryState();
// Clean temp
try { File.Delete(tempPath); } catch { }
try
{
File.Delete(tempPath);
}
catch
{
}
}
catch (Exception ex)
{
await _navigationService.DisplayAlertAsync("Error", $"Failed to add book: {ex.Message}", "OK");
await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось добавить книгу: {ex.Message}", "OK");
}
finally
{
@@ -157,29 +164,41 @@ public partial class BookshelfViewModel : BaseViewModel
[RelayCommand]
public async Task DeleteBookAsync(Book book)
{
if (book == null) return;
if (book == null)
{
return;
}
var confirm = await _navigationService.DisplayAlertAsync("Delete Book",
$"Are you sure you want to delete \"{book.Title}\"?", "Delete", "Cancel");
var confirmed = await _navigationService.DisplayAlertAsync(
"Удалить книгу",
$"Удалить \"{book.Title}\" из библиотеки?",
"Удалить",
"Отмена");
if (!confirm) return;
if (!confirmed)
{
return;
}
try
{
await _databaseService.DeleteBookAsync(book);
Books.Remove(book);
IsEmpty = Books.Count == 0;
RefreshLibraryState();
}
catch (Exception ex)
{
await _navigationService.DisplayAlertAsync("Error", $"Failed to delete book: {ex.Message}", "OK");
await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось удалить книгу: {ex.Message}", "OK");
}
}
[RelayCommand]
public async Task OpenBookAsync(Book book)
{
if (book == null) return;
if (book == null)
{
return;
}
var navigationParameter = new Dictionary<string, object>
{
@@ -190,14 +209,20 @@ public partial class BookshelfViewModel : BaseViewModel
}
[RelayCommand]
public async Task OpenSettingsAsync()
{
await _navigationService.GoToAsync("settings");
}
public Task OpenSettingsAsync() => _navigationService.GoToAsync("//settings");
[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 ISettingsService _settingsService;
private readonly INavigationService _navigationService;
private readonly ICachedImageLoadingService _imageLoadingService;
public ObservableCollection<CalibreBook> Books { get; } = new();
@@ -34,21 +33,27 @@ public partial class CalibreLibraryViewModel : BaseViewModel
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(
ICalibreWebService calibreWebService,
IBookParserService bookParserService,
IDatabaseService databaseService,
ISettingsService settingsService,
INavigationService navigationService,
ICachedImageLoadingService imageLoadingService)
INavigationService navigationService)
{
_calibreWebService = calibreWebService;
_bookParserService = bookParserService;
_databaseService = databaseService;
_settingsService = settingsService;
_navigationService = navigationService;
_imageLoadingService = imageLoadingService;
Title = "Calibre Library";
Title = "Calibre";
}
[RelayCommand]
@@ -59,35 +64,42 @@ public partial class CalibreLibraryViewModel : BaseViewModel
var password = await _settingsService.GetSecurePasswordAsync();
IsConfigured = !string.IsNullOrWhiteSpace(url);
ConnectionErrorMessage = string.Empty;
if (IsConfigured)
{
_calibreWebService.Configure(url, username, password);
await LoadBooksAsync();
}
else
{
Books.Clear();
}
}
[RelayCommand]
public async Task LoadBooksAsync()
{
if (IsBusy || !IsConfigured) return;
if (IsBusy || !IsConfigured)
{
return;
}
IsBusy = true;
_currentPage = 0;
ConnectionErrorMessage = string.Empty;
try
{
var books = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
Books.Clear();
foreach (var book in books)
var result = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
if (!result.IsSuccess)
{
Books.Add(book);
Books.Clear();
ConnectionErrorMessage = result.ErrorMessage ?? "Не удалось загрузить каталог Calibre.";
return;
}
}
catch (Exception ex)
{
ConnectionErrorMessage = "No connection to Calibre server";
System.Diagnostics.Debug.WriteLine($"Error loading Calibre library: {ex.Message}");
ReplaceBooks(result.Value ?? new List<CalibreBook>());
}
finally
{
@@ -98,7 +110,11 @@ public partial class CalibreLibraryViewModel : BaseViewModel
[RelayCommand]
public async Task RefreshBooksAsync()
{
if (IsRefreshing || !IsConfigured) return;
if (IsRefreshing || !IsConfigured)
{
return;
}
IsRefreshing = true;
try
@@ -114,19 +130,32 @@ public partial class CalibreLibraryViewModel : BaseViewModel
[RelayCommand]
public async Task LoadMoreBooksAsync()
{
if (IsBusy || !IsConfigured) return;
if (IsBusy || !IsConfigured || HasConnectionError)
{
return;
}
IsBusy = true;
_currentPage++;
try
{
var books = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
foreach (var book in books)
var result = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
if (!result.IsSuccess)
{
Books.Add(book);
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);
}
}
}
catch { }
finally
{
IsBusy = false;
@@ -142,16 +171,19 @@ public partial class CalibreLibraryViewModel : BaseViewModel
[RelayCommand]
public async Task DownloadBookAsync(CalibreBook calibreBook)
{
if (calibreBook == null) return;
if (calibreBook == null)
{
return;
}
IsBusy = true;
DownloadStatus = $"Downloading {calibreBook.Title}...";
DownloadStatus = $"Загрузка: {calibreBook.Title}";
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);
@@ -161,27 +193,36 @@ public partial class CalibreLibraryViewModel : BaseViewModel
book.CalibreId = calibreBook.Id;
if (calibreBook.CoverImage != null)
{
book.CoverImage = calibreBook.CoverImage;
}
await _databaseService.UpdateBookAsync(book);
DownloadStatus = "Download complete!";
await _navigationService.DisplayAlertAsync("Success", $"\"{calibreBook.Title}\" has been added to your library.", "OK");
DownloadStatus = "Книга добавлена в библиотеку";
await _navigationService.DisplayAlertAsync("Готово", $"\"{calibreBook.Title}\" добавлена в библиотеку.", "OK");
}
catch (Exception ex)
{
await _navigationService.DisplayAlertAsync("Error", $"Failed to download: {ex.Message}", "OK");
await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось скачать книгу: {ex.Message}", "OK");
}
finally
{
IsBusy = false;
DownloadStatus = string.Empty;
}
}
[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 CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -11,7 +10,13 @@ public partial class ReaderViewModel : BaseViewModel
{
private readonly IDatabaseService _databaseService;
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]
private Book? _book;
@@ -28,6 +33,12 @@ public partial class ReaderViewModel : BaseViewModel
[ObservableProperty]
private string _fontFamily = "serif";
[ObservableProperty]
private string _readerTheme = Constants.Reader.DefaultTheme;
[ObservableProperty]
private double _brightness = Constants.Reader.DefaultBrightness;
[ObservableProperty]
private List<string> _chapters = new();
@@ -44,19 +55,18 @@ public partial class ReaderViewModel : BaseViewModel
private int _currentPage = 1;
[ObservableProperty]
private int _totalPages = 1;
private int _totalPages = 100;
// Это свойство будет обновляться автоматически при изменении любого из полей выше
public string ChapterProgressText => $"{ChapterCurrentPage} из {ChapterTotalPages}";
public string ChapterProgressText => ChapterTotalPages > 1
? $"Глава: {ChapterCurrentPage} из {ChapterTotalPages}"
: "Позиция внутри главы появится после перелистывания";
// Это свойство показывает процент прогресса
public string ProgressText => $"{CurrentPage}%";
public string ProgressText => TotalPages == 100
? $"{CurrentPage}%"
: $"Стр. {CurrentPage} из {TotalPages}";
// Чтобы ChapterProgressText уведомлял интерфейс, добавим частичные методы (особенность Toolkit)
partial void OnChapterCurrentPageChanged(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 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
};
// Events for the view to subscribe to
public event Action<string>? OnJavaScriptRequested;
public event Action? OnBookReady;
public ReaderViewModel(
IDatabaseService databaseService,
ISettingsService settingsService,
INavigationService navigationService)
ISettingsService settingsService)
{
_databaseService = databaseService;
_settingsService = settingsService;
_navigationService = navigationService;
_fontSize = Constants.Reader.DefaultFontSize;
}
public async Task InitializeAsync()
{
// Валидация: книга должна быть загружена
if (Book == null)
{
System.Diagnostics.Debug.WriteLine("[ReaderViewModel] Book is null, cannot initialize");
return;
}
// Валидация: файл книги должен существовать
if (!File.Exists(Book.FilePath))
{
System.Diagnostics.Debug.WriteLine($"[ReaderViewModel] Book file not found: {Book.FilePath}");
return;
}
var savedFontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, Constants.Reader.DefaultFontSize);
var savedFontFamily = await _settingsService.GetAsync(SettingsKeys.DefaultFontFamily, "serif");
FontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, Constants.Reader.DefaultFontSize);
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;
FontFamily = savedFontFamily;
CurrentPage = Book.CurrentPage > 0 ? Book.CurrentPage : 1;
TotalPages = Book.TotalPages > 0 ? Book.TotalPages : 100;
RememberPersistedProgress(Book.ReadingProgress, Book.LastCfi, Book.LastChapter, Book.CurrentPage, Book.TotalPages);
}
[RelayCommand]
@@ -121,7 +129,9 @@ public partial class ReaderViewModel : BaseViewModel
{
IsMenuVisible = !IsMenuVisible;
if (!IsMenuVisible)
{
IsChapterListVisible = false;
}
}
[RelayCommand]
@@ -149,14 +159,34 @@ public partial class ReaderViewModel : BaseViewModel
public void ChangeFontFamily(string family)
{
FontFamily = family;
OnJavaScriptRequested?.Invoke($"setFontFamily('{family}')");
OnJavaScriptRequested?.Invoke($"setFontFamily('{EscapeJs(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]
public void GoToChapter(string chapter)
{
if (string.IsNullOrEmpty(chapter)) return;
if (string.IsNullOrEmpty(chapter))
{
return;
}
OnJavaScriptRequested?.Invoke($"goToChapter('{EscapeJs(chapter)}')");
IsChapterListVisible = false;
IsMenuVisible = false;
@@ -169,11 +199,12 @@ public partial class ReaderViewModel : BaseViewModel
System.Diagnostics.Debug.WriteLine("[ReaderViewModel] Cannot save locations: Book is null");
return;
}
Book.Locations = locations;
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)
{
@@ -181,8 +212,13 @@ public partial class ReaderViewModel : BaseViewModel
return;
}
// Важно: если CFI пустой, не перезаписываем старый прогресс (защита от багов JS)
if (string.IsNullOrEmpty(cfi) && progress <= 0) return;
if (string.IsNullOrEmpty(cfi) && progress <= 0)
{
return;
}
CurrentPage = currentPage > 0 ? currentPage : CurrentPage;
TotalPages = totalPages > 0 ? totalPages : TotalPages;
Book.ReadingProgress = progress;
Book.LastCfi = cfi;
@@ -191,6 +227,23 @@ public partial class ReaderViewModel : BaseViewModel
Book.TotalPages = totalPages;
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.SaveProgressAsync(new ReadingProgress
@@ -201,30 +254,35 @@ public partial class ReaderViewModel : BaseViewModel
CurrentPage = currentPage,
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";
}
public string? GetLastCfi()
{
return Book?.LastCfi;
}
public string? GetLocations()
{
return Book?.Locations;
_lastPersistedProgress = progress;
_lastPersistedCfi = cfi ?? string.Empty;
_lastPersistedChapter = chapter ?? string.Empty;
_lastPersistedCurrentPage = currentPage;
_lastPersistedTotalPages = totalPages;
_lastPersistedAt = DateTime.UtcNow;
}
private static string EscapeJs(string value)
{
return value.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\n", "\\n").Replace("\r", "\\r");
}
}
}

View File

@@ -2,11 +2,17 @@
using BookReader.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Maui.Graphics;
namespace BookReader.ViewModels;
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 ICalibreWebService _calibreWebService;
private readonly INavigationService _navigationService;
@@ -26,9 +32,24 @@ public partial class SettingsViewModel : BaseViewModel
[ObservableProperty]
private string _defaultFontFamily = "serif";
[ObservableProperty]
private string _defaultReaderTheme = "Тёплая";
[ObservableProperty]
private double _defaultBrightness = Constants.Reader.DefaultBrightness;
[ObservableProperty]
private string _connectionStatus = string.Empty;
[ObservableProperty]
private Color _connectionStatusColor = NeutralTone;
[ObservableProperty]
private string _connectionSecurityHint = string.Empty;
[ObservableProperty]
private Color _connectionSecurityColor = NeutralTone;
[ObservableProperty]
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
};
public List<string> AvailableReaderThemes { get; } = new()
{
"Тёплая",
"Светлая",
"Тёмная"
};
partial void OnCalibreUrlChanged(string value)
{
UpdateConnectionSecurityHint();
}
public SettingsViewModel(
ISettingsService settingsService,
ICalibreWebService calibreWebService,
@@ -51,7 +84,7 @@ public partial class SettingsViewModel : BaseViewModel
_settingsService = settingsService;
_calibreWebService = calibreWebService;
_navigationService = navigationService;
Title = "Settings";
Title = "Настройки";
}
[RelayCommand]
@@ -62,49 +95,144 @@ public partial class SettingsViewModel : BaseViewModel
CalibrePassword = await _settingsService.GetSecurePasswordAsync();
DefaultFontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, Constants.Reader.DefaultFontSize);
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]
public async Task SaveSettingsAsync()
{
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 PersistSettingsAsync(showNotification: true);
}
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]
public async Task TestConnectionAsync()
{
if (string.IsNullOrWhiteSpace(CalibreUrl))
if (!TryValidateCalibreUrl(out var validationMessage))
{
ConnectionStatus = "Please enter a URL";
ConnectionStatus = validationMessage;
ConnectionStatusColor = DangerTone;
return;
}
IsConnectionTesting = true;
ConnectionStatus = "Testing connection...";
ConnectionStatus = "Проверяю соединение...";
ConnectionStatusColor = NeutralTone;
try
{
var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword);
ConnectionStatus = success ? "✅ Connection successful!" : "❌ Connection failed";
ConnectionStatus = success ? "Соединение установлено." : "Сервер ответил ошибкой или недоступен.";
ConnectionStatusColor = success ? SuccessTone : DangerTone;
}
catch (Exception ex)
{
ConnectionStatus = $"❌ Error: {ex.Message}";
ConnectionStatus = $"Ошибка проверки: {ex.Message}";
ConnectionStatusColor = DangerTone;
}
finally
{
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"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:BookReader.ViewModels"
@@ -6,192 +6,223 @@
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:Class="BookReader.Views.BookshelfPage"
x:DataType="vm:BookshelfViewModel"
Title="{Binding Title}"
Shell.NavBarIsVisible="True">
Title="Библиотека"
Background="{StaticResource AppBackgroundBrush}">
<ContentPage.Resources>
<toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" />
</ContentPage.Resources>
<Shell.TitleView>
<Grid ColumnDefinitions="*,Auto" Padding="0,0,5,0">
<Label Grid.Column="0"
Text="📚 Моя книжная полка"
FontSize="20"
FontAttributes="Bold"
VerticalOptions="Center"
TextColor="White" />
<ImageButton Grid.Column="1"
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" />
<Grid RowDefinitions="Auto,*">
<VerticalStackLayout Grid.Row="0"
Padding="20,24,20,14"
Spacing="16">
<VerticalStackLayout Spacing="6">
<Label Text="Личная полка"
Style="{StaticResource PageTitleStyle}" />
<Label Text="Соберите библиотеку, возвращайтесь к начатому и держите Calibre под рукой."
Style="{StaticResource PageSubtitleStyle}" />
</VerticalStackLayout>
<!-- Book Collection -->
<CollectionView ItemsSource="{Binding Books}"
<Border Style="{StaticResource CardBorderStyle}">
<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"
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>
<GridItemsLayout Orientation="Vertical"
Span="3"
HorizontalItemSpacing="10"
VerticalItemSpacing="15" />
<GridItemsLayout x:Name="BooksGridLayout"
Orientation="Vertical"
Span="2"
HorizontalItemSpacing="14"
VerticalItemSpacing="14" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Book">
<SwipeView>
<SwipeView.RightItems>
<SwipeItems>
<SwipeItem Text="Delete"
BackgroundColor="#D32F2F"
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=DeleteBookCommand}"
CommandParameter="{Binding .}" />
</SwipeItems>
</SwipeView.RightItems>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Book">
<SwipeView>
<SwipeView.RightItems>
<SwipeItems>
<SwipeItem Text="Удалить"
BackgroundColor="{StaticResource DangerColor}"
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=DeleteBookCommand}"
CommandParameter="{Binding .}" />
</SwipeItems>
</SwipeView.RightItems>
<Frame Padding="0"
CornerRadius="8"
BackgroundColor="#5D4037"
HasShadow="True"
BorderColor="Transparent">
<Frame.GestureRecognizers>
<TapGestureRecognizer
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=OpenBookCommand}"
CommandParameter="{Binding .}" />
</Frame.GestureRecognizers>
<Border BackgroundColor="{StaticResource SurfaceColor}"
Stroke="{StaticResource BorderColor}"
StrokeThickness="1"
StrokeShape="RoundRectangle 22">
<Border.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=OpenBookCommand}"
CommandParameter="{Binding .}" />
</Border.GestureRecognizers>
<Grid RowDefinitions="150,Auto,Auto,Auto" Padding="0">
<!-- Cover Image -->
<Frame Grid.Row="0"
Padding="0"
IsClippedToBounds="True"
HasShadow="False"
BorderColor="Transparent">
<Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
Aspect="AspectFill"
HeightRequest="150" />
</Frame>
<Grid RowDefinitions="168,Auto,Auto,Auto"
Padding="0">
<Border Grid.Row="0"
StrokeThickness="0"
StrokeShape="RoundRectangle 22,22,0,0"
BackgroundColor="{StaticResource SurfaceStrong}">
<Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
Aspect="AspectFill"
HeightRequest="168" />
</Border>
<!-- Progress Bar -->
<Grid Grid.Row="0"
VerticalOptions="End"
HeightRequest="4"
BackgroundColor="#44000000">
<BoxView BackgroundColor="#4CAF50"
HorizontalOptions="Start"
WidthRequest="{Binding ReadingProgress, Converter={StaticResource ProgressToWidthConverter}}" />
</Grid>
<ProgressBar Grid.Row="0"
Progress="{Binding ReadingProgress}"
ProgressColor="{StaticResource SuccessColor}"
BackgroundColor="#33FFFFFF"
VerticalOptions="End"
HeightRequest="6" />
<!-- Title -->
<Label Grid.Row="1"
Text="{Binding Title}"
FontSize="11"
FontAttributes="Bold"
TextColor="#EFEBE9"
LineBreakMode="TailTruncation"
MaxLines="2"
Padding="5,5,5,0" />
<Label Grid.Row="1"
Margin="12,12,12,0"
Text="{Binding Title}"
FontFamily="OpenSansSemibold"
FontSize="14"
TextColor="{StaticResource InkColor}"
MaxLines="2"
LineBreakMode="TailTruncation" />
<!-- Author -->
<Label Grid.Row="2"
Text="{Binding Author}"
FontSize="9"
TextColor="#A1887F"
LineBreakMode="TailTruncation"
MaxLines="1"
Padding="5,0,5,2" />
<Label Grid.Row="2"
Margin="12,4,12,0"
Text="{Binding Author}"
FontSize="12"
TextColor="{StaticResource InkSoftColor}"
MaxLines="1"
LineBreakMode="TailTruncation" />
<!-- Progress Text -->
<Label Grid.Row="3"
Text="{Binding ProgressText}"
FontSize="9"
TextColor="#81C784"
Padding="5,0,5,5" />
</Grid>
</Frame>
</SwipeView>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</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}" />
<Label Grid.Row="3"
Margin="12,6,12,12"
Text="{Binding ProgressText}"
FontSize="12"
TextColor="{StaticResource AccentDarkColor}" />
</Grid>
</Border>
</SwipeView>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</Grid>
</ContentPage>

View File

@@ -1,4 +1,4 @@
using BookReader.Services;
using BookReader.Services;
using BookReader.ViewModels;
namespace BookReader.Views;
@@ -6,20 +6,20 @@ namespace BookReader.Views;
public partial class BookshelfPage : ContentPage
{
private readonly BookshelfViewModel _viewModel;
private readonly INavigationService _navigationService;
public BookshelfPage(BookshelfViewModel viewModel, INavigationService navigationService, ICachedImageLoadingService imageLoadingService)
public BookshelfPage(BookshelfViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
_navigationService = navigationService;
BindingContext = viewModel;
SizeChanged += OnPageSizeChanged;
}
protected override void OnAppearing()
{
base.OnAppearing();
// Загружаем книги только если коллекция пуста
UpdateGridSpan(Width);
if (_viewModel.Books.Count == 0)
{
_ = _viewModel.LoadBooksCommand.ExecuteAsync(null);
@@ -29,31 +29,32 @@ public partial class BookshelfPage : ContentPage
protected override async void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
// Если вернулись на главную страницу и книги уже загружены - обновляем прогресс
// (например, после чтения)
if (_viewModel.Books.Count > 0 && !_viewModel.IsBusy)
{
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",
"⚙️ 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;
}
UpdateGridSpan(Width);
}
}
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"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:BookReader.ViewModels"
xmlns:models="clr-namespace:BookReader.Models"
xmlns:converters="clr-namespace:BookReader.Converters"
x:Class="BookReader.Views.CalibreLibraryPage"
x:DataType="vm:CalibreLibraryViewModel"
Title="{Binding Title}"
BackgroundColor="#1E1E1E">
Title="Calibre"
Background="{StaticResource AppBackgroundBrush}">
<ContentPage.Resources>
<converters:ByteArrayToImageSourceConverter x:Key="ByteArrayToImageConverter" />
</ContentPage.Resources>
<Grid RowDefinitions="Auto,Auto,*,Auto">
<!-- Not Configured Message -->
<Grid RowDefinitions="Auto,*">
<VerticalStackLayout Grid.Row="0"
IsVisible="{Binding IsConfigured, Converter={StaticResource InvertedBoolConverter}}"
Padding="30"
Spacing="15"
VerticalOptions="Center">
<Label Text="☁️ Calibre-Web not configured"
FontSize="20"
TextColor="White"
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" />
Padding="20,24,20,14"
Spacing="16">
<VerticalStackLayout Spacing="6">
<Label Text="Каталог Calibre"
Style="{StaticResource PageTitleStyle}" />
<Label Text="Ищите книги на сервере, скачивайте их в одно касание и держите онлайн-каталог рядом с локальной полкой."
Style="{StaticResource PageSubtitleStyle}" />
</VerticalStackLayout>
<Border Style="{StaticResource CardBorderStyle}"
IsVisible="{Binding IsConfigured}">
<SearchBar Text="{Binding SearchQuery}"
Placeholder="Поиск по Calibre"
SearchCommand="{Binding SearchCommand}"
Style="{StaticResource AppSearchBarStyle}" />
</Border>
</VerticalStackLayout>
<!-- Connection Error Message (Offline Mode) -->
<VerticalStackLayout Grid.Row="0"
IsVisible="{Binding IsConfigured}"
Padding="30"
Spacing="15"
VerticalOptions="Center">
<Label Text="⚠️ Connection failed"
FontSize="20"
TextColor="#FF7043"
HorizontalOptions="Center"
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource IsNotNullOrEmptyConverter}}" />
<Label Text="{Binding ConnectionErrorMessage}"
FontSize="14"
TextColor="#B0B0B0"
HorizontalOptions="Center"
HorizontalTextAlignment="Center"
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource IsNotNullOrEmptyConverter}}" />
<Button Text="🔄 Retry"
BackgroundColor="#5D4037"
TextColor="White"
CornerRadius="8"
Command="{Binding RefreshBooksCommand}"
HorizontalOptions="Center"
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource IsNotNullOrEmptyConverter}}" />
</VerticalStackLayout>
<Grid Grid.Row="1">
<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>
<!-- Search Bar -->
<SearchBar Grid.Row="1"
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}"
IsRefreshing="{Binding IsRefreshing}">
<CollectionView ItemsSource="{Binding Books}"
SelectionMode="None"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreBooksCommand}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:CalibreBook">
<Frame Margin="10,5"
Padding="10"
BackgroundColor="#2C2C2C"
CornerRadius="10"
HasShadow="True"
BorderColor="Transparent">
<Grid ColumnDefinitions="80,*,Auto" ColumnSpacing="12">
<!-- Cover -->
<Frame Grid.Column="0"
Padding="0"
CornerRadius="6"
IsClippedToBounds="True"
HasShadow="False"
BorderColor="Transparent"
HeightRequest="110"
WidthRequest="80">
<Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
Aspect="AspectFill" />
</Frame>
<!-- Info -->
<VerticalStackLayout Grid.Column="1"
VerticalOptions="Center"
Spacing="4">
<Label Text="{Binding Title}"
FontSize="15"
FontAttributes="Bold"
TextColor="White"
MaxLines="2"
LineBreakMode="TailTruncation" />
<Label Text="{Binding Author}"
FontSize="12"
TextColor="#A1887F" />
<Label Text="{Binding Format, StringFormat='Format: {0}'}"
FontSize="11"
TextColor="#81C784" />
</VerticalStackLayout>
<!-- Download Button -->
<Button Grid.Column="2"
Text="⬇️"
FontSize="20"
BackgroundColor="#4CAF50"
TextColor="White"
CornerRadius="25"
WidthRequest="50"
HeightRequest="50"
VerticalOptions="Center"
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:CalibreLibraryViewModel}}, Path=DownloadBookCommand}"
CommandParameter="{Binding .}" />
<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}"
Style="{StaticResource PageSubtitleStyle}" />
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
<Button Grid.Column="0"
Text="Повторить"
Style="{StaticResource PrimaryButtonStyle}"
Command="{Binding RefreshBooksCommand}" />
<Button Grid.Column="1"
Text="Настройки"
Style="{StaticResource SecondaryButtonStyle}"
Command="{Binding OpenSettingsCommand}" />
</Grid>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</RefreshView>
</VerticalStackLayout>
</Border>
</VerticalStackLayout>
<!-- 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" />
<RefreshView IsVisible="{Binding IsConfigured}"
IsEnabled="{Binding HasConnectionError, Converter={StaticResource InvertedBoolConverter}}"
Command="{Binding RefreshBooksCommand}"
IsRefreshing="{Binding IsRefreshing}"
Margin="0,0,0,24">
<CollectionView ItemsSource="{Binding Books}"
SelectionMode="None"
RemainingItemsThreshold="5"
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>
<DataTemplate x:DataType="models:CalibreBook">
<Border BackgroundColor="{StaticResource SurfaceColor}"
Stroke="{StaticResource BorderColor}"
StrokeThickness="1"
StrokeShape="RoundRectangle 24"
Margin="20,0,20,14"
Padding="14">
<Grid ColumnDefinitions="84,*,Auto"
ColumnSpacing="14">
<Border Grid.Column="0"
BackgroundColor="{StaticResource SurfaceStrong}"
StrokeThickness="0"
StrokeShape="RoundRectangle 16"
HeightRequest="118"
WidthRequest="84"
Padding="0">
<Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
Aspect="AspectFill" />
</Border>
<VerticalStackLayout Grid.Column="1"
Spacing="4"
VerticalOptions="Center">
<Label Text="{Binding Title}"
Style="{StaticResource SectionTitleStyle}"
MaxLines="2"
LineBreakMode="TailTruncation" />
<Label Text="{Binding Author}"
Style="{StaticResource PageSubtitleStyle}"
MaxLines="2"
LineBreakMode="TailTruncation" />
<Label Text="{Binding Format, StringFormat='Формат: {0}'}"
FontSize="12"
TextColor="{StaticResource SuccessColor}" />
</VerticalStackLayout>
<Button Grid.Column="2"
Text="Скачать"
Style="{StaticResource PrimaryButtonStyle}"
Padding="16,10"
VerticalOptions="Center"
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:CalibreLibraryViewModel}}, Path=DownloadBookCommand}"
CommandParameter="{Binding .}" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</RefreshView>
</Grid>
</Grid>
</ContentPage>
</ContentPage>

View File

@@ -1,5 +1,4 @@
using BookReader.Services;
using BookReader.ViewModels;
using BookReader.ViewModels;
namespace BookReader.Views;
@@ -7,7 +6,7 @@ public partial class CalibreLibraryPage : ContentPage
{
private readonly CalibreLibraryViewModel _viewModel;
public CalibreLibraryPage(CalibreLibraryViewModel viewModel, ICachedImageLoadingService imageLoadingService)
public CalibreLibraryPage(CalibreLibraryViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
@@ -19,4 +18,4 @@ public partial class CalibreLibraryPage : ContentPage
base.OnAppearing();
await _viewModel.InitializeCommand.ExecuteAsync(null);
}
}
}

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

View File

@@ -1,6 +1,7 @@
using BookReader.Services;
using BookReader.Services;
using BookReader.ViewModels;
using Newtonsoft.Json.Linq;
using System.Globalization;
namespace BookReader.Views;
@@ -8,9 +9,10 @@ public partial class ReaderPage : ContentPage
{
private readonly ReaderViewModel _viewModel;
private readonly INavigationService _navigationService;
private bool _isBookLoaded;
private readonly List<JObject> _chapterData = new();
private bool _isActive;
private bool _isBookLoaded;
private bool _isSubscribedToJavaScriptRequests;
public ReaderPage(ReaderViewModel viewModel, INavigationService navigationService)
{
@@ -18,19 +20,18 @@ public partial class ReaderPage : ContentPage
_viewModel = viewModel;
_navigationService = navigationService;
BindingContext = viewModel;
_viewModel.OnJavaScriptRequested += OnJavaScriptRequested;
}
protected override async void OnAppearing()
{
base.OnAppearing();
_isActive = true;
EnsureSubscribed();
try
{
await _viewModel.InitializeAsync();
System.Diagnostics.Debug.WriteLine("[Reader] ViewModel initialized");
}
catch (Exception ex)
{
@@ -41,7 +42,7 @@ public partial class ReaderPage : ContentPage
protected override async void OnDisappearing()
{
_isActive = false;
_viewModel.OnJavaScriptRequested -= OnJavaScriptRequested;
EnsureUnsubscribed();
base.OnDisappearing();
await SaveCurrentProgress();
}
@@ -50,107 +51,84 @@ public partial class ReaderPage : ContentPage
{
_isActive = false;
base.OnNavigatedFrom(args);
// Сохраняем немедленно при любом уходе со страницы
await SaveCurrentProgress();
}
// ========== ЗАГРУЗКА КНИГИ ==========
private async Task LoadBookIntoWebView()
{
try
{
var book = _viewModel.Book;
if (book == null)
{
return;
}
if (_isBookLoaded)
if (book == null || _isBookLoaded)
{
return;
}
if (!File.Exists(book.FilePath))
{
await _navigationService.DisplayAlertAsync("Error", "Book file not found", "OK");
await _navigationService.DisplayAlertAsync("Ошибка", "Файл книги не найден.", "OK");
return;
}
// Читаем файл и конвертируем в Base64
var fileBytes = await File.ReadAllBytesAsync(book.FilePath);
var base64 = Convert.ToBase64String(fileBytes);
var format = book.Format.ToLowerInvariant();
var lastCfi = _viewModel.GetLastCfi() ?? "";
var locations=_viewModel.GetLocations() ?? "";
var lastCfi = _viewModel.GetLastCfi() ?? string.Empty;
var locations = _viewModel.GetLocations() ?? string.Empty;
// Отправляем данные чанками чтобы не превысить лимит JS строки
const int chunkSize = 400_000;
await EvalJsAsync("window._bkChunks = [];");
if (base64.Length > chunkSize)
using (var fileStream = File.OpenRead(book.FilePath))
{
var chunks = SplitString(base64, chunkSize);
var buffer = new byte[Constants.Reader.Base64RawChunkSize];
int bytesRead;
await EvalJsAsync("window._bkChunks = [];");
for (int i = 0; i < chunks.Count; i++)
while ((bytesRead = await fileStream.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0)
{
await EvalJsAsync($"window._bkChunks.push('{chunks[i]}');");
var base64Chunk = Convert.ToBase64String(buffer, 0, bytesRead);
await EvalJsAsync($"window._bkChunks.push('{base64Chunk}');");
}
}
await EvalJsAsync(
$"window.loadBookFromBase64(window._bkChunks.join(''), '{format}', '{EscapeJs(lastCfi)}','{EscapeJs(locations)}');"
);
await EvalJsAsync("delete window._bkChunks;");
}
else
{
await EvalJsAsync(
$"window.loadBookFromBase64('{base64}', '{format}', '{EscapeJs(lastCfi)}','{EscapeJs(locations)}');"
);
}
await EvalJsAsync(
$"window.loadBookFromBase64(window._bkChunks.join(''), '{format}', '{EscapeJs(lastCfi)}', '{EscapeJs(locations)}');");
await EvalJsAsync("delete window._bkChunks;");
_isBookLoaded = true;
System.Diagnostics.Debug.WriteLine("[Reader] Book load command sent");
// Применяем настройки шрифта сразу
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize.ToString(CultureInfo.InvariantCulture)})");
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
System.Diagnostics.Debug.WriteLine("[Reader] Book fully loaded");
await EvalJsAsync($"window.setReaderTheme('{EscapeJs(_viewModel.ReaderTheme)}')");
await EvalJsAsync($"window.setBrightness({_viewModel.Brightness.ToString(CultureInfo.InvariantCulture)})");
}
catch (Exception ex)
{
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()
{
if (!_isBookLoaded) return;
if (!_isBookLoaded)
{
return;
}
try
{
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;
}
result = UnescapeJsResult(result);
var data = JObject.Parse(result);
var progress = data["progress"]?.Value<double>() ?? 0;
var cfi = data["cfi"]?.ToString();
var currentPage = data["currentPage"]?.Value<int>() ?? 0;
var totalPages = data["totalPages"]?.Value<int>() ?? 0;
await _viewModel.SaveProgressAsync(progress, cfi, null, currentPage, totalPages);
System.Diagnostics.Debug.WriteLine($"[Reader] Saved progress: {progress:P0}");
await _viewModel.SaveProgressAsync(progress, cfi, _viewModel.Book?.LastChapter, currentPage, totalPages, force: true);
}
catch (Exception ex)
{
@@ -158,16 +136,16 @@ public partial class ReaderPage : ContentPage
}
}
// ========== ОБРАБОТКА СООБЩЕНИЙ ОТ JS ==========
private async void OnRawMessageReceived(object? sender, HybridWebViewRawMessageReceivedEventArgs e)
{
try
{
var message = e.Message;
if (string.IsNullOrEmpty(message)) return;
if (string.IsNullOrEmpty(message))
{
return;
}
// ... (оставляем логику логирования и парсинга JSON) ...
var json = JObject.Parse(message);
var action = json["action"]?.ToString();
var data = json["data"] as JObject;
@@ -175,16 +153,11 @@ public partial class ReaderPage : ContentPage
switch (action)
{
case "readerReady":
// Вызываем загрузку книги ТОЛЬКО после того, как JS подтвердил готовность
_ = MainThread.InvokeOnMainThreadAsync(LoadBookIntoWebView);
break;
case "toggleMenu":
MainThread.BeginInvokeOnMainThread(() =>
{
_viewModel.ToggleMenuCommand.Execute(null);
});
MainThread.BeginInvokeOnMainThread(() => _viewModel.ToggleMenuCommand.Execute(null));
break;
case "progressUpdate":
@@ -195,17 +168,17 @@ public partial class ReaderPage : ContentPage
var chapter = data["chapter"]?.ToString();
var currentPage = data["currentPage"]?.Value<int>() ?? 0;
var totalPages = data["totalPages"]?.Value<int>() ?? 0;
// Ловим новые данные по главе
var chapterPage = data["chapterCurrentPage"]?.Value<int>() ?? 1;
var chapterTotal = data["chapterTotalPages"]?.Value<int>() ?? 1;
// Обновляем ViewModel на главном потоке
MainThread.BeginInvokeOnMainThread(() => {
MainThread.BeginInvokeOnMainThread(() =>
{
_viewModel.ChapterCurrentPage = chapterPage;
_viewModel.ChapterTotalPages = chapterTotal;
_viewModel.TotalPages = totalPages;
_viewModel.CurrentPage = currentPage;
});
await _viewModel.SaveProgressAsync(progress, cfi, chapter, currentPage, totalPages);
}
break;
@@ -213,35 +186,35 @@ public partial class ReaderPage : ContentPage
case "chaptersLoaded":
if (data != null)
{
var chapters = data["chapters"]?.ToObject<List<JObject>>() ?? new();
var chapters = data["chapters"]?.ToObject<List<JObject>>() ?? new List<JObject>();
_chapterData.Clear();
_chapterData.AddRange(chapters);
MainThread.BeginInvokeOnMainThread(() =>
{
_viewModel.Chapters = chapters
.Select(c => c["label"]?.ToString() ?? "")
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(chapter => chapter["label"]?.ToString() ?? string.Empty)
.Where(label => !string.IsNullOrWhiteSpace(label))
.ToList();
});
}
break;
case "bookReady":
if (data != null)
MainThread.BeginInvokeOnMainThread(async () =>
{
MainThread.BeginInvokeOnMainThread(async () =>
{
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
});
}
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize.ToString(CultureInfo.InvariantCulture)})");
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
await EvalJsAsync($"window.setReaderTheme('{EscapeJs(_viewModel.ReaderTheme)}')");
await EvalJsAsync($"window.setBrightness({_viewModel.Brightness.ToString(CultureInfo.InvariantCulture)})");
});
break;
case "saveLocations":
// Извлекаем строку локаций из данных
string locations = data["locations"]?.ToString();
// Сохраняем в базу данных
await _viewModel.SaveLocationsAsync(locations);
if (data != null)
{
var locations = data["locations"]?.ToString() ?? string.Empty;
await _viewModel.SaveLocationsAsync(locations);
}
break;
}
}
@@ -251,34 +224,54 @@ public partial class ReaderPage : ContentPage
}
}
// ========== ОБРАБОТКА ЗАПРОСОВ JS ОТ VIEWMODEL ==========
private async void OnJavaScriptRequested(string script)
{
if (!_isActive) return;
if (!_isActive)
{
return;
}
await EvalJsAsync(script);
}
// ========== UI EVENTS ==========
private void OnDecreaseFontSize(object? sender, EventArgs e)
{
var idx = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
if (idx > 0)
var index = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
if (index > 0)
{
_viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[idx - 1]);
_viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[index - 1]);
}
}
private void OnIncreaseFontSize(object? sender, EventArgs e)
{
var idx = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
if (idx < _viewModel.AvailableFontSizes.Count - 1)
var index = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
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)
{
if (FontFamilyPicker.SelectedItem is string family)
@@ -291,15 +284,14 @@ public partial class ReaderPage : ContentPage
{
if (e.CurrentSelection.FirstOrDefault() is string chapterLabel)
{
var chapterObj = _chapterData.FirstOrDefault(c => c["label"]?.ToString() == chapterLabel);
var href = chapterObj?["href"]?.ToString() ?? chapterLabel;
var chapterObject = _chapterData.FirstOrDefault(chapter => chapter["label"]?.ToString() == chapterLabel);
var href = chapterObject?["href"]?.ToString() ?? chapterLabel;
_viewModel.GoToChapterCommand.Execute(href);
}
}
private void OnMenuPanelTapped(object? sender, TappedEventArgs e)
{
// Предотвращаем всплытие тапа на оверлей
}
private async void OnBackToLibrary(object? sender, EventArgs e)
@@ -308,11 +300,28 @@ public partial class ReaderPage : ContentPage
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)
{
try
@@ -335,9 +344,6 @@ public partial class ReaderPage : ContentPage
}
}
/// <summary>
/// Выполняет JavaScript и возвращает результат
/// </summary>
private async Task<string?> EvalJsWithResultAsync(string script)
{
string? result = null;
@@ -359,29 +365,16 @@ public partial class ReaderPage : ContentPage
{
System.Diagnostics.Debug.WriteLine($"[Reader] JS result dispatch error: {ex.Message}");
}
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)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value
.Replace("\\", "\\\\")
@@ -392,30 +385,25 @@ public partial class ReaderPage : ContentPage
.Replace("\t", "\\t");
}
/// <summary>
/// Убирает экранирование из результата EvaluateJavaScriptAsync.
/// Android WebView оборачивает результат в кавычки и экранирует.
/// </summary>
private static string UnescapeJsResult(string result)
{
if (string.IsNullOrEmpty(result))
{
return result;
}
// Убираем обрамляющие кавычки если есть
if (result.StartsWith("\"") && result.EndsWith("\""))
{
result = result.Substring(1, result.Length - 2);
}
// Убираем экранирование
result = result
return result
.Replace("\\\"", "\"")
.Replace("\\\\", "\\")
.Replace("\\/", "/")
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.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"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:BookReader.ViewModels"
x:Class="BookReader.Views.SettingsPage"
x:DataType="vm:SettingsViewModel"
Title="{Binding Title}"
BackgroundColor="#1E1E1E">
<ScrollView>
<VerticalStackLayout Spacing="20" Padding="20">
<!-- Calibre-Web Settings -->
<Frame BackgroundColor="#2C2C2C"
CornerRadius="12"
Padding="15"
HasShadow="True"
BorderColor="Transparent">
<VerticalStackLayout Spacing="12">
<Label Text="☁️ Calibre-Web Connection"
FontSize="18"
FontAttributes="Bold"
TextColor="White" />
<VerticalStackLayout Spacing="5">
<Label Text="Server URL"
FontSize="12"
TextColor="#B0B0B0" />
<Entry Text="{Binding CalibreUrl}"
Placeholder="https://your-calibre-server.com"
PlaceholderColor="#666"
TextColor="White"
BackgroundColor="#3C3C3C"
Keyboard="Url" />
</VerticalStackLayout>
<VerticalStackLayout Spacing="5">
<Label Text="Username"
FontSize="12"
TextColor="#B0B0B0" />
<Entry Text="{Binding CalibreUsername}"
Placeholder="Username"
PlaceholderColor="#666"
TextColor="White"
BackgroundColor="#3C3C3C" />
</VerticalStackLayout>
<VerticalStackLayout Spacing="5">
<Label Text="Password"
FontSize="12"
TextColor="#B0B0B0" />
<Entry Text="{Binding CalibrePassword}"
Placeholder="Password"
PlaceholderColor="#666"
TextColor="White"
BackgroundColor="#3C3C3C"
IsPassword="True" />
</VerticalStackLayout>
<Button Text="Test Connection"
BackgroundColor="#5D4037"
TextColor="White"
CornerRadius="8"
Command="{Binding TestConnectionCommand}"
IsEnabled="{Binding IsConnectionTesting, Converter={StaticResource InvertedBoolConverter}}" />
<Label Text="{Binding ConnectionStatus}"
FontSize="13"
TextColor="#81C784"
IsVisible="{Binding ConnectionStatus, Converter={StaticResource IsNotNullOrEmptyConverter}}" />
</VerticalStackLayout>
</Frame>
<!-- Reading Settings -->
<Frame BackgroundColor="#2C2C2C"
CornerRadius="12"
Padding="15"
HasShadow="True"
BorderColor="Transparent">
<VerticalStackLayout Spacing="12">
<Label Text="📖 Reading Defaults"
FontSize="18"
FontAttributes="Bold"
TextColor="White" />
<VerticalStackLayout Spacing="5">
<Label Text="Default Font Size"
FontSize="12"
TextColor="#B0B0B0" />
<Picker ItemsSource="{Binding AvailableFontSizes}"
SelectedItem="{Binding DefaultFontSize}"
TextColor="White"
BackgroundColor="#3C3C3C" />
</VerticalStackLayout>
<VerticalStackLayout Spacing="5">
<Label Text="Default Font Family"
FontSize="12"
TextColor="#B0B0B0" />
<Picker ItemsSource="{Binding AvailableFonts}"
SelectedItem="{Binding DefaultFontFamily}"
TextColor="White"
BackgroundColor="#3C3C3C" />
</VerticalStackLayout>
</VerticalStackLayout>
</Frame>
<!-- Save Button -->
<Button Text="💾 Save Settings"
BackgroundColor="#4CAF50"
TextColor="White"
FontSize="16"
FontAttributes="Bold"
CornerRadius="12"
HeightRequest="50"
Command="{Binding SaveSettingsCommand}" />
Title="Настройки"
Background="{StaticResource AppBackgroundBrush}">
<Grid RowDefinitions="Auto,*">
<VerticalStackLayout Grid.Row="0"
Padding="20,24,20,14"
Spacing="6">
<Label Text="Настройки"
Style="{StaticResource PageTitleStyle}" />
<Label Text="Подключите Calibre и задайте параметры чтения, которые будут применяться ко всем новым книгам."
Style="{StaticResource PageSubtitleStyle}" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>
<ScrollView Grid.Row="1"
Margin="0,0,0,24">
<VerticalStackLayout Padding="20,0,20,0"
Spacing="16">
<Border Style="{StaticResource CardBorderStyle}">
<VerticalStackLayout Spacing="14">
<Label Text="Подключение к Calibre"
Style="{StaticResource SectionTitleStyle}" />
<VerticalStackLayout Spacing="6">
<Label Text="Адрес сервера"
Style="{StaticResource CaptionStyle}" />
<Entry Text="{Binding CalibreUrl}"
Placeholder="https://your-calibre-server.example"
Style="{StaticResource AppEntryStyle}"
Keyboard="Url" />
</VerticalStackLayout>
<VerticalStackLayout Spacing="6">
<Label Text="Логин"
Style="{StaticResource CaptionStyle}" />
<Entry Text="{Binding CalibreUsername}"
Placeholder="Имя пользователя"
Style="{StaticResource AppEntryStyle}" />
</VerticalStackLayout>
<VerticalStackLayout Spacing="6">
<Label Text="Пароль"
Style="{StaticResource CaptionStyle}" />
<Entry Text="{Binding CalibrePassword}"
Placeholder="Пароль"
Style="{StaticResource AppEntryStyle}"
IsPassword="True" />
</VerticalStackLayout>
<Border Style="{StaticResource MutedCardBorderStyle}">
<Label Text="{Binding ConnectionSecurityHint}"
TextColor="{Binding ConnectionSecurityColor}"
FontSize="13"
LineBreakMode="WordWrap" />
</Border>
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
<Button Grid.Column="0"
Text="Проверить"
Style="{StaticResource SecondaryButtonStyle}"
Command="{Binding TestConnectionCommand}"
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}"
TextColor="{Binding ConnectionStatusColor}"
FontSize="13"
VerticalOptions="Center" />
<ActivityIndicator Grid.Column="1"
IsRunning="{Binding IsConnectionTesting}"
IsVisible="{Binding IsConnectionTesting}"
Color="{StaticResource AccentColor}" />
</Grid>
</VerticalStackLayout>
</Border>
<Border Style="{StaticResource CardBorderStyle}">
<VerticalStackLayout Spacing="14">
<Label Text="Чтение по умолчанию"
Style="{StaticResource SectionTitleStyle}" />
<VerticalStackLayout Spacing="6">
<Label Text="Размер шрифта"
Style="{StaticResource CaptionStyle}" />
<Picker ItemsSource="{Binding AvailableFontSizes}"
SelectedItem="{Binding DefaultFontSize}"
Style="{StaticResource AppPickerStyle}" />
</VerticalStackLayout>
<VerticalStackLayout Spacing="6">
<Label Text="Гарнитура"
Style="{StaticResource CaptionStyle}" />
<Picker ItemsSource="{Binding AvailableFonts}"
SelectedItem="{Binding DefaultFontFamily}"
Style="{StaticResource AppPickerStyle}" />
</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>
</Border>
</VerticalStackLayout>
</ScrollView>
</Grid>
</ContentPage>

View File

@@ -1,4 +1,4 @@
using BookReader.ViewModels;
using BookReader.ViewModels;
namespace BookReader.Views;
@@ -31,14 +31,13 @@ public partial class SettingsPage : ContentPage
{
base.OnDisappearing();
// Автосохранение при выходе со страницы настроек
try
{
await _viewModel.SaveSettingsCommand.ExecuteAsync(null);
await _viewModel.SaveSilentlyAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error auto-saving settings: {ex.Message}");
}
}
}
}