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