Skip to content

Instantly share code, notes, and snippets.

@komadorirobin
Forked from ebanDev/2-foldercover.lua
Created June 23, 2025 20:10
Show Gist options
  • Select an option

  • Save komadorirobin/d7835d35e1cfa9b406862f9cb8653fe5 to your computer and use it in GitHub Desktop.

Select an option

Save komadorirobin/d7835d35e1cfa9b406862f9cb8653fe5 to your computer and use it in GitHub Desktop.
FolderCover patch for KOReader
--[[
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment