Добавьте файлы проекта.

This commit is contained in:
Курнат Андрей
2026-02-14 16:45:01 +03:00
parent f736c00e9f
commit 74ab86a1a8
67 changed files with 4135 additions and 0 deletions

3
BookReader.slnx Normal file
View File

@@ -0,0 +1,3 @@
<Solution>
<Project Path="BookReader/BookReader.csproj" />
</Solution>

13
BookReader/App.xaml Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:BookReader"
x:Class="BookReader.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/AppStyles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

19
BookReader/App.xaml.cs Normal file
View File

@@ -0,0 +1,19 @@
using BookReader.Services;
namespace BookReader;
public partial class App : Application
{
public App(IDatabaseService databaseService)
{
InitializeComponent();
// Initialize database
Task.Run(async () => await databaseService.InitializeAsync()).Wait();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new AppShell());
}
}

16
BookReader/AppShell.xaml Normal file
View File

@@ -0,0 +1,16 @@
<?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.ForegroundColor="White"
Shell.TitleColor="White">
<ShellContent
Title="Library"
ContentTemplate="{DataTemplate views:BookshelfPage}"
Route="bookshelf" />
</Shell>

View File

@@ -0,0 +1,15 @@
using BookReader.Views;
namespace BookReader;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute("reader", typeof(ReaderPage));
Routing.RegisterRoute("settings", typeof(SettingsPage));
Routing.RegisterRoute("calibre", typeof(CalibreLibraryPage));
}
}

View File

@@ -0,0 +1,57 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0-android</TargetFrameworks>
<OutputType>Exe</OutputType>
<RootNamespace>BookReader</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationTitle>BookReader</ApplicationTitle>
<ApplicationId>com.bookreader.app</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
<!-- Разрешить понижение версий пакетов если нужно -->
<NoWarn>$(NoWarn);NU1605</NoWarn>
</PropertyGroup>
<ItemGroup>
<!-- Явно указываем версию MAUI Controls чтобы удовлетворить зависимость CommunityToolkit -->
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.40" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="10.0.40" />
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.11" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CommunityToolkit.Maui" Version="14.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="VersOne.Epub" Version="3.3.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<AndroidResource Remove="Platforms\Android\Resources\xml\network_security_config.xml" />
</ItemGroup>
<ItemGroup>
<MauiXaml Update="Resources\Styles\AppStyles.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
</ItemGroup>
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#3E2723" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#3E2723" BaseSize="128,128" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using System.Globalization;
namespace BookReader.Converters;
public class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is true;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is true;
}
}

View File

@@ -0,0 +1,20 @@
using System.Globalization;
namespace BookReader.Converters;
public class ByteArrayToImageSourceConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is byte[] bytes && bytes.Length > 0)
{
return ImageSource.FromStream(() => new MemoryStream(bytes));
}
return ImageSource.FromFile("default_cover.png");
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,23 @@
using System.Globalization;
namespace BookReader.Converters;
public class ProgressToWidthConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is double progress)
{
var maxWidth = 120.0;
if (parameter is string paramStr && double.TryParse(paramStr, out var max))
maxWidth = max;
return progress * maxWidth;
}
return 0;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

51
BookReader/MauiProgram.cs Normal file
View File

@@ -0,0 +1,51 @@
using BookReader.Services;
using BookReader.ViewModels;
using BookReader.Views;
using CommunityToolkit.Maui;
using Microsoft.Extensions.Logging;
namespace BookReader;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseMauiCommunityToolkit()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
#if DEBUG
builder.Services.AddHybridWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif
// Register Services (Singleton for shared state)
builder.Services.AddSingleton<IDatabaseService, DatabaseService>();
builder.Services.AddSingleton<IBookParserService, BookParserService>();
builder.Services.AddSingleton<ISettingsService, SettingsService>();
// HTTP Client for Calibre
builder.Services.AddHttpClient<ICalibreWebService, CalibreWebService>();
// Register ViewModels
builder.Services.AddTransient<BookshelfViewModel>();
builder.Services.AddTransient<ReaderViewModel>();
builder.Services.AddTransient<SettingsViewModel>();
builder.Services.AddTransient<CalibreLibraryViewModel>();
// Register Pages
builder.Services.AddTransient<BookshelfPage>();
builder.Services.AddTransient<ReaderPage>();
builder.Services.AddTransient<SettingsPage>();
builder.Services.AddTransient<CalibreLibraryPage>();
return builder.Build();
}
}

View File

@@ -0,0 +1,22 @@
using SQLite;
namespace BookReader.Models;
public class AppSettings
{
[PrimaryKey]
public string Key { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}
public static class SettingsKeys
{
public const string CalibreUrl = "CalibreUrl";
public const string CalibreUsername = "CalibreUsername";
public const string CalibrePassword = "CalibrePassword";
public const string DefaultFontSize = "DefaultFontSize";
public const string DefaultFontFamily = "DefaultFontFamily";
public const string Theme = "Theme";
public const string Brightness = "Brightness";
}

56
BookReader/Models/Book.cs Normal file
View File

@@ -0,0 +1,56 @@
using SQLite;
namespace BookReader.Models;
public class Book
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty; // "epub" or "fb2"
public byte[]? CoverImage { get; set; }
public double ReadingProgress { get; set; } // 0.0 to 1.0
public string? LastCfi { get; set; } // CFI location for epub, or position for fb2
public string? LastChapter { get; set; }
public DateTime DateAdded { get; set; } = DateTime.UtcNow;
public DateTime LastRead { get; set; } = DateTime.UtcNow;
public int TotalPages { get; set; }
public int CurrentPage { get; set; }
public string? CalibreId { get; set; } // ID from Calibre-Web if downloaded from there
[Ignore]
public ImageSource? CoverImageSource
{
get
{
if (CoverImage != null && CoverImage.Length > 0)
{
return ImageSource.FromStream(() => new MemoryStream(CoverImage));
}
return ImageSource.FromFile("default_cover.png");
}
}
[Ignore]
public string ProgressText => $"{(ReadingProgress * 100):F0}%";
[Ignore]
public double ProgressBarWidth => ReadingProgress * 120; // relative to cover width
}

View File

@@ -0,0 +1,24 @@
namespace BookReader.Models;
public class CalibreBook
{
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
public string? CoverUrl { get; set; }
public string? DownloadUrl { get; set; }
public string Format { get; set; } = string.Empty;
public byte[]? CoverImage { get; set; }
public ImageSource? CoverImageSource
{
get
{
if (CoverImage != null && CoverImage.Length > 0)
return ImageSource.FromStream(() => new MemoryStream(CoverImage));
if (!string.IsNullOrEmpty(CoverUrl))
return ImageSource.FromUri(new Uri(CoverUrl));
return null;
}
}
}

View File

@@ -0,0 +1,22 @@
using SQLite;
namespace BookReader.Models;
public class ReadingProgress
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[Indexed]
public int BookId { get; set; }
public string? Cfi { get; set; }
public double Progress { get; set; }
public int CurrentPage { get; set; }
public string? ChapterTitle { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:supportsRtl="true"
android:usesCleartextTraffic="true">
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="content" />
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,22 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
namespace BookReader;
[Activity(
Theme = "@style/Maui.SplashTheme",
MainLauncher = true,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density,
LaunchMode = LaunchMode.SingleTop)]
public class MainActivity : MauiAppCompatActivity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
// Keep screen on while reading
Window?.AddFlags(Android.Views.WindowManagerFlags.KeepScreenOn);
}
}

View File

@@ -0,0 +1,15 @@
using Android.App;
using Android.Runtime;
namespace BookReader;
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -0,0 +1,10 @@
using Foundation;
namespace BookReader
{
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
<dict>
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The Mac App Store requires you specify if the app uses encryption. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
<!-- Please indicate <true/> or <false/> here. -->
<!-- Specify the category for your app here. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
<!-- <key>LSApplicationCategoryType</key> -->
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
<key>UIDeviceFamily</key>
<array>
<integer>2</integer>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.lifestyle</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
using ObjCRuntime;
using UIKit;
namespace BookReader
{
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
}

View File

@@ -0,0 +1,8 @@
<maui:MauiWinUIApplication
x:Class="BookReader.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:BookReader.WinUI">
</maui:MauiWinUIApplication>

View File

@@ -0,0 +1,25 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace BookReader.WinUI
{
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="DFDD7B32-2689-4EC9-936B-A7A76B16971A" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="BookReader.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,10 @@
using Foundation;
namespace BookReader
{
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
using ObjCRuntime;
using UIKit;
namespace BookReader
{
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
You are responsible for adding extra entries as needed for your application.
More information: https://aka.ms/maui-privacy-manifest
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<!--
The entry below is only needed when you're using the Preferences API in your app.
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
{
"profiles": {
"Windows Machine": {
"commandName": "Project",
"nativeDebugging": false
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -0,0 +1,15 @@
Any raw assets you want to be deployed with your application can be placed in
this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd();
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,715 @@
<!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>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #faf8ef;
font-family: serif;
touch-action: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
#reader-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
#book-content {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
#fb2-content {
width: 100%;
height: 100%;
overflow: hidden;
padding: 20px;
font-size: 18px;
line-height: 1.6;
display: none;
}
#loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
font-size: 18px;
color: #666;
flex-direction: column;
gap: 15px;
}
#loading .spinner {
width: 40px;
height: 40px;
border: 4px solid #ddd;
border-top: 4px solid #5D4037;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
#error-display {
display: none;
justify-content: center;
align-items: center;
height: 100%;
font-size: 16px;
color: #d32f2f;
padding: 30px;
text-align: center;
flex-direction: column;
gap: 10px;
}
#debug-log {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background: rgba(0,0,0,0.9);
color: #0f0;
font-size: 11px;
font-family: monospace;
padding: 5px;
z-index: 9999;
display: block;
}
</style>
</head>
<body>
<div id="reader-container">
<div id="book-content"></div>
<div id="fb2-content"></div>
<div id="loading">
<div class="spinner"></div>
<span id="loading-text">Initializing...</span>
</div>
<div id="error-display">
<span style="font-size:40px">⚠️</span>
<span id="error-text"></span>
</div>
</div>
<div id="debug-log"></div>
<script src="_framework/hybridwebview.js"></script>
<script>
// ========== DEBUG LOGGING ==========
function debugLog(msg) {
console.log('[Reader] ' + msg);
var logDiv = document.getElementById('debug-log');
if (logDiv) {
var line = document.createElement('div');
line.textContent = new Date().toLocaleTimeString() + ': ' + msg;
logDiv.appendChild(line);
logDiv.scrollTop = logDiv.scrollHeight;
while (logDiv.children.length > 50) {
logDiv.removeChild(logDiv.firstChild);
}
}
}
function showError(msg) {
document.getElementById('loading').style.display = 'none';
document.getElementById('error-display').style.display = 'flex';
document.getElementById('error-text').textContent = msg;
debugLog('ERROR: ' + msg);
}
function setLoadingText(msg) {
var el = document.getElementById('loading-text');
if (el) el.textContent = msg;
debugLog(msg);
}
// ========== MESSAGE BRIDGE ==========
function sendMessage(action, data) {
var message = JSON.stringify({ action: action, data: data || {} });
try {
// .NET MAUI HybridWebView uses hybridWebViewHost
if (window.HybridWebView && typeof window.HybridWebView.SendRawMessage === 'function') {
window.HybridWebView.SendRawMessage(message);
return;
}
debugLog('No bridge method found on hybridWebViewHost');
} catch (e) {
debugLog('Bridge error: ' + e.message);
}
}
// ========== SWIPE DETECTION ==========
var touchStartX = 0;
var touchStartY = 0;
var touchStartTime = 0;
var SWIPE_THRESHOLD = 50;
var SWIPE_TIME_LIMIT = 500;
document.addEventListener('touchstart', function (e) {
touchStartX = e.changedTouches[0].clientX;
touchStartY = e.changedTouches[0].clientY;
touchStartTime = Date.now();
}, { passive: true });
document.addEventListener('touchend', function (e) {
var touchEndX = e.changedTouches[0].clientX;
var touchEndY = e.changedTouches[0].clientY;
var elapsed = Date.now() - touchStartTime;
if (elapsed > SWIPE_TIME_LIMIT) return;
var diffX = touchEndX - touchStartX;
var diffY = touchEndY - touchStartY;
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > SWIPE_THRESHOLD) {
if (diffX < 0) {
nextPage();
} else {
prevPage();
}
} else if (Math.abs(diffX) < 15 && Math.abs(diffY) < 15) {
var screenWidth = window.innerWidth;
if (touchStartX > screenWidth * 0.3 && touchStartX < screenWidth * 0.7) {
sendMessage('toggleMenu', {});
} else if (touchStartX <= screenWidth * 0.3) {
prevPage();
} else {
nextPage();
}
}
}, { passive: true });
// ========== EPUB READER ==========
var book = null;
var rendition = null;
var currentCfi = null;
var totalPages = 0;
var currentPage = 0;
var bookFormat = '';
var isBookLoaded = false;
window.loadBookFromBase64 = function (base64Data, format, lastPosition) {
debugLog('loadBookFromBase64: format=' + format + ', len=' + (base64Data ? base64Data.length : 0));
bookFormat = format;
if (format === 'epub') {
loadEpubFromBase64(base64Data, lastPosition);
} else if (format === 'fb2') {
loadFb2FromBase64(base64Data, lastPosition);
} else {
showError('Unsupported format: ' + format);
}
};
function base64ToArrayBuffer(base64) {
var binaryString = atob(base64);
var len = binaryString.length;
var bytes = new Uint8Array(len);
for (var i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
function loadEpubFromBase64(base64Data, lastCfi) {
setLoadingText('Decoding EPUB...');
try {
var arrayBuffer = base64ToArrayBuffer(base64Data);
debugLog('EPUB size: ' + arrayBuffer.byteLength + ' bytes');
// Check JSZip
if (typeof JSZip === 'undefined') {
showError('JSZip not loaded! Cannot open EPUB.');
return;
}
debugLog('JSZip version: ' + (JSZip.version || 'unknown'));
// Check epub.js
if (typeof ePub === 'undefined') {
showError('epub.js not loaded!');
return;
}
debugLog('ePub function available');
setLoadingText('Opening EPUB...');
// First unzip manually to verify the file is valid
JSZip.loadAsync(arrayBuffer).then(function (zip) {
debugLog('ZIP opened, files: ' + Object.keys(zip.files).length);
// Now use epub.js
book = ePub(arrayBuffer);
document.getElementById('fb2-content').style.display = 'none';
document.getElementById('book-content').style.display = 'block';
rendition = book.renderTo('book-content', {
width: '100%',
height: '100%',
spread: 'none',
flow: 'paginated'
});
rendition.themes.default({
'body': {
'font-family': 'serif !important',
'font-size': '18px !important',
'line-height': '1.6 !important',
'padding': '15px !important',
'background-color': '#faf8ef !important',
'color': '#333 !important'
},
'p': {
'text-indent': '1.5em',
'margin-bottom': '0.5em'
}
});
book.ready.then(function () {
debugLog('EPUB ready');
document.getElementById('loading').style.display = 'none';
try {
var toc = book.navigation.toc || [];
var chapters = toc.map(function (ch) {
return { label: (ch.label || '').trim(), href: ch.href || '' };
});
sendMessage('chaptersLoaded', { chapters: chapters });
debugLog('TOC chapters: ' + chapters.length);
} catch (e) {
debugLog('TOC error: ' + e.message);
}
return book.locations.generate(1600);
}).then(function () {
totalPages = book.locations.length();
debugLog('Pages: ' + totalPages);
sendMessage('bookReady', { totalPages: totalPages });
isBookLoaded = true;
}).catch(function (e) {
debugLog('Book ready error: ' + e.message);
});
rendition.on('relocated', function (location) {
if (!location || !location.start) return;
currentCfi = location.start.cfi;
try {
var progress = book.locations.percentageFromCfi(currentCfi) || 0;
currentPage = location.start.location || 0;
sendMessage('progressUpdate', {
progress: progress,
cfi: currentCfi,
currentPage: currentPage,
totalPages: totalPages,
chapter: location.start.href || ''
});
} catch (e) { }
});
setLoadingText('Rendering...');
if (lastCfi && lastCfi.length > 0 && lastCfi !== 'null' && lastCfi !== 'undefined') {
rendition.display(lastCfi);
} else {
rendition.display();
}
}).catch(function (e) {
showError('ZIP error: ' + e.message);
});
} catch (e) {
showError('EPUB load error: ' + e.message);
}
}
function loadFb2FromBase64(base64Data, lastPosition) {
setLoadingText('Parsing FB2...');
try {
var arrayBuffer = base64ToArrayBuffer(base64Data);
var bytes = new Uint8Array(arrayBuffer);
var xmlText;
try {
xmlText = new TextDecoder('utf-8').decode(bytes);
} catch (e) {
xmlText = new TextDecoder('windows-1251').decode(bytes);
}
// Check encoding declaration and re-decode if needed
var encodingMatch = xmlText.match(/encoding=["\']([^"\']+)["\']/i);
if (encodingMatch) {
var declaredEncoding = encodingMatch[1].toLowerCase();
debugLog('FB2 encoding: ' + declaredEncoding);
if (declaredEncoding !== 'utf-8') {
try {
xmlText = new TextDecoder(declaredEncoding).decode(bytes);
} catch (e) {
debugLog('Re-decode error: ' + e.message);
}
}
}
document.getElementById('book-content').style.display = 'none';
document.getElementById('fb2-content').style.display = 'block';
var parser = new DOMParser();
var doc = parser.parseFromString(xmlText, 'text/xml');
var parseError = doc.querySelector('parsererror');
if (parseError) {
showError('FB2 XML parse error');
debugLog(parseError.textContent.substring(0, 200));
return;
}
var fb2Html = parseFb2Document(doc);
var fb2Container = document.getElementById('fb2-content');
fb2Container.innerHTML = fb2Html.html;
document.getElementById('loading').style.display = 'none';
setTimeout(function () {
setupFb2Pagination();
sendMessage('chaptersLoaded', { chapters: fb2Html.chapters });
sendMessage('bookReady', { totalPages: totalPages });
isBookLoaded = true;
bookFormat = 'fb2';
if (lastPosition && parseFloat(lastPosition) > 0) {
goToFb2Position(parseFloat(lastPosition));
}
updateFb2Progress();
}, 500);
} catch (e) {
showError('FB2 error: ' + e.message);
}
}
// ========== FB2 PARSER ==========
function parseFb2Document(doc) {
var chapters = [];
var html = '<div id="fb2-inner" style="font-size:18px; font-family:serif; line-height:1.6;">';
var bodies = doc.querySelectorAll('body');
if (bodies.length === 0) {
bodies = doc.getElementsByTagNameNS('http://www.gribuser.ru/xml/fictionbook/2.0', 'body');
}
var chapterIndex = 0;
for (var b = 0; b < bodies.length; b++) {
var children = bodies[b].children;
for (var s = 0; s < children.length; s++) {
var child = children[s];
var tagName = child.tagName ? child.tagName.toLowerCase().replace(/.*:/, '') : '';
if (tagName === 'section') {
var result = parseFb2Section(child, chapterIndex);
html += result.html;
for (var c = 0; c < result.chapters.length; c++) {
chapters.push(result.chapters[c]);
}
chapterIndex += Math.max(result.chapters.length, 1);
}
}
}
html += '</div>';
if (chapters.length === 0) chapters.push({ label: 'Start', href: '0' });
return { html: html, chapters: chapters };
}
function parseFb2Section(section, startIndex) {
var chapters = [];
var html = '<div class="fb2-section" data-section="' + startIndex + '">';
var children = section.children;
for (var i = 0; i < children.length; i++) {
var child = children[i];
var tag = child.tagName ? child.tagName.toLowerCase().replace(/.*:/, '') : '';
switch (tag) {
case 'title':
var titleText = (child.textContent || '').trim();
html += '<h2 class="fb2-title" data-chapter="' + startIndex + '" style="text-align:center; margin:1em 0 0.5em;">' + escapeHtml(titleText) + '</h2>';
chapters.push({ label: titleText, href: startIndex.toString() });
break;
case 'p':
html += '<p style="text-indent:1.5em; margin-bottom:0.3em;">' + getInlineHtml(child) + '</p>';
break;
case 'empty-line':
html += '<br/>';
break;
case 'subtitle':
html += '<h3 style="text-align:center; margin:0.8em 0;">' + escapeHtml(child.textContent || '') + '</h3>';
break;
case 'epigraph':
html += '<blockquote style="font-style:italic; margin:1em 2em;">' + parseInnerParagraphs(child) + '</blockquote>';
break;
case 'poem':
html += '<div style="margin:1em 2em;">' + parsePoem(child) + '</div>';
break;
case 'cite':
html += '<blockquote style="margin:1em 2em; padding-left:1em; border-left:3px solid #ccc;">' + parseInnerParagraphs(child) + '</blockquote>';
break;
case 'section':
var sub = parseFb2Section(child, startIndex + chapters.length);
html += sub.html;
for (var j = 0; j < sub.chapters.length; j++) chapters.push(sub.chapters[j]);
break;
}
}
html += '</div>';
return { html: html, chapters: chapters };
}
function getInlineHtml(el) {
var result = '';
for (var i = 0; i < el.childNodes.length; i++) {
var node = el.childNodes[i];
if (node.nodeType === 3) {
result += escapeHtml(node.textContent);
} else if (node.nodeType === 1) {
var tag = node.tagName.toLowerCase().replace(/.*:/, '');
if (tag === 'strong' || tag === 'bold') result += '<strong>' + getInlineHtml(node) + '</strong>';
else if (tag === 'emphasis' || tag === 'em') result += '<em>' + getInlineHtml(node) + '</em>';
else if (tag === 'strikethrough') result += '<s>' + getInlineHtml(node) + '</s>';
else result += getInlineHtml(node);
}
}
return result;
}
function parseInnerParagraphs(el) {
var html = '';
for (var i = 0; i < el.children.length; i++) {
var child = el.children[i];
var tag = child.tagName ? child.tagName.toLowerCase().replace(/.*:/, '') : '';
if (tag === 'p') html += '<p>' + getInlineHtml(child) + '</p>';
else if (tag === 'text-author') html += '<p style="text-align:right; font-style:italic;">— ' + escapeHtml(child.textContent || '') + '</p>';
}
return html;
}
function parsePoem(el) {
var html = '';
for (var i = 0; i < el.children.length; i++) {
var child = el.children[i];
var tag = child.tagName ? child.tagName.toLowerCase().replace(/.*:/, '') : '';
if (tag === 'stanza') {
html += '<div style="margin-bottom:1em;">';
for (var j = 0; j < child.children.length; j++) {
var v = child.children[j];
if (v.tagName && v.tagName.toLowerCase().replace(/.*:/, '') === 'v')
html += '<p style="text-indent:0;">' + escapeHtml(v.textContent || '') + '</p>';
}
html += '</div>';
} else if (tag === 'title') {
html += '<h4>' + escapeHtml(child.textContent || '') + '</h4>';
}
}
return html;
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ========== FB2 PAGINATION ==========
var fb2CurrentPage = 0;
var fb2TotalPages = 1;
function setupFb2Pagination() {
var container = document.getElementById('fb2-content');
var inner = document.getElementById('fb2-inner');
if (!container || !inner) return;
var w = container.clientWidth;
var h = container.clientHeight;
inner.style.columnWidth = w + 'px';
inner.style.columnGap = '40px';
inner.style.columnFill = 'auto';
inner.style.height = h + 'px';
inner.style.overflow = 'hidden';
setTimeout(function () {
fb2TotalPages = Math.max(1, Math.ceil(inner.scrollWidth / w));
totalPages = fb2TotalPages;
fb2CurrentPage = 0;
showFb2Page(0);
debugLog('FB2 pages: ' + fb2TotalPages);
}, 300);
}
function showFb2Page(idx) {
if (idx < 0) idx = 0;
if (idx >= fb2TotalPages) idx = fb2TotalPages - 1;
fb2CurrentPage = idx;
var inner = document.getElementById('fb2-inner');
var container = document.getElementById('fb2-content');
if (inner && container) {
inner.style.transform = 'translateX(-' + (idx * container.clientWidth) + 'px)';
}
}
function updateFb2Progress() {
var progress = fb2TotalPages > 1 ? fb2CurrentPage / (fb2TotalPages - 1) : 0;
sendMessage('progressUpdate', {
progress: progress,
cfi: progress.toString(),
currentPage: fb2CurrentPage + 1,
totalPages: fb2TotalPages,
chapter: getCurrentFb2Chapter()
});
}
function getCurrentFb2Chapter() {
var inner = document.getElementById('fb2-inner');
var container = document.getElementById('fb2-content');
if (!inner || !container) return '';
var offset = fb2CurrentPage * container.clientWidth;
var ch = '';
var titles = inner.querySelectorAll('.fb2-title');
for (var i = 0; i < titles.length; i++) {
if (titles[i].offsetLeft <= offset + container.clientWidth) ch = titles[i].textContent;
}
return ch;
}
function goToFb2Position(progress) {
showFb2Page(Math.floor(progress * (fb2TotalPages - 1)));
}
// ========== PUBLIC API ==========
window.nextPage = function () {
if (bookFormat === 'epub' && rendition) rendition.next();
else if (bookFormat === 'fb2' && fb2CurrentPage < fb2TotalPages - 1) {
showFb2Page(fb2CurrentPage + 1);
updateFb2Progress();
}
};
window.prevPage = function () {
if (bookFormat === 'epub' && rendition) rendition.prev();
else if (bookFormat === 'fb2' && fb2CurrentPage > 0) {
showFb2Page(fb2CurrentPage - 1);
updateFb2Progress();
}
};
window.setFontSize = function (size) {
if (bookFormat === 'epub' && rendition) rendition.themes.fontSize(size + 'px');
else if (bookFormat === 'fb2') {
var inner = document.getElementById('fb2-inner');
if (inner) { inner.style.fontSize = size + 'px'; setTimeout(setupFb2Pagination, 100); }
}
};
window.setFontFamily = function (family) {
if (bookFormat === 'epub' && rendition) rendition.themes.font(family);
else if (bookFormat === 'fb2') {
var inner = document.getElementById('fb2-inner');
if (inner) { inner.style.fontFamily = family; setTimeout(setupFb2Pagination, 100); }
}
};
window.goToChapter = function (href) {
if (bookFormat === 'epub' && rendition) rendition.display(href);
else if (bookFormat === 'fb2') {
var inner = document.getElementById('fb2-inner');
var container = document.getElementById('fb2-content');
if (inner && container) {
var el = inner.querySelector('[data-chapter="' + href + '"]');
if (el) { showFb2Page(Math.floor(el.offsetLeft / container.clientWidth)); updateFb2Progress(); }
}
}
};
window.getProgress = function () {
if (bookFormat === 'epub' && book && currentCfi) {
try {
return JSON.stringify({ progress: book.locations.percentageFromCfi(currentCfi) || 0, cfi: currentCfi, currentPage: currentPage, totalPages: totalPages });
} catch (e) { return '{}'; }
} else if (bookFormat === 'fb2') {
var p = fb2TotalPages > 1 ? fb2CurrentPage / (fb2TotalPages - 1) : 0;
return JSON.stringify({ progress: p, cfi: p.toString(), currentPage: fb2CurrentPage + 1, totalPages: fb2TotalPages });
}
return '{}';
};
// ========== INIT ==========
debugLog('Page loaded');
// Log hybridWebViewHost details
if (window.HybridWebView) {
debugLog('hybridWebView found!');
var methods = [];
for (var k in window.HybridWebView) {
methods.push(k + ':' + typeof window.HybridWebView[k]);
}
debugLog('Methods: ' + methods.join(', '));
}
setLoadingText('Waiting for book...');
sendMessage('readerReady', {});
</script>
<!-- IMPORTANT: Load JSZip BEFORE epub.js -->
<!-- JSZip 3.x is required by epub.js -->
<script>
// Verify after script load
debugLog('Checking libraries after inline script...');
</script>
<script src="js/jszip.min.js" onload="debugLog('JSZip loaded: ' + typeof JSZip)" onerror="debugLog('ERROR: jszip.min.js failed to load')"></script>
<script>
// After JSZip loads, verify it
if (typeof JSZip !== 'undefined') {
debugLog('JSZip OK, version: ' + (JSZip.version || 'unknown'));
} else {
debugLog('JSZip NOT available after script tag');
}
</script>
<script src="js/epub.min.js" onload="debugLog('epub.js loaded: ' + typeof ePub)" onerror="debugLog('ERROR: epub.min.js failed to load')"></script>
<script>
if (typeof ePub !== 'undefined') {
debugLog('ePub OK');
} else {
debugLog('ePub NOT available after script tag');
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,289 @@
class FB2Reader {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.pages = [];
this.currentPageIndex = 0;
this.chapters = [];
this.fontSize = 18;
this.fontFamily = 'serif';
this.content = '';
this.sections = [];
}
async load(xmlText) {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, 'text/xml');
const fb = 'http://www.gribuser.ru/xml/fictionbook/2.0';
// Extract body sections
const bodies = doc.getElementsByTagNameNS(fb, 'body');
let html = '';
this.chapters = [];
for (let body of bodies) {
const sections = body.getElementsByTagNameNS(fb, 'section');
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
const sectionHtml = this.parseFb2Section(section, fb, i);
html += sectionHtml;
}
}
this.content = html;
this.render();
this.paginate();
}
parseFb2Section(section, ns, index) {
let html = '';
let chapterTitle = '';
for (let child of section.children) {
const localName = child.localName;
switch (localName) {
case 'title':
const titleText = this.extractText(child);
chapterTitle = titleText;
html += `<h2 class="fb2-title" data-chapter="${index}">${titleText}</h2>`;
this.chapters.push({
label: titleText,
href: index.toString(),
index: index
});
break;
case 'p':
html += `<p>${this.extractText(child)}</p>`;
break;
case 'empty-line':
html += '<br/>';
break;
case 'subtitle':
html += `<h3>${this.extractText(child)}</h3>`;
break;
case 'epigraph':
html += `<blockquote class="epigraph">${this.parseInnerContent(child, ns)}</blockquote>`;
break;
case 'poem':
html += `<div class="poem">${this.parsePoem(child, ns)}</div>`;
break;
case 'cite':
html += `<blockquote>${this.parseInnerContent(child, ns)}</blockquote>`;
break;
case 'image':
// Handle inline images if needed
break;
case 'section':
html += this.parseFb2Section(child, ns, this.chapters.length);
break;
}
}
return `<div class="fb2-section" data-section="${index}">${html}</div>`;
}
extractText(element) {
let text = '';
for (let node of element.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
} else if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.localName;
switch (tag) {
case 'strong':
text += `<strong>${this.extractText(node)}</strong>`;
break;
case 'emphasis':
text += `<em>${this.extractText(node)}</em>`;
break;
case 'a':
text += `<a>${this.extractText(node)}</a>`;
break;
case 'strikethrough':
text += `<s>${this.extractText(node)}</s>`;
break;
default:
text += this.extractText(node);
}
}
}
return text;
}
parseInnerContent(element, ns) {
let html = '';
for (let child of element.children) {
if (child.localName === 'p') {
html += `<p>${this.extractText(child)}</p>`;
} else if (child.localName === 'text-author') {
html += `<p class="text-author">— ${this.extractText(child)}</p>`;
}
}
return html;
}
parsePoem(element, ns) {
let html = '';
for (let child of element.children) {
if (child.localName === 'stanza') {
html += '<div class="stanza">';
for (let v of child.children) {
if (v.localName === 'v') {
html += `<p class="verse">${this.extractText(v)}</p>`;
}
}
html += '</div>';
} else if (child.localName === 'title') {
html += `<h4>${this.extractText(child)}</h4>`;
} else if (child.localName === 'text-author') {
html += `<p class="text-author">— ${this.extractText(child)}</p>`;
}
}
return html;
}
render() {
this.container.innerHTML = `
<div id="fb2-inner" style="
font-size: ${this.fontSize}px;
font-family: ${this.fontFamily};
line-height: 1.6;
padding: 20px;
height: 100%;
overflow: hidden;
column-width: ${window.innerWidth - 40}px;
column-gap: 40px;
column-fill: auto;
">
<style>
.fb2-title { margin: 1em 0 0.5em; text-align: center; }
.fb2-section { margin-bottom: 1em; }
p { text-indent: 1.5em; margin-bottom: 0.3em; }
.epigraph { font-style: italic; margin: 1em 2em; }
.poem { margin: 1em 2em; }
.verse { text-indent: 0; }
.stanza { margin-bottom: 1em; }
.text-author { text-align: right; font-style: italic; }
blockquote { margin: 1em 2em; padding-left: 1em; border-left: 3px solid #ccc; }
</style>
${this.content}
</div>
`;
}
paginate() {
const inner = document.getElementById('fb2-inner');
if (!inner) return;
const containerWidth = this.container.clientWidth;
const scrollWidth = inner.scrollWidth;
const pageWidth = containerWidth;
this.totalPages = Math.ceil(scrollWidth / pageWidth);
if (this.totalPages < 1) this.totalPages = 1;
this.showPage(this.currentPageIndex);
}
showPage(pageIndex) {
if (pageIndex < 0) pageIndex = 0;
if (pageIndex >= this.totalPages) pageIndex = this.totalPages - 1;
this.currentPageIndex = pageIndex;
const inner = document.getElementById('fb2-inner');
if (inner) {
const offset = pageIndex * this.container.clientWidth;
inner.style.transform = `translateX(-${offset}px)`;
}
}
nextPage() {
if (this.currentPageIndex < this.totalPages - 1) {
this.showPage(this.currentPageIndex + 1);
}
}
prevPage() {
if (this.currentPageIndex > 0) {
this.showPage(this.currentPageIndex - 1);
}
}
getProgress() {
if (this.totalPages <= 1) return 0;
return this.currentPageIndex / (this.totalPages - 1);
}
getCurrentPage() {
return this.currentPageIndex + 1;
}
getTotalPages() {
return this.totalPages || 1;
}
getCurrentChapter() {
// Find which chapter we're in based on page position
const inner = document.getElementById('fb2-inner');
if (!inner) return '';
const titles = inner.querySelectorAll('.fb2-title');
let currentChapter = '';
for (let title of titles) {
const rect = title.getBoundingClientRect();
const containerRect = this.container.getBoundingClientRect();
const offset = this.currentPageIndex * this.container.clientWidth;
if (title.offsetLeft <= offset + this.container.clientWidth) {
currentChapter = title.textContent;
}
}
return currentChapter;
}
getChapters() {
return this.chapters;
}
setFontSize(size) {
this.fontSize = size;
this.render();
setTimeout(() => this.paginate(), 100);
}
setFontFamily(family) {
this.fontFamily = family;
this.render();
setTimeout(() => this.paginate(), 100);
}
goToChapter(chapterIndex) {
const inner = document.getElementById('fb2-inner');
if (!inner) return;
const section = inner.querySelector(`[data-chapter="${chapterIndex}"]`);
if (section) {
const offset = section.offsetLeft;
const pageIndex = Math.floor(offset / this.container.clientWidth);
this.showPage(pageIndex);
}
}
goToPosition(progress) {
const pageIndex = Math.floor(progress * (this.totalPages - 1));
this.showPage(pageIndex);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
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:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<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>
<!-- Styles -->
<Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="{StaticResource DarkBrown}" />
<Setter Property="BarTextColor" Value="White" />
</Style>
<Style TargetType="Shell">
<Setter Property="Shell.BackgroundColor" Value="{StaticResource DarkBrown}" />
<Setter Property="Shell.ForegroundColor" Value="White" />
<Setter Property="Shell.TitleColor" Value="White" />
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,174 @@
using BookReader.Models;
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using VersOne.Epub;
namespace BookReader.Services;
public class BookParserService : IBookParserService
{
private readonly IDatabaseService _databaseService;
private readonly string _booksDir;
public BookParserService(IDatabaseService databaseService)
{
_databaseService = databaseService;
_booksDir = Path.Combine(FileSystem.AppDataDirectory, "Books");
Directory.CreateDirectory(_booksDir);
}
public string GetBooksDirectory() => _booksDir;
public async Task<Book> ParseAndStoreBookAsync(string sourceFilePath, string originalFileName)
{
var extension = Path.GetExtension(originalFileName).ToLowerInvariant();
var bookId = Guid.NewGuid().ToString();
var destPath = Path.Combine(_booksDir, $"{bookId}{extension}");
// Copy file to app storage
if (sourceFilePath != destPath)
{
using var sourceStream = File.OpenRead(sourceFilePath);
using var destStream = File.Create(destPath);
await sourceStream.CopyToAsync(destStream);
}
var book = new Book
{
FilePath = destPath,
FileName = originalFileName,
Format = extension.TrimStart('.'),
DateAdded = DateTime.UtcNow,
LastRead = DateTime.UtcNow
};
switch (extension)
{
case ".epub":
await ParseEpubMetadataAsync(book);
break;
case ".fb2":
await ParseFb2MetadataAsync(book);
break;
default:
book.Title = Path.GetFileNameWithoutExtension(originalFileName);
break;
}
if (string.IsNullOrWhiteSpace(book.Title))
book.Title = Path.GetFileNameWithoutExtension(originalFileName);
await _databaseService.SaveBookAsync(book);
return book;
}
public Task<string> GetBookContentPathAsync(Book book)
{
return Task.FromResult(book.FilePath);
}
private async Task ParseEpubMetadataAsync(Book book)
{
try
{
var epubBook = await EpubReader.ReadBookAsync(book.FilePath);
book.Title = epubBook.Title ?? "Unknown Title";
book.Author = epubBook.Author ?? "Unknown Author";
// Extract cover
if (epubBook.CoverImage != null)
{
book.CoverImage = epubBook.CoverImage;
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error parsing EPUB: {ex.Message}");
book.Title = Path.GetFileNameWithoutExtension(book.FileName);
book.Author = "Unknown";
}
}
private async Task ParseFb2MetadataAsync(Book book)
{
try
{
string xml;
if (book.FilePath.EndsWith(".fb2.zip", StringComparison.OrdinalIgnoreCase))
{
using var zip = ZipFile.OpenRead(book.FilePath);
var entry = zip.Entries.FirstOrDefault(e => e.Name.EndsWith(".fb2", StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
using var stream = entry.Open();
using var reader = new StreamReader(stream);
xml = await reader.ReadToEndAsync();
}
else return;
}
else
{
xml = await File.ReadAllTextAsync(book.FilePath);
}
var doc = XDocument.Parse(xml);
XNamespace fb = "http://www.gribuser.ru/xml/fictionbook/2.0";
var titleInfo = doc.Descendants(fb + "title-info").FirstOrDefault();
if (titleInfo != null)
{
// Title
var bookTitle = titleInfo.Element(fb + "book-title")?.Value;
book.Title = bookTitle ?? "Unknown Title";
// Author
var authorElement = titleInfo.Element(fb + "author");
if (authorElement != null)
{
var firstName = authorElement.Element(fb + "first-name")?.Value ?? "";
var lastName = authorElement.Element(fb + "last-name")?.Value ?? "";
var middleName = authorElement.Element(fb + "middle-name")?.Value ?? "";
book.Author = $"{firstName} {middleName} {lastName}".Trim();
}
// Cover
var coverPage = titleInfo.Element(fb + "coverpage");
if (coverPage != null)
{
var imageElement = coverPage.Descendants().FirstOrDefault(e => e.Name.LocalName == "image");
if (imageElement != null)
{
XNamespace xlink = "http://www.w3.org/1999/xlink";
var href = imageElement.Attribute(xlink + "href")?.Value
?? imageElement.Attribute("href")?.Value;
if (href != null)
{
href = href.TrimStart('#');
var binary = doc.Descendants(fb + "binary")
.FirstOrDefault(b => b.Attribute("id")?.Value == href);
if (binary != null)
{
try
{
book.CoverImage = Convert.FromBase64String(binary.Value.Trim());
}
catch { }
}
}
}
}
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error parsing FB2: {ex.Message}");
book.Title = Path.GetFileNameWithoutExtension(book.FileName);
book.Author = "Unknown";
}
}
}

View File

@@ -0,0 +1,142 @@
using BookReader.Models;
using Newtonsoft.Json.Linq;
using System.Net.Http.Headers;
using System.Text;
namespace BookReader.Services;
public class CalibreWebService : ICalibreWebService
{
private readonly HttpClient _httpClient;
private string _baseUrl = string.Empty;
private string _username = string.Empty;
private string _password = string.Empty;
public CalibreWebService(HttpClient httpClient)
{
_httpClient = httpClient;
_httpClient.Timeout = TimeSpan.FromSeconds(30);
}
public void Configure(string url, string username, string password)
{
_baseUrl = url.TrimEnd('/');
_username = username;
_password = password;
if (!string.IsNullOrEmpty(_username))
{
var authBytes = Encoding.ASCII.GetBytes($"{_username}:{_password}");
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", Convert.ToBase64String(authBytes));
}
}
public async Task<bool> TestConnectionAsync(string url, string username, string password)
{
try
{
Configure(url, username, password);
var response = await _httpClient.GetAsync($"{_baseUrl}/ajax/search?query=&num=1");
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
public async Task<List<CalibreBook>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = 20)
{
var books = new List<CalibreBook>();
try
{
var offset = page * pageSize;
var query = string.IsNullOrEmpty(searchQuery) ? "" : Uri.EscapeDataString(searchQuery);
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>();
foreach (var bookId in bookIds)
{
try
{
var bookUrl = $"{_baseUrl}/ajax/book/{bookId}";
var bookResponse = await _httpClient.GetStringAsync(bookUrl);
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));
if (supportedFormat == null) continue;
var authors = bookJson["authors"]?.ToObject<List<string>>() ?? new List<string>();
var calibreBook = new CalibreBook
{
Id = bookId.ToString(),
Title = bookJson["title"]?.ToString() ?? "Unknown",
Author = string.Join(", ", authors),
Format = supportedFormat.ToLowerInvariant(),
CoverUrl = $"{_baseUrl}/get/cover/{bookId}",
DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}"
};
// Try to load cover
try
{
calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl);
}
catch { }
books.Add(calibreBook);
}
catch { continue; }
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error fetching Calibre books: {ex.Message}");
}
return books;
}
public async Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null)
{
var booksDir = Path.Combine(FileSystem.AppDataDirectory, "Books");
Directory.CreateDirectory(booksDir);
var fileName = $"{Guid.NewGuid()}.{book.Format}";
var filePath = Path.Combine(booksDir, fileName);
using var response = await _httpClient.GetAsync(book.DownloadUrl, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? -1;
var bytesRead = 0L;
using var contentStream = await response.Content.ReadAsStreamAsync();
using var fileStream = File.Create(filePath);
var buffer = new byte[8192];
int read;
while ((read = await contentStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await fileStream.WriteAsync(buffer, 0, read);
bytesRead += read;
if (totalBytes > 0)
progress?.Report((double)bytesRead / totalBytes);
}
return filePath;
}
}

View File

@@ -0,0 +1,122 @@
using BookReader.Models;
using SQLite;
namespace BookReader.Services;
public class DatabaseService : IDatabaseService
{
private SQLiteAsyncConnection? _database;
private readonly string _dbPath;
public DatabaseService()
{
_dbPath = Path.Combine(FileSystem.AppDataDirectory, "bookreader.db3");
}
public async Task InitializeAsync()
{
if (_database != null) return;
_database = new SQLiteAsyncConnection(_dbPath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
await _database.CreateTableAsync<Book>();
await _database.CreateTableAsync<AppSettings>();
await _database.CreateTableAsync<ReadingProgress>();
}
private async Task EnsureInitializedAsync()
{
if (_database == null)
await InitializeAsync();
}
// Books
public async Task<List<Book>> GetAllBooksAsync()
{
await EnsureInitializedAsync();
return await _database!.Table<Book>().OrderByDescending(b => b.LastRead).ToListAsync();
}
public async Task<Book?> GetBookByIdAsync(int id)
{
await EnsureInitializedAsync();
return await _database!.Table<Book>().Where(b => b.Id == id).FirstOrDefaultAsync();
}
public async Task<int> SaveBookAsync(Book book)
{
await EnsureInitializedAsync();
if (book.Id != 0)
return await _database!.UpdateAsync(book);
return await _database!.InsertAsync(book);
}
public async Task<int> UpdateBookAsync(Book book)
{
await EnsureInitializedAsync();
return await _database!.UpdateAsync(book);
}
public async Task<int> DeleteBookAsync(Book book)
{
await EnsureInitializedAsync();
// Delete associated file
if (File.Exists(book.FilePath))
{
try { File.Delete(book.FilePath); } catch { }
}
// Delete progress records
await _database!.Table<ReadingProgress>().DeleteAsync(p => p.BookId == book.Id);
return await _database!.DeleteAsync(book);
}
// Settings
public async Task<string?> GetSettingAsync(string key)
{
await EnsureInitializedAsync();
var setting = await _database!.Table<AppSettings>().Where(s => s.Key == key).FirstOrDefaultAsync();
return setting?.Value;
}
public async Task SetSettingAsync(string key, string value)
{
await EnsureInitializedAsync();
var existing = await _database!.Table<AppSettings>().Where(s => s.Key == key).FirstOrDefaultAsync();
if (existing != null)
{
existing.Value = value;
await _database.UpdateAsync(existing);
}
else
{
await _database.InsertAsync(new AppSettings { Key = key, Value = value });
}
}
public async Task<Dictionary<string, string>> GetAllSettingsAsync()
{
await EnsureInitializedAsync();
var settings = await _database!.Table<AppSettings>().ToListAsync();
return settings.ToDictionary(s => s.Key, s => s.Value);
}
// Reading Progress
public async Task SaveProgressAsync(ReadingProgress progress)
{
await EnsureInitializedAsync();
progress.Timestamp = DateTime.UtcNow;
await _database!.InsertAsync(progress);
}
public async Task<ReadingProgress?> GetLatestProgressAsync(int bookId)
{
await EnsureInitializedAsync();
return await _database!.Table<ReadingProgress>()
.Where(p => p.BookId == bookId)
.OrderByDescending(p => p.Timestamp)
.FirstOrDefaultAsync();
}
}

View File

@@ -0,0 +1,10 @@
using BookReader.Models;
namespace BookReader.Services;
public interface IBookParserService
{
Task<Book> ParseAndStoreBookAsync(string sourceFilePath, string originalFileName);
Task<string> GetBookContentPathAsync(Book book);
string GetBooksDirectory();
}

View File

@@ -0,0 +1,11 @@
using BookReader.Models;
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<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null);
void Configure(string url, string username, string password);
}

View File

@@ -0,0 +1,24 @@
using BookReader.Models;
namespace BookReader.Services;
public interface IDatabaseService
{
Task InitializeAsync();
// Books
Task<List<Book>> GetAllBooksAsync();
Task<Book?> GetBookByIdAsync(int id);
Task<int> SaveBookAsync(Book book);
Task<int> UpdateBookAsync(Book book);
Task<int> DeleteBookAsync(Book book);
// Settings
Task<string?> GetSettingAsync(string key);
Task SetSettingAsync(string key, string value);
Task<Dictionary<string, string>> GetAllSettingsAsync();
// Reading Progress
Task SaveProgressAsync(ReadingProgress progress);
Task<ReadingProgress?> GetLatestProgressAsync(int bookId);
}

View File

@@ -0,0 +1,10 @@
namespace BookReader.Services;
public interface ISettingsService
{
Task<string> GetAsync(string key, string defaultValue = "");
Task SetAsync(string key, string value);
Task<int> GetIntAsync(string key, int defaultValue = 0);
Task SetIntAsync(string key, int value);
Task<Dictionary<string, string>> GetAllAsync();
}

View File

@@ -0,0 +1,40 @@
namespace BookReader.Services;
public class SettingsService : ISettingsService
{
private readonly IDatabaseService _databaseService;
public SettingsService(IDatabaseService databaseService)
{
_databaseService = databaseService;
}
public async Task<string> GetAsync(string key, string defaultValue = "")
{
var value = await _databaseService.GetSettingAsync(key);
return value ?? defaultValue;
}
public async Task SetAsync(string key, string value)
{
await _databaseService.SetSettingAsync(key, value);
}
public async Task<int> GetIntAsync(string key, int defaultValue = 0)
{
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());
}
public async Task<Dictionary<string, string>> GetAllAsync()
{
return await _databaseService.GetAllSettingsAsync();
}
}

View File

@@ -0,0 +1,15 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace BookReader.ViewModels;
public partial class BaseViewModel : ObservableObject
{
[ObservableProperty]
private bool _isBusy;
[ObservableProperty]
private string _title = string.Empty;
[ObservableProperty]
private string _statusMessage = string.Empty;
}

View File

@@ -0,0 +1,159 @@
using BookReader.Models;
using BookReader.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
namespace BookReader.ViewModels;
public partial class BookshelfViewModel : BaseViewModel
{
private readonly IDatabaseService _databaseService;
private readonly IBookParserService _bookParserService;
private readonly ISettingsService _settingsService;
public ObservableCollection<Book> Books { get; } = new();
[ObservableProperty]
private bool _isEmpty;
public BookshelfViewModel(
IDatabaseService databaseService,
IBookParserService bookParserService,
ISettingsService settingsService)
{
_databaseService = databaseService;
_bookParserService = bookParserService;
_settingsService = settingsService;
Title = "My Library";
}
[RelayCommand]
public async Task LoadBooksAsync()
{
if (IsBusy) return;
IsBusy = true;
try
{
var books = await _databaseService.GetAllBooksAsync();
Books.Clear();
foreach (var book in books)
{
Books.Add(book);
}
IsEmpty = Books.Count == 0;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error loading books: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task AddBookFromFileAsync()
{
try
{
var customFileTypes = new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
{
{ DevicePlatform.Android, new[] { "application/epub+zip", "application/x-fictionbook+xml", "application/octet-stream", "*/*" } }
});
var result = await FilePicker.Default.PickAsync(new PickOptions
{
PickerTitle = "Select a book",
FileTypes = customFileTypes
});
if (result == null) return;
var extension = Path.GetExtension(result.FileName).ToLowerInvariant();
if (extension != ".epub" && extension != ".fb2")
{
await Shell.Current.DisplayAlert("Error", "Only EPUB and FB2 formats are supported.", "OK");
return;
}
IsBusy = true;
StatusMessage = "Adding book...";
// Copy to temp if needed and parse
string filePath;
using var stream = await result.OpenReadAsync();
var tempPath = Path.Combine(FileSystem.CacheDirectory, result.FileName);
using (var fileStream = File.Create(tempPath))
{
await stream.CopyToAsync(fileStream);
}
filePath = tempPath;
var book = await _bookParserService.ParseAndStoreBookAsync(filePath, result.FileName);
Books.Insert(0, book);
IsEmpty = false;
// Clean temp
try { File.Delete(tempPath); } catch { }
}
catch (Exception ex)
{
await Shell.Current.DisplayAlert("Error", $"Failed to add book: {ex.Message}", "OK");
}
finally
{
IsBusy = false;
StatusMessage = string.Empty;
}
}
[RelayCommand]
public async Task DeleteBookAsync(Book book)
{
if (book == null) return;
var confirm = await Shell.Current.DisplayAlert("Delete Book",
$"Are you sure you want to delete \"{book.Title}\"?", "Delete", "Cancel");
if (!confirm) return;
try
{
await _databaseService.DeleteBookAsync(book);
Books.Remove(book);
IsEmpty = Books.Count == 0;
}
catch (Exception ex)
{
await Shell.Current.DisplayAlert("Error", $"Failed to delete book: {ex.Message}", "OK");
}
}
[RelayCommand]
public async Task OpenBookAsync(Book book)
{
if (book == null) return;
var navigationParameter = new Dictionary<string, object>
{
{ "Book", book }
};
await Shell.Current.GoToAsync("reader", navigationParameter);
}
[RelayCommand]
public async Task OpenSettingsAsync()
{
await Shell.Current.GoToAsync("settings");
}
[RelayCommand]
public async Task OpenCalibreLibraryAsync()
{
await Shell.Current.GoToAsync("calibre");
}
}

View File

@@ -0,0 +1,157 @@
using BookReader.Models;
using BookReader.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
namespace BookReader.ViewModels;
public partial class CalibreLibraryViewModel : BaseViewModel
{
private readonly ICalibreWebService _calibreWebService;
private readonly IBookParserService _bookParserService;
private readonly IDatabaseService _databaseService;
private readonly ISettingsService _settingsService;
public ObservableCollection<CalibreBook> Books { get; } = new();
[ObservableProperty]
private string _searchQuery = string.Empty;
[ObservableProperty]
private bool _isConfigured;
[ObservableProperty]
private string _downloadStatus = string.Empty;
private int _currentPage;
public CalibreLibraryViewModel(
ICalibreWebService calibreWebService,
IBookParserService bookParserService,
IDatabaseService databaseService,
ISettingsService settingsService)
{
_calibreWebService = calibreWebService;
_bookParserService = bookParserService;
_databaseService = databaseService;
_settingsService = settingsService;
Title = "Calibre Library";
}
[RelayCommand]
public async Task InitializeAsync()
{
var url = await _settingsService.GetAsync(SettingsKeys.CalibreUrl);
var username = await _settingsService.GetAsync(SettingsKeys.CalibreUsername);
var password = await _settingsService.GetAsync(SettingsKeys.CalibrePassword);
IsConfigured = !string.IsNullOrWhiteSpace(url);
if (IsConfigured)
{
_calibreWebService.Configure(url, username, password);
await LoadBooksAsync();
}
}
[RelayCommand]
public async Task LoadBooksAsync()
{
if (IsBusy || !IsConfigured) return;
IsBusy = true;
_currentPage = 0;
try
{
var books = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
Books.Clear();
foreach (var book in books)
{
Books.Add(book);
}
}
catch (Exception ex)
{
await Shell.Current.DisplayAlert("Error", $"Failed to load library: {ex.Message}", "OK");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task LoadMoreBooksAsync()
{
if (IsBusy || !IsConfigured) return;
IsBusy = true;
_currentPage++;
try
{
var books = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
foreach (var book in books)
{
Books.Add(book);
}
}
catch { }
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task SearchAsync()
{
await LoadBooksAsync();
}
[RelayCommand]
public async Task DownloadBookAsync(CalibreBook calibreBook)
{
if (calibreBook == null) return;
IsBusy = true;
DownloadStatus = $"Downloading {calibreBook.Title}...";
try
{
var progress = new Progress<double>(p =>
{
DownloadStatus = $"Downloading... {p * 100:F0}%";
});
var filePath = await _calibreWebService.DownloadBookAsync(calibreBook, progress);
var fileName = $"{calibreBook.Title}.{calibreBook.Format}";
var book = await _bookParserService.ParseAndStoreBookAsync(filePath, fileName);
book.CalibreId = calibreBook.Id;
if (calibreBook.CoverImage != null)
book.CoverImage = calibreBook.CoverImage;
await _databaseService.UpdateBookAsync(book);
DownloadStatus = "Download complete!";
await Shell.Current.DisplayAlert("Success", $"\"{calibreBook.Title}\" has been added to your library.", "OK");
}
catch (Exception ex)
{
await Shell.Current.DisplayAlert("Error", $"Failed to download: {ex.Message}", "OK");
}
finally
{
IsBusy = false;
DownloadStatus = string.Empty;
}
}
[RelayCommand]
public async Task OpenSettingsAsync()
{
await Shell.Current.GoToAsync("settings");
}
}

View File

@@ -0,0 +1,162 @@
using Android.Graphics.Fonts;
using BookReader.Models;
using BookReader.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace BookReader.ViewModels;
[QueryProperty(nameof(Book), "Book")]
public partial class ReaderViewModel : BaseViewModel
{
private readonly IDatabaseService _databaseService;
private readonly ISettingsService _settingsService;
[ObservableProperty]
private Book? _book;
[ObservableProperty]
private bool _isMenuVisible;
[ObservableProperty]
private bool _isChapterListVisible;
[ObservableProperty]
private int _fontSize;
[ObservableProperty]
private string _fontFamily = "serif";
[ObservableProperty]
private List<string> _chapters = new();
[ObservableProperty]
private string? _selectedChapter;
public List<string> AvailableFonts { get; } = new()
{
"serif",
"sans-serif",
"monospace",
"Georgia",
"Palatino",
"Times New Roman",
"Arial",
"Verdana",
"Courier New"
};
public List<int> AvailableFontSizes { get; } = new()
{
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)
{
_databaseService = databaseService;
_settingsService = settingsService;
_fontSize = 18;
}
public async Task InitializeAsync()
{
var savedFontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, 18);
var savedFontFamily = await _settingsService.GetAsync(SettingsKeys.DefaultFontFamily, "serif");
FontSize = savedFontSize;
FontFamily = savedFontFamily;
}
[RelayCommand]
public void ToggleMenu()
{
IsMenuVisible = !IsMenuVisible;
if (!IsMenuVisible)
IsChapterListVisible = false;
}
[RelayCommand]
public void HideMenu()
{
IsMenuVisible = false;
IsChapterListVisible = false;
}
[RelayCommand]
public void ToggleChapterList()
{
IsChapterListVisible = !IsChapterListVisible;
}
[RelayCommand]
public void ChangeFontSize(int size)
{
FontSize = size;
OnJavaScriptRequested?.Invoke($"setFontSize({size})");
_ = _settingsService.SetIntAsync(SettingsKeys.DefaultFontSize, size);
}
[RelayCommand]
public void ChangeFontFamily(string family)
{
FontFamily = family;
OnJavaScriptRequested?.Invoke($"setFontFamily('{family}')");
_ = _settingsService.SetAsync(SettingsKeys.DefaultFontFamily, family);
}
[RelayCommand]
public void GoToChapter(string chapter)
{
if (string.IsNullOrEmpty(chapter)) return;
OnJavaScriptRequested?.Invoke($"goToChapter('{EscapeJs(chapter)}')");
IsChapterListVisible = false;
IsMenuVisible = false;
}
public async Task SaveProgressAsync(double progress, string? cfi, string? chapter, int currentPage, int totalPages)
{
if (Book == null) return;
Book.ReadingProgress = progress;
Book.LastCfi = cfi;
Book.LastChapter = chapter;
Book.CurrentPage = currentPage;
Book.TotalPages = totalPages;
Book.LastRead = DateTime.UtcNow;
await _databaseService.UpdateBookAsync(Book);
await _databaseService.SaveProgressAsync(new ReadingProgress
{
BookId = Book.Id,
Cfi = cfi,
Progress = progress,
CurrentPage = currentPage,
ChapterTitle = chapter
});
}
public string GetBookFilePath()
{
return Book?.FilePath ?? string.Empty;
}
public string GetBookFormat()
{
return Book?.Format ?? "epub";
}
public string? GetLastCfi()
{
return Book?.LastCfi;
}
private static string EscapeJs(string value)
{
return value.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\n", "\\n").Replace("\r", "\\r");
}
}

View File

@@ -0,0 +1,105 @@
using BookReader.Models;
using BookReader.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace BookReader.ViewModels;
public partial class SettingsViewModel : BaseViewModel
{
private readonly ISettingsService _settingsService;
private readonly ICalibreWebService _calibreWebService;
[ObservableProperty]
private string _calibreUrl = string.Empty;
[ObservableProperty]
private string _calibreUsername = string.Empty;
[ObservableProperty]
private string _calibrePassword = string.Empty;
[ObservableProperty]
private int _defaultFontSize = 18;
[ObservableProperty]
private string _defaultFontFamily = "serif";
[ObservableProperty]
private string _connectionStatus = string.Empty;
[ObservableProperty]
private bool _isConnectionTesting;
public List<string> AvailableFonts { get; } = new()
{
"serif", "sans-serif", "monospace", "Georgia", "Palatino",
"Times New Roman", "Arial", "Verdana", "Courier New"
};
public List<int> AvailableFontSizes { get; } = new()
{
12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40
};
public SettingsViewModel(ISettingsService settingsService, ICalibreWebService calibreWebService)
{
_settingsService = settingsService;
_calibreWebService = calibreWebService;
Title = "Settings";
}
[RelayCommand]
public async Task LoadSettingsAsync()
{
CalibreUrl = await _settingsService.GetAsync(SettingsKeys.CalibreUrl);
CalibreUsername = await _settingsService.GetAsync(SettingsKeys.CalibreUsername);
CalibrePassword = await _settingsService.GetAsync(SettingsKeys.CalibrePassword);
DefaultFontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, 18);
DefaultFontFamily = await _settingsService.GetAsync(SettingsKeys.DefaultFontFamily, "serif");
}
[RelayCommand]
public async Task SaveSettingsAsync()
{
await _settingsService.SetAsync(SettingsKeys.CalibreUrl, CalibreUrl);
await _settingsService.SetAsync(SettingsKeys.CalibreUsername, CalibreUsername);
await _settingsService.SetAsync(SettingsKeys.CalibrePassword, CalibrePassword);
await _settingsService.SetIntAsync(SettingsKeys.DefaultFontSize, DefaultFontSize);
await _settingsService.SetAsync(SettingsKeys.DefaultFontFamily, DefaultFontFamily);
if (!string.IsNullOrEmpty(CalibreUrl))
{
_calibreWebService.Configure(CalibreUrl, CalibreUsername, CalibrePassword);
}
await Shell.Current.DisplayAlert("Settings", "Settings saved successfully.", "OK");
}
[RelayCommand]
public async Task TestConnectionAsync()
{
if (string.IsNullOrWhiteSpace(CalibreUrl))
{
ConnectionStatus = "Please enter a URL";
return;
}
IsConnectionTesting = true;
ConnectionStatus = "Testing connection...";
try
{
var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword);
ConnectionStatus = success ? "✅ Connection successful!" : "❌ Connection failed";
}
catch (Exception ex)
{
ConnectionStatus = $"❌ Error: {ex.Message}";
}
finally
{
IsConnectionTesting = false;
}
}
}

View File

@@ -0,0 +1,198 @@
<?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.BookshelfPage"
x:DataType="vm:BookshelfViewModel"
Title="{Binding Title}"
Shell.NavBarIsVisible="True">
<ContentPage.Resources>
<converters:ByteArrayToImageSourceConverter x:Key="ByteArrayToImageConverter" />
<converters:ProgressToWidthConverter x:Key="ProgressToWidthConverter" />
</ContentPage.Resources>
<Shell.TitleView>
<Grid ColumnDefinitions="*,Auto" Padding="0,0,5,0">
<Label Grid.Column="0"
Text="📚 My Library"
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" BackgroundColor="#3E2723">
<!-- Bookshelf Background -->
<Grid Grid.Row="0">
<!-- Empty state -->
<VerticalStackLayout IsVisible="{Binding IsEmpty}"
VerticalOptions="Center"
HorizontalOptions="Center"
Spacing="20"
Padding="40">
<Label Text="📖"
FontSize="80"
HorizontalOptions="Center" />
<Label Text="Your bookshelf is empty"
FontSize="20"
TextColor="#D7CCC8"
HorizontalOptions="Center" />
<Label Text="Add books from your device or Calibre library"
FontSize="14"
TextColor="#A1887F"
HorizontalOptions="Center"
HorizontalTextAlignment="Center" />
</VerticalStackLayout>
<!-- Book Collection -->
<CollectionView ItemsSource="{Binding Books}"
IsVisible="{Binding IsEmpty, Converter={StaticResource InvertedBoolConverter}}"
SelectionMode="None"
Margin="10">
<CollectionView.ItemsLayout>
<GridItemsLayout Orientation="Vertical"
Span="3"
HorizontalItemSpacing="10"
VerticalItemSpacing="15" />
</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>
<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>
<Grid RowDefinitions="150,Auto,Auto,Auto" Padding="0">
<!-- Cover Image -->
<Frame Grid.Row="0"
Padding="0"
CornerRadius="8,8,0,0"
IsClippedToBounds="True"
HasShadow="False"
BorderColor="Transparent">
<Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
Aspect="AspectFill"
HeightRequest="150" />
</Frame>
<!-- Progress Bar -->
<Grid Grid.Row="0"
VerticalOptions="End"
HeightRequest="4"
BackgroundColor="#44000000">
<BoxView BackgroundColor="#4CAF50"
HorizontalOptions="Start"
WidthRequest="{Binding ReadingProgress, Converter={StaticResource ProgressToWidthConverter}}" />
</Grid>
<!-- Title -->
<Label Grid.Row="1"
Text="{Binding Title}"
FontSize="11"
FontAttributes="Bold"
TextColor="#EFEBE9"
LineBreakMode="TailTruncation"
MaxLines="2"
Padding="5,5,5,0" />
<!-- Author -->
<Label Grid.Row="2"
Text="{Binding Author}"
FontSize="9"
TextColor="#A1887F"
LineBreakMode="TailTruncation"
MaxLines="1"
Padding="5,0,5,2" />
<!-- 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>
<!-- Loading Indicator -->
<ActivityIndicator IsRunning="{Binding IsBusy}"
IsVisible="{Binding IsBusy}"
Color="#FF8A65"
VerticalOptions="Center"
HorizontalOptions="Center"
HeightRequest="50"
WidthRequest="50" />
</Grid>
<!-- Bottom Shelf / Action Bar -->
<Grid Grid.Row="1"
BackgroundColor="#4E342E"
Padding="15,10"
ColumnDefinitions="*,Auto,Auto">
<!-- Shelf decoration -->
<BoxView Grid.ColumnSpan="3"
BackgroundColor="#6D4C41"
HeightRequest="3"
VerticalOptions="Start"
Margin="0,-10,0,0" />
<Label Grid.Column="0"
Text="{Binding Books.Count, StringFormat='{0} books'}"
TextColor="#A1887F"
FontSize="12"
VerticalOptions="Center" />
<Button Grid.Column="1"
Text="📁 Add File"
BackgroundColor="#6D4C41"
TextColor="White"
FontSize="12"
CornerRadius="20"
Padding="15,8"
Margin="5,0"
Command="{Binding AddBookFromFileCommand}" />
<Button Grid.Column="2"
Text="☁️ Calibre"
BackgroundColor="#6D4C41"
TextColor="White"
FontSize="12"
CornerRadius="20"
Padding="15,8"
Command="{Binding OpenCalibreLibraryCommand}" />
</Grid>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,40 @@
using BookReader.ViewModels;
namespace BookReader.Views;
public partial class BookshelfPage : ContentPage
{
private readonly BookshelfViewModel _viewModel;
public BookshelfPage(BookshelfViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
BindingContext = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _viewModel.LoadBooksCommand.ExecuteAsync(null);
}
private async void OnMenuClicked(object? sender, EventArgs e)
{
var action = await DisplayActionSheet("Menu", "Cancel", null,
"⚙️ 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 DisplayAlert("About", "BookReader v1.0\nEPUB & FB2 Reader", "OK");
break;
}
}
}

View File

@@ -0,0 +1,134 @@
<?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">
<ContentPage.Resources>
<converters:ByteArrayToImageSourceConverter x:Key="ByteArrayToImageConverter" />
</ContentPage.Resources>
<Grid RowDefinitions="Auto,Auto,*,Auto">
<!-- Not Configured Message -->
<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" />
</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 -->
<CollectionView Grid.Row="2"
IsVisible="{Binding IsConfigured}"
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 .}" />
</Grid>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<!-- Status Bar -->
<Grid Grid.Row="3"
BackgroundColor="#2C2C2C"
Padding="15,8"
IsVisible="{Binding IsConfigured}">
<Label Text="{Binding DownloadStatus}"
TextColor="#81C784"
FontSize="12"
VerticalOptions="Center" />
<ActivityIndicator IsRunning="{Binding IsBusy}"
IsVisible="{Binding IsBusy}"
Color="#FF8A65"
HorizontalOptions="End"
HeightRequest="20"
WidthRequest="20" />
</Grid>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,21 @@
using BookReader.ViewModels;
namespace BookReader.Views;
public partial class CalibreLibraryPage : ContentPage
{
private readonly CalibreLibraryViewModel _viewModel;
public CalibreLibraryPage(CalibreLibraryViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
BindingContext = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _viewModel.InitializeCommand.ExecuteAsync(null);
}
}

View File

@@ -0,0 +1,138 @@
<?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"
x:Class="BookReader.Views.ReaderPage"
x:DataType="vm:ReaderViewModel"
Shell.NavBarIsVisible="False"
NavigationPage.HasNavigationBar="False">
<ContentPage.Resources>
<toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" />
</ContentPage.Resources>
<Grid>
<!-- HybridWebView for book rendering -->
<HybridWebView x:Name="ReaderWebView"
RawMessageReceived="OnRawMessageReceived"
DefaultFile="index.html"
HorizontalOptions="Fill"
VerticalOptions="Fill" />
<!-- Overlay Menu -->
<Grid IsVisible="{Binding IsMenuVisible}"
BackgroundColor="#88000000"
InputTransparent="False">
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding HideMenuCommand}" />
</Grid.GestureRecognizers>
<!--Menu Panel-->
<Frame VerticalOptions="Center"
HorizontalOptions="Center"
WidthRequest="320"
BackgroundColor="#2C2C2C"
CornerRadius="16"
Padding="20"
HasShadow="True"
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" />
<!--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>
<!--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>
<!--Chapters Button-->
<Button Text="📑 Chapters"
BackgroundColor="#5D4037"
TextColor="White"
FontSize="14"
CornerRadius="8"
HeightRequest="45"
Command="{Binding ToggleChapterListCommand}" />
<!--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" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<!--Back Button-->
<Button Text="← Back to Library"
BackgroundColor="#D32F2F"
TextColor="White"
FontSize="14"
CornerRadius="8"
HeightRequest="45"
Clicked="OnBackToLibrary" />
</VerticalStackLayout>
</Frame>
</Grid>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,494 @@
using BookReader.ViewModels;
using Newtonsoft.Json.Linq;
namespace BookReader.Views;
public partial class ReaderPage : ContentPage
{
private readonly ReaderViewModel _viewModel;
private bool _isBookLoaded;
private readonly List<JObject> _chapterData = new();
private IDispatcherTimer? _pollTimer;
private IDispatcherTimer? _progressTimer;
private bool _isActive;
public ReaderPage(ReaderViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
BindingContext = viewModel;
_viewModel.OnJavaScriptRequested += OnJavaScriptRequested;
}
protected override async void OnAppearing()
{
base.OnAppearing();
_isActive = true;
try
{
await _viewModel.InitializeAsync();
System.Diagnostics.Debug.WriteLine("[Reader] ViewModel initialized");
StartProgressTimer();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Reader] Init error: {ex.Message}");
}
}
protected override async void OnDisappearing()
{
_isActive = false;
StopProgressTimer();
base.OnDisappearing();
await SaveCurrentProgress();
}
// ========== ТАЙМЕРЫ ==========
private void StartProgressTimer()
{
_progressTimer = Dispatcher.CreateTimer();
_progressTimer.Interval = TimeSpan.FromSeconds(10);
_progressTimer.Tick += async (s, e) =>
{
if (_isActive && _isBookLoaded)
{
await SaveCurrentProgress();
}
};
_progressTimer.Start();
}
private void StopProgressTimer()
{
_progressTimer?.Stop();
_progressTimer = null;
}
// ========== ЗАГРУЗКА КНИГИ ==========
private async Task LoadBookIntoWebView()
{
try
{
var book = _viewModel.Book;
if (book == null)
{
System.Diagnostics.Debug.WriteLine("[Reader] Book is null");
return;
}
if (_isBookLoaded)
{
System.Diagnostics.Debug.WriteLine("[Reader] Already loaded");
return;
}
if (!File.Exists(book.FilePath))
{
await DisplayAlert("Error", "Book file not found", "OK");
return;
}
System.Diagnostics.Debug.WriteLine($"[Reader] Loading: {book.Title} ({book.Format})");
System.Diagnostics.Debug.WriteLine($"[Reader] Path: {book.FilePath}");
// Читаем файл и конвертируем в Base64
var fileBytes = await File.ReadAllBytesAsync(book.FilePath);
var base64 = Convert.ToBase64String(fileBytes);
var format = book.Format.ToLowerInvariant();
var lastCfi = _viewModel.GetLastCfi() ?? "";
System.Diagnostics.Debug.WriteLine($"[Reader] File: {fileBytes.Length} bytes, Base64: {base64.Length} chars");
// Отправляем данные чанками чтобы не превысить лимит JS строки
const int chunkSize = 400_000;
if (base64.Length > chunkSize)
{
var chunks = SplitString(base64, chunkSize);
System.Diagnostics.Debug.WriteLine($"[Reader] Sending {chunks.Count} chunks");
await EvalJsAsync("window._bkChunks = [];");
for (int i = 0; i < chunks.Count; i++)
{
await EvalJsAsync($"window._bkChunks.push('{chunks[i]}');");
System.Diagnostics.Debug.WriteLine($"[Reader] Chunk {i + 1}/{chunks.Count}");
}
await EvalJsAsync(
$"window.loadBookFromBase64(window._bkChunks.join(''), '{format}', '{EscapeJs(lastCfi)}');"
);
await EvalJsAsync("delete window._bkChunks;");
}
else
{
await EvalJsAsync(
$"window.loadBookFromBase64('{base64}', '{format}', '{EscapeJs(lastCfi)}');"
);
}
_isBookLoaded = true;
System.Diagnostics.Debug.WriteLine("[Reader] Book load command sent");
// Применяем настройки шрифта сразу
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
System.Diagnostics.Debug.WriteLine("[Reader] Book fully loaded");
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Reader] Load error: {ex.Message}\n{ex.StackTrace}");
await DisplayAlert("Error", $"Failed to load book: {ex.Message}", "OK");
}
}
// ========== ПОЛУЧЕНИЕ ГЛАВ ИЗ JS ==========
private async Task FetchChaptersFromJs()
{
try
{
var result = await EvalJsWithResultAsync(@"
(function() {
try {
if (typeof book !== 'undefined' && book && book.navigation && book.navigation.toc) {
var toc = book.navigation.toc;
var arr = [];
for (var i = 0; i < toc.length; i++) {
arr.push({ label: (toc[i].label || '').trim(), href: toc[i].href || '' });
}
return JSON.stringify(arr);
}
var titles = document.querySelectorAll('.fb2-title');
if (titles.length > 0) {
var arr2 = [];
for (var j = 0; j < titles.length; j++) {
arr2.push({ label: titles[j].textContent.trim(), href: titles[j].getAttribute('data-chapter') || j.toString() });
}
return JSON.stringify(arr2);
}
return '[]';
} catch(e) {
return '[]';
}
})()
");
System.Diagnostics.Debug.WriteLine($"[Reader] Chapters raw: {result}");
if (string.IsNullOrEmpty(result) || result == "null") return;
// EvaluateJavaScriptAsync может вернуть экранированную строку
result = UnescapeJsResult(result);
var chapters = JArray.Parse(result);
_chapterData.Clear();
var chapterLabels = new List<string>();
foreach (var ch in chapters)
{
var obj = ch as JObject;
if (obj != null)
{
_chapterData.Add(obj);
var label = obj["label"]?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(label))
{
chapterLabels.Add(label);
}
}
}
MainThread.BeginInvokeOnMainThread(() =>
{
_viewModel.Chapters = chapterLabels;
});
System.Diagnostics.Debug.WriteLine($"[Reader] Loaded {chapterLabels.Count} chapters");
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Reader] FetchChapters error: {ex.Message}");
}
}
// ========== СОХРАНЕНИЕ ПРОГРЕССА ==========
private async Task SaveCurrentProgress()
{
if (!_isBookLoaded) return;
try
{
var result = await EvalJsWithResultAsync("window.getProgress()");
System.Diagnostics.Debug.WriteLine($"[Reader] Progress raw: {result}");
if (string.IsNullOrEmpty(result) || result == "null" || result == "{}" || result == "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}");
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Reader] Save progress error: {ex.Message}");
}
}
// ========== ОБРАБОТКА СООБЩЕНИЙ ОТ JS ==========
private async void OnRawMessageReceived(object? sender, HybridWebViewRawMessageReceivedEventArgs e)
{
try
{
var message = e.Message;
if (string.IsNullOrEmpty(message)) return;
// ... (оставляем логику логирования и парсинга JSON) ...
var json = JObject.Parse(message);
var action = json["action"]?.ToString();
var data = json["data"] as JObject;
switch (action)
{
case "readerReady":
System.Diagnostics.Debug.WriteLine("[Reader] JS is ready! Loading book...");
// Вызываем загрузку книги ТОЛЬКО после того, как JS подтвердил готовность
_ = MainThread.InvokeOnMainThreadAsync(LoadBookIntoWebView);
break;
case "toggleMenu":
MainThread.BeginInvokeOnMainThread(() =>
{
_viewModel.ToggleMenuCommand.Execute(null);
});
break;
case "progressUpdate":
if (data != null)
{
var progress = data["progress"]?.Value<double>() ?? 0;
var cfi = data["cfi"]?.ToString();
var chapter = data["chapter"]?.ToString();
var currentPage = data["currentPage"]?.Value<int>() ?? 0;
var totalPages = data["totalPages"]?.Value<int>() ?? 0;
await _viewModel.SaveProgressAsync(progress, cfi, chapter, currentPage, totalPages);
}
break;
case "chaptersLoaded":
if (data != null)
{
var chapters = data["chapters"]?.ToObject<List<JObject>>() ?? new();
_chapterData.Clear();
_chapterData.AddRange(chapters);
MainThread.BeginInvokeOnMainThread(() =>
{
_viewModel.Chapters = chapters
.Select(c => c["label"]?.ToString() ?? "")
.Where(l => !string.IsNullOrWhiteSpace(l))
.ToList();
});
}
break;
case "bookReady":
MainThread.BeginInvokeOnMainThread(async () =>
{
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
});
break;
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Reader] RawMsg error: {ex.Message}");
}
}
// ========== ОБРАБОТКА ЗАПРОСОВ JS ОТ VIEWMODEL ==========
private async void OnJavaScriptRequested(string script)
{
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)
{
_viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[idx - 1]);
}
}
private void OnIncreaseFontSize(object? sender, EventArgs e)
{
var idx = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
if (idx < _viewModel.AvailableFontSizes.Count - 1)
{
_viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[idx + 1]);
}
}
private void OnFontFamilyChanged(object? sender, EventArgs e)
{
if (FontFamilyPicker.SelectedItem is string family)
{
_viewModel.ChangeFontFamilyCommand.Execute(family);
}
}
private void OnChapterSelected(object? sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is string chapterLabel)
{
var chapterObj = _chapterData.FirstOrDefault(c => c["label"]?.ToString() == chapterLabel);
var href = chapterObj?["href"]?.ToString() ?? chapterLabel;
_viewModel.GoToChapterCommand.Execute(href);
}
}
private void OnMenuPanelTapped(object? sender, TappedEventArgs e)
{
// Предотвращаем всплытие тапа на оверлей
}
private async void OnBackToLibrary(object? sender, EventArgs e)
{
await SaveCurrentProgress();
await Shell.Current.GoToAsync("..");
}
// ========== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ==========
/// <summary>
/// Выполняет JavaScript без ожидания результата
/// </summary>
private async Task EvalJsAsync(string script)
{
try
{
await MainThread.InvokeOnMainThreadAsync(async () =>
{
try
{
await ReaderWebView.EvaluateJavaScriptAsync(script);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Reader] JS eval error: {ex.Message}");
}
});
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Reader] JS dispatch error: {ex.Message}");
}
}
/// <summary>
/// Выполняет JavaScript и возвращает результат
/// </summary>
private async Task<string?> EvalJsWithResultAsync(string script)
{
string? result = null;
try
{
await MainThread.InvokeOnMainThreadAsync(async () =>
{
try
{
result = await ReaderWebView.EvaluateJavaScriptAsync(script);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Reader] JS result error: {ex.Message}");
}
});
}
catch (Exception ex)
{
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("\\", "\\\\")
.Replace("'", "\\'")
.Replace("\"", "\\\"")
.Replace("\n", "\\n")
.Replace("\r", "\\r")
.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
.Replace("\\\"", "\"")
.Replace("\\\\", "\\")
.Replace("\\/", "/")
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.Replace("\\t", "\t");
return result;
}
}

View File

@@ -0,0 +1,120 @@
<?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}" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -0,0 +1,44 @@
using BookReader.ViewModels;
namespace BookReader.Views;
public partial class SettingsPage : ContentPage
{
private readonly SettingsViewModel _viewModel;
public SettingsPage(SettingsViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
BindingContext = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
try
{
await _viewModel.LoadSettingsCommand.ExecuteAsync(null);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error loading settings: {ex.Message}");
}
}
protected override async void OnDisappearing()
{
base.OnDisappearing();
// Автосохранение при выходе со страницы настроек
try
{
await _viewModel.SaveSettingsCommand.ExecuteAsync(null);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error auto-saving settings: {ex.Message}");
}
}
}