Добавьте файлы проекта.
This commit is contained in:
3
BookReader.slnx
Normal file
3
BookReader.slnx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="BookReader/BookReader.csproj" />
|
||||||
|
</Solution>
|
||||||
13
BookReader/App.xaml
Normal file
13
BookReader/App.xaml
Normal 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
19
BookReader/App.xaml.cs
Normal 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
16
BookReader/AppShell.xaml
Normal 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>
|
||||||
15
BookReader/AppShell.xaml.cs
Normal file
15
BookReader/AppShell.xaml.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
57
BookReader/BookReader.csproj
Normal file
57
BookReader/BookReader.csproj
Normal 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>
|
||||||
16
BookReader/Converters/BoolToVisibilityConverter.cs
Normal file
16
BookReader/Converters/BoolToVisibilityConverter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
BookReader/Converters/ByteArrayToImageSourceConverter.cs
Normal file
20
BookReader/Converters/ByteArrayToImageSourceConverter.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
BookReader/Converters/ProgressToWidthConverter.cs
Normal file
23
BookReader/Converters/ProgressToWidthConverter.cs
Normal 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
51
BookReader/MauiProgram.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
BookReader/Models/AppSettings.cs
Normal file
22
BookReader/Models/AppSettings.cs
Normal 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
56
BookReader/Models/Book.cs
Normal 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
|
||||||
|
}
|
||||||
24
BookReader/Models/CalibreBook.cs
Normal file
24
BookReader/Models/CalibreBook.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
BookReader/Models/ReadingProgress.cs
Normal file
22
BookReader/Models/ReadingProgress.cs
Normal 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;
|
||||||
|
}
|
||||||
23
BookReader/Platforms/Android/AndroidManifest.xml
Normal file
23
BookReader/Platforms/Android/AndroidManifest.xml
Normal 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>
|
||||||
22
BookReader/Platforms/Android/MainActivity.cs
Normal file
22
BookReader/Platforms/Android/MainActivity.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
BookReader/Platforms/Android/MainApplication.cs
Normal file
15
BookReader/Platforms/Android/MainApplication.cs
Normal 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();
|
||||||
|
}
|
||||||
6
BookReader/Platforms/Android/Resources/values/colors.xml
Normal file
6
BookReader/Platforms/Android/Resources/values/colors.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
10
BookReader/Platforms/MacCatalyst/AppDelegate.cs
Normal file
10
BookReader/Platforms/MacCatalyst/AppDelegate.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using Foundation;
|
||||||
|
|
||||||
|
namespace BookReader
|
||||||
|
{
|
||||||
|
[Register("AppDelegate")]
|
||||||
|
public class AppDelegate : MauiUIApplicationDelegate
|
||||||
|
{
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
BookReader/Platforms/MacCatalyst/Entitlements.plist
Normal file
14
BookReader/Platforms/MacCatalyst/Entitlements.plist
Normal 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>
|
||||||
|
|
||||||
40
BookReader/Platforms/MacCatalyst/Info.plist
Normal file
40
BookReader/Platforms/MacCatalyst/Info.plist
Normal 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>
|
||||||
16
BookReader/Platforms/MacCatalyst/Program.cs
Normal file
16
BookReader/Platforms/MacCatalyst/Program.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
BookReader/Platforms/Windows/App.xaml
Normal file
8
BookReader/Platforms/Windows/App.xaml
Normal 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>
|
||||||
25
BookReader/Platforms/Windows/App.xaml.cs
Normal file
25
BookReader/Platforms/Windows/App.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
46
BookReader/Platforms/Windows/Package.appxmanifest
Normal file
46
BookReader/Platforms/Windows/Package.appxmanifest
Normal 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>
|
||||||
17
BookReader/Platforms/Windows/app.manifest
Normal file
17
BookReader/Platforms/Windows/app.manifest
Normal 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>
|
||||||
10
BookReader/Platforms/iOS/AppDelegate.cs
Normal file
10
BookReader/Platforms/iOS/AppDelegate.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using Foundation;
|
||||||
|
|
||||||
|
namespace BookReader
|
||||||
|
{
|
||||||
|
[Register("AppDelegate")]
|
||||||
|
public class AppDelegate : MauiUIApplicationDelegate
|
||||||
|
{
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
BookReader/Platforms/iOS/Info.plist
Normal file
32
BookReader/Platforms/iOS/Info.plist
Normal 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>
|
||||||
16
BookReader/Platforms/iOS/Program.cs
Normal file
16
BookReader/Platforms/iOS/Program.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
BookReader/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
Normal file
51
BookReader/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
Normal 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>
|
||||||
8
BookReader/Properties/launchSettings.json
Normal file
8
BookReader/Properties/launchSettings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"Windows Machine": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"nativeDebugging": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
BookReader/Resources/AppIcon/appicon.svg
Normal file
4
BookReader/Resources/AppIcon/appicon.svg
Normal 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 |
8
BookReader/Resources/AppIcon/appiconfg.svg
Normal file
8
BookReader/Resources/AppIcon/appiconfg.svg
Normal 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 |
BIN
BookReader/Resources/Fonts/OpenSans-Regular.ttf
Normal file
BIN
BookReader/Resources/Fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
BookReader/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
BIN
BookReader/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
Binary file not shown.
BIN
BookReader/Resources/Images/default_cover.png
Normal file
BIN
BookReader/Resources/Images/default_cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 693 KiB |
BIN
BookReader/Resources/Images/dotnet_bot.png
Normal file
BIN
BookReader/Resources/Images/dotnet_bot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
15
BookReader/Resources/Raw/AboutAssets.txt
Normal file
15
BookReader/Resources/Raw/AboutAssets.txt
Normal 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();
|
||||||
|
}
|
||||||
1
BookReader/Resources/Raw/wwwroot/css/styles.css
Normal file
1
BookReader/Resources/Raw/wwwroot/css/styles.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
715
BookReader/Resources/Raw/wwwroot/index.html
Normal file
715
BookReader/Resources/Raw/wwwroot/index.html
Normal 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>
|
||||||
1
BookReader/Resources/Raw/wwwroot/js/epub.min.js
vendored
Normal file
1
BookReader/Resources/Raw/wwwroot/js/epub.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
289
BookReader/Resources/Raw/wwwroot/js/fb2reader.js
Normal file
289
BookReader/Resources/Raw/wwwroot/js/fb2reader.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
BookReader/Resources/Raw/wwwroot/js/jszip.min.js
vendored
Normal file
13
BookReader/Resources/Raw/wwwroot/js/jszip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
BookReader/Resources/Splash/splash.svg
Normal file
8
BookReader/Resources/Splash/splash.svg
Normal 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 |
37
BookReader/Resources/Styles/AppStyles.xaml
Normal file
37
BookReader/Resources/Styles/AppStyles.xaml
Normal 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>
|
||||||
174
BookReader/Services/BookParserService.cs
Normal file
174
BookReader/Services/BookParserService.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
BookReader/Services/CalibreWebService.cs
Normal file
142
BookReader/Services/CalibreWebService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
BookReader/Services/DatabaseService.cs
Normal file
122
BookReader/Services/DatabaseService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
BookReader/Services/IBookParserService.cs
Normal file
10
BookReader/Services/IBookParserService.cs
Normal 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();
|
||||||
|
}
|
||||||
11
BookReader/Services/ICalibreWebService.cs
Normal file
11
BookReader/Services/ICalibreWebService.cs
Normal 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);
|
||||||
|
}
|
||||||
24
BookReader/Services/IDatabaseService.cs
Normal file
24
BookReader/Services/IDatabaseService.cs
Normal 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);
|
||||||
|
}
|
||||||
10
BookReader/Services/ISettingsService.cs
Normal file
10
BookReader/Services/ISettingsService.cs
Normal 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();
|
||||||
|
}
|
||||||
40
BookReader/Services/SettingsService.cs
Normal file
40
BookReader/Services/SettingsService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
BookReader/ViewModels/BaseViewModel.cs
Normal file
15
BookReader/ViewModels/BaseViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
159
BookReader/ViewModels/BookshelfViewModel.cs
Normal file
159
BookReader/ViewModels/BookshelfViewModel.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
157
BookReader/ViewModels/CalibreLibraryViewModel.cs
Normal file
157
BookReader/ViewModels/CalibreLibraryViewModel.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
162
BookReader/ViewModels/ReaderViewModel.cs
Normal file
162
BookReader/ViewModels/ReaderViewModel.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
105
BookReader/ViewModels/SettingsViewModel.cs
Normal file
105
BookReader/ViewModels/SettingsViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
BookReader/Views/BookshelfPage.xaml
Normal file
198
BookReader/Views/BookshelfPage.xaml
Normal 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>
|
||||||
40
BookReader/Views/BookshelfPage.xaml.cs
Normal file
40
BookReader/Views/BookshelfPage.xaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
BookReader/Views/CalibreLibraryPage.xaml
Normal file
134
BookReader/Views/CalibreLibraryPage.xaml
Normal 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>
|
||||||
21
BookReader/Views/CalibreLibraryPage.xaml.cs
Normal file
21
BookReader/Views/CalibreLibraryPage.xaml.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
BookReader/Views/ReaderPage.xaml
Normal file
138
BookReader/Views/ReaderPage.xaml
Normal 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>
|
||||||
494
BookReader/Views/ReaderPage.xaml.cs
Normal file
494
BookReader/Views/ReaderPage.xaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
BookReader/Views/SettingsPage.xaml
Normal file
120
BookReader/Views/SettingsPage.xaml
Normal 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>
|
||||||
44
BookReader/Views/SettingsPage.xaml.cs
Normal file
44
BookReader/Views/SettingsPage.xaml.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user