-
-
Save ebanDev/51a76595dd609cdacb35a5d375b97e61 to your computer and use it in GitHub Desktop.
--[[ | |
FolderCover patch for KOReader (v3.0) | |
======================================== | |
Features: | |
- Automatically detects cover images in directories | |
- Supports multiple image formats (jpg, jpeg, png, webp, gif) | |
- Configurable folder name and file count display with positioning options | |
- Rounded corners and proper image cropping | |
- Settings integration with BookInfoManager | |
- Text overlay positioning (top, middle, bottom) | |
- Enhanced styling and alpha transparency | |
Supported filenames: cover.*, folder.*, .cover.*, .folder.* (case insensitive) | |
Settings (accessible through BookInfoManager): | |
- folder_cover_disabled: Enable/disable folder covers | |
- folder_cover_show_folder_name: Show folder name overlay | |
- folder_cover_show_file_count: Show file count overlay | |
- folder_cover_folder_name_position: Position of folder name (top/middle/bottom) | |
- folder_cover_file_count_position: Position of file count (top/middle/bottom) | |
Author: Eban | |
License: Same as KOReader (AGPL v3) | |
]] | |
-- Prevent double-loading | |
if rawget(_G, "FolderCoverPatchApplied") then return end | |
_G.FolderCoverPatchApplied = true | |
------------------------------------------------------------ | |
-- SETTINGS INTEGRATION | |
------------------------------------------------------------ | |
-- Mock BookInfoManager if not available (for standalone testing) | |
local BookInfoManager = {} | |
local settings_cache = {} | |
function BookInfoManager:getSetting(key) | |
-- Default settings | |
local defaults = { | |
folder_cover_disabled = false, | |
folder_cover_show_folder_name = false, | |
folder_cover_show_file_count = false, | |
folder_cover_folder_name_position = "middle", | |
folder_cover_file_count_position = "bottom", | |
} | |
-- Try to get real BookInfoManager if available | |
local success, real_manager = pcall(require, "bookinfomanager") | |
if success and real_manager and real_manager.getSetting then | |
return real_manager:getSetting(key) | |
end | |
-- Fallback to defaults | |
return settings_cache[key] or defaults[key] | |
end | |
-- Allow runtime setting changes for testing | |
function BookInfoManager:setSetting(key, value) | |
settings_cache[key] = value | |
end | |
------------------------------------------------------------ | |
-- CONFIGURATION | |
------------------------------------------------------------ | |
local VERBOSE = false | |
local COVER_CANDIDATES = {"cover", "folder", ".cover", ".folder"} | |
local COVER_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} | |
------------------------------------------------------------ | |
-- INITIALIZATION | |
------------------------------------------------------------ | |
local LoggerFactory = require("logger") | |
local log = (type(LoggerFactory)=="function" and LoggerFactory("FolderCover")) | |
or (LoggerFactory and LoggerFactory.new and LoggerFactory:new("FolderCover")) | |
or { dbg=function() end, info=print, warn=print, err=print } | |
if not VERBOSE then | |
log.dbg = function() end | |
log.info = function() end | |
end | |
local Device = require("device") | |
local Screen = Device.screen | |
------------------------------------------------------------ | |
-- FOLDER COVER MANAGER | |
------------------------------------------------------------ | |
local FolderCoverManager = { | |
cover_candidates = COVER_CANDIDATES, | |
cover_extensions = COVER_EXTENSIONS, | |
} | |
function FolderCoverManager:findCover(dir_path) | |
if not dir_path or dir_path == "" or dir_path == ".." or dir_path:match("%.%.$") then | |
return nil | |
end | |
dir_path = dir_path:gsub("[/\\]+$", "") | |
-- Try exact matches with lowercase and uppercase extensions | |
for _, candidate in ipairs(self.cover_candidates) do | |
for _, ext in ipairs(self.cover_extensions) do | |
local exact_path = dir_path .. "/" .. candidate .. ext | |
local f = io.open(exact_path, "rb") | |
if f then | |
f:close() | |
return exact_path | |
end | |
local upper_path = dir_path .. "/" .. candidate .. ext:upper() | |
if upper_path ~= exact_path then | |
f = io.open(upper_path, "rb") | |
if f then | |
f:close() | |
return upper_path | |
end | |
end | |
end | |
end | |
-- Fallback: scan directory for case-insensitive matches | |
local success, handle = pcall(io.popen, 'ls -1 "' .. dir_path .. '" 2>/dev/null') | |
if success and handle then | |
for file in handle:lines() do | |
if file and file ~= "." and file ~= ".." and file ~= "" then | |
local file_lower = file:lower() | |
for _, candidate in ipairs(self.cover_candidates) do | |
for _, ext in ipairs(self.cover_extensions) do | |
if file_lower == candidate .. ext then | |
handle:close() | |
return dir_path .. "/" .. file | |
end | |
end | |
end | |
end | |
end | |
handle:close() | |
end | |
return nil | |
end | |
function FolderCoverManager:createFolderCoverWidget(image_path, width, height, folder_name, file_count) | |
local Size = require("ui/size") | |
local Geom = require("ui/geometry") | |
local ImageWidget = require("ui/widget/imagewidget") | |
local FrameContainer = require("ui/widget/container/framecontainer") | |
local CenterContainer = require("ui/widget/container/centercontainer") | |
local OverlapGroup = require("ui/widget/overlapgroup") | |
local Blitbuffer = require("ffi/blitbuffer") | |
local margin = Screen:scaleBySize(5) | |
local border = Size.border.thick | |
local inner_width = width - (margin + border) * 2 | |
local inner_height = height - (margin + border) * 2 | |
if inner_width <= 0 or inner_height <= 0 then | |
inner_width, inner_height = Size.item.height_default, Size.item.height_default | |
end | |
-- Create cover image with center cropping | |
local success, cover_image = pcall(function() | |
local temp_image = ImageWidget:new{ file = image_path, scale_factor = 1 } | |
temp_image:_render() | |
local orig_w = temp_image:getOriginalWidth() | |
local orig_h = temp_image:getOriginalHeight() | |
temp_image:free() | |
local scale_to_fill = 0 | |
if orig_w and orig_h then | |
local scale_x = inner_width / orig_w | |
local scale_y = inner_height / orig_h | |
scale_to_fill = math.max(scale_x, scale_y) | |
end | |
return ImageWidget:new{ | |
file = image_path, | |
width = inner_width, | |
height = inner_height, | |
scale_factor = scale_to_fill, | |
center_x_ratio = 0.5, | |
center_y_ratio = 0.5, | |
} | |
end) | |
if not success or not cover_image then | |
return nil | |
end | |
local overlays = {} | |
-- Create text overlays if enabled | |
if BookInfoManager:getSetting("folder_cover_show_folder_name") or | |
BookInfoManager:getSetting("folder_cover_show_file_count") then | |
local Font = require("ui/font") | |
local TextBoxWidget = require("ui/widget/textboxwidget") | |
local BD = require("ui/bidi") | |
local AlphaContainer = require("ui/widget/container/alphacontainer") | |
local TopContainer = require("ui/widget/container/topcontainer") | |
local BottomContainer = require("ui/widget/container/bottomcontainer") | |
local VerticalGroup = require("ui/widget/verticalgroup") | |
-- Helper function to create text overlay widget | |
local function createOverlayWidget(text, font_face, font_size, is_bold) | |
local text_widget = TextBoxWidget:new{ | |
text = text, | |
face = Font:getFace(font_face, font_size), | |
width = inner_width, | |
alignment = "center", | |
bold = is_bold or false, | |
fgcolor = Blitbuffer.COLOR_BLACK, | |
} | |
return AlphaContainer:new{ | |
alpha = 0.8, | |
FrameContainer:new{ | |
bordersize = 0, | |
margin = 0, | |
padding = Size.padding.tiny, | |
background = Blitbuffer.COLOR_WHITE, | |
radius = Screen:scaleBySize(3), | |
CenterContainer:new{ | |
dimen = Geom:new{ | |
w = inner_width - Screen:scaleBySize(4), | |
h = text_widget:getSize().h + (is_bold and 6 or 4), | |
}, | |
text_widget, | |
}, | |
}, | |
} | |
end | |
-- Create text widgets for folder name and file count | |
local folder_name_widget, file_count_widget | |
if BookInfoManager:getSetting("folder_cover_show_folder_name") and folder_name and folder_name ~= "" then | |
local display_name = folder_name | |
if display_name:match('/$') then | |
display_name = display_name:sub(1, -2) | |
end | |
display_name = BD.directory(display_name) | |
folder_name_widget = createOverlayWidget(display_name, "cfont", 18, true) | |
end | |
if BookInfoManager:getSetting("folder_cover_show_file_count") and file_count then | |
file_count_widget = createOverlayWidget(tostring(file_count), "infont", 15, false) | |
end | |
-- Get positions and group overlays | |
local folder_name_position = BookInfoManager:getSetting("folder_cover_folder_name_position") or "middle" | |
local file_count_position = BookInfoManager:getSetting("folder_cover_file_count_position") or "bottom" | |
local position_groups = { top = {}, middle = {}, bottom = {} } | |
if folder_name_widget then | |
table.insert(position_groups[folder_name_position], folder_name_widget) | |
end | |
if file_count_widget then | |
table.insert(position_groups[file_count_position], file_count_widget) | |
end | |
-- Helper function to create positioned overlay container | |
local function createPositionedOverlay(widgets_group, position) | |
if #widgets_group == 0 then return nil end | |
local container_widget = #widgets_group == 1 and widgets_group[1] or VerticalGroup:new(widgets_group) | |
if position == "top" then | |
return TopContainer:new{ | |
dimen = Geom:new{w = inner_width, h = inner_height}, | |
container_widget, | |
} | |
elseif position == "bottom" then | |
return BottomContainer:new{ | |
dimen = Geom:new{w = inner_width, h = inner_height}, | |
container_widget, | |
} | |
else -- middle | |
return CenterContainer:new{ | |
dimen = Geom:new{w = inner_width, h = inner_height}, | |
container_widget, | |
} | |
end | |
end | |
-- Create positioned overlays for each position group | |
for position, widgets_group in pairs(position_groups) do | |
local positioned_overlay = createPositionedOverlay(widgets_group, position) | |
if positioned_overlay then | |
table.insert(overlays, positioned_overlay) | |
end | |
end | |
end | |
-- Combine cover image with overlays | |
local content_parts = { | |
CenterContainer:new{ | |
dimen = Geom:new{w = inner_width, h = inner_height}, | |
cover_image, | |
} | |
} | |
for _, overlay in ipairs(overlays) do | |
table.insert(content_parts, overlay) | |
end | |
return FrameContainer:new{ | |
width = width, | |
height = height, | |
margin = margin, | |
padding = 0, | |
bordersize = border, | |
background = Blitbuffer.COLOR_WHITE, | |
radius = Screen:scaleBySize(10), | |
OverlapGroup:new{ | |
dimen = Geom:new{w = inner_width, h = inner_height}, | |
unpack(content_parts) | |
}, | |
} | |
end | |
------------------------------------------------------------ | |
-- MOSAIC MENU PATCHING | |
------------------------------------------------------------ | |
local function patch_mosaic_menu(MosaicMenu) | |
if MosaicMenu.__foldercover_patched then return end | |
MosaicMenu.__foldercover_patched = true | |
local MosaicMenuItem | |
for _, func_name in ipairs{"_updateItemsBuildUI", "update", "init"} do | |
local func = MosaicMenu[func_name] | |
if type(func) == "function" then | |
local i = 1 | |
repeat | |
local name, value = debug.getupvalue(func, i) | |
if not name then break end | |
if name == "MosaicMenuItem" then | |
MosaicMenuItem = value | |
break | |
end | |
i = i + 1 | |
until false | |
end | |
if MosaicMenuItem then break end | |
end | |
if not MosaicMenuItem then | |
if package.loaded.mosaicmenu and package.loaded.mosaicmenu.MosaicMenuItem then | |
MosaicMenuItem = package.loaded.mosaicmenu.MosaicMenuItem | |
else | |
log.err("FolderCover: Could not find MosaicMenuItem") | |
return | |
end | |
end | |
local original_update = MosaicMenuItem.update | |
if not original_update then | |
log.err("FolderCover: MosaicMenuItem.update method not found") | |
return | |
end | |
function MosaicMenuItem:update(...) | |
local result = original_update(self, ...) | |
-- Skip if folder covers are disabled | |
if BookInfoManager:getSetting("folder_cover_disabled") then | |
return result | |
end | |
if self._foldercover_processed then return result end | |
local is_directory = self.entry and ( | |
self.entry.is_directory or | |
self.entry.isDir or | |
self.entry.type == "directory" or | |
self.entry.is_file == false or | |
(self.entry.is_file == nil and self.entry.file == nil) | |
) | |
if not is_directory then return result end | |
local dir_path = self.entry and ( | |
self.entry.path or | |
self.entry.full_path or | |
self.entry.fullName or | |
self.entry.name | |
) | |
if not dir_path then return result end | |
local cover_path = FolderCoverManager:findCover(dir_path) | |
if not cover_path then return result end | |
self._foldercover_processed = true | |
local folder_name = BookInfoManager:getSetting("folder_cover_show_folder_name") and self.text or nil | |
local file_count = BookInfoManager:getSetting("folder_cover_show_file_count") and self.mandatory or nil | |
local cover_widget = FolderCoverManager:createFolderCoverWidget( | |
cover_path, | |
self.width, | |
self.height, | |
folder_name, | |
file_count | |
) | |
if not cover_widget then return result end | |
if not self._underline_container then | |
local Size = require("ui/size") | |
local Geom = require("ui/geometry") | |
local UnderlineContainer = require("ui/widget/container/underlinecontainer") | |
local uh = Size.line.focus_indicator | |
local up = Size.padding.tiny | |
self._underline_container = UnderlineContainer:new{ | |
vertical_align = "top", | |
padding = up, | |
dimen = Geom:new{ | |
x = 0, y = 0, | |
w = self.width, | |
h = self.height + uh + up | |
}, | |
linesize = uh | |
} | |
self[1] = self._underline_container | |
end | |
if self._underline_container[1] then | |
self._underline_container[1]:free(true) | |
end | |
self._underline_container[1] = cover_widget | |
self._has_cover_image = true | |
if self.menu then | |
self.menu._has_cover_images = true | |
end | |
if VERBOSE then | |
log.info("FolderCover: Applied cover to", self.text or "directory") | |
end | |
return result | |
end | |
end | |
------------------------------------------------------------ | |
-- MODULE HOOKING | |
------------------------------------------------------------ | |
local original_require = require | |
function require(module_name) | |
local result = original_require(module_name) | |
if module_name == "mosaicmenu" and type(result) == "table" then | |
pcall(patch_mosaic_menu, result) | |
end | |
return result | |
end | |
if package.loaded.mosaicmenu then | |
pcall(patch_mosaic_menu, package.loaded.mosaicmenu) | |
end |
These settings are in the file manager menu: Settings -> Mosaic settings -> Folder covers ;)
The mosaic settings don't appear for me. I think it's because I'm using the "Project Title" plugin too. Speaking of which, your cover patch and the Project Title plugin make Koreader much more beautiful! Would it be possible to change the menu to be somewhere else, outside of the mosaic settings? Thanks
These settings are in the file manager menu: Settings -> Mosaic settings -> Folder covers ;)
Did you mean mosaic and detailed list settings?. I've looked into it in my kindle and there is no option for folder covers.
And also I cant change the settings even by editing the lua file in v3.0, even tho I could do it in v2.1.
Hi @ebanDev
Thank you for your work on this and for licensing it under the same terms as KOReader. I've adapted a couple portions of it to include in my KOReader plugin. Not only does it save me time doing what you had already done, it means that users can switch between your userpatch and my plugin and not have to set things up twice.
I also added some automatic generation based on books in the sub-folder, as well as a fallback image that's used elsewhere in my plugin. It's still very much a work in progress and I haven't wired up any options for it yet. If you want a peek, you can check out this branch: https://github.com/joshuacant/ProjectTitle/tree/foldercovers
Thanks again, and good luck with the KOReader PR.
Hey, :) I really enjoy the cover folders, thank you, but I don't seem to have the options? (Folder name overlay, etc.) I go Settings > Mosaic and detailed list settings > and there aren't any options. Am I missing something, or did I make a mistake somewhere?
Also, is there any way to make the folders smaller? They are way bigger than my book covers
Your patch is great, but please consider using KOReader's userpatch tools instead of hacking your way into the CoverBrowser plugin. It currently prevents other patches from accessing the CoverBrowser plugin object, which is quite problematic.
@ebanDev Thank you very much for the patch!
Where can I find the option to change the settings of your patch in the Koreader interface? I couldn't find anything about "BookInfoManager" in the menus.