local Blitbuffer = require("ffi/blitbuffer") local BookStatusWidget = require("ui/widget/bookstatuswidget") local BottomContainer = require("ui/widget/container/bottomcontainer") local Device = require("device") local DocSettings = require("docsettings") local DocumentRegistry = require("document/documentregistry") local FileManagerBookInfo = require("apps/filemanager/filemanagerbookinfo") local Font = require("ui/font") local Geom = require("ui/geometry") local InfoMessage = require("ui/widget/infomessage") local ImageWidget = require("ui/widget/imagewidget") local Math = require("optmath") local OverlapGroup = require("ui/widget/overlapgroup") local ScreenSaverWidget = require("ui/widget/screensaverwidget") local ScreenSaverLockWidget = require("ui/widget/screensaverlockwidget") local SpinWidget = require("ui/widget/spinwidget") local TextBoxWidget = require("ui/widget/textboxwidget") local TopContainer = require("ui/widget/container/topcontainer") local UIManager = require("ui/uimanager") local VerticalGroup = require("ui/widget/verticalgroup") local VerticalSpan = require("ui/widget/verticalspan") local datetime = require("datetime") local ffiUtil = require("ffi/util") local filemanagerutil = require("apps/filemanager/filemanagerutil") local lfs = require("libs/libkoreader-lfs") local logger = require("logger") local util = require("util") local _ = require("gettext") local Screen = Device.screen -- Default settings if G_reader_settings:hasNot("screensaver_show_message") then G_reader_settings:makeFalse("screensaver_show_message") end if G_reader_settings:hasNot("screensaver_type") then G_reader_settings:saveSetting("screensaver_type", "disable") G_reader_settings:makeTrue("screensaver_show_message") end if G_reader_settings:hasNot("screensaver_img_background") then G_reader_settings:saveSetting("screensaver_img_background", "black") end if G_reader_settings:hasNot("screensaver_msg_background") then G_reader_settings:saveSetting("screensaver_msg_background", "none") end if G_reader_settings:hasNot("screensaver_message_position") then G_reader_settings:saveSetting("screensaver_message_position", "middle") end if G_reader_settings:hasNot("screensaver_stretch_images") then G_reader_settings:makeFalse("screensaver_stretch_images") end if G_reader_settings:hasNot("screensaver_delay") then G_reader_settings:saveSetting("screensaver_delay", "disable") end if G_reader_settings:hasNot("screensaver_hide_fallback_msg") then G_reader_settings:makeFalse("screensaver_hide_fallback_msg") end local Screensaver = { default_screensaver_message = _("Sleeping"), -- State values show_message = nil, screensaver_type = nil, prefix = nil, event_message = nil, overlay_message = nil, screensaver_background = nil, image = nil, image_file = nil, delayed_close = nil, screensaver_widget = nil, } -- Remind emulator users that Power is bound to F2 if Device:isEmulator() then Screensaver.default_screensaver_message = Screensaver.default_screensaver_message .. "\n" .. _("(Press F2 to resume)") end local function _getRandomImage(dir) if not dir then return end local match_func = function(file) -- images, ignore macOS resource forks return not util.stringStartsWith(ffiUtil.basename(file), "._") and DocumentRegistry:isImageFile(file) end return filemanagerutil.getRandomFile(dir, match_func) end -- This is implemented by the Statistics plugin function Screensaver:getAvgTimePerPage() return end function Screensaver:_calcAverageTimeForPages(pages) local sec = _("N/A") local average_time_per_page = self:getAvgTimePerPage() -- Compare average_time_per_page against itself to make sure it's not nan if average_time_per_page and average_time_per_page == average_time_per_page and pages then local user_duration_format = G_reader_settings:readSetting("duration_format", "classic") sec = datetime.secondsToClockDuration(user_duration_format, pages * average_time_per_page, true) end return sec end function Screensaver:expandSpecial(message, fallback) -- Expand special character sequences in given message. -- %T document title -- %A document authors -- %S document series -- %c current page (if there are hidden flows, current page in current flow) -- %t total pages (if there are hidden flows, total pages in current flow) -- %p percentage read (if there are hidden flows, percentage read of current flow) -- %h time left in chapter -- %H time left in document (if there are hidden flows, time left in current flow) -- %b battery level if G_reader_settings:hasNot("lastfile") then return fallback end local ret = message local lastfile = G_reader_settings:readSetting("lastfile") local totalpages = 0 local percent = 0 local currentpage = 0 local title = _("N/A") local authors = _("N/A") local series = _("N/A") local time_left_chapter = _("N/A") local time_left_document = _("N/A") local batt_lvl = _("N/A") local props local ReaderUI = require("apps/reader/readerui") local ui = ReaderUI.instance if ui and ui.document then -- If we have a ReaderUI instance, use it. local doc = ui.document if doc:hasHiddenFlows() then local currentpageAll = ui.view.state.page or currentpage currentpage = doc:getPageNumberInFlow(ui.view.state.page or currentpageAll) totalpages = doc:getTotalPagesInFlow(doc:getPageFlow(currentpageAll)) time_left_chapter = self:_calcAverageTimeForPages(ui.toc:getChapterPagesLeft(currentpageAll) or (totalpages - currentpage)) time_left_document = self:_calcAverageTimeForPages(totalpages - currentpage) else currentpage = ui.view.state.page or currentpage totalpages = doc:getPageCount() or totalpages time_left_chapter = self:_calcAverageTimeForPages(ui.toc:getChapterPagesLeft(currentpage) or doc:getTotalPagesLeft(currentpage)) time_left_document = self:_calcAverageTimeForPages(doc:getTotalPagesLeft(currentpage)) end percent = Math.round((currentpage * 100) / totalpages) props = ui.doc_props elseif DocSettings:hasSidecarFile(lastfile) then -- If there's no ReaderUI instance, but the file has sidecar data, use that local doc_settings = DocSettings:open(lastfile) totalpages = doc_settings:readSetting("doc_pages") or totalpages percent = doc_settings:readSetting("percent_finished") or percent currentpage = Math.round(percent * totalpages) percent = Math.round(percent * 100) props = FileManagerBookInfo.extendProps(doc_settings:readSetting("doc_props"), lastfile) -- Unable to set time_left_chapter and time_left_document without ReaderUI, so leave N/A end if props then title = props.display_title if props.authors then authors = props.authors end if props.series then series = props.series if props.series_index then series = series .. " #" .. props.series_index end end end if Device:hasBattery() then local powerd = Device:getPowerDevice() if Device:hasAuxBattery() and powerd:isAuxBatteryConnected() then batt_lvl = powerd:getCapacity() + powerd:getAuxCapacity() else batt_lvl = powerd:getCapacity() end end local replace = { ["%T"] = title, ["%A"] = authors, ["%S"] = series, ["%c"] = currentpage, ["%t"] = totalpages, ["%p"] = percent, ["%h"] = time_left_chapter, ["%H"] = time_left_document, ["%b"] = batt_lvl, } ret = ret:gsub("(%%%a)", replace) return ret end local function addOverlayMessage(widget, widget_height, text) local FrameContainer = require("ui/widget/container/framecontainer") local RightContainer = require("ui/widget/container/rightcontainer") local Size = require("ui/size") local TextWidget = require("ui/widget/textwidget") local face = Font:getFace("infofont") local screen_w, screen_h = Screen:getWidth(), Screen:getHeight() local textw = TextWidget:new{ text = text, face = face, } -- Don't make our message reach full screen width if textw:getWidth() > screen_w * 0.9 then -- Text too wide: use TextBoxWidget for multi lines display textw = TextBoxWidget:new{ text = text, face = face, width = math.floor(screen_w * 0.9) } end textw = FrameContainer:new{ background = Blitbuffer.COLOR_WHITE, bordersize = Size.border.default, margin = 0, textw, } -- If our host widget is already at the top, we'll position ourselves below it. if widget_height then textw = VerticalGroup:new{ VerticalSpan:new{ width = widget_height, }, textw, } end textw = RightContainer:new{ dimen = { w = screen_w, h = textw:getSize().h, }, textw, } widget = OverlapGroup:new{ dimen = { h = screen_w, w = screen_h, }, widget, textw, } return widget end function Screensaver:chooseFolder() local title_header = _("Current screensaver folder:") local current_path = G_reader_settings:readSetting("screensaver_dir") local caller_callback = function(path) G_reader_settings:saveSetting("screensaver_dir", path) end filemanagerutil.showChooseDialog(title_header, caller_callback, current_path) end function Screensaver:chooseFile(document_cover) local title_header, current_path, file_filter, caller_callback if document_cover then title_header = _("Current screensaver document cover:") current_path = G_reader_settings:readSetting("screensaver_document_cover") file_filter = function(filename) return DocumentRegistry:hasProvider(filename) end caller_callback = function(path) G_reader_settings:saveSetting("screensaver_document_cover", path) end else title_header = _("Current screensaver image:") current_path = G_reader_settings:readSetting("screensaver_image") file_filter = function(filename) return DocumentRegistry:isImageFile(filename) end caller_callback = function(path) G_reader_settings:saveSetting("screensaver_image", path) end end filemanagerutil.showChooseDialog(title_header, caller_callback, current_path, nil, file_filter) end function Screensaver:isExcluded() local ReaderUI = require("apps/reader/readerui") local ui = ReaderUI.instance if ui and ui.doc_settings then local doc_settings = ui.doc_settings return doc_settings:isTrue("exclude_screensaver") else if G_reader_settings:hasNot("lastfile") then return false end local lastfile = G_reader_settings:readSetting("lastfile") if DocSettings:hasSidecarFile(lastfile) then local doc_settings = DocSettings:open(lastfile) return doc_settings:isTrue("exclude_screensaver") else -- No DocSetting, not excluded return false end end end function Screensaver:setMessage() local InputDialog = require("ui/widget/inputdialog") local screensaver_message = G_reader_settings:readSetting("screensaver_message") or self.default_screensaver_message local input_dialog input_dialog = InputDialog:new{ title = _("Screensaver message"), description = _([[ Enter the message to be displayed by the screensaver. The following escape sequences can be used: %T title %A author(s) %S series %c current page number %t total page number %p percentage read %h time left in chapter %H time left in document %b battery level]]), input = screensaver_message, buttons = { { { text = _("Cancel"), id = "close", callback = function() UIManager:close(input_dialog) end, }, { text = _("Set message"), is_enter_default = true, callback = function() G_reader_settings:saveSetting("screensaver_message", input_dialog:getInputText()) UIManager:close(input_dialog) end, }, }, }, } UIManager:show(input_dialog) input_dialog:onShowKeyboard() end function Screensaver:setStretchLimit(touchmenu_instance) UIManager:show(SpinWidget:new{ value = G_reader_settings:readSetting("screensaver_stretch_limit_percentage", 8), value_min = 0, value_max = 25, default_value = 8, unit = "%", title_text = _("Set maximum stretch limit"), ok_text = _("Set"), ok_always_enabled = true, callback = function(spin) G_reader_settings:saveSetting("screensaver_stretch_limit_percentage", spin.value) G_reader_settings:makeTrue("screensaver_stretch_images") if touchmenu_instance then touchmenu_instance:updateItems() end end, extra_text = _("Disable stretch"), extra_callback = function() G_reader_settings:makeFalse("screensaver_stretch_images") if touchmenu_instance then touchmenu_instance:updateItems() end end, option_text = _("Full stretch"), option_callback = function() G_reader_settings:makeTrue("screensaver_stretch_images") G_reader_settings:delSetting("screensaver_stretch_limit_percentage") if touchmenu_instance then touchmenu_instance:updateItems() end end, }) end -- When called after setup(), may not match the saved settings, because it accounts for fallbacks that might have kicked in. function Screensaver:getMode() return self.screensaver_type end function Screensaver:modeExpectsPortrait() return self.screensaver_type ~= "message" and self.screensaver_type ~= "disable" and self.screensaver_type ~= "readingprogress" and self.screensaver_type ~= "bookstatus" end function Screensaver:modeIsImage() return self.screensaver_type == "cover" or self.screensaver_type == "random_image" or self.screensaver_type == "image_file" end function Screensaver:withBackground() return self.screensaver_background ~= "none" end function Screensaver:setup(event, event_message) self.show_message = G_reader_settings:isTrue("screensaver_show_message") self.screensaver_type = G_reader_settings:readSetting("screensaver_type") local screensaver_img_background = G_reader_settings:readSetting("screensaver_img_background") local screensaver_msg_background = G_reader_settings:readSetting("screensaver_msg_background") -- These 2 (optional) parameters are to support poweroff and reboot actions on Kobo (c.f., UIManager) self.prefix = event and event .. "_" or "" -- "", "poweroff_" or "reboot_" self.event_message = event_message if G_reader_settings:has(self.prefix .. "screensaver_type") then self.screensaver_type = G_reader_settings:readSetting(self.prefix .. "screensaver_type") else if event and G_reader_settings:isFalse("screensaver_hide_fallback_msg") then -- Display the provided event_message over the screensaver, -- so the user can distinguish between suspend (no overlay), -- and reboot/poweroff (overlaid message). self.overlay_message = self.event_message end end -- Check lastfile and setup the requested mode's resources, or a fallback mode if the required resources are unavailable. local ReaderUI = require("apps/reader/readerui") local ui = ReaderUI.instance local lastfile = G_reader_settings:readSetting("lastfile") if self.screensaver_type == "document_cover" then -- Set lastfile to the document of which we want to show the cover. lastfile = G_reader_settings:readSetting("screensaver_document_cover") self.screensaver_type = "cover" end if self.screensaver_type == "cover" then local excluded if lastfile and DocSettings:hasSidecarFile(lastfile) then local doc_settings if ui and ui.doc_settings then doc_settings = ui.doc_settings else doc_settings = DocSettings:open(lastfile) end excluded = doc_settings:isTrue("exclude_screensaver") else -- No DocSetting, not excluded excluded = false end if not excluded then if lastfile and lfs.attributes(lastfile, "mode") == "file" then self.image = FileManagerBookInfo:getCoverImage(ui and ui.document, lastfile) if self.image == nil then self.screensaver_type = "random_image" end else self.screensaver_type = "random_image" end else -- Fallback to random images if this book cover is excluded self.screensaver_type = "random_image" end end if self.screensaver_type == "bookstatus" then if not ui or not lastfile or lfs.attributes(lastfile, "mode") ~= "file" or (ui.doc_settings and ui.doc_settings:isTrue("exclude_screensaver")) then self.screensaver_type = "random_image" end end if self.screensaver_type == "image_file" then self.image_file = G_reader_settings:readSetting(self.prefix .. "screensaver_image") or G_reader_settings:readSetting("screensaver_image") if self.image_file == nil or lfs.attributes(self.image_file, "mode") ~= "file" then self.screensaver_type = "random_image" end end if self.screensaver_type == "readingprogress" then -- This is implemented by the Statistics plugin if Screensaver.getReaderProgress == nil then self.screensaver_type = "random_image" end end if self.screensaver_type == "disable" then if ui and ui.doc_settings and ui.doc_settings:isTrue("exclude_screensaver") then self.screensaver_type = "random_image" end end if self.screensaver_type == "random_image" then local screensaver_dir = G_reader_settings:readSetting(self.prefix .. "screensaver_dir") or G_reader_settings:readSetting("screensaver_dir") self.image_file = _getRandomImage(screensaver_dir) or "resources/koreader.png" -- Fallback image end -- Use the right background setting depending on the effective mode, now that fallbacks have kicked in. if self:modeIsImage() then self.screensaver_background = screensaver_img_background else self.screensaver_background = screensaver_msg_background end end function Screensaver:show() -- Notify Device methods that we're in screen saver mode, so they know whether to suspend or resume on Power events. Device.screen_saver_mode = true -- Check if we requested a lock gesture local with_gesture_lock = Device:isTouchDevice() and G_reader_settings:readSetting("screensaver_delay") == "gesture" -- In as-is mode with no message, no overlay and no lock, we've got nothing to show :) if self.screensaver_type == "disable" and not self.show_message and not self.overlay_message and not with_gesture_lock then return end -- We mostly always suspend in Portrait/Inverted Portrait mode... -- ... except when we just show an InfoMessage or when the screensaver -- is disabled, as it plays badly with Landscape mode (c.f., #4098 and #5290). -- We also exclude full-screen widgets that work fine in Landscape mode, -- like ReadingProgress and BookStatus (c.f., #5724) if self:modeExpectsPortrait() then Device.orig_rotation_mode = Screen:getRotationMode() -- Leave Portrait & Inverted Portrait alone, that works just fine. if bit.band(Device.orig_rotation_mode, 1) == 1 then -- i.e., only switch to Portrait if we're currently in *any* Landscape orientation (odd number) Screen:setRotationMode(Screen.DEVICE_ROTATED_UPRIGHT) else Device.orig_rotation_mode = nil end -- On eInk, if we're using a screensaver mode that shows an image, -- flash the screen to white first, to eliminate ghosting. if Device:hasEinkScreen() and self:modeIsImage() then if self:withBackground() then Screen:clear() end Screen:refreshFull(0, 0, Screen:getWidth(), Screen:getHeight()) -- On Kobo, on sunxi SoCs with a recent kernel, wait a tiny bit more to avoid weird refresh glitches... if Device:isKobo() and Device:isSunxi() then ffiUtil.usleep(150 * 1000) end end else -- nil it, in case user switched ScreenSaver modes during our lifetime. Device.orig_rotation_mode = nil end -- Build the main widget for the effective mode, all the sanity checks were handled in setup local widget = nil if self.screensaver_type == "cover" then widget = ImageWidget:new{ image = self.image, image_disposable = true, width = Screen:getWidth(), height = Screen:getHeight(), scale_factor = G_reader_settings:isFalse("screensaver_stretch_images") and 0 or nil, stretch_limit_percentage = G_reader_settings:readSetting("screensaver_stretch_limit_percentage"), } elseif self.screensaver_type == "bookstatus" then local ReaderUI = require("apps/reader/readerui") local ui = ReaderUI.instance local doc = ui.document local doc_settings = ui.doc_settings widget = BookStatusWidget:new{ thumbnail = FileManagerBookInfo:getCoverImage(doc), props = ui.doc_props, document = doc, settings = doc_settings, ui = ui, readonly = true, } elseif self.screensaver_type == "random_image" or self.screensaver_type == "image_file" then widget = ImageWidget:new{ file = self.image_file, file_do_cache = false, alpha = true, width = Screen:getWidth(), height = Screen:getHeight(), scale_factor = G_reader_settings:isFalse("screensaver_stretch_images") and 0 or nil, stretch_limit_percentage = G_reader_settings:readSetting("screensaver_stretch_limit_percentage"), } elseif self.screensaver_type == "readingprogress" then widget = Screensaver.getReaderProgress() end -- Assume that we'll be covering the full-screen by default (either because of a widget, or a background fill). local covers_fullscreen = true -- Speaking of, set that background fill up... local background if self.screensaver_background == "black" then background = Blitbuffer.COLOR_BLACK elseif self.screensaver_background == "white" then background = Blitbuffer.COLOR_WHITE elseif self.screensaver_background == "none" then background = nil end local message_height if self.show_message then -- Handle user settings & fallbacks, with that prefix mess on top... local screensaver_message if G_reader_settings:has(self.prefix .. "screensaver_message") then screensaver_message = G_reader_settings:readSetting(self.prefix .. "screensaver_message") else if G_reader_settings:has("screensaver_message") then screensaver_message = G_reader_settings:readSetting("screensaver_message") else -- In the absence of a custom message, use the event message if any, barring that, use the default message. if self.event_message then screensaver_message = self.event_message -- The overlay is only ever populated with the event message, and we only want to show it once ;). self.overlay_message = nil else screensaver_message = self.default_screensaver_message end end end -- NOTE: Only attempt to expand if there are special characters in the message. if screensaver_message:find("%%") then screensaver_message = self:expandSpecial(screensaver_message, self.event_message or self.default_screensaver_message) end local message_pos if G_reader_settings:has(self.prefix .. "screensaver_message_position") then message_pos = G_reader_settings:readSetting(self.prefix .. "screensaver_message_position") else message_pos = G_reader_settings:readSetting("screensaver_message_position") end -- The only case where we *won't* cover the full-screen is when we only display a message and no background. if widget == nil and self.screensaver_background == "none" then covers_fullscreen = false end local message_widget if message_pos == "middle" then message_widget = InfoMessage:new{ text = screensaver_message, readonly = true, dismissable = false, } else local face = Font:getFace("infofont") local container if message_pos == "bottom" then container = BottomContainer else container = TopContainer end local screen_w, screen_h = Screen:getWidth(), Screen:getHeight() message_widget = container:new{ dimen = Geom:new{ w = screen_w, h = screen_h, }, TextBoxWidget:new{ text = screensaver_message, face = face, width = screen_w, alignment = "center", } } -- Forward the height of the top message to the overlay widget if message_pos == "top" then message_height = message_widget[1]:getSize().h end end -- Check if message_widget should be overlaid on another widget if message_widget then if widget then -- We have a Screensaver widget -- Show message_widget on top of previously created widget widget = OverlapGroup:new{ dimen = { w = Screen:getWidth(), h = Screen:getHeight(), }, widget, message_widget, } else -- No prevously created widget, so just show message widget widget = message_widget end end end if self.overlay_message then widget = addOverlayMessage(widget, message_height, self.overlay_message) end -- NOTE: Make sure InputContainer gestures are not disabled, to prevent stupid interactions with UIManager on close. UIManager:setIgnoreTouchInput(false) if widget then self.screensaver_widget = ScreenSaverWidget:new{ widget = widget, background = background, covers_fullscreen = covers_fullscreen, } self.screensaver_widget.modal = true self.screensaver_widget.dithered = true UIManager:show(self.screensaver_widget, "full") end -- Setup the gesture lock through an additional invisible widget, so that it works regardless of the configuration. if with_gesture_lock then self.screensaver_lock_widget = ScreenSaverLockWidget:new{} -- It's flagged as modal, so it'll stay on top UIManager:show(self.screensaver_lock_widget) end end function Screensaver:close_widget() if self.screensaver_widget then UIManager:close(self.screensaver_widget) end end function Screensaver:close() if self.screensaver_widget == nil and self.screensaver_lock_widget == nil then -- When we *do* have a widget, this is handled by ScreenSaver(Lock)Widget:onCloseWidget ;). self:cleanup() return end local screensaver_delay = G_reader_settings:readSetting("screensaver_delay") local screensaver_delay_number = tonumber(screensaver_delay) if screensaver_delay_number then UIManager:scheduleIn(screensaver_delay_number, self.close_widget, self) self.delayed_close = true elseif screensaver_delay == "disable" then self:close_widget() -- NOTE: Notify platforms that race with the native system (e.g., Kindle or needsScreenRefreshAfterResume) -- that we've actually closed the widget *right now*. return true elseif screensaver_delay == "gesture" then -- ScreenSaverLockWidget's onResume handler should now paint the not-a-widget InfoMessage logger.dbg("waiting for screensaver unlock gesture") else logger.dbg("tap to exit from screensaver") end end function Screensaver:cleanup() self.show_message = nil self.screensaver_type = nil self.prefix = nil self.event_message = nil self.overlay_message = nil self.screensaver_background = nil self.image = nil self.image_file = nil self.delayed_close = nil self.screensaver_widget = nil self.screensaver_lock_widget = nil -- We run *after* the screensaver has been dismissed, so reset the Device flags Device.screen_saver_mode = false Device.screen_saver_lock = false end return Screensaver