Skin creation just got much easier! New features include:
"auto" to center, "max" for full width/height, "max-20" for marginslayoutDirection and they space themselves!See Positioning and Anchoring and Container sections for details.
Creating a custom skin for Librarian Player is straightforward:
manifest.json file with your layoutassets folderpreview.png (512x512) showing your skinThat's it! No coding required - skins are pure JSON + assets.
π See also: Skin Layout System Guide for detailed examples and migration guide
A skin is a complete visual layout for the Librarian audiobook player. It defines:
A theme is a color scheme that can be applied to any skin. Themes override the skin's default colors, allowing users to customize the look without changing the layout.
Your skin is a ZIP file with this structure:
my-awesome-skin.zip
βββ manifest.json # REQUIRED: Your skin definition
βββ preview.png # REQUIRED: 512x512 preview image
βββ assets/ # OPTIONAL: Custom resources
β βββ images/
β β βββ play-button.png
β β βββ pause-button.png
β β βββ background.png
β βββ fonts/
β βββ custom-font.ttf
βββ README.md # OPTIONAL: Credits/info
REQUIRED:
manifest.json - Your skin definition (root level)preview.png - Preview image, 512x512 pixels (root level)OPTIONAL:
assets/images/ - Custom button images, backgrounds, etc.assets/fonts/ - Custom fonts (.ttf or .otf)README.md - Credits, changelog, license infoThe manifest.json is the heart of your skin. Here's a minimal example:
{
"version": "1.0",
"skinName": "My First Skin",
"author": "Your Name",
"description": "A simple custom skin",
"minPlayerVersion": "2.0.0",
"previewImage": "preview.png",
"dimensions": {
"portrait": {
"width": 360,
"height": 640,
"aspectRatio": "free"
},
"landscape": {
"width": 640,
"height": 360,
"aspectRatio": "free"
}
},
"theme": {
"allowColorOverride": true,
"embeddedThemes": {
"default": {
"version": "1.0",
"themeName": "My Skin Default",
"author": "Your Name",
"description": "Default theme for my skin",
"colors": {
"primary": "#2196F3",
"background": "#121212",
"surface": "#1E1E1E",
"text": "#FFFFFF",
"accent": "#FF4081",
"progressActive": "#2196F3",
"progressInactive": "#424242",
"timeText": "#FFFFFF",
"buttonTint": "#2196F3"
}
}
},
"defaultTheme": "default"
},
"layout": {
"portrait": [
// Your elements go here
],
"landscape": [
// Your landscape elements go here
]
}
}
| Property | Type | Required | Description |
|--------------------|--------|----------|-------------------------------------------|
| version | string | β
| Manifest version (use "1.0") |
| skinName | string | β
| Display name of your skin |
| author | string | β¬ | Your name/username |
| description | string | β¬ | Brief description |
| minPlayerVersion | string | β¬ | Min app version (default: "2.0.0") |
| previewImage | string | β¬ | Preview filename (default: "preview.png") |
| dimensions | object | β
| Canvas dimensions for layouts |
| theme | object | β
| Theme configuration |
| layout | object | β
| Element layouts |
| fonts | array | β¬ | Custom font definitions |
| animations | object | β¬ | Animation definitions |
| sleepTimer | object | β¬ | Sleep timer bubble configuration |
The sleepTimer block allows the skin to customize the styling and behavior of the draggable floating sleep timer bubble (only shown when the sleep timer is active and the layout doesn't natively display the remaining time):
"sleepTimer": {
"bubbleEnabled": true, // Set false to disable the bubble entirely
"bubbleInitialX": 140, // Initial X coordinate in dp (default: 140)
"bubbleInitialY": 80, // Initial Y coordinate in dp (default: 80)
"bubbleBackgroundColor": "surface", // Background color (hex or theme color key)
"bubbleTextColor": "text", // Text color (hex or theme color key)
"bubbleFontSize": 14, // Font size in sp (default: 14)
"bubbleCornerRadius": 16, // Corner radius in dp (default: 16)
"bubblePadding": 12, // Inner padding in dp (default: 12)
"bubbleShadowColor": "#000000",// Shadow color (default: black/semi-transparent)
"bubbleShadowRadius": 8 // Shadow blur radius in dp (default: 8)
}
Define your design canvas size. The app will scale your skin to fit any screen:
"dimensions": {
"portrait": {
"width": 360, // Design width in pixels
"height": 640, // Design height in pixels
"aspectRatio": "free"
},
"landscape": {
"width": 640,
"height": 360,
"aspectRatio": "free"
},
"drivePortrait": { // Optional - drive mode portrait dimensions
"width": 360,
"height": 800,
"aspectRatio": "free"
},
"driveLandscape": { // Optional - drive mode landscape dimensions
"width": 800,
"height": 360,
"aspectRatio": "free"
}
}
Tip: Use 360x640 for portrait - it's a common baseline and scales well.
A skin without a functional landscape layout is considered NON-FUNCTIONAL.
All skins MUST provide both portrait AND landscape layouts. Users expect to be able to rotate their device and have a usable player interface in both orientations.
Minimum Landscape Requirements:
Recommended Landscape Layout Pattern:
Example:
"landscape": [
{
"id": "cover",
"type": "cover-image",
"x": 20,
"y": 20,
"width": 320,
"height": 320,
"anchor": "top-left"
},
{
"id": "title-text",
"type": "text",
"x": 360,
"y": 20,
"width": 420,
"height": 30,
"anchor": "top-left",
"dataBinding": "book.title"
}
// ... more elements
]
An empty landscape layout ("landscape": []) will cause the skin to fail validation and provide a poor user experience.
Drive mode provides a simplified, distraction-reduced interface with large touch targets for use while driving. Skins can optionally define two additional layout keys: drivePortrait and driveLandscape.
If a skin does not include drive mode layouts, the app falls back to the regular portrait/landscape layouts.
Drive Mode Design Principles:
exit-drive-mode action so the user can return to normal modeDrive Mode Layout Keys:
| Layout Key | Description |
|-------------------|-------------------------------------------------|
| drivePortrait | Drive mode layout for portrait orientation |
| driveLandscape | Drive mode layout for landscape orientation |
Both keys are optional. You can provide one or both. Their dimensions should match the corresponding portrait/landscape dimensions.
Example β Drive Portrait Layout:
"drivePortrait": [
{
"id": "background",
"type": "image",
"x": 0, "y": 0,
"width": 360, "height": 800,
"anchor": "top-left",
"image": "assets/images/background.png",
"themeable": { "tint": "background" }
},
{
"id": "exit-button",
"type": "button",
"x": 20, "y": 40,
"width": 60, "height": 60,
"anchor": "top-left",
"action": "exit-drive-mode",
"iconStyle": "filled",
"buttonShape": "circle",
"themeable": {
"tint": "surface",
"backgroundColor": "error"
}
},
{
"id": "cover",
"type": "cover-image",
"x": 0, "y": 120,
"width": 280, "height": 280,
"anchor": "top-center",
"visible": true,
"themeable": {
"borderColor": "primary",
"backgroundColor": "surface"
}
},
{
"id": "title-text",
"type": "text",
"x": 0, "y": 420,
"width": 340, "height": 40,
"anchor": "top-center",
"dataBinding": "book.title",
"fontSize": 24,
"fontWeight": "bold",
"textAlign": "center",
"themeable": { "color": "text" },
"scrolling": { "enabled": true, "speed": 30 }
},
{
"id": "controls-container",
"type": "container",
"x": 0, "y": 550,
"width": 360, "height": 120,
"anchor": "top-left",
"layoutDirection": "horizontal",
"padding": 10,
"children": [
{
"id": "btn-back-30",
"type": "button",
"width": 80, "height": 80,
"action": "skip-backward-30",
"image": "assets/images/button_back_30s.png",
"themeable": { "tint": "primary" },
"x": "line", "y": "center"
},
{
"id": "btn-play-pause",
"type": "button",
"width": 100, "height": 100,
"action": "toggle-play-pause",
"images": {
"playing": "assets/images/button_pause.png",
"paused": "assets/images/button_play.png"
},
"themeable": {
"tint": "background",
"backgroundColor": "primary"
},
"buttonShape": "circle",
"x": "line", "y": "center"
},
{
"id": "btn-fwd-30",
"type": "button",
"width": 80, "height": 80,
"action": "skip-forward-30",
"image": "assets/images/button_forward_30s.png",
"themeable": { "tint": "primary" },
"x": "line", "y": "center"
}
]
}
]
Example β Drive Landscape Layout:
"driveLandscape": [
{
"id": "background",
"type": "image",
"x": 0, "y": 0,
"width": 800, "height": 360,
"anchor": "top-left",
"image": "assets/images/background.png",
"themeable": { "tint": "background" }
},
{
"id": "exit-button",
"type": "button",
"x": 20, "y": 20,
"width": 60, "height": 60,
"anchor": "top-left",
"action": "exit-drive-mode",
"iconStyle": "filled",
"buttonShape": "circle",
"themeable": {
"tint": "surface",
"backgroundColor": "error"
}
},
{
"id": "cover",
"type": "cover-image",
"x": 100, "y": 20,
"width": 200, "height": 200,
"anchor": "top-left",
"visible": true,
"themeable": {
"borderColor": "primary",
"backgroundColor": "surface"
}
},
{
"id": "info-container",
"type": "container",
"x": 320, "y": 20,
"width": 460, "height": 320,
"anchor": "top-left",
"layoutDirection": "vertical",
"padding": 10,
"children": [
{
"id": "title-text",
"type": "text",
"width": 440, "height": 40,
"dataBinding": "book.title",
"fontSize": 24,
"fontWeight": "bold",
"themeable": { "color": "text" },
"scrolling": { "enabled": true },
"x": "center", "y": "line"
},
{
"id": "author-text",
"type": "text",
"width": 440, "height": 30,
"dataBinding": "book.author",
"fontSize": 18,
"themeable": { "color": "text" },
"x": "center", "y": "line"
},
{
"id": "controls-row",
"type": "container",
"width": 440, "height": 120,
"layoutDirection": "horizontal",
"children": [
{
"id": "btn-back-30",
"type": "button",
"width": 80, "height": 80,
"action": "skip-backward-30",
"image": "assets/images/button_back_30s.png",
"themeable": { "tint": "primary" },
"x": "line", "y": "center"
},
{
"id": "btn-play-pause",
"type": "button",
"width": 100, "height": 100,
"action": "toggle-play-pause",
"images": {
"playing": "assets/images/button_pause.png",
"paused": "assets/images/button_play.png"
},
"themeable": {
"tint": "background",
"backgroundColor": "primary"
},
"buttonShape": "circle",
"x": "line", "y": "center"
},
{
"id": "btn-fwd-30",
"type": "button",
"width": 80, "height": 80,
"action": "skip-forward-30",
"image": "assets/images/button_forward_30s.png",
"themeable": { "tint": "primary" },
"x": "line", "y": "center"
}
],
"x": "center", "y": "line"
}
]
}
]
Key differences from regular layouts:
exit-drive-mode button is required so users can leave drive modeDisplays the audiobook cover art.
{
"id": "cover",
"type": "cover-image",
"x": 20,
"y": 20,
"width": 320,
"height": 320,
"anchor": "top-left",
"visible": true,
"themeable": {
"borderColor": "primary"
},
"gestures": {
"tap": "toggle-play-pause",
"longPress": "show-chapters",
"swipeLeft": "next-chapter",
"swipeRight": "prev-chapter"
}
}
Properties:
themeable.borderColor - Border color (theme color key)themeable.backgroundColor - Background colorcustomImage - Use a static image instead of book covergestures - Touch interactions. When tap is set to "toggle-play-pause", the player also shows a large, low-opacity play/pause icon over the cover for about 2 seconds after the tap, then hides it automatically.Displays dynamic text (book title, time, etc.).
{
"id": "title",
"type": "text",
"x": 20,
"y": 360,
"width": 320,
"height": 40,
"anchor": "top-left",
"dataBinding": "book.title",
"fontSize": 20,
"fontWeight": "bold",
"fontFamily": "serif",
"textAlign": "center",
"themeable": {
"color": "text"
},
"scrolling": {
"enabled": true,
"speed": 30,
"pauseDuration": 2000
}
}
Properties:
dataBinding - What data to display (see Data Bindings)fontSize - Text size in pixelsfontWeight - Font weight (see Font Weights)fontFamily - Font family (see Font Families)textAlign - "left", "center", or "right"themeable.color - Text color (theme color key)scrolling.enabled - Scroll long text (marquee)scrolling.speed - Scroll speed (pixels per second)scrolling.pauseDuration - Pause at ends (milliseconds)Font Families: Cross-platform standard fonts (work on Android, iOS, and Desktop):
"serif" - Serif font (Times, Georgia-style)"sans-serif" - Sans-serif font (Roboto, SF Pro, Arial-style)"monospace" - Monospace font (Courier, Menlo, Consolas-style)"cursive" - Cursive/handwriting font"default" - System default fontNamed fonts (automatically mapped to platform equivalents):
"roboto" - Maps to Roboto (Android), San Francisco (iOS), Arial (Desktop)"arial", "helvetica" - Maps to platform sans-serif"times", "georgia" - Maps to platform serif"courier", "menlo", "consolas" - Maps to platform monospaceCustom fonts from your skin bundle:
"custom:my-font.ttf" - Font file in assets/fonts/my-font.ttfGoogle Fonts (downloaded automatically):
"google:Poppins" - Google Font name (future feature)Font Weights:
"thin" or "100" - Thinnest weight"extra-light" or "200""light" or "300""normal" or "400" - Default"medium" or "500""semi-bold" or "600""bold" or "700" - Standard bold"extra-bold" or "800""black" or "900" - Heaviest weightCross-Platform Note: All standard font families automatically map to the best available font on each platform, ensuring your skin looks great everywhere!
Interactive button for playback control.
{
"id": "play-button",
"type": "button",
"x": 140,
"y": 500,
"width": 80,
"height": 80,
"anchor": "bottom-center",
"action": "toggle-play-pause",
"iconStyle": "rounded",
"themeable": {
"tint": "primary"
},
"gestures": {
"tap": "toggle-play-pause",
"longPress": "create-bookmark"
}
}
Properties:
action - Button action (see Actions)iconStyle - Icon variant: "filled", "outlined", "rounded", "sharp"buttonShape - Background shape: "circle", "rounded", "square", "none" (optional)buttonPadding - Padding inside button in dp (optional, default: 0)themeable.tint - Icon tint color (theme color key)themeable.backgroundColor - Button background color (theme color key, optional)images.default - Custom button image (optional)foregroundImage - The button's own picture, drawn on top of everything else (optional). If
customImage is also set, it's drawn as a background fill behind foregroundImage instead of
being used as the button's picture. If foregroundImage is omitted, customImage is used as the
button's sole picture (legacy behavior, kept for skins authored before foregroundImage existed).gestures - Override default tap behaviorBuilt-in Icons: The app provides Material Icons for common actions. Set action to get the
right icon automatically:
toggle-play-pause β Play/Pause icon (changes based on state)prev-chapter β Skip Previous iconnext-chapter β Skip Next iconskip-backward-30 β Replay 30 iconskip-forward-30 β Forward 30 iconcreate-bookmark β Bookmark iconButton Shapes Example:
{
"id": "play-button",
"type": "button",
"x": 140,
"y": 500,
"width": 80,
"height": 80,
"action": "toggle-play-pause",
"buttonShape": "circle",
// Circular background
"buttonPadding": 10,
// 10dp padding inside
"themeable": {
"tint": "primary",
// Icon color
"backgroundColor": "surface"
// Background color
}
}
Tip: Use buttonShape: "none" or omit backgroundColor for transparent buttons without
backgrounds.
Shows and controls playback position.
{
"id": "progress",
"type": "progress-bar",
"x": 20,
"y": 450,
"width": 320,
"height": 20,
"anchor": "top-left",
"dataBinding": "playback.position",
"interactive": true,
"themeable": {
"activeColor": "progressActive",
"inactiveColor": "progressInactive",
"thumbColor": "primary"
}
}
Properties:
dataBinding - Must be "playback.position" (0.0 to 1.0)interactive - Allow seeking by tapping/draggingthemeable.activeColor - Filled portion colorthemeable.inactiveColor - Unfilled portion colorthemeable.thumbColor - Draggable thumb colorStatic decorative image with advanced background modes, gradient support, and decorative positioning.
{
"id": "background",
"type": "image",
"x": 0,
"y": 0,
"width": 360,
"height": 640,
"anchor": "top-left",
"customImage": "assets/images/background.png",
"backgroundMode": "stretch",
"backgroundColor": "#121212",
"themeable": {
"tint": "surface"
},
"decorativeImages": [
{
"path": "assets/images/corner-decoration.png",
"position": "top-right",
"offsetX": -10,
"offsetY": 10,
"width": 50,
"height": 50,
"alpha": 0.8
}
]
}
Properties:
customImage - Path to main image file in your ZIPnightImage - Optional dark-mode replacement for customImage. When the resolved theme is dark
(see Night Mode below), the renderer/preview tool uses nightImage instead of
customImage for this element, falling back to customImage if nightImage is unset. Useful
for a background element that should swap to different artwork at night.foregroundImage - The element's own picture, drawn on top of everything else (optional). If
customImage is also set, it's drawn as a background fill behind foregroundImage instead of
being used as the main picture. If foregroundImage is omitted, customImage is used as the
sole picture (legacy behavior, kept for skins authored before foregroundImage existed).backgroundMode - How image fills space (see below)backgroundColor - Hex color for padding areas (solid color)backgroundGradient - Gradient background (see Gradient Backgrounds)backgroundGradientDark - Dark-mode replacement for backgroundGradient (see Night Mode)themeable.tint - Optional color tint overlaydecorativeImages - Array of positioned decorative imagesNote: backgroundGradient overrides backgroundColor if both are specified.
Background Modes:
"stretch" - Stretch to fill entire area (may distort)"fit-width" - Scale to fit width, maintain aspect ratio"fit-height" - Scale to fit height, maintain aspect ratio"center" - Center image at original size"tile" - Tile image to fill area (both directions)"tile-horizontal" - Tile horizontally, fit vertically"tile-vertical" - Tile vertically, fit horizontallyDecorative Images: Decorative images are additional images positioned relative to the element:
{
"path": "assets/images/star.png",
"position": "top-right",
// Anchor point
"offsetX": -20,
// Pixels from anchor (negative = left/up)
"offsetY": 10,
// Pixels from anchor (positive = right/down)
"width": 40,
// Optional fixed width
"height": 40,
// Optional fixed height
"tint": "accent",
// Optional theme color tint
"alpha": 0.9
// Opacity (0.0 to 1.0)
}
Position Values:
"top", "bottom", "left", "right""top-left", "top-right", "bottom-left", "bottom-right""center"A container element groups multiple child elements together with automatic layout or relative positioning. Containers now support automatic horizontal/vertical spacing, making button layouts trivial!
The easiest way to position buttons - just set layoutDirection and children space themselves automatically:
{
"id": "button-row",
"type": "container",
"x": 0,
"y": 500,
"width": "max",
"height": 80,
"layoutDirection": "horizontal",
"gap": 15,
"padding": 20,
"backgroundColor": "#1a1a1a80",
"children": [
{
"id": "btn-back",
"type": "button",
"x": "line",
"y": "auto",
"width": 60,
"height": 60,
"action": "skip-backward-30"
},
{
"id": "btn-play",
"type": "button",
"x": "line",
"y": "auto",
"width": 60,
"height": 60,
"action": "toggle-play-pause"
},
{
"id": "btn-forward",
"type": "button",
"x": "line",
"y": "auto",
"width": 60,
"height": 60,
"action": "skip-forward-30"
}
]
}
Result: Three buttons automatically spaced horizontally with 15px gaps, 20px padding, and vertically centered!
You can still manually position children if you prefer:
{
"id": "control-panel",
"type": "container",
"x": 0,
"y": 400,
"width": 360,
"height": 240,
"backgroundColor": "#1a1a1a",
"children": [
{
"id": "play-button",
"type": "button",
"x": 160,
"y": 100,
"width": 60,
"height": 60,
"action": "toggle-play-pause"
}
]
}
Properties:
children - Array of child elements (positioned relative to container)layoutDirection - β NEW: "horizontal" or "vertical" for automatic spacinggap - β NEW: Space between items in line layout (default: 0)padding - β NEW: Padding inside container (default: 0)backgroundColor - Solid color background (hex format)backgroundGradient - Gradient background (see Gradient Backgrounds)backgroundGradientDark - Dark-mode replacement for backgroundGradient (see Night Mode)customImage - Background image pathbackgroundMode - How background image fills spaceKey Features:
layoutDirection and children space themselves"line" for auto-spacing, "auto" for centeringx, y relative to container's top-left cornerLayout Direction Examples:
Horizontal layout (buttons in a row):
{
"layoutDirection": "horizontal",
"gap": 10,
"children": [
{"id": "btn1", "x": "line", "y": "auto", "width": 60, "height": 60},
{"id": "btn2", "x": "line", "y": "auto", "width": 60, "height": 60}
]
}
Vertical layout (buttons in a column):
{
"layoutDirection": "vertical",
"gap": 10,
"children": [
{"id": "btn1", "x": "auto", "y": "line", "width": 60, "height": 60},
{"id": "btn2", "x": "auto", "y": "line", "width": 60, "height": 60}
]
}
Example Use Cases:
A simple filled rectangle for backgrounds, separators, and overlays. Supports solid colors and gradients.
{
"type": "rectangle",
"id": "separator",
"x": 0,
"y": 300,
"width": 360,
"height": 2,
"backgroundColor": "#CCCCCC"
}
With gradient:
{
"type": "rectangle",
"id": "background-overlay",
"x": 0,
"y": 0,
"width": 360,
"height": 640,
"backgroundGradient": {
"type": "linear",
"colors": ["#00000000", "#000000AA"],
"angle": 180
}
}
Properties:
backgroundColor - Solid color fill (hex format)backgroundGradient - Gradient fill (see Gradient Backgrounds)backgroundGradientDark - Dark-mode replacement for backgroundGradient (see Night Mode)Note: backgroundGradient overrides backgroundColor if both are specified.
Example Use Cases:
Elements can be positioned using flexible values for easier layout:
{
"x": 20, // Numeric: 20 pixels from anchor point
"y": "auto", // Auto: automatically centered
"width": "max", // Max: full container width
"height": 40 // Numeric: 40 pixels tall
}
Important: The app automatically scales your skin to fit any screen size. Design at your comfortable size (like 360x640) and it will scale perfectly.
Instead of only numeric values, you can now use:
| Value | Description | Example |
|-------|-------------|---------|
| 100 | Numeric pixel position | "x": 100 |
| "auto" | Automatically centers the element | "x": "auto" |
| "line" | Used in containers for automatic spacing | "x": "line" |
| "max" | Sets to maximum dimension | "x": "max" |
| "max-20" | Maximum minus N pixels | "x": "max-20" |
| Value | Description | Example |
|-------|-------------|---------|
| 200 | Numeric pixel size | "width": 200 |
| "auto" | Full container size | "width": "auto" |
| "max" | Maximum dimension | "width": "max" |
| "max-40" | Maximum minus N pixels | "width": "max-40" |
Examples:
// Centered cover image
{
"id": "cover",
"type": "cover-image",
"x": "auto",
"y": "auto",
"width": 300,
"height": 300
}
// Full-width background
{
"id": "background",
"type": "image",
"x": 0,
"y": 0,
"width": "max",
"height": "max"
}
// Progress bar with margins
{
"id": "progress",
"type": "progress-bar",
"x": 20,
"y": "max-60",
"width": "max-40",
"height": 6
}
The anchor property determines where an element is positioned from:
"top-left" - Position from top-left corner"top-center" - Position from top-center (x is relative to center)"top-right" - Position from top-right corner"center-left" - Position from vertical center, left edge"center" - Position from center of screen"center-right" - Position from vertical center, right edge"bottom-left" - Position from bottom-left corner"bottom-center" - Position from bottom-center"bottom-right" - Position from bottom-right cornerBottom anchors are crucial for responsive layouts. When using bottom anchors, the y value is
measured from the bottom of the screen upward.
Example: Position a button 100 pixels from the bottom:
{
"id": "play-button",
"type": "button",
"x": 140,
"y": 100,
// 100 pixels FROM BOTTOM
"width": 80,
"height": 80,
"anchor": "bottom-center"
// Anchor to bottom
}
This ensures controls stay at the bottom regardless of screen height!
β DO:
bottom-* anchors for navigation/control buttonstop-* anchors for titles, cover artcenter or *-center anchorsβ DON'T:
All background-capable elements (containers, rectangles, and images) support gradient backgrounds with extensive customization options.
Any element with backgroundGradient can also set backgroundGradientDark β a dark-mode
replacement gradient, swapped in under the same condition as nightImage (see
Night Mode). Useful when your light gradient would look washed-out or muddy simply
recolored, and you'd rather author a second gradient by hand than ship a rasterized night image.
Straight-line gradients from one point to another.
{
"backgroundGradient": {
"type": "linear",
"colors": ["#FF0000", "#0000FF"],
"angle": 90
}
}
Angle guide β 0Β° (default) flows colors[0] β colors[last] top-to-bottom; rotating clockwise shifts the direction:
0Β° = Top β Bottom (β) β default90Β° = Left β Right (β)180Β° = Bottom β Top (β) β reverses color order visually270Β° = Right β Left (β)Circular gradients radiating from a center point.
{
"backgroundGradient": {
"type": "radial",
"colors": ["#FFFFFF", "#000000"],
"centerX": 0.5,
"centerY": 0.5,
"radius": 0.7
}
}
Properties:
centerX - Center X position (0.0 to 1.0, 0.5 = middle)centerY - Center Y position (0.0 to 1.0, 0.5 = middle)radius - Gradient radius (0.0 to 1.0, relative to smallest dimension)Circular gradients that sweep around a center point like a color wheel.
{
"backgroundGradient": {
"type": "sweep",
"colors": ["#FF0000", "#00FF00", "#0000FF", "#FF0000"],
"centerX": 0.5,
"centerY": 0.5
}
}
Use "cover" as a color in any gradient to dynamically use the dominant color extracted from the current book's cover art. This enables backgrounds that automatically match the album art.
{
"backgroundGradient": {
"type": "linear",
"angle": 180,
"colors": ["cover", "#000000"]
}
}
This creates a gradient from the cover's dominant color at the top to black at the bottom. For a light skin, use "#FFFFFF" as the end color instead.
Other examples with "cover":
// Left-to-right (landscape split)
{ "type": "linear", "angle": 90, "colors": ["cover", "#000000"] }
// Radial burst from cover color
{ "type": "radial", "colors": ["cover", "#000000"], "radius": 0.8 }
// Soft vignette (cover color in center)
{ "type": "radial", "colors": ["cover", "#00000099"], "centerX": 0.5, "centerY": 0.3 }
Note: The
"cover"token resolves toColor.Transparentif no cover art is loaded, so the gradient gracefully falls back to the remaining colors.
Control exact positioning of colors in gradients:
{
"backgroundGradient": {
"type": "linear",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"stops": [0.0, 0.3, 1.0],
"angle": 90
}
}
Without stops, colors are evenly distributed (0.0, 0.5, 1.0 for 3 colors).
With stops, you control exact positions (0.0 = start, 1.0 = end).
All gradient properties:
{
"backgroundGradient": {
"type": "linear | radial | sweep",
"colors": ["#RRGGBB", "#RRGGBB", ...], // REQUIRED
"stops": [0.0, 0.5, 1.0], // OPTIONAL
"angle": 0.0, // For linear gradients
"centerX": 0.5, // For radial/sweep gradients
"centerY": 0.5, // For radial/sweep gradients
"radius": 0.5 // For radial gradients
}
}
| Property | Type | Required | Default | Description |
|-----------|----------|----------|---------|------------------------------------------------------|
| type | string | No | "linear" | Gradient type: "linear", "radial", or "sweep" |
| colors | string[] | Yes | - | Array of hex colors or "cover" (e.g., ["cover", "#000000"]) |
| stops | float[] | No | Even | Color stop positions (0.0-1.0). Must match colors length if provided |
| angle | float | No | 0 | Direction: 0=topβbottom, 90=leftβright, 180=bottomβtop, 270=rightβleft |
| centerX | float | No | 0.5 | Center X position for radial/sweep (0.0-1.0) |
| centerY | float | No | 0.5 | Center Y position for radial/sweep (0.0-1.0) |
| radius | float | No | 0.5 | Radius for radial gradients (0.0-1.0) |
Sunset gradient:
{
"type": "rectangle",
"id": "sunset-bg",
"x": 0,
"y": 0,
"width": 360,
"height": 640,
"backgroundGradient": {
"type": "linear",
"colors": ["#FF6B6B", "#FFD93D", "#6BCF7F", "#4D96FF"],
"stops": [0.0, 0.3, 0.6, 1.0],
"angle": 180
}
}
Circular spotlight:
{
"type": "rectangle",
"id": "spotlight",
"x": 0,
"y": 0,
"width": 360,
"height": 640,
"backgroundGradient": {
"type": "radial",
"colors": ["#FFFFFFAA", "#00000000"],
"centerX": 0.5,
"centerY": 0.3,
"radius": 0.4
}
}
Color wheel effect:
{
"type": "rectangle",
"id": "color-wheel",
"x": 80,
"y": 200,
"width": 200,
"height": 200,
"backgroundGradient": {
"type": "sweep",
"colors": [
"#FF0000", "#FFFF00", "#00FF00",
"#00FFFF", "#0000FF", "#FF00FF", "#FF0000"
],
"centerX": 0.5,
"centerY": 0.5
}
}
Subtle fade overlay:
{
"type": "rectangle",
"id": "fade-overlay",
"x": 0,
"y": 400,
"width": 360,
"height": 240,
"backgroundGradient": {
"type": "linear",
"colors": ["#00000000", "#000000DD"],
"angle": 180
}
}
Themes define color palettes that can be applied to any skin:
"theme": {
"allowColorOverride": true,
"embeddedThemes": {
"dark": {
"version": "1.0",
"themeName": "Dark Mode",
"author": "Your Name",
"description": "Dark color scheme",
"colors": {
"primary": "#2196F3",
"background": "#121212",
"surface": "#1E1E1E",
"text": "#FFFFFF",
"accent": "#FF4081",
"progressActive": "#2196F3",
"progressInactive": "#424242",
"timeText": "#B3E5FC",
"buttonTint": "#2196F3"
}
},
"light": {
"version": "1.0",
"themeName": "Light Mode",
"author": "Your Name",
"description": "Light color scheme",
"colors": {
"primary": "#1976D2",
"background": "#FFFFFF",
"surface": "#F5F5F5",
"text": "#212121",
"accent": "#D81B60",
"progressActive": "#1976D2",
"progressInactive": "#E0E0E0",
"timeText": "#424242",
"buttonTint": "#1976D2"
}
}
},
"defaultTheme": "dark"
}
Use these standard keys for consistency:
| Key | Purpose | Example |
|--------------------|------------------------|-----------|
| primary | Main brand color | #2196F3 |
| background | Screen background | #121212 |
| surface | Card/panel backgrounds | #1E1E1E |
| text | Primary text color | #FFFFFF |
| accent | Highlight color | #FF4081 |
| progressActive | Filled progress bar | #2196F3 |
| progressInactive | Empty progress bar | #424242 |
| timeText | Time display text | #B3E5FC |
| buttonTint | Button icon tint | #2196F3 |
You can add custom keys for your specific elements:
"colors": {
"primary": "#2196F3",
"titleText": "#FFD700",
"authorText": "#C0C0C0",
"bookmarkIcon": "#FF4081"
}
Then reference them in elements:
{
"id": "author",
"type": "text",
"dataBinding": "book.author",
"themeable": {
"color": "authorText"
// Uses your custom color
}
}
Colors must be in hex format:
#RRGGBB (e.g., #2196F3)#RRGGBBAA (e.g., #2196F380 for 50% opacity)β NOT SUPPORTED:
"red", "blue")rgb(33, 150, 243))You can bundle multiple themes with your skin:
"theme": {
"allowColorOverride": true,
"embeddedThemes": {
"neon": {/* neon theme colors */},
"pastel": {/* pastel theme colors */},
"monochrome": {
/* monochrome theme colors */
}
},
"defaultTheme": "neon"
}
Users can switch between your embedded themes or apply their own custom themes.
"allowColorOverride": true // Users can apply their own themes
"allowColorOverride": false // Lock to your embedded themes only
Recommendation: Always set to true unless your skin relies on specific colors to look correct.
Actions are player functions that buttons and gestures can trigger:
toggle-play-pause - Toggle between play and pauseplay - Start playbackpause - Pause playbacknext-chapter - Skip to next chapterprev-chapter - Go to previous chapterStandard Skip Times (with dedicated icons):
skip-forward-5 - Skip forward 5 secondsskip-backward-5 - Skip backward 5 secondsskip-forward-10 - Skip forward 10 secondsskip-backward-10 - Skip backward 10 secondsskip-forward-30 - Skip forward 30 secondsskip-backward-30 - Skip backward 30 secondsskip-forward-60 - Skip forward 1 minuteskip-backward-60 - Skip backward 1 minuteskip-forward-600 - Skip forward 10 minutesskip-backward-600 - Skip backward 10 minutesCustom Skip Times (any number of seconds):
skip-forward:{seconds} - Skip forward custom time (e.g., skip-forward:22)skip-backward:{seconds} - Skip backward custom time (e.g., skip-backward:77)Examples:
{
"id": "skip-22s",
"type": "button",
"action": "skip-forward:22",
// Custom 22 second skip
"themeable": {
"tint": "primary"
}
}
{
"id": "skip-7s",
"type": "button",
"action": "skip-backward:7",
// Custom 7 second rewind
"themeable": {
"tint": "primary"
}
}
Note: Custom skip times use generic fast-forward/fast-rewind icons. Standard times (5, 10, 30) have dedicated numbered icons.
show-chapters - Open chapter listopen-chapters - Alias of show-chapters (used by some skins)show-bookmarks - Open bookmarks listopen-bookmarks - Alias of show-bookmarks (used by some skins)show-speed-selector - Open playback speed selectoropen-speed - Alias of show-speed-selector (used by some skins)show-sleep-timer - Open sleep timercreate-bookmark - Create bookmark at current positionshow-history - Show listening historyexit-drive-mode - Exit drive mode and return to the normal player interface. This action should be placed on a clearly visible button (typically a circle with an "X" icon) in drive mode layouts.These actions enable high-speed seeking while the user holds a button (typically via longPress on a skip button):
start-5x-forward - Begin 5x-speed forward seekstop-5x-forward - Stop the 5x-speed forward seekstart-5x-backward - Begin 5x-speed backward seekstop-5x-backward - Stop the 5x-speed backward seekRecommended usage:
start-5x-forward as a longPress gesture on a forward skip button (for example, skip-forward-30).start-5x-backward as a longPress gesture on a backward skip button (for example, skip-backward-30).stop-β¦ action when the long press is released, so you normally only need to reference the start-β¦ actions in your manifest.Gestures define touch interactions:
"gestures": {
"tap": "toggle-play-pause",
"doubleTap": "skip-forward-30",
"longPress": "create-bookmark",
"swipeLeft": "next-chapter",
"swipeRight": "prev-chapter",
"swipeUp": "show-chapters",
"swipeDown": "show-sleep-timer"
}
Supported Gestures:
tap - Single quick tapdoubleTap - Two quick tapslongPress - Press and hold (>500ms)swipeLeft - Swipe leftswipeRight - Swipe rightswipeUp - Swipe upswipeDown - Swipe downWhere to Use:
Use textFormat on text elements to combine multiple values into a single display string.
Tokens are wrapped in {curly braces}:
{
"id": "time-remaining-label",
"type": "text",
"textFormat": "{chapterTimeRemainingHuman} ({speed})"
}
| Token | Example | Description |
|-------|---------|-------------|
| {currentTime} | "2:15:42" | Full book current time |
| {totalTime} | "10:53:07" | Full book total time |
| {timeRemaining} | "8:37:25" | Full book time remaining (speed-adjusted, H:MM:SS) |
| {timeRemainingHuman} | "8h 37m 25s" | Full book time remaining (speed-adjusted, human-readable) |
| {timeRemainingLabel} | "left" | Localized label (e.g. "left") |
| {speed} | "1.5x" | Current playback speed |
| {chapterTitle} | "Chapter 3" | Current chapter name |
| {chapterCurrentTime} | "0:12:30" | Chapter current time |
| {chapterTotalTime} | "0:45:00" | Chapter total duration |
| {chapterTimeRemaining} | "0:32:30" | Chapter time remaining (speed-adjusted, M:SS) |
| {chapterTimeRemainingHuman} | "32m 30s" | Chapter time remaining (speed-adjusted, human-readable) |
| {bookTitle} | "The Martian" | Book title |
| {bookAuthor} | "Andy Weir" | Book author |
| {sleepTimeRemaining} | "14:59" | Sleep timer remaining time (M:SS) |
| {sleepTimeRemainingHuman} | "14m 59s" | Sleep timer remaining time (human-readable) |
Tip: Use
{chapterTimeRemainingHuman}instead of{chapterTimeRemaining}for a more readable display like "32m 30s" instead of "0:32:30".
Data bindings connect elements to live player data. Use these in the dataBinding property of text
elements:
"dataBinding": "book.title" // "The Martian"
"dataBinding": "book.author" // "Andy Weir"
"dataBinding": "playback.currentTime" // "2:15:42"
"dataBinding": "playback.totalTime" // "10:53:07"
"dataBinding": "playback.position" // 0.0 to 1.0 (for progress bars)
"dataBinding": "playback.state" // "PLAYING", "PAUSED", or "LOADING"
"dataBinding": "playback.speed" // "1.0x", "1.5x", etc.
"dataBinding": "chapter.current" // "Chapter 3: The Storm"
"dataBinding": "chapter.index" // "3"
"dataBinding": "chapter.currentTime" // "0:12:30"
"dataBinding": "chapter.totalTime" // "0:45:00"
"dataBinding": "chapter.timeRemaining" // "0:32:30"
"dataBinding": "chapter.position" // 0.0 to 1.0 (for progress bars)
"dataBinding": "sleepTimer.remainingTime" // "14:59"
"dataBinding": "sleepTimer.remainingTimeHuman" // "14m 59s"
{
"id": "time-current",
"type": "text",
"x": 20,
"y": 480,
"width": 100,
"height": 30,
"dataBinding": "playback.currentTime",
"fontSize": 16,
"textAlign": "left",
"themeable": {
"color": "timeText"
}
},
{
"id": "time-separator",
"type": "text",
"x": 160,
"y": 480,
"width": 40,
"height": 30,
"dataBinding": null, // Static text, no binding
"fontSize": 16,
"textAlign": "center"
// Note: Static text not yet implemented, placeholder for future
},
{
"id": "time-total",
"type": "text",
"x": 240,
"y": 480,
"width": 100,
"height": 30,
"dataBinding": "playback.totalTime",
"fontSize": 16,
"textAlign": "right",
"themeable": {
"color": "timeText"
}
}
Let's create a simple skin called "Minimal Player":
minimal-player/
βββ manifest.json
βββ preview.png
{
"version": "1.0",
"skinName": "Minimal Player",
"author": "Your Name",
"description": "Clean and simple layout",
"minPlayerVersion": "2.0.0",
"previewImage": "preview.png",
"dimensions": {
"portrait": {
"width": 360,
"height": 640,
"aspectRatio": "free"
},
"landscape": {
"width": 640,
"height": 360,
"aspectRatio": "free"
}
},
"theme": {
"allowColorOverride": true,
"embeddedThemes": {
"minimal": {
"version": "1.0",
"themeName": "Minimal",
"author": "Your Name",
"description": "Clean minimal theme",
"colors": {
"primary": "#6200EE",
"background": "#FFFFFF",
"surface": "#F5F5F5",
"text": "#000000",
"accent": "#03DAC6",
"progressActive": "#6200EE",
"progressInactive": "#E0E0E0",
"timeText": "#666666",
"buttonTint": "#6200EE"
}
}
},
"defaultTheme": "minimal"
},
"layout": {
"portrait": [
{
"id": "cover",
"type": "cover-image",
"x": 30,
"y": 80,
"width": 300,
"height": 300,
"anchor": "top-left",
"themeable": {
"borderColor": "primary"
}
},
{
"id": "title",
"type": "text",
"x": 30,
"y": 400,
"width": 300,
"height": 50,
"anchor": "top-left",
"dataBinding": "book.title",
"fontSize": 24,
"fontWeight": "bold",
"textAlign": "center",
"themeable": {
"color": "text"
}
},
{
"id": "author",
"type": "text",
"x": 30,
"y": 455,
"width": 300,
"height": 30,
"anchor": "top-left",
"dataBinding": "book.author",
"fontSize": 18,
"textAlign": "center",
"themeable": {
"color": "text"
}
},
{
"id": "progress",
"type": "progress-bar",
"x": 30,
"y": 200,
"width": 300,
"height": 10,
"anchor": "bottom-left",
"dataBinding": "playback.position",
"interactive": true,
"themeable": {
"activeColor": "progressActive",
"inactiveColor": "progressInactive"
}
},
{
"id": "time-current",
"type": "text",
"x": 30,
"y": 165,
"width": 100,
"height": 25,
"anchor": "bottom-left",
"dataBinding": "playback.currentTime",
"fontSize": 14,
"textAlign": "left",
"themeable": {
"color": "timeText"
}
},
{
"id": "time-total",
"type": "text",
"x": 230,
"y": 165,
"width": 100,
"height": 25,
"anchor": "bottom-left",
"dataBinding": "playback.totalTime",
"fontSize": 14,
"textAlign": "right",
"themeable": {
"color": "timeText"
}
},
{
"id": "btn-prev",
"type": "button",
"x": 60,
"y": 100,
"width": 60,
"height": 60,
"anchor": "bottom-left",
"action": "prev-chapter",
"iconStyle": "rounded",
"themeable": {
"tint": "primary"
}
},
{
"id": "btn-play",
"type": "button",
"x": 150,
"y": 100,
"width": 80,
"height": 80,
"anchor": "bottom-left",
"action": "toggle-play-pause",
"iconStyle": "rounded",
"themeable": {
"tint": "primary"
}
},
{
"id": "btn-next",
"type": "button",
"x": 240,
"y": 100,
"width": 60,
"height": 60,
"anchor": "bottom-left",
"action": "next-chapter",
"iconStyle": "rounded",
"themeable": {
"tint": "primary"
}
}
],
"landscape": []
}
}
Create a 512x512 PNG showing what your skin looks like. You can:
Select all files in your minimal-player folder and create a ZIP:
manifest.jsonpreview.pngName it minimal-player.zip
minimal-player.zipAdd custom fonts to make your skin unique:
my-skin.zip
βββ manifest.json
βββ preview.png
βββ assets/
βββ fonts/
βββ my-font.ttf
"fonts": [
{
"id": "my-font.ttf",
"path": "assets/fonts/my-font.ttf",
"family": "MyCustomFont"
}
]
{
"id": "title",
"type": "text",
"fontFamily": "custom:my-font.ttf",
// Reference by id
"dataBinding": "book.title"
}
Note: Custom font loading is planned but not yet implemented. Currently use built-in families: " serif", "sans-serif", "monospace", "cursive".
Replace default icons with your own images:
my-skin.zip
βββ manifest.json
βββ preview.png
βββ assets/
βββ images/
βββ play.png
βββ pause.png
{
"id": "btn-play",
"type": "button",
"action": "toggle-play-pause",
"images": {
"paused": "assets/images/play.png", // Show play icon when paused
"playing": "assets/images/pause.png" // Show pause icon when playing
}
}
Note: Custom images override the built-in Material Icons.
State-Specific Images Explained:
For toggle-play-pause buttons, the system automatically switches images based on playback state:
| Playback State | Image Key Used | Typical Image | Why? |
|----------------|----------------|---------------|------|
| Playing | "playing" | Pause button | User needs to pause |
| Paused | "paused" | Play button | User needs to play |
Example:
{
"action": "toggle-play-pause",
"images": {
"paused": "assets/images/play.png", // β Shows when audio is PAUSED
"playing": "assets/images/pause.png" // β Shows when audio is PLAYING
}
}
Without Custom Images:
If you don't provide images, the system uses built-in Material Icons that automatically change:
{
"action": "toggle-play-pause",
"iconStyle": "filled", // Optional: "filled", "outlined", "rounded"
"themeable": {
"tint": "primary" // Icon color from theme
}
}
Make long text scroll (marquee effect):
{
"id": "title",
"type": "text",
"dataBinding": "book.title",
"width": 300,
"scrolling": {
"enabled": true,
"speed": 30,
// Pixels per second
"pauseDuration": 2000
// Pause 2 seconds at each end
}
}
Offer users variety within your skin:
"embeddedThemes": {
"light": {/* light colors */},
"dark": {/* dark colors */},
"sunset": {
/* warm orange tones */
},
"ocean": {
/* cool blue tones */
}
},
"defaultTheme": "dark"
Users can switch themes without changing skins!
A skin can automatically switch to a dark look when the device's system dark mode is on, without the user having to pick a different theme:
"dark" in embeddedThemes. Whenever the system is in
dark mode, the player (and preview_skin.py --dark) prefers embeddedThemes.dark over whatever
defaultTheme points to. Skins that don't define a "dark" entry are unaffected β they keep
using defaultTheme regardless of system dark mode, exactly as before this convention existed.image-type element (most commonly your background element), set nightImage to a
dark-mode replacement picture (see Image). It's swapped in for customImage under
the same condition.backgroundGradient (most commonly a background element with no
customImage), set backgroundGradientDark to a dark-mode replacement gradient (see
Gradient Backgrounds). It's swapped in for backgroundGradient under
the same condition β no image asset required."embeddedThemes": {
"light": {/* light colors */},
"dark": {/* dark colors, chosen automatically when system dark mode is on */}
},
"defaultTheme": "light"
{
"id": "background",
"type": "image",
"customImage": "assets/images/background-day.jpg",
"nightImage": "assets/images/background-night.jpg"
}
{
"id": "background",
"type": "image",
"backgroundGradient": {
"type": "linear",
"colors": ["#e7e1e2", "#a6a519"],
"angle": 0
},
"backgroundGradientDark": {
"type": "linear",
"colors": ["#141412", "#3a3d10"],
"angle": 0
}
}
Provide optimized landscape layout:
"layout": {
"portrait": [/* portrait elements */],
"landscape": [
{
"id": "cover",
"type": "cover-image",
"x": 20,
"y": 20,
"width": 250,
"height": 250,
"anchor": "top-left"
},
{
"id": "controls",
"type": "button",
"x": 300,
"y": 100,
// ... more landscape-specific positioning
}
]
}
Before sharing your skin, test:
Librarian includes a powerful validation tool that automatically checks your skin for errors, missing files, invalid properties, and best practice violations.
The validator is located in the project repository:
# Download the validator
curl -O https://raw.githubusercontent.com/your-repo/librarian/main/tools/validate_skin.py
# Or clone the entire repository
git clone https://github.com/your-repo/librarian.git
Requirements: Python 3.6 or higher (no external dependencies needed)
# Validate a skin ZIP file
python3 validate_skin.py my-awesome-skin.zip
# Validate a skin directory
python3 validate_skin.py my-skin-folder/
The validator performs 50+ automated checks across all aspects of your skin:
File Structure:
Manifest Validation:
Element Validation:
Property Validation:
Best Practices:
Valid skin:
Validating skin: my-skin.zip
SUCCESSS (1):
SUCCESS: manifest.json is valid JSON
============================================================
SUMMARY:
Errors: 0
Warnings: 0
Info: 0
Success: 1
β
VALIDATION PASSED
Skin is valid and ready to use!
Skin with issues:
Validating skin: broken-skin.zip
ERRORS (3):
ERROR: Invalid color format: 'red' (use #RRGGBB or #RRGGBBAA)
[manifest.theme.embeddedThemes.default.colors.primary]
ERROR: Landscape layout is empty - skins must provide functional
landscape layouts [manifest.layout.landscape]
ERROR: Invalid action: 'invalid-action' (see documentation for
valid actions) [manifest.layout.portrait[2].action]
WARNINGS (1):
WARNING: Button size (20x20) is below recommended minimum (48x48 dp)
[manifest.layout.portrait[1]]
============================================================
SUMMARY:
Errors: 3
Warnings: 1
Info: 0
Success: 0
β VALIDATION FAILED
Please fix the errors above before using this skin.
The validator categorizes findings into four levels:
During Development:
# Quick check after making changes
python3 validate_skin.py my-skin/
# Before creating the final ZIP
python3 validate_skin.py my-skin.zip
Before Sharing:
In Automated Workflows:
# In a build script
python3 validate_skin.py my-skin.zip || exit 1
# In a pre-commit hook
python3 validate_skin.py skins/*.zip
Error: "Invalid color format"
// β Wrong
"color": "red"
"color": "rgb(255, 0, 0)"
// β
Correct
"color": "#FF0000"
"color": "#FF0000AA" // With alpha
Error: "Landscape layout is empty"
// β Wrong
"landscape": []
// β
Correct - provide functional landscape layout
"landscape": [
{
"id": "cover",
"type": "cover-image",
"x": 20,
"y": 20,
"width": 200,
"height": 200
},
// ... more landscape elements
]
Error: "Invalid action"
// β Wrong
"action": "playPause"
"action": "skip_30"
// β
Correct
"action": "toggle-play-pause"
"action": "skip-forward-30"
Error: "Duplicate element id"
// β Wrong - two elements with same id
[
{"id": "button1", "type": "button", ...},
{"id": "button1", "type": "button", ...} // Duplicate!
]
// β
Correct - unique ids
[
{"id": "button1", "type": "button", ...},
{"id": "button2", "type": "button", ...}
]
Error: "Image file not found"
// β Wrong - file doesn't exist in ZIP
"customImage": "assets/background.png" // File missing!
// β
Correct - ensure file exists
// Make sure assets/background.png is in your ZIP
Warning: "Button size below minimum"
// β οΈ Warning - too small for comfortable tapping
{
"type": "button",
"width": 20,
"height": 20
}
// β
Better - meets accessibility guidelines
{
"type": "button",
"width": 60,
"height": 60
}
Error: "Gradient must have at least 2 colors"
// β Wrong
"backgroundGradient": {
"type": "linear",
"colors": ["#FF0000"] // Only 1 color!
}
// β
Correct
"backgroundGradient": {
"type": "linear",
"colors": ["#FF0000", "#0000FF"] // 2+ colors
}
The validator also checks:
Gradient Properties:
// Validates color stops match colors length
{
"backgroundGradient": {
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"stops": [0.0, 0.5, 1.0] // β
Length matches
}
}
Container Children:
// Recursively validates all nested elements
{
"type": "container",
"children": [
{
"type": "button",
"action": "play" // β
Validated
},
{
"type": "container", // β
Nested containers validated
"children": [...]
}
]
}
Custom Skip Actions:
// Validates custom skip durations
{
"action": "skip-forward:45" // β
Valid custom skip
}
The validator returns exit codes for automation:
0 - Validation passed (no errors, warnings OK)1 - Validation failed (errors found)This makes it easy to integrate into build scripts:
#!/bin/bash
if python3 validate_skin.py my-skin.zip; then
echo "Skin is valid, creating release..."
# ... build steps
else
echo "Skin validation failed!"
exit 1
fi
The Skin Preview Tool (preview_skin.py) generates realistic visual previews of your skin by rendering it with sample data. This helps you see exactly how your skin will look on a device before testing it in the app.
Requirements:
Install Python and Pillow:
On Windows:
pip install Pillow
On macOS:
# Python 3 is usually pre-installed
# Install Pillow
pip3 install Pillow
On Linux:
# Install Python and Pillow
sudo apt-get install python3 python3-pip
pip3 install Pillow
Get the Tool:
# Download just the preview tool
curl -O https://raw.githubusercontent.com/your-repo/librarian/main/tools/preview_skin.py
# Or clone the entire repository
git clone https://github.com/your-repo/librarian.git
Basic Preview:
# Preview a skin ZIP file
python3 preview_skin.py my-awesome-skin.zip
# Preview a skin directory
python3 preview_skin.py my-skin-folder/
# Preview multiple skins
python3 preview_skin.py skin1.zip skin2.zip skin3.zip
On Windows, use python instead of python3:
python preview_skin.py my-awesome-skin.zip
Auto-Open Preview:
# Generate and automatically open the preview
python3 preview_skin.py my-skin.zip --open
Custom Output Directory:
# Save previews to a specific directory
python3 preview_skin.py my-skin.zip --output ./previews/
Preview with Color Theme:
# Preview with a specific app color theme
python3 preview_skin.py my-skin.zip --color-theme "Dark"
python3 preview_skin.py my-skin.zip --color-theme "Metro Blue"
python3 preview_skin.py my-skin.zip --color-theme "Forest Green"
Watch Mode (highly recommended during development):
# Automatically regenerate preview when files change
python3 preview_skin.py my-skin-folder/ --watch
Watch mode will:
Custom Image Viewer:
# Use a specific image viewer (Linux)
python3 preview_skin.py my-skin.zip --open --viewer eog
# Use a specific image viewer (Windows)
python preview_skin.py my-skin.zip --open --viewer mspaint
The tool generates three preview images in the output directory:
my-skin-portrait.png - Portrait orientation previewmy-skin-landscape.png - Landscape orientation previewmy-skin-combined.png - Side-by-side comparison of both orientationsWhen using --open, only the combined image opens to avoid overwhelming your screen.
The preview shows your skin with realistic data:
All element types are rendered as they would appear in the app:
The preview tool automatically scales buttons to match how they appear on real devices (approximately 27% smaller than manifest dimensions). This accounts for the difference between design dimensions and actual device rendering.
If you notice buttons appearing different sizes on your device:
preview_skin.py in a text editorBUTTON_SCALE constant (around line 325)Development Workflow:
Start watch mode while editing your skin:
python3 preview_skin.py my-skin/ --watch
Edit your skin files in your favorite editor
Save changes - preview automatically updates
Check the preview to see your changes instantly
Iterate until satisfied
Before Sharing:
Generate final previews with different themes:
python3 preview_skin.py my-skin.zip --color-theme "Dark" --output ./previews/dark/
python3 preview_skin.py my-skin.zip --color-theme "Light" --output ./previews/light/
Include in your skin package - add preview images to your skin documentation
Validate one final time:
python3 validate_skin.py my-skin.zip
"Pillow not found" error:
# Install Pillow
pip install Pillow # Windows
pip3 install Pillow # macOS/Linux
Preview looks different from device:
BUTTON_SCALE in the script if needed (see Button Size Scaling above)Watch mode not working:
Preview shows "Image not found":
customImage paths exist in your skinWindows: "python3 not found":
python instead of python3 on Windowsvisible: true is set#RRGGBB or #RRGGBBAAthemeable propertiesallowColorOverride is true if using external themesaction is a valid action name (see Actions)bottom-* anchor for bottom controlsy value makes sense for bottom positioning(Coming in future update)
Enable debug mode to see:
Keep It Simple: Start with basic layouts before adding complexity.
Test Early: Import your skin frequently during development to catch issues.
Use Anchors: Always use appropriate anchors (bottom anchors for controls!).
Responsive Design: Remember your skin will scale to any screen size.
Accessibility:
Optimize Images:
Limit Elements:
Font Sizes:
Element IDs:
"title", "btn-play", "time-current"btn-* for buttons, time-* for time displaysGroup Related Elements: Order elements logically in your manifest:
Comment Your Manifest: While JSON doesn't support comments, use descriptive IDs and consider a separate README.md explaining your design choices.
Include a README:
# My Awesome Skin
Created by: Your Name
Version: 1.0
License: CC BY 4.0
## Features
- Clean minimal design
- 3 embedded themes
- Optimized for one-handed use
## Credits
- Font: [Font Name] by [Author]
- Icons: Material Design Icons
## Changelog
### 1.0
- Initial release
Preview Image:
Test on Multiple Devices:
| Type | Purpose | Key Properties |
|----------------|-----------------------|------------------------------------------------------|
| cover-image | Book cover | gestures, customImage |
| text | Dynamic text | dataBinding, fontSize, fontFamily, scrolling |
| button | Control button | action, iconStyle, images |
| progress-bar | Seek bar | interactive, themeable |
| image | Static image | customImage, backgroundMode, backgroundGradient|
| container | Group child elements | children, backgroundColor, backgroundGradient |
| rectangle | Filled box | backgroundColor, backgroundGradient |
| visualizer | Audio viz | (not yet implemented) |
| Anchor | Position From | X Direction | Y Direction |
|-----------------|------------------|-------------|-------------|
| top-left | Top-left corner | Right β | Down β |
| top-center | Top-center | Center β | Down β |
| top-right | Top-right corner | β Left | Down β |
| center-left | Middle-left | Right β | Center β |
| center | Screen center | Center β | Center β |
| center-right | Middle-right | β Left | Center β |
| bottom-left | Bottom-left | Right β | β Up |
| bottom-center | Bottom-center | Center β | β Up |
| bottom-right | Bottom-right | β Left | β Up |
| Action | Description | Typical Use |
|-----------------------|-------------------|-----------------------------------|
| toggle-play-pause | Play/pause toggle | Play button |
| prev-chapter | Previous chapter | Skip back button |
| next-chapter | Next chapter | Skip forward button |
| skip-backward-30 | Back 30 sec | Rewind 30s button |
| skip-forward-30 | Forward 30 sec | Forward 30s button |
| create-bookmark | Create bookmark | Bookmark button, long-press |
| show-chapters | Open chapters | Chapters button, cover long-press |
| show-bookmarks | Open bookmarks | Bookmarks button |
| show-speed-selector | Change speed | Speed button |
| show-sleep-timer | Sleep timer | Timer button |
| Binding | Example Value | Use For |
|------------------------|-----------------|----------------------|
| book.title | "The Martian" | Title text |
| book.author | "Andy Weir" | Author text |
| playback.currentTime | "2:15:42" | Current time display |
| playback.totalTime | "10:53:07" | Total time display |
| playback.position | 0.207 (0.0-1.0) | Progress bar |
| playback.state | "PLAYING" | State indicator |
| playback.speed | "1.5x" | Speed display |
| chapter.current | "Chapter 3" | Chapter name |
| chapter.index | "3" | Chapter number |
| chapter.currentTime | "0:12:30" | Chapter current time |
| chapter.totalTime | "0:45:00" | Chapter total time |
| chapter.timeRemaining| "0:32:30" | Chapter time left |
| chapter.position | 0.278 (0.0-1.0) | Chapter progress bar |
| sleepTimer.remainingTime | "14:59" | Sleep timer time left|
| sleepTimer.remainingTimeHuman| "14m 59s"| Human readable sleep time |
PLAYER_SPECIFICATION.md - Full specSKIN_DESIGNER_GUIDE.md - Design guidelinesFound a bug? Have a feature request?
Here's a complete, minimal working skin you can use as a template:
{
"version": "1.0",
"skinName": "Ultra Minimal",
"author": "Librarian Team",
"description": "Absolute bare minimum skin",
"previewImage": "preview.png",
"dimensions": {
"portrait": {
"width": 360,
"height": 640,
"aspectRatio": "free"
},
"landscape": {
"width": 640,
"height": 360,
"aspectRatio": "free"
}
},
"theme": {
"allowColorOverride": true,
"embeddedThemes": {
"default": {
"version": "1.0",
"themeName": "Default",
"colors": {
"primary": "#2196F3",
"background": "#000000",
"text": "#FFFFFF",
"progressActive": "#2196F3",
"progressInactive": "#424242"
}
}
},
"defaultTheme": "default"
},
"layout": {
"portrait": [
{
"id": "cover",
"type": "cover-image",
"x": 30,
"y": 100,
"width": 300,
"height": 300,
"anchor": "top-left"
},
{
"id": "title",
"type": "text",
"x": 30,
"y": 420,
"width": 300,
"height": 40,
"dataBinding": "book.title",
"fontSize": 20,
"textAlign": "center",
"themeable": {
"color": "text"
}
},
{
"id": "play",
"type": "button",
"x": 140,
"y": 120,
"width": 80,
"height": 80,
"anchor": "bottom-center",
"action": "toggle-play-pause",
"themeable": {
"tint": "primary"
}
},
{
"id": "progress",
"type": "progress-bar",
"x": 30,
"y": 200,
"width": 300,
"height": 10,
"anchor": "bottom-left",
"dataBinding": "playback.position",
"interactive": true,
"themeable": {
"activeColor": "progressActive",
"inactiveColor": "progressInactive"
}
}
],
"landscape": []
}
}
Save this as manifest.json, add a preview.png, ZIP them up, and you have a working skin!
Happy skinning! π¨