1/* 2 * Copyright (C) 2015 Dan Leinir Turthra Jensen <admin@leinir.dk> 3 * 4 * This library is free software; you can redistribute it and/or 5 * modify it under the terms of the GNU Lesser General Public 6 * License as published by the Free Software Foundation; either 7 * version 2.1 of the License, or (at your option) version 3, or any 8 * later version accepted by the membership of KDE e.V. (or its 9 * successor approved by the membership of KDE e.V.), which shall 10 * act as a proxy defined in Section 6 of version 3 of the license. 11 * 12 * This library is distributed in the hope that it will be useful, 13 * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 15 * Lesser General Public License for more details. 16 * 17 * You should have received a copy of the GNU Lesser General Public 18 * License along with this library. If not, see <http://www.gnu.org/licenses/>. 19 * 20 */ 21 22import QtQuick 2.12 23import QtQuick.Controls 2.12 as QtControls 24import QtQuick.Window 2.12 25 26import org.kde.kirigami 2.12 as Kirigami 27 28import org.kde.peruse 0.1 as Peruse 29import "listcomponents" as ListComponents 30/** 31 * @brief Page that handles reading the book. 32 * 33 * 34 */ 35Kirigami.Page { 36 id: root; 37 objectName: "bookViewer"; 38 clip: true; 39 property bool isCurrentContext: isCurrentPage && applicationWindow().bookOpen 40 41 // Remove all the padding when we've hidden controls. Content is king! 42 topPadding: applicationWindow().controlsVisible ? (applicationWindow() && applicationWindow().header ? applicationWindow().header.height : 0) : 0; 43 leftPadding: applicationWindow().controlsVisible ? Kirigami.Units.gridUnit : 0; 44 rightPadding: applicationWindow().controlsVisible ? Kirigami.Units.gridUnit : 0; 45 bottomPadding: applicationWindow().controlsVisible ? Kirigami.Units.gridUnit * 2 : 0; 46 47 background: Rectangle { 48 anchors.fill: parent; 49 opacity: applicationWindow().controlsVisible ? 0 : 1; 50 Behavior on opacity { NumberAnimation { duration: applicationWindow().animationDuration; } } 51 color: "black"; 52 } 53 54 // Perhaps we should store and restore this? 55 property bool showControls: true; 56 property Item pageStackItem: applicationWindow().pageStack.layers.currentItem; 57 onPageStackItemChanged: { 58 if(root.isCurrentContext) { 59 applicationWindow().controlsVisible = root.showControls; 60 } 61 else { 62 root.showControls = applicationWindow().controlsVisible; 63 applicationWindow().controlsVisible = true; 64 } 65 } 66 67 property bool rtlMode: false; 68 /** 69 * zoomMode: Peruse.Config.ZoomMode 70 */ 71 property int zoomMode: Peruse.Config.ZoomFull; 72 73 property string file; 74 property int currentPage; 75 property int totalPages; 76 onCurrentPageChanged: { 77 // set off a timer to slightly postpone saving the current page, so it doesn't happen during animations etc 78 updateCurrent.start(); 79 } 80 81 function nextFrame() { 82 // If there is a next frame to go to, or whether it is supported at all 83 if(viewLoader.item.hasFrames === true) { 84 viewLoader.item.nextFrame(); 85 } 86 else { 87 nextPage(); 88 } 89 } 90 function previousFrame() { 91 // If there is a next frame to go to, or whether it is supported at all 92 if(viewLoader.item.hasFrames === true) { 93 viewLoader.item.previousFrame(); 94 } 95 else { 96 previousPage(); 97 } 98 } 99 function nextPage() { 100 if(viewLoader.item.currentPage < viewLoader.item.pageCount - 1) { 101 viewLoader.item.currentPage++; 102 } else { 103 bookInfo.showBookInfo(file); 104 } 105 } 106 function previousPage() { 107 if(viewLoader.item.currentPage > 0) { 108 viewLoader.item.currentPage--; 109 } else { 110 bookInfo.showBookInfo(file); 111 } 112 } 113 function closeBook() { 114 applicationWindow().contextDrawer.close(); 115 // also for storing current page (otherwise postponed a bit after page change, done here as well to ensure it really happens) 116 applicationWindow().controlsVisible = true; 117 applicationWindow().pageStack.layers.pop(); 118 applicationWindow().globalDrawer.open(); 119 } 120 121 property Item contextualTopItems: ListView { 122 id: thumbnailNavigator; 123 anchors.fill: parent; 124 clip: true; 125 delegate: thumbnailComponent; 126 } 127 Component { 128 id: thumbnailComponent; 129 Item { 130 width: parent !== null ? parent.width : height; 131 height: Kirigami.Units.gridUnit * 6; 132 MouseArea { 133 anchors.fill: parent; 134 onClicked: viewLoader.item.currentPage = model.index; 135 } 136 Rectangle { 137 anchors.fill: parent; 138 color: Kirigami.Theme.highlightColor; 139 opacity: root.currentPage === model.index ? 1 : 0; 140 Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration; } } 141 } 142 Image { 143 anchors { 144 top: parent.top; 145 left: parent.left; 146 right: parent.right; 147 margins: Kirigami.Units.smallSpacing; 148 } 149 height: parent.height - pageTitle.height - Kirigami.Units.smallSpacing * 2; 150 asynchronous: true; 151 fillMode: Image.PreserveAspectFit; 152 source: model.url; 153 } 154 QtControls.Label { 155 id: pageTitle; 156 anchors { 157 left: parent.left; 158 right: parent.right; 159 bottom: parent.bottom; 160 } 161 height: paintedHeight; 162 text: model.title; 163 elide: Text.ElideMiddle; 164 horizontalAlignment: Text.AlignHCenter; 165 } 166 } 167 } 168 169 function toggleFullscreen() { 170 applicationWindow().contextDrawer.close(); 171 if(applicationWindow().visibility !== Window.FullScreen) { 172 applicationWindow().visibility = Window.FullScreen; 173 applicationWindow().controlsVisible = false; 174 } 175 else { 176 applicationWindow().visibility = Window.AutomaticVisibility; 177 applicationWindow().controlsVisible = true; 178 } 179 } 180 181 property list<QtObject> mobileActions: [ 182 Kirigami.Action { 183 text: applicationWindow().visibility !== Window.FullScreen ? i18nc("Enter full screen mode on a touch-based device", "Go Full Screen") : i18nc("Exit full sceen mode on a touch based device", "Exit Full Screen"); 184 iconName: "view-fullscreen"; 185 onTriggered: toggleFullscreen(); 186 enabled: root.isCurrentContext && Kirigami.Settings.isMobile 187 }, 188 Kirigami.Action { 189 text: i18nc("Action used on touch devices to close the currently open book and return to whatever page was most recently shown", "Close Book"); 190 shortcut: bookInfo.sheetOpen ? "" : "Esc"; 191 iconName: "dialog-close"; 192 onTriggered: closeBook(); 193 enabled: root.isCurrentContext && Kirigami.Settings.isMobile 194 } 195 ] 196 property list<QtObject> desktopActions: [ 197 Kirigami.Action { 198 text: i18nc("Top level entry leading to a submenu with options for the book display", "View Options"); 199 iconName: "configure"; 200 Kirigami.Action { 201 text: i18nc("Header title for the section in which the direction the book will be navigated can be picked", "Reading Direction") 202 } 203 Kirigami.Action { 204 text: i18nc("Title for the option which will make the book navigate from left to right", "Left to Right") 205 iconName: "format-text-direction-ltr"; 206 shortcut: rtlMode ? "r" : ""; 207 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile && root.rtlMode; 208 onTriggered: { root.rtlMode = false; } 209 } 210 Kirigami.Action { 211 text: i18nc("Title for the option which will make the book navigate from right to left", "Right to Left") 212 iconName: "format-text-direction-rtl"; 213 shortcut: rtlMode ? "" : "r"; 214 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile && !root.rtlMode; 215 onTriggered: { root.rtlMode = true; } 216 } 217// QtObject { 218// property string text: "Zoom" 219// } 220// Kirigami.Action { 221// text: "Fit full page" 222// iconName: "zoom-fit-best"; 223// enabled: root.isCurrentContext && !Kirigami.Settings.isMobile && root.zoomMode !== Peruse.Config.ZoomFull; 224// onTriggered: { root.zoomMode = Peruse.Config.ZoomFull; } 225// } 226// Kirigami.Action { 227// text: "Fit width" 228// iconName: "zoom-fit-width"; 229// enabled: root.isCurrentContext && !Kirigami.Settings.isMobile && root.zoomMode !== Peruse.Config.ZoomFitWidth; 230// onTriggered: { root.zoomMode = Peruse.Config.ZoomFitWidth; } 231// } 232// Kirigami.Action { 233// text: "Fit height" 234// iconName: "zoom-fit-height"; 235// enabled: root.isCurrentContext && !Kirigami.Settings.isMobile && root.zoomMode !== Peruse.Config.ZoomFitHeight; 236// onTriggered: { root.zoomMode = Peruse.Config.ZoomFitHeight; } 237// } 238// QtObject {} 239 }, 240 Kirigami.Action { 241 text: i18nc("Go to the previous frame on the current page", "Previous Frame"); 242 shortcut: root.isCurrentContext && bookInfo.sheetOpen ? "" : StandardKey.MoveToPreviousChar; 243 iconName: "go-previous"; 244 onTriggered: previousFrame(); 245 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile 246 }, 247 Kirigami.Action { 248 text: i18nc("Go to the next frame on the current page", "Next Frame"); 249 shortcut: root.isCurrentContext && bookInfo.sheetOpen ? "" : StandardKey.MoveToNextChar; 250 iconName: "go-next"; 251 onTriggered: nextFrame(); 252 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile 253 }, 254 Kirigami.Action { 255 text: i18nc("Go to the previous page in the book", "Previous Page"); 256 shortcut: root.isCurrentContext && bookInfo.sheetOpen ? "" : StandardKey.MoveToNextPage; 257 iconName: "go-previous"; 258 onTriggered: previousPage(); 259 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile; 260 }, 261 Kirigami.Action { 262 text: i18nc("Go to the next page in the book", "Next Page"); 263 shortcut: bookInfo.sheetOpen ? "" : StandardKey.MoveToNextPage; 264 iconName: "go-next"; 265 onTriggered: nextPage(); 266 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile; 267 }, 268 Kirigami.Action { 269 text: applicationWindow().visibility !== Window.FullScreen ? i18nc("Enter full screen mode on a non-touch-based device", "Go Full Screen") : i18nc("Exit full sceen mode on a non-touch based device", "Exit Full Screen"); 270 shortcut: (applicationWindow().visibility === Window.FullScreen) ? (bookInfo.sheetOpen ? "" : "Esc") : "f"; 271 iconName: "view-fullscreen"; 272 onTriggered: toggleFullscreen(); 273 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile; 274 }, 275 Kirigami.Action { 276 text: i18nc("Action used on non-touch devices to close the currently open book and return to whatever page was most recently shown", "Close Book"); 277 shortcut: (applicationWindow().visibility === Window.FullScreen) ? "" : (bookInfo.sheetOpen ? "" : "Esc"); 278 iconName: "dialog-close"; 279 onTriggered: closeBook(); 280 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile; 281 }, 282 283 // Invisible actions, for use in bookInfo 284 Kirigami.Action { 285 visible: false; 286 shortcut: bookInfo.sheetOpen ? StandardKey.MoveToPreviousChar : ""; 287 onTriggered: bookInfo.previousBook(); 288 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile; 289 }, 290 Kirigami.Action { 291 visible: false; 292 shortcut: bookInfo.sheetOpen ? StandardKey.MoveToNextChar : ""; 293 onTriggered: bookInfo.nextBook(); 294 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile; 295 }, 296 Kirigami.Action { 297 visible: false; 298 shortcut: bookInfo.sheetOpen ? "Return" : ""; 299 onTriggered: bookInfo.openSelected(); 300 enabled: root.isCurrentContext && !Kirigami.Settings.isMobile; 301 } 302 ] 303 actions { 304 contextualActions: Kirigami.Settings.isMobile ? mobileActions : desktopActions; 305 main: bookInfo.sheetOpen ? bookInfoAction : mainBookAction; 306 } 307 Kirigami.Action { 308 id: mainBookAction; 309 text: applicationWindow().visibility !== Window.FullScreen ? i18nc("Enter full screen mode on any device type", "Go Full Screen") : i18nc("Exit full screen mode on any device type", "Exit Full Screen"); 310 iconName: "view-fullscreen"; 311 onTriggered: toggleFullscreen(); 312 enabled: root.isCurrentContext; 313 } 314 Kirigami.Action { 315 id: bookInfoAction; 316 text: i18nc("Closes the book information drawer", "Close"); 317 shortcut: bookInfo.sheetOpen ? "Esc" : ""; 318 iconName: "dialog-cancel"; 319 onTriggered: bookInfo.close(); 320 enabled: root.isCurrentContext; 321 } 322 323 /** 324 * This holds an instance of ViewerBase, which can either be the 325 * Okular viewer(the fallback one), or one of the type specific 326 * ones(ImageBrowser based). 327 */ 328 Item { 329 width: root.width - (root.leftPadding + root.rightPadding); 330 height: root.height - (root.topPadding + root.bottomPadding); 331 Timer { 332 id: updateCurrent; 333 interval: applicationWindow().animationDuration; 334 running: false; 335 repeat: false; 336 onTriggered: { 337 if(viewLoader.item && viewLoader.item.pagesModel && viewLoader.item.pagesModel.currentPage !== undefined) { 338 viewLoader.item.pagesModel.currentPage = root.currentPage; 339 } 340 } 341 } 342 NumberAnimation { id: thumbnailMovementAnimation; target: thumbnailNavigator; property: "contentY"; duration: applicationWindow().animationDuration; easing.type: Easing.InOutQuad; } 343 Loader { 344 id: viewLoader; 345 anchors.fill: parent; 346 property bool loadingCompleted: false; 347 onStatusChanged: { 348 if (status === Loader.Error) { 349 console.debug("Error loading up the reader..."); 350 } 351 } 352 onLoaded: item.file = root.file; 353 Binding { 354 target: viewLoader.item; 355 property: "rtlMode"; 356 value: root.rtlMode; 357 } 358 Binding { 359 target: viewLoader.item; 360 property: "zoomMode"; 361 value: root.zoomMode; 362 } 363 Connections { 364 target: viewLoader.item; 365 onLoadingCompleted: { 366 if(success) { 367 thumbnailNavigator.model = viewLoader.item.pagesModel; 368 if(viewLoader.item.thumbnailComponent) { 369 thumbnailNavigator.delegate = viewLoader.item.thumbnailComponent; 370 } 371 else { 372 thumbnailNavigator.delegate = thumbnailComponent; 373 } 374 peruseConfig.setFilesystemProperty(root.file, "totalPages", viewLoader.item.pageCount); 375 if(root.totalPages !== viewLoader.item.pageCount) { 376 root.totalPages = viewLoader.item.pageCount; 377 } 378 viewLoader.item.currentPage = root.currentPage; 379 viewLoader.loadingCompleted = true; 380 applicationWindow().globalDrawer.close(); 381 } 382 } 383 onTitleChanged: root.title = viewLoader.item.title; 384 onCurrentPageChanged: { 385 if(root.currentPage !== viewLoader.item.currentPage && viewLoader.loadingCompleted) { 386 root.currentPage = viewLoader.item.currentPage; 387 } 388 thumbnailMovementAnimation.running = false; 389 var currentPos = thumbnailNavigator.contentY; 390 var newPos; 391 thumbnailNavigator.positionViewAtIndex(viewLoader.item.currentPage, ListView.Center); 392 newPos = thumbnailNavigator.contentY; 393 thumbnailMovementAnimation.from = currentPos; 394 thumbnailMovementAnimation.to = newPos; 395 thumbnailMovementAnimation.running = true; 396 } 397 onGoNextPage: root.nextPage(); 398 onGoPreviousPage: root.previousPage(); 399 } 400 } 401 Kirigami.PlaceholderMessage { 402 anchors.centerIn: parent 403 width: parent.width - (Kirigami.Units.largeSpacing * 4) 404 405 visible: viewLoader.status === Loader.Error; 406 text: i18nc("Message shown on the book reader view when there is an issue loading any reader at all (usually when Okular's qml components are not installed for some reason)", "Failed to load the reader component. This is generally caused by broken packaging. Contact whoever you got this package from and inform them of this error."); 407 } 408 } 409 /** 410 * Overlay with book information and a series selection. 411 */ 412 Kirigami.OverlaySheet { 413 id: bookInfo; 414 function setNewCurrentIndex(newIndex) { 415 seriesListAnimation.running = false; 416 var currentPos = seriesListView.contentX; 417 var newPos; 418 seriesListView.positionViewAtIndex(newIndex, ListView.Center); 419 newPos = seriesListView.contentX; 420 seriesListAnimation.from = currentPos; 421 seriesListAnimation.to = newPos; 422 seriesListAnimation.running = true; 423 seriesListView.currentIndex = newIndex; 424 } 425 function nextBook() { 426 if(seriesListView.currentIndex < seriesListView.model.rowCount() - 1) { 427 setNewCurrentIndex(seriesListView.currentIndex + 1); 428 } 429 } 430 function previousBook() { 431 if(seriesListView.currentIndex > 0) { 432 setNewCurrentIndex(seriesListView.currentIndex - 1); 433 } 434 } 435 function openSelected() { 436 if (detailsTile.filename!==root.file) { 437 closeBook(); 438 applicationWindow().showBook(detailsTile.filename, detailsTile.currentPage); 439 } 440 } 441 function showBookInfo(filename) { 442 if(sheetOpen) { 443 return; 444 } 445 seriesListView.model = contentList.seriesModelForEntry(filename); 446 if (seriesListView.model) { 447 setNewCurrentIndex(seriesListView.model.indexOfFile(filename)); 448 } 449 open(); 450 } 451 onSheetOpenChanged: { 452 if(sheetOpen === false) { 453 applicationWindow().controlsVisible = controlsShown; 454 } 455 else { 456 controlsShown = applicationWindow().controlsVisible; 457 applicationWindow().controlsVisible = true; 458 } 459 } 460 property bool controlsShown; 461 property QtObject currentBook: fakeBook; 462 property QtObject fakeBook: Peruse.PropertyContainer { 463 property var author: [""]; 464 property string title: ""; 465 property string filename: ""; 466 property string publisher: ""; 467 property string thumbnail: ""; 468 property string currentPage: "0"; 469 property string totalPages: "0"; 470 property string comment: ""; 471 property var tags: [""]; 472 property var description: [""]; 473 property string rating: "0"; 474 } 475 Column { 476 clip: true; 477 width: root.width - Kirigami.Units.largeSpacing * 2; 478 height: childrenRect.height + Kirigami.Units.largeSpacing * 2; 479 spacing: Kirigami.Units.largeSpacing; 480 ListComponents.BookTile { 481 id: detailsTile; 482 height: neededHeight; 483 width: parent.width; 484 author: bookInfo.currentBook.readProperty("author"); 485 publisher: bookInfo.currentBook.readProperty("publisher"); 486 title: bookInfo.currentBook.readProperty("title"); 487 filename: bookInfo.currentBook.readProperty("filename"); 488 thumbnail: bookInfo.currentBook.readProperty("thumbnail"); 489 categoryEntriesCount: 0; 490 currentPage: bookInfo.currentBook.readProperty("currentPage"); 491 totalPages: bookInfo.currentBook.readProperty("totalPages"); 492 description: bookInfo.currentBook.readProperty("description"); 493 onBookSelected: { 494 if(root.file !== fileSelected) { 495 openSelected(); 496 } 497 } 498 onBookDeleteRequested: { 499 // Not strictly needed for the listview itself, but it's kind of 500 // nice for making sure the details tile is right 501 var oldIndex = seriesListView.currentIndex; 502 seriesListView.currentIndex = -1; 503 contentList.removeBook(fileSelected, true); 504 seriesListView.currentIndex = oldIndex; 505 } 506 } 507 // tags and ratings, comment by self 508 // store hook for known series with more content 509 ListView { 510 id: seriesListView; 511 width: parent.width; 512 height: Kirigami.Units.gridUnit * 12; 513 orientation: ListView.Horizontal; 514 NumberAnimation { id: seriesListAnimation; target: seriesListView; property: "contentX"; duration: applicationWindow().animationDuration; easing.type: Easing.InOutQuad; } 515 delegate: ListComponents.BookTileTall { 516 height: model.filename !== "" ? neededHeight : 1; 517 width: seriesListView.width / 3; 518 author: model.author; 519 title: model.title; 520 filename: model.filename; 521 thumbnail: model.thumbnail; 522 categoryEntriesCount: 0; 523 currentPage: model.currentPage; 524 totalPages: model.totalPages; 525 onBookSelected:{ 526 if (seriesListView.currentIndex !== model.index) { 527 bookInfo.setNewCurrentIndex(model.index); 528 } else { 529 bookInfo.openSelected(); 530 } 531 } 532 selected: seriesListView.currentIndex === model.index; 533 } 534 onCurrentIndexChanged: { 535 bookInfo.currentBook = model.get(currentIndex); 536 } 537 } 538 } 539 } 540 541 onFileChanged: { 542 // Let's set the page title to something useful 543 var book = contentList.bookFromFile(file); 544 root.title = book.readProperty("title"); 545 546 // The idea is to have a number of specialised options as relevant to various 547 // types of comic books, and then finally fall back to Okular as a catch-all 548 // but generic viewer component. 549 var attemptFallback = true; 550 551 var mimetype = contentList.contentModel.getMimetype(file); 552 console.debug("Mimetype is " + mimetype); 553 if(mimetype == "application/x-cbz" || mimetype == "application/x-cbr" || mimetype == "application/vnd.comicbook+zip" || mimetype == "application/vnd.comicbook+rar") { 554 viewLoader.source = "viewers/cbr.qml"; 555 attemptFallback = false; 556 } 557 if(mimetype == "inode/directory" || mimetype == "image/jpeg" || mimetype == "image/png") { 558 viewLoader.source = "viewers/folderofimages.qml"; 559 attemptFallback = false; 560 } 561 562 if(attemptFallback) { 563 viewLoader.source = "viewers/okular.qml"; 564 } 565 } 566} 567