Lettore EPUB nativo: ReaderPage (WebView+capitoli+progresso), segno locale in QSettings, sync POST /ajax/bookmark con fallback CSRF, pulsante Leggi nel dettaglio

This commit is contained in:
2026-08-12 14:21:45 +02:00
parent ae15c39421
commit 6f0ca6ed07
10 changed files with 489 additions and 13 deletions
+62
View File
@@ -8,6 +8,8 @@ Page {
property string coverSource: ""
property bool downloading: false
property string downloadError: ""
property bool readingNow: false
property string readingUrl: ""
SilicaFlickable {
anchors.fill: parent
@@ -29,6 +31,14 @@ Page {
visible: source.length > 0
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: "Leggi (EPUB)"
visible: page.hasEpubFormat()
enabled: !page.downloading
onClicked: startReading()
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
@@ -157,6 +167,50 @@ Page {
return out.trim()
}
function hasEpubFormat() {
var fmts = bookData.formats
if (!fmts)
return false
for (var i = 0; i < fmts.length; i++)
if (String(fmts[i].format).toLowerCase() === "epub")
return true
return false
}
function firstEpubFormat() {
var fmts = bookData.formats
if (!fmts)
return null
for (var i = 0; i < fmts.length; i++)
if (String(fmts[i].format).toLowerCase() === "epub")
return fmts[i]
return null
}
// estrae l'id numerico calibre da un url di download: /opds/download/<id>/<formato>/
function extractBookId(u) {
var parts = u.split("/")
for (var i = 0; i < parts.length - 1; i++)
if (parts[i] === "download" && parts[i + 1].length > 0
&& !isNaN(parseInt(parts[i + 1])))
return parseInt(parts[i + 1])
return 0
}
function startReading() {
var f = page.firstEpubFormat()
if (!f)
return
var id = page.extractBookId(f.url)
if (id <= 0)
return
page.readingNow = true
page.readingUrl = f.url
page.downloading = true
page.downloadError = ""
downloader.download(resolveUrl(f.url), appSettings.bookFilePath(id, "epub"))
}
function startDownload(format) {
if (page.downloading)
return
@@ -196,11 +250,19 @@ Page {
onFinished: {
page.downloading = false
if (ok) {
if (page.readingNow) {
page.readingNow = false
var id = page.extractBookId(page.readingUrl)
pageStack.push(Qt.resolvedUrl("ReaderPage.qml"),
{ epubPath: path, bookId: id, bookFormat: "epub" })
return
}
page.downloadError = ""
page.downloadNotice = "Salvato in " + path
hideNoticeTimer.restart()
} else {
page.downloadNotice = ""
page.readingNow = false
page.downloadError = "Errore: " + error
}
}
+41
View File
@@ -0,0 +1,41 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Dialog {
id: dialog
property var model: []
property int selectedIndex: 0
SilicaListView {
anchors.fill: parent
header: DialogHeader { title: "Indice" }
model: dialog.model
delegate: ListItem {
id: item
contentHeight: Theme.itemSizeMedium
Label {
anchors.left: parent.left
anchors.leftMargin: Theme.horizontalPageMargin
anchors.right: parent.right
anchors.rightMargin: Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
text: (modelData.index + 1) + ". " + modelData.title
color: item.highlighted ? Theme.highlightColor : Theme.primaryColor
truncationMode: TruncationMode.Elide
}
onClicked: {
dialog.selectedIndex = modelData.index
dialog.accept()
}
}
ViewPlaceholder {
enabled: dialog.model.length === 0
text: "Nessun capitolo"
}
}
}
+227
View File
@@ -0,0 +1,227 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
import Sailfish.WebView 1.0
import harbour.calibreweb 1.0
Page {
id: page
property string epubPath: ""
property int bookId: 0
property string bookFormat: "epub"
property int currentChapter: 0
property double chapterFraction: 0.0
property bool restoring: false
property int saveCounter: 0
property int syncCounter: 0
property string errorMessage: ""
allowedOrientations: Orientation.All
EpubBook {
id: book
}
SilicaFlickable {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: toolbar.top
contentHeight: parent.height
WebView {
id: web
anchors.fill: parent
active: true
onLoadedChanged: {
if (!loaded)
return
if (page.restoring)
page.restoring = false
else
restorePosition()
page.ready = true
}
}
}
BusyIndicator {
anchors.centerIn: parent
running: !page.ready
size: BusyIndicatorSize.Large
}
Label {
anchors.centerIn: parent
text: page.errorMessage
color: Theme.errorColor
visible: page.errorMessage.length > 0
}
// ---- barra di controllo ----
Rectangle {
id: toolbar
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Theme.itemSizeMedium
color: Theme.rgba(Theme.primaryColor, 0.08)
IconButton {
id: prevBtn
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
icon.source: "image://theme/icon-m-left"
enabled: page.currentChapter > 0
onClicked: gotoChapter(page.currentChapter - 1)
}
Label {
id: chapterLabel
anchors.left: prevBtn.right
anchors.right: nextBtn.left
anchors.verticalCenter: parent.verticalCenter
text: (book.chapterCount > 0)
? (page.currentChapter + 1) + "/" + book.chapterCount
+ " · " + book.chapterTitle(page.currentChapter)
+ " · " + Math.round(page.overallProgress() * 100) + "%"
: "…"
truncationMode: TruncationMode.Elide
color: Theme.primaryColor
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Theme.fontSizeSmall
}
IconButton {
id: nextBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
icon.source: "image://theme/icon-m-right"
enabled: page.currentChapter < book.chapterCount - 1
onClicked: gotoChapter(page.currentChapter + 1)
}
ProgressBar {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 6
value: page.overallProgress()
minimumValue: 0
maximumValue: 1
}
}
PullDownMenu {
MenuItem {
text: "Indice capitoli"
onClicked: showChapterList()
}
MenuItem {
text: "Aggiorna posizione sul server"
onClicked: syncNow()
}
}
// ---- timer di tracciamento ----
Timer {
id: trackTimer
interval: 2000
running: page.ready
repeat: true
onTriggered: readScrollPosition()
}
function overallProgress() {
if (book.chapterCount <= 0)
return 0
return (page.currentChapter + page.chapterFraction) / book.chapterCount
}
function readScrollPosition() {
if (!web.loaded)
return
web.runJavaScript(
"window.scrollY / Math.max(1, document.documentElement.scrollHeight - window.innerHeight)",
function(f) {
var v = Math.max(0, Math.min(1, f))
if (Math.abs(v - page.chapterFraction) > 0.005) {
page.chapterFraction = v
maybeSave(true)
}
})
}
function maybeSave(force) {
if (bookId <= 0)
return
page.saveCounter++
// salva locale ogni ~10 s, sync server ogni ~30 s
if (force || page.saveCounter % 5 === 0) {
appSettings.setBookmarkKey(page.bookId, page.bookFormat,
book.makeBookmarkKey(page.currentChapter, page.chapterFraction))
}
if (force || page.syncCounter++ % 15 === 0)
syncNow()
}
function syncNow() {
if (bookId <= 0)
return
apiClient.postBookmark(page.bookId, page.bookFormat,
book.makeBookmarkKey(page.currentChapter, page.chapterFraction))
}
function gotoChapter(index) {
if (index < 0 || index >= book.chapterCount)
return
if (index !== page.currentChapter) {
maybeSave(true)
page.chapterFraction = 0
}
page.currentChapter = index
page.restoring = true
page.ready = false
web.url = "file://" + book.chapterFilePath(index)
}
function restorePosition() {
if (page.chapterFraction > 0.01) {
web.runJavaScript(
"window.scrollTo(0, " + page.chapterFraction
+ " * Math.max(1, document.documentElement.scrollHeight - window.innerHeight)); true",
function() { })
}
}
function showChapterList() {
var dialog = pageStack.push(Qt.resolvedUrl("ChapterListDialog.qml"),
{ model: book.chapterList() })
dialog.accepted.connect(function() {
gotoChapter(dialog.selectedIndex)
})
}
Component.onCompleted: {
page.ready = false
if (!book.open(page.epubPath)) {
page.errorMessage = "Impossibile aprire il libro EPUB"
page.ready = true
return
}
// ripristina il segno locale
var key = appSettings.bookmarkKey(page.bookId, page.bookFormat)
var ch = 0
var fr = 0.0
if (key.length > 0 && book.parseBookmarkKey(key, ch, fr)) {
page.currentChapter = Math.max(0, Math.min(ch, book.chapterCount - 1))
page.chapterFraction = fr
}
web.url = "file://" + book.chapterFilePath(page.currentChapter)
}
onStatusChanged: {
if (status === PageStatus.Inactive)
maybeSave(true)
}
}