Free & open source — MIT License

Theme Development Guide

Everything you need to build custom themes for TubePress.

Overview

TubePress themes are self-contained folders inside the themes/ directory. Each theme controls the entire front-end appearance of your site: HTML structure, CSS styling, JavaScript behavior, and helper functions.

A theme requires only two files to work: theme.json (metadata) and templates/layout.php (master layout). Everything else is optional — TubePress will automatically fall back to the default Simply theme for any missing template.

Key concept: TubePress renders each page template into a $content variable, then passes it to layout.php. Your layout wraps all pages with a consistent header, footer, and navigation.

File Structure

A complete theme directory looks like this:

themes/ your-theme/ theme.json # Required — name, version, author, colors functions.php # Optional — helper functions, asset registration templates/ layout.php # Required — master layout (header + footer) home.php # Homepage video.php # Single video page categories.php # Category listing category.php # Single category (videos filtered) tags.php # Tag cloud tag.php # Single tag (videos filtered) performers.php # Pornstar listing performer.php # Single pornstar profile + videos studios.php # Studio listing studio.php # Single studio + videos search.php # Search results favorites.php # User favorites (authenticated) history.php # Watch history (authenticated) page.php # Static CMS pages 404.php # Not found page auth/ login.php # Login form register.php # Registration form profile.php # User profile partials/ # Reusable snippets (optional) assets/ css/ style.css js/ app.js

Fallback system: You don't need to create every template. If a template is missing from your theme, TubePress automatically uses the corresponding file from the simply theme. Start with just layout.php and override templates one by one.

theme.json

This file describes your theme. It is read by the admin panel to display theme information.

{
    "name": "My Theme",
    "description": "A custom theme for TubePress",
    "version": "1.0.0",
    "author": "Your Name",
    "screenshot": "assets/images/screenshot.png",
    "colors": {
        "primary": "#2563eb",
        "primary-dark": "#1d4ed8",
        "secondary": "#7c3aed",
        "background": "#ffffff",
        "surface": "#f8fafc",
        "text": "#1e293b",
        "text-muted": "#64748b"
    },
    "settings": {
        "videos_per_row": 4,
        "show_sidebar": false,
        "hero_enabled": false
    }
}
FieldTypeDescription
namestringDisplay name shown in admin
descriptionstringShort description of the theme
versionstringSemantic version number
authorstringTheme author name
screenshotstringPath to preview image (relative to theme folder)
colorsobjectColor palette (informational, used by admin)
settingsobjectTheme-specific default settings

functions.php

This file runs once when the theme boots. Use it to register CSS/JS assets and define helper functions used by your templates.

<?php
declare(strict_types=1);

// Register your CSS and JS assets
ThemeRenderer::enqueueCSS(ThemeManager::assetUrl('css/style.css'), 1);
ThemeRenderer::enqueueJS(ThemeManager::assetUrl('js/app.js'), 1);

// Define a helper function for video cards
function myThemeVideoCard(array $video): string
{
    // Allow plugins to track impressions (e.g. CTR plugin)
    HookSystem::doAction('video.card.before', $video);

    $title    = htmlspecialchars($video['title']);
    $slug     = htmlspecialchars($video['slug']);
    $duration = Format::duration((int)($video['duration'] ?? 0));
    $views    = Format::number((int)($video['views'] ?? 0));
    $timeAgo  = Format::timeAgo($video['published_at'] ?? $video['created_at']);

    $thumb    = htmlspecialchars(VideoAsset::thumbUrl($video));
    $preview  = htmlspecialchars(VideoAsset::previewUrl($video));

    return <<<HTML
    <article class="video-card">
        <a href="/video/{$slug}">
            <img src="{$thumb}" alt="{$title}" loading="lazy">
            <span class="duration">{$duration}</span>
        </a>
        <h3><a href="/video/{$slug}">{$title}</a></h3>
        <span>{$views} views &middot; {$timeAgo}</span>
    </article>
    HTML;
}

// Define a helper function for pagination
function myThemePagination(Pagination $pagination, string $baseUrl = ''): string
{
    if ($pagination->totalPages <= 1) return '';

    $html = '<nav class="pagination">';
    if ($pagination->hasPrev()) {
        $html .= '<a href="' . htmlspecialchars($pagination->prevUrl($baseUrl)) . '">&laquo; Prev</a>';
    }
    foreach ($pagination->pages() as $p) {
        if ($p === '...') {
            $html .= '<span class="ellipsis">&hellip;</span>';
        } elseif ($p === $pagination->page) {
            $html .= '<span class="active">' . $p . '</span>';
        } else {
            $url = $baseUrl ? $baseUrl . '?page=' . $p : '?' . http_build_query(['page' => $p] + $_GET);
            $html .= '<a href="' . htmlspecialchars($url) . '">' . $p . '</a>';
        }
    }
    if ($pagination->hasNext()) {
        $html .= '<a href="' . htmlspecialchars($pagination->nextUrl($baseUrl)) . '">Next &raquo;</a>';
    }
    $html .= '</nav>';
    return $html;
}

Priority parameter: enqueueCSS(url, priority) and enqueueJS(url, priority) accept a priority number. Lower numbers load first. Use 1 for your main assets.

Naming convention: Prefix your helper functions with your theme name (e.g. myThemeVideoCard) to avoid naming collisions with other themes or plugins. The built-in Simply theme uses simplyVideoCard() and simplyPagination().

Layout Template

layout.php is the skeleton of every page. It receives a $content variable containing the rendered page template. You must echo it inside your <main> element.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= isset($pageTitle)
        ? htmlspecialchars($pageTitle).' - '.htmlspecialchars($_siteName)
        : htmlspecialchars($_siteName) ?></title>
    <?php if (!empty($_siteFavicon)): ?>
    <link rel="icon" href="/uploads/branding/<?= htmlspecialchars($_siteFavicon) ?>">
    <?php endif; ?>
    <?= ThemeRenderer::renderCSS() ?>
    <?= $_colorOverrides ?>
    <?php HookSystem::doAction('head.meta'); ?>
</head>
<body class="cols-<?= (int)($_videosPerRow ?? 4) ?> <?= ThemeRenderer::bodyClass() ?>">

    <header>
        <a href="/">
            <?php if (!empty($_siteLogo)): ?>
                <img src="/uploads/branding/<?= htmlspecialchars($_siteLogo) ?>"
                     alt="<?= htmlspecialchars($_siteName) ?>">
            <?php else: ?>
                <?= htmlspecialchars($_siteName) ?>
            <?php endif; ?>
        </a>
        <nav>
            <?php foreach ($_menuItems as $mi): ?>
                <?php if ($mi['enabled']): ?>
                    <a href="<?= $mi['url'] ?>"
                       class="<?= in_array($_template, $mi['templates']) ? 'active' : '' ?>">
                        <?= $mi['label'] ?>
                    </a>
                <?php endif; ?>
            <?php endforeach; ?>
        </nav>
        <?php if ($_menuSearch): ?>
        <form action="/search" method="GET">
            <input type="text" name="q" placeholder="Search...">
        </form>
        <?php endif; ?>
        <?php if ($_user): ?>
            <a href="/profile"><?= htmlspecialchars($_user['username']) ?></a>
            <a href="/logout">Logout</a>
        <?php else: ?>
            <a href="/login">Login</a>
            <a href="/register">Sign Up</a>
        <?php endif; ?>
    </header>

    <main><?= $content ?></main>

    <footer class="footer footer__cols-<?= count($_footerColumns) ?>">
        <!-- Render columns from Footer Builder (see Footer Builder section) -->
        <?php foreach ($_footerColumns as $col): ?>
            <div><!-- render by $col['type'] --></div>
        <?php endforeach; ?>
        &copy; <?= date('Y').' '.htmlspecialchars($_siteName) ?>
    </footer>

    <?= ThemeRenderer::renderJS() ?>
    <?php HookSystem::doAction('footer.scripts'); ?>
</body>
</html>

Required calls: Your layout must include:

  • ThemeRenderer::renderCSS() in the <head>
  • ThemeRenderer::renderJS() before </body>
  • HookSystem::doAction('head.meta') and HookSystem::doAction('footer.scripts') for plugins to inject their assets

Template List

Each template receives specific variables from its controller. Here is the complete list:

TemplateRouteVariables
home.php/$videos, $pagination, $sort
video.php/video/{slug}$video, $categories, $tags, $performers, $studios, $comments, $similar, $recommended, $userVote, $isFavorited
categories.php/categories$categories
category.php/category/{slug}$category, $videos, $pagination
tags.php/tags$tags
tag.php/tag/{slug}$tag, $videos, $pagination
performers.php/pornstars$performers, $pagination
performer.php/pornstar/{slug}$performer, $videos, $pagination
studios.php/studios$studios, $pagination
studio.php/studio/{slug}$studio, $videos, $pagination
search.php/search?q=...$query, $videos, $pagination, $total
favorites.php/favorites$videos, $pagination
history.php/history$videos, $pagination
page.php/{slug}$page, $alternates
auth/login.php/login$error
auth/register.php/register$error
auth/profile.php/profile$error, $success, $profileUser
404.phpany unmatched(none)

Note: Every template also receives all global variables ($_siteName, $_user, $pageTitle, etc.) in addition to the variables listed above.

Variables in Detail

Upload Directories

All uploaded files live under public/uploads/ with the following subfolders:

SubdirectoryContains
/uploads/videos/{hash}/Per-video folder — contains video.mp4, thumb.jpg, thumb_small.jpg, preview.mp4 (see VideoAsset)
/uploads/performers/Pornstar photos
/uploads/studios/Studio logos
/uploads/branding/Site logo, favicon, background image

Per-video folder storage: Each video gets its own folder at /uploads/videos/{16-char-hex}/ with fixed filenames (thumb.jpg, video.mp4, etc.). Use the VideoAsset helper class to resolve all video paths — never construct paths manually. See VideoAsset.

Full URLs: The thumbnail field may contain a full HTTP URL for embed videos (no local folder). The VideoAsset::thumbUrl($video) method handles both cases automatically.

Video Array

In listing pages (home.php, category.php, etc.), each item in $videos contains:

$video = [
    'id'             => 42,
    'title'          => 'Video Title',
    'slug'           => 'video-title',
    'description'    => 'Full description...',
    'embed_code'     => '<iframe ...>',       // optional embed HTML, or null
    'video_folder'   => 'a1b2c3d4e5f67890',  // 16-char hex folder name, or null for embed-only
    'thumbnail'      => 'https://...',        // external thumbnail URL (embed videos only), or null
    'duration'       => 325,                  // seconds
    'views'          => 15420,
    'likes'          => 230,
    'dislikes'       => 12,
    'status'         => 'published',          // 'draft', 'published', or 'private'
    'user_id'        => 1,                    // uploader's user ID
    'published_at'   => '2026-01-15 14:30:00',
    'created_at'     => '2026-01-15 14:00:00',
    'updated_at'     => '2026-01-15 15:00:00',
    'performer_name' => 'Performer Name',     // first performer or null (listings only)
];

Video assets: Use VideoAsset to resolve all video-related URLs:

VideoAsset::thumbUrl($video)             // thumbnail URL (folder-based or external fallback)
VideoAsset::thumbUrl($video, true)       // prefer thumb_small.jpg for listings
VideoAsset::previewUrl($video)           // preview clip URL, or ''
VideoAsset::videoUrl($video['video_folder'])  // main video file URL

Never construct paths like '/uploads/videos/' . $video['video_folder'] . '/thumb.jpg' manually — always use the helper.

On the single video page (video.php), the $video array also includes related entities:

// Additional fields on single video page:
$video['categories'] = [ ['id' => 3, 'name' => '...', 'slug' => '...'], ... ];
$video['tags']       = [ ['id' => 12, 'name' => '...', 'slug' => '...'], ... ];
$video['performers'] = [ ['id' => 5, 'name' => '...', 'slug' => '...'], ... ];
$video['studios']    = [ ['id' => 2, 'name' => '...', 'slug' => '...'], ... ];

Video-specific template variables

VariableTypeDescription
$similararrayRelated videos (same categories/tags). Plugins can extend the matching criteria via the video.similar filter.
$recommendedarrayRecommended videos (excludes current + similar). Plugins can override the algorithm via the video.recommended filter.
$userVotestring|nullCurrent user's vote: 'like', 'dislike', or null
$isFavoritedboolWhether the current user has favorited this video
$commentsarrayNested tree of comment arrays (see below)

Performer Array

$performer = [
    'id'           => 5,
    'name'         => 'Performer Name',
    'slug'         => 'performer-name',
    'bio'          => 'Biography text...',
    'photo'        => 'photo_abc.jpg',       // relative to /uploads/performers/
    'country'      => 'US',                  // ISO 3166-1 alpha-2 code
    'birth_date'   => '1995-03-12',          // or null
    'social_links' => '{"link":"https://..."}', // JSON string, or null
    'video_count'  => 48,
    'created_at'   => '2026-01-10 10:00:00',

    // Core field (set by admin/front controllers)
    'best_video_folder'     => 'a1b2c3d4e5f67890',  // video_folder of best video, or null

    // Plugin fields (added by CTR plugin when active)
    'total_ctr'             => 12.45,        // sum of ctr_score of all videos
];

Social links: The social_links field is a JSON string. Decode it with json_decode($performer['social_links'], true) to get an associative array. The key is link containing a website or social profile URL.

Fallback thumbnail: Use best_video_folder to show a fallback thumbnail when the performer has no photo:
$img = !empty($performer['photo']) ? '/uploads/performers/' . $performer['photo'] : (!empty($performer['best_video_folder']) ? VideoAsset::thumbUrlFromFolder($performer['best_video_folder']) : '');

Studio Array

$studio = [
    'id'          => 2,
    'name'        => 'Studio Name',
    'slug'        => 'studio-name',
    'description' => 'Studio description...',
    'logo'        => 'logo_abc.jpg',         // relative to /uploads/studios/, or null
    'website'     => 'https://example.com',  // or null
    'video_count' => 120,
    'created_at'  => '2026-01-10 10:00:00',
    'updated_at'  => '2026-01-10 10:00:00',

    // Core field
    'best_video_folder'     => 'a1b2c3d4e5f67890',  // video_folder of best video, or null

    // Plugin fields (added by CTR plugin when active)
    'total_ctr'             => 25.80,        // sum of ctr_score of all videos
];

Fallback thumbnail: Use best_video_folder to show a fallback thumbnail when the studio has no logo:
$img = !empty($studio['logo']) ? '/uploads/studios/' . $studio['logo'] : (!empty($studio['best_video_folder']) ? VideoAsset::thumbUrlFromFolder($studio['best_video_folder']) : '');

Category Array

$category = [
    'id'          => 3,
    'name'        => 'Category Name',
    'slug'        => 'category-name',
    'description' => 'Category description...',
    'thumbnail'   => 'cat.jpg',              // relative to /uploads/thumbnails/, or null
    'sort_order'  => 0,
    'video_count' => 156,                    // computed, not stored

    // Core field
    'best_video_folder'     => 'a1b2c3d4e5f67890',  // video_folder of best video, or null
];

Fallback thumbnail: Use best_video_folder to show a fallback thumbnail when the category has no custom thumbnail:
$img = !empty($category['thumbnail']) ? '/uploads/thumbnails/' . $category['thumbnail'] : (!empty($category['best_video_folder']) ? VideoAsset::thumbUrlFromFolder($category['best_video_folder']) : '');

Tag Array

$tag = [
    'id'          => 12,
    'name'        => 'Tag Name',
    'slug'        => 'tag-name',
    'video_count' => 89,                     // computed, not stored
];

Comment Array

$comment = [
    'id'         => 101,
    'video_id'   => 42,
    'user_id'    => 7,
    'username'   => 'johndoe',               // joined from users table
    'body'       => 'Comment text...',
    'parent_id'  => null,                    // null = top-level, int = reply
    'status'     => 'approved',              // 'pending', 'approved', or 'spam'
    'created_at' => '2026-02-10 09:15:00',
    'replies'    => [ ... ],                 // nested array of child comments
];

Page Array

$page = [
    'id'               => 1,
    'title'            => 'About Us',              // translated if available
    'slug'             => 'about-us',              // translated if available
    'body'             => '<p>Rich HTML...</p>',   // TinyMCE content, translated if available
    'meta_title'       => 'About Us | My Site',    // SEO title (fallback to default lang)
    'meta_description' => 'Learn about us...',     // SEO description (fallback to default lang)
    'status'           => 'published',
    'show_in_footer'   => 1,                       // 1 = show in footer, 0 = don't
    'sort_order'       => 0,
    'created_at'       => '2026-01-10 10:00:00',
];

// $alternates = hreflang URLs for multilingual pages
$alternates = [
    'en'        => 'https://example.com/about-us',
    'fr'        => 'https://example.com/fr/a-propos',
    'x-default' => 'https://example.com/about-us',
];

Pages & Translations: Pages support multilingual content. The $page array automatically contains translated fields (title, slug, body, meta_title, meta_description) when a translation exists for the current locale. If no translation exists, the default language content is used as fallback.

Page URLs use a clean /{slug} pattern (no /page/ prefix). The page route is a catch-all registered last, so it won't conflict with other routes. Use url('/' . $page['slug']) to generate locale-aware URLs.

The page body contains rich HTML from TinyMCE. Wrap it in <div class="rich-content"> and provide CSS for headings, lists, tables, blockquotes, code blocks, images, etc. in your theme.

Use $alternates for hreflang tags in layout.php:

<?php if (!empty($alternates)): foreach ($alternates as $hl => $hu): ?>
<link rel="alternate" hreflang="<?= htmlspecialchars($hl) ?>" href="<?= htmlspecialchars($hu) ?>">
<?php endforeach; endif; ?>

Global Variables

These variables are available in every template, including layout.php:

VariableTypeDescription
$contentstringRendered page HTML (only in layout.php)
$_templatestringCurrent template name (home, video, category...)
$_siteNamestringSite name from admin settings
$_siteDescriptionstringSite description from admin settings
$_siteUrlstringSite URL from admin settings
$_videosPerRowintGrid columns setting (4, 5, or 6)
$_userarray|nullLogged-in user array or null if guest
$_footerPagesarrayCMS pages marked for the footer (in_footer = 1) — backward compat
$_footerColumnsarrayResolved footer columns from Footer Builder (see Footer Builder)
$_footerBottomarrayFooter bottom bar config: show_copyright, show_disclaimer, show_social, custom texts
$_socialLinksarraySocial media URLs keyed by platform: ['twitter' => 'https://...', ...]
$pageTitlestringPage-specific title set by the controller
$_siteLogostringLogo filename (relative to /uploads/branding/), or empty for text logo
$_siteFaviconstringFavicon filename (relative to /uploads/branding/), or empty
$_siteBackgroundstringBackground image filename (relative to /uploads/branding/), or empty
$_siteBackgroundModestringBackground display mode: cover, contain, or repeat
$_colorOverridesstringA <style> tag with CSS variable overrides from admin, or empty string
$_menuItemsarrayNavigation items array (see Appearance Integration)
$_menuSearchboolWhether the search bar should be displayed
$_cardShowDurationboolShow duration badge on card thumbnails (see Video Cards)
$_cardShowTitleboolShow video title on cards
$_cardMetaLeftstringLeft meta info: views, likes, time, or none
$_cardMetaRightstringRight meta info: views, likes, time, or none
$_cardGridGapintGap between cards in pixels (0–30)
$_cardBorderRadiusintCard border-radius in pixels (0–24)
$_cardThumbnailHoverboolEnable zoom effect on thumbnail hover
$_cardTitleLinesintTitle lines: 1 (ellipsis) or 2 (line-clamp)
$_registrationEnabledboolWhether user registration is enabled
$_commentsEnabledboolWhether comments are enabled
$_captchaProviderstringActive captcha provider: 'none', 'v2', or 'v3'
$_captchaSiteKeystringGoogle reCAPTCHA site key (empty if not configured)
$_captchaOnRegisterboolWhether captcha is required on the registration form
$_captchaOnCommentboolWhether captcha is required on comment forms
$_watchShowViewsboolShow view count on video page
$_watchShowDurationboolShow duration on video page
$_watchShowDateboolShow publish date on video page
$_watchShowLikesboolShow like/dislike buttons on video page
$_watchShowFavoritesboolShow favorite button on video page
$_watchShowPornstarsboolShow performers on video page
$_watchShowStudiosboolShow studios on video page
$_watchShowCategoriesboolShow categories on video page
$_watchShowTagsboolShow tags on video page
$_localestringCurrent locale code (e.g. 'en', 'fr')
$_directionstring'ltr' or 'rtl'
$_isRtlboolWhether current language is right-to-left
$_availableLangsarrayArray of available languages
$_langPrefixstringURL prefix for current language (e.g. '/fr' or '')

Use $_template to highlight the active navigation item:

<a href="/" class="<?= $_template === 'home' ? 'active' : '' ?>">Home</a>
<a href="/categories" class="<?= in_array($_template, ['categories','category']) ? 'active' : '' ?>">Categories</a>

The $_user array (when logged in) contains:

$_user = [
    'id'       => 1,
    'username' => 'johndoe',
    'email'    => 'john@example.com',
    'role'     => 'user',           // 'admin' or 'user'
];

ThemeRenderer API

All methods are static. Call them with ThemeRenderer::methodName().

MethodReturnsDescription
render($template, $data)voidCore method — renders a template with data, sets global variables, applies theme.template_data filter, wraps with layout.php
enqueueCSS($url, $priority)voidRegister a CSS file for <head>. Lower priority loads first.
enqueueJS($url, $priority)voidRegister a JS file for before </body>. Lower priority loads first.
renderCSS()stringReturns all CSS <link> tags as HTML string
renderJS()stringReturns all JS <script> tags as HTML string
setBodyClass($class)voidSet a custom CSS class string on the body element
bodyClass()stringGet the current body class string
partial($name, $data)voidInclude and render a partial from templates/partials/. Echoes output directly.
buildColorOverrides()stringReturns a <style> tag with CSS variable overrides from admin color settings, or an empty string if no colors are customized

Important: partial() echoes output directly (returns void). Use it inline: <?php ThemeRenderer::partial('video-card', ['video' => $v]); ?>

ThemeManager API

All methods are static. Call them with ThemeManager::methodName().

MethodReturnsDescription
boot()voidInitialize the active theme: loads theme.json and runs functions.php. Called automatically by the core.
active()stringReturns the slug of the currently active theme
activate($slug)boolActivate a theme by slug. Returns true on success.
getAll()arrayReturns an array of all available themes with their metadata
info($key, $default)mixedRead a value from the active theme's theme.json
assetUrl($path)stringReturns the public URL to a theme asset.
ThemeManager::assetUrl('css/style.css')/themes/your-theme/assets/css/style.css
themePath($slug)stringFilesystem path to a theme directory
templatePath($template)stringResolves a template name to its file path (active theme first, then simply fallback)

Player (Video Player)

The Player class is a core helper that handles all video playback logic. It automatically detects whether to render an embed iframe or initialize FluidPlayer (the built-in HTML5 video player). Themes don't need to implement any player logic.

Usage

In your video.php template, render the player with a single call:

<?= Player::render($video) ?>

That's it. The Player class handles everything:

  • Embed videos (embed_code set): renders the iframe inside a .video-player container with 16:9 aspect ratio
  • Uploaded/local videos (video_folder set, no embed): renders a <video> tag and initializes FluidPlayer with controls, poster image, and theme color integration. Uses VideoAsset::videoUrl() and VideoAsset::thumbUrl() to resolve file paths.
  • No video source: returns an empty string

What happens behind the scenes

When Player::render() is called with a video_folder:

  1. Enqueues /assets/vendor/fluidplayer/fluidplayer.min.js via ThemeRenderer::enqueueJS()
  2. Injects FluidPlayer CSS into the <head> via head.meta hook
  3. Registers the init script via footer.scripts hook (runs after the JS loads)
  4. Reads the theme's --primary CSS variable to match the player controls to your color scheme

All assets are enqueued only once, even if render() is called multiple times.

Generated HTML

Embed:

<div class="video-player">
    <iframe src="..." width="100%" height="100%"></iframe>
</div>

FluidPlayer:

<div class="video-player video-player--fluid">
    <video id="mainVideo" poster="/uploads/videos/a1b2c3d4e5f67890/thumb.jpg">
        <source src="/uploads/videos/a1b2c3d4e5f67890/video.mp4" type="video/mp4">
    </video>
</div>

CSS for themes

The core injects the functional CSS for .video-player--fluid automatically. Your theme only needs to style the basic container:

/* Theme CSS — only visual styles needed */
.video-player {
    background: #000;
    border-radius: 12px;
    overflow: hidden;
    aspect-ratio: 16/9;
}
.video-player iframe {
    width: 100%;
    height: 100%;
    display: block;
}

No player code in themes: FluidPlayer is part of the CMS core, pre-built and ready to use. Themes do not need to load any player library, write any JavaScript, or handle embed vs upload detection. Just call Player::render($video).

Layout requirement: Your layout must include HookSystem::doAction('head.meta') in the <head> and HookSystem::doAction('footer.scripts') before </body> for the player assets to load correctly.

Helper Classes

Format

MethodInputOutput
Format::duration(325)32505:25
Format::number(15420)1542015.4K
Format::timeAgo('2026-02-10 09:00:00')datetime3 days ago
Format::date('2026-02-10')date10/02/2026
Format::fileSize(5242880)bytes5 MB

Slug

MethodDescription
Slug::generate('Hello World')Returns hello-world — converts text to URL-safe slug
Slug::unique($table, $text, $excludeId)Generates a unique slug for a database table, appending -2, -3, etc. if needed. $excludeId is optional (used for updates).

VideoAsset (Video File Helper)

The VideoAsset class centralizes all video file path resolution. Each video gets its own folder at public/uploads/videos/{16-char-hex}/ containing fixed-name files:

FileDescription
video.mp4Main video file (also .webm or .ogg)
thumb.jpgFull-size thumbnail
thumb_small.jpgSmall thumbnail (for grids/listings)
preview.mp4Short preview clip for hover-to-play

Theme-facing methods

These are the methods you will use most often in theme templates and functions.php:

MethodReturnsDescription
VideoAsset::thumbUrl($video)stringFull-size thumbnail URL. Checks video_folder first, falls back to thumbnail field (external URL). Returns empty string if neither exists.
VideoAsset::thumbUrl($video, true)stringPrefers thumb_small.jpg if it exists, otherwise falls back to full-size thumb. Use this for video card listings for faster loading.
VideoAsset::previewUrl($video)stringPreview clip URL, or empty string if no preview exists.
VideoAsset::videoUrl($folder)stringMain video file URL. Auto-detects the extension (.mp4, .webm, .ogg).
VideoAsset::thumbUrlFromFolder($folder)stringThumbnail URL from a folder hash directly (used for best_video_folder on categories, performers, studios).
VideoAsset::assignUniqueThumbFolders(&$items, $joinTable, $fkColumn, $manualField, $orderBy)voidAssigns deduplicated best_video_folder to a list of entities. See Thumbnail Deduplication below.
VideoAsset::url($folder, $type)stringGeneric URL builder. $type is 'thumb', 'thumb_small', or 'preview'.

Usage examples

// In a video card helper function:
$thumb   = VideoAsset::thumbUrl($video, true);  // small thumb for listings
$preview = VideoAsset::previewUrl($video);       // hover preview clip

// On the single video page:
$poster  = VideoAsset::thumbUrl($video);         // full-size for player poster
$src     = VideoAsset::videoUrl($video['video_folder']); // video file URL

// For category/performer/studio fallback thumbnails:
$img = VideoAsset::thumbUrlFromFolder($item['best_video_folder']);

// Deduplicate thumbnails on listing pages (called in models automatically):
VideoAsset::assignUniqueThumbFolders($categories, 'video_categories', 'category_id', 'thumbnail');
VideoAsset::assignUniqueThumbFolders($performers, 'video_performers', 'performer_id', 'photo');
VideoAsset::assignUniqueThumbFolders($studios, 'video_studios', 'studio_id', 'logo');

Never construct paths manually. Always use VideoAsset methods. The helper checks file existence, handles missing folders, and gracefully falls back to empty strings. Manual path construction like '/uploads/videos/' . $folder . '/thumb.jpg' will break when files are missing.

Embed-only videos: Videos with only embed_code (no video_folder) may store an external thumbnail URL in the thumbnail field. VideoAsset::thumbUrl($video) handles this automatically — it returns the external URL when no folder-based thumbnail exists.

Pagination

Pages that list videos receive a $pagination object (instance of Pagination):

Property / MethodTypeDescription
$pagination->pageintCurrent page number
$pagination->perPageintItems per page
$pagination->totalintTotal number of items
$pagination->totalPagesintTotal number of pages
$pagination->offsetintDatabase offset for current page (useful for queries)
hasPrev()boolIs there a previous page?
hasNext()boolIs there a next page?
prevUrl($base = '')stringURL to the previous page. Pass a base URL or leave empty to preserve current $_GET params.
nextUrl($base = '')stringURL to the next page. Same $base behavior.
pages()arrayArray of page numbers and '...' ellipsis markers for building navigation

Creating pagination in controllers

// Factory method: reads ?page= from URL, computes totalPages and offset
$pagination = Pagination::fromRequest($totalItems, $perPage);

Example template usage

<?php if ($pagination->totalPages > 1): ?>
<nav class="pagination">
    <?php if ($pagination->hasPrev()): ?>
        <a href="<?= htmlspecialchars($pagination->prevUrl()) ?>">&laquo; Prev</a>
    <?php endif; ?>

    <?php foreach ($pagination->pages() as $p): ?>
        <?php if ($p === '...'): ?>
            <span class="ellipsis">&hellip;</span>
        <?php elseif ($p === $pagination->page): ?>
            <span class="active"><?= $p ?></span>
        <?php else: ?>
            <a href="?<?= http_build_query(['page' => $p] + $_GET) ?>"><?= $p ?></a>
        <?php endif; ?>
    <?php endforeach; ?>

    <?php if ($pagination->hasNext()): ?>
        <a href="<?= htmlspecialchars($pagination->nextUrl()) ?>">Next &raquo;</a>
    <?php endif; ?>
</nav>
<?php endif; ?>

Appearance Integration

TubePress provides admin-configurable appearance settings that your theme can use for dynamic branding, colors, backgrounds, and navigation. All values are set via Settings → Appearance in the admin panel.

Menu Items ($_menuItems)

The $_menuItems array contains navigation entries with admin-controlled visibility:

$_menuItems = [
    [
        'key'       => 'home',        // identifier
        'label'     => 'Home',        // display text
        'url'       => '/',           // link href
        'enabled'   => true,          // admin toggle (always true for Home)
        'templates' => ['home'],      // templates where this item is "active"
    ],
    [
        'key'       => 'categories',
        'label'     => 'Categories',
        'url'       => '/categories',
        'enabled'   => true,          // false if admin unchecked it
        'templates' => ['categories', 'category'],
    ],
    // ... tags, pornstars, studios
];

Use it to build dynamic navigation with active state:

<nav>
    <?php foreach ($_menuItems as $mi): ?>
        <?php if ($mi['enabled']): ?>
            <a href="<?= $mi['url'] ?>"
               class="<?= in_array($_template, $mi['templates']) ? 'active' : '' ?>">
                <?= $mi['label'] ?>
            </a>
        <?php endif; ?>
    <?php endforeach; ?>
</nav>

Color Overrides ($_colorOverrides)

When the admin sets custom colors, $_colorOverrides contains a <style> tag that overrides the theme's CSS variables. Place it after renderCSS() so the overrides take precedence:

<?= ThemeRenderer::renderCSS() ?>
<?= $_colorOverrides ?>
<!-- If no colors are customized, $_colorOverrides is an empty string -->

The overrides map to these CSS variables: --primary, --primary-dark, --secondary, --bg, --bg-surface, --bg-card, --bg-dark, --text, --text-muted, --border.

Logo & Favicon

Use $_siteLogo and $_siteFavicon to show uploaded images or fall back to text:

<!-- Favicon in <head> -->
<?php if (!empty($_siteFavicon)): ?>
    <link rel="icon" href="/uploads/branding/<?= htmlspecialchars($_siteFavicon) ?>">
<?php endif; ?>

<!-- Logo in header -->
<a href="/">
    <?php if (!empty($_siteLogo)): ?>
        <img src="/uploads/branding/<?= htmlspecialchars($_siteLogo) ?>"
             alt="<?= htmlspecialchars($_siteName) ?>">
    <?php else: ?>
        <?= htmlspecialchars($_siteName) ?>
    <?php endif; ?>
</a>

Background Image

When $_siteBackground is set, apply it as an inline style on <body>. The $_siteBackgroundMode value (cover, contain, or repeat) determines how the image is displayed.

Default behavior: When no appearance settings are configured (all empty), the theme renders exactly as before — text logo, no favicon, no background image, no color overrides, and all menu items visible. Themes should always handle the empty state gracefully.

The admin can configure the footer layout via Settings → Appearance → Footer. The footer is divided into 1–4 columns, each with a configurable type. Your theme receives resolved footer data as global variables.

Variables

VariableTypeDescription
$_footerColumnsarrayResolved column data (see column types below)
$_footerBottomarrayBottom bar config: show_copyright, copyright_text, show_disclaimer, disclaimer_text, show_social
$_socialLinksarraySocial URLs: ['twitter' => '...', 'instagram' => '...', ...]

Column Types

Each entry in $_footerColumns has a type key and type-specific data:

TypeExtra KeysDescription
logologo, site_name, descriptionSite logo/name and description
navigationRenders main menu items (use $_menuItems)
pagespages (array of [id, title, slug])Selected CMS pages or all footer pages
custom_linkslinks (array of [label, url])Custom links defined by admin
badgesbadges (array of [icon, label])Info badges with SVG icons
textcontent (HTML string)Free-form HTML content
sociallinks (filtered $_socialLinks)Social media icon links

Rendering Example

<footer class="footer footer__cols-<?= count($_footerColumns) ?>">
  <div class="container">
    <div class="footer__inner">
      <?php foreach ($_footerColumns as $col): ?>
        <div class="footer__col">
          <?php if ($col['title']): ?><h4><?= htmlspecialchars($col['title']) ?></h4><?php endif; ?>
          <?php switch ($col['type']):
            case 'logo': ?>
              <!-- logo/site_name/description -->
            <?php break; case 'pages': ?>
              <?php foreach ($col['pages'] as $pg): ?>
                <a href="<?= url('/' . $pg['slug']) ?>"><?= htmlspecialchars($pg['title']) ?></a>
              <?php endforeach; ?>
            <?php break; case 'badges': ?>
              <?php $icons = FooterConfig::badgeIcons();
              foreach ($col['badges'] as $b):
                if (isset($icons[$b['icon']])): ?>
                <div class="footer__badge">
                  <svg width="18" height="18" viewBox="0 0 24 24" fill="none"
                       stroke="currentColor" stroke-width="2">
                    <?= $icons[$b['icon']]['svg'] ?>
                  </svg>
                  <span><?= htmlspecialchars($b['label']) ?></span>
                </div>
              <?php endif; endforeach; ?>
            <?php break; ?>
            <!-- ... other types -->
          <?php endswitch; ?>
        </div>
      <?php endforeach; ?>
    </div>
  </div>
</footer>

Badge Icons

Use FooterConfig::badgeIcons() to get the SVG data for each icon key: shield, upload, video, star, check, heart, globe, lock, zap, award.

Backward compat: $_footerPages and $_socialLinks are still passed for themes not yet using the footer builder. When no footer config exists in the database, the default layout (4 columns: logo, navigation, pages, badges) is used automatically.

Video Cards

TubePress provides admin-configurable video card settings via Settings → Video Cards. These settings control what elements appear on each card, which meta info is displayed, grid spacing, and border radius. Your theme receives these as global variables.

Card Variables

These variables are available in every template alongside the other global variables:

VariableTypeDefaultDescription
$_cardShowDurationbooltrueShow the duration badge on the thumbnail
$_cardShowTitlebooltrueShow the video title below the thumbnail
$_cardMetaLeftstring'views'Left meta info: views, likes, time, or none
$_cardMetaRightstring'time'Right meta info: views, likes, time, or none
$_cardGridGapint4Gap between cards in pixels (0–30)
$_cardBorderRadiusint8Card border-radius in pixels (0–24)
$_cardThumbnailHoverbooltrueEnable zoom effect on thumbnail hover
$_cardTitleLinesint1Number of title lines: 1 (ellipsis) or 2 (line-clamp)

CSS Custom Properties

The $_colorOverrides style tag also includes two card-specific CSS variables that you can use in your stylesheet:

:root {
    --card-gap: 4px;       /* from admin setting card_grid_gap */
    --card-radius: 8px;    /* from admin setting card_border_radius */
}

Use them in your grid and card styles:

.video-grid {
    display: grid;
    grid-template-columns: repeat(var(--grid-cols), 1fr);
    gap: var(--card-gap, 4px);
}

.video-card {
    border-radius: var(--card-radius, 8px);
    overflow: hidden;
}

Body Classes

The layout adds conditional classes to <body> based on card settings:

ClassWhenPurpose
no-thumb-hover$_cardThumbnailHover === falseDisable thumbnail zoom on hover
title-lines-1$_cardTitleLines === 1Single-line titles with ellipsis
title-lines-2$_cardTitleLines === 2Two-line titles with line-clamp

Use them to disable hover effects and control title wrapping in CSS:

/* Zoom on hover (default) */
.video-card:hover .video-card__thumb img {
    transform: scale(1.05);
}

/* Disable when admin turns it off */
body.no-thumb-hover .video-card:hover .video-card__thumb img {
    transform: none;
}

/* Single-line title (default) */
.video-card__title {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Two-line clamp variant */
.video-card__title--clamp2 {
    white-space: normal;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}

Video Card Partial

The built-in Simply theme uses a partial template at templates/partials/video-card.php for all video cards. This ensures the exact same card is rendered everywhere: homepage, categories, tags, performers, studios, search results, and related videos.

The partial receives these pre-computed variables:

VariableTypeDescription
$thumbstringThumbnail URL (escaped)
$titlestringVideo title (escaped)
$slugstringVideo slug (escaped)
$durationstringFormatted duration (e.g. 05:25)
$showDurationboolFrom $_cardShowDuration
$showTitleboolFrom $_cardShowTitle
$titleClassstringCSS class: video-card__title or video-card__title video-card__title--clamp2
$leftstringLeft meta text (or empty)
$rightstringRight meta text (or empty)

The partial template

Here is the default templates/partials/video-card.php:

<article class="video-card">
    <a href="/video/<?= $slug ?>" class="video-card__thumb">
        <img src="<?= $thumb ?>" alt="<?= $title ?>" loading="lazy">
        <?php if ($showDuration): ?>
            <span class="video-card__duration"><?= $duration ?></span>
        <?php endif; ?>
    </a>
    <?php if ($showTitle || $left !== '' || $right !== ''): ?>
        <div class="video-card__info">
            <?php if ($showTitle): ?>
                <h3 class="<?= $titleClass ?>">
                    <a href="/video/<?= $slug ?>"><?= $title ?></a>
                </h3>
            <?php endif; ?>
            <?php if ($left !== '' || $right !== ''): ?>
                <div class="video-card__meta">
                    <span><?= $left ?></span>
                    <span><?= $right ?></span>
                </div>
            <?php endif; ?>
        </div>
    <?php endif; ?>
</article>

The helper function

The helper function prepares variables from the card settings and renders the partial:

function myThemeVideoCard(array $video): string
{
    // Allow plugins to track impressions (e.g. CTR plugin)
    HookSystem::doAction('video.card.before', $video);

    global $_cardShowDuration, $_cardShowTitle, $_cardMetaLeft, $_cardMetaRight,
           $_cardBorderRadius, $_cardTitleLines;

    // Allow plugins to override entirely
    $card = HookSystem::applyFilter('video.card.html', '', $video);
    if ($card !== '') { return $card; }

    $thumb   = htmlspecialchars(VideoAsset::thumbUrl($video, true)); // prefer small for listings
    $preview = htmlspecialchars(VideoAsset::previewUrl($video));

    $metaValues = [
        'views' => Format::number((int)($video['views'] ?? 0)) . ' views',
        'likes' => Format::number((int)($video['likes'] ?? 0)) . ' likes',
        'time'  => Format::timeAgo($video['published_at'] ?? $video['created_at'] ?? ''),
        'none'  => '',
    ];

    $data = [
        'thumb'        => $thumb,
        'preview'      => $preview,
        'title'        => htmlspecialchars($video['title']),
        'slug'         => htmlspecialchars($video['slug']),
        'duration'     => Format::duration((int)($video['duration'] ?? 0)),
        'showDuration' => $_cardShowDuration ?? true,
        'showTitle'    => $_cardShowTitle ?? true,
        'titleClass'   => (($_cardTitleLines ?? 1) >= 2)
            ? 'video-card__title video-card__title--clamp2'
            : 'video-card__title',
        'left'         => $metaValues[$_cardMetaLeft ?? 'views'] ?? '',
        'right'        => $metaValues[$_cardMetaRight ?? 'time'] ?? '',
    ];

    // Render the partial as a string
    $path = ThemeManager::templatePath('partials/video-card');
    if ($path === '') { return ''; }
    extract($data, EXTR_SKIP);
    ob_start();
    require $path;
    return ob_get_clean();
}

Overriding the card: Since the partial is resolved via ThemeManager::templatePath(), you can override it in your own theme by creating templates/partials/video-card.php. Your version will be used instead of Simply's. This makes it easy to completely redesign the card without touching the helper function.

Default behavior: When no card settings are configured (fresh install), cards show duration, title (1 line), views on the left, time ago on the right, 4px gap, and 8px border radius. Themes should always provide sensible fallbacks using the ?? operator.

Layout requirement: For the --card-gap and --card-radius CSS variables to work, your layout must include <?= $_colorOverrides ?> after ThemeRenderer::renderCSS(). For body classes (no-thumb-hover, title-lines-N), include them on your <body> tag:

<body class="cols-<?= (int)($_videosPerRow ?? 4) ?><?php if (isset($_cardThumbnailHover) && !$_cardThumbnailHover): ?> no-thumb-hover<?php endif; ?> title-lines-<?= (int)($_cardTitleLines ?? 1) ?> <?= ThemeRenderer::bodyClass() ?>">

Listing Pages & Card Helpers (Theme-Driven)

TubePress allows themes to customize listing pages (categories, pornstars, studios) via hooks. Settings are theme-specific: when a theme is deactivated, its settings are cleaned up automatically via Setting::deleteByPrefix('simply_').

Hook Points

HookTypePurpose
admin.subtabs.appearanceFilterAdd/remove sub-tabs in Appearance settings
admin.appearance_extra.{section}ActionInject extra fields into Layout sub-tab sections (categories, performers, studios)
admin.appearance_render.{sub}ActionRender a full sub-tab when no core template exists
admin.appearance_save.{sub}ActionSave theme-specific settings for a sub-tab
theme.deactivateActionClean up when theme is deactivated

Theme Variables (injected via theme.template_data filter)

VariableTypeDefaultDescription
$_performersPerRowint5Performers per row (4–7)
$_studiosPerRowint5Studios per row (4–7)
$_darkModeEnabledbooltrueWhether visitors can toggle dark/light mode
$_darkModeDefaultstring'light'Default theme for first-time visitors (light or dark)
$_topbarEnabledboolfalseWhether the top bar is enabled
$_topbarTextstring''Top bar free text
$_topbarLinksarray[]Top bar links (each: {text, url, icon})
$_topbarBgColorstring''Top bar background CSS color (hex or rgba)
$_topbarTextColorstring''Top bar text CSS color (hex or rgba)
$_topbarHoverTextColorstring''Top bar link hover text CSS color (hex or rgba)
$_topbarDarkBgColorstring''Top bar background color in dark mode (hex or rgba)
$_topbarDarkTextColorstring''Top bar text color in dark mode (hex or rgba)
$_topbarDarkHoverTextColorstring''Top bar hover color in dark mode (hex or rgba)
$_topbarTextBoldboolfalseBold announcement text
$_topbarTextItalicboolfalseItalic announcement text
$_topbarLinkBoldboolfalseBold link text
$_topbarLinkItalicboolfalseItalic link text
$_topbarLinkUnderlineboolfalseUnderline link text
$_topbarLinkHoverUnderlinebooltrueUnderline links on hover

Card Helper Functions (Simply theme)

The Simply theme provides three helper functions for listing cards:

// Category card (overlay style with thumbnail)
echo simplyCategoryCard($category);

// Performer card (circle style with name, video count, views)
echo simplyPerformerCard($performer);

// Studio card (circle style, same design as performer)
echo simplyStudioCard($studio);

Top Bar (Sur-Header)

The Simply theme provides a configurable top bar above the main header. It can display free text, partner links, or both. Configure it in Appearance → Header.

  • Enable/Disable toggle to show or hide the bar
  • Content:
    • Text: free text displayed on the left
    • Links: repeater for icon + label + URL (FontAwesome icon picker, opens in new tab)
    • Text formatting: bold/italic toggles for announcement text; bold/italic/underline toggles for links; hover underline toggle
  • Appearance (Light / Dark mode tabs):
    • Light mode colors: background, text, and hover text colors with opacity (HSVA picker)
    • Dark mode colors: separate background, text, and hover text colors for [data-theme="dark"]
    • Live preview strip showing normal text + hover state for each mode

Content settings: simply_topbar_enabled, simply_topbar_text, simply_topbar_links (JSON).

Text formatting settings: simply_topbar_text_bold, simply_topbar_text_italic, simply_topbar_link_bold, simply_topbar_link_italic, simply_topbar_link_underline, simply_topbar_link_hover_underline.

Light mode color settings: simply_topbar_bg_color, simply_topbar_bg_opacity, simply_topbar_text_color, simply_topbar_text_opacity, simply_topbar_hover_text_color, simply_topbar_hover_text_opacity.

Dark mode color settings: simply_topbar_dark_bg_color, simply_topbar_dark_bg_opacity, simply_topbar_dark_text_color, simply_topbar_dark_text_opacity, simply_topbar_dark_hover_text_color, simply_topbar_dark_hover_text_opacity.

Dark Mode

The Simply theme includes a built-in dark/light mode system. Configure it in Appearance → Header (Dark Mode card).

  • Enable toggle: when enabled, visitors see a sun/moon toggle button to switch between light and dark themes. When disabled, the site uses the default theme only.
  • Default theme: choose light or dark as the default for first-time visitors. Returning visitors keep their saved preference in localStorage.
  • FOUC prevention: an inline <script> in <head> sets data-theme on <html> before first paint, reading from localStorage with fallback to the admin-configured default.
  • CSS variables: dark mode styles are applied via [data-theme="dark"] selectors in the theme stylesheet.

Settings: simply_dark_mode_enabled (1/0), simply_dark_mode_default (light or dark).

Implementing in Your Theme

To add listing customization in a custom theme:

  1. Register a theme.template_data filter to inject your variables
  2. Register admin.appearance_extra.* actions to render pickers in admin
  3. Use admin.subtabs.appearance filter to add custom sub-tabs
  4. Register an admin.appearance_save.layout action to save your settings
  5. Register a theme.deactivate action to clean up on deactivation

Translation System

TubePress includes a full multilingual system supporting 30 languages. Translation files live in lang/{code}.php and return a flat array of key-value pairs.

Using Translations in Themes

The global __() helper function translates a key:

<!-- Simple translation -->
<h1><?= __('home') ?></h1>

<!-- With placeholder replacements -->
<p><?= __('video_count_plural', [':count' => $total]) ?></p>

<!-- Conditional singular/plural -->
<p><?= $total === 1 ? __('video_count', [':count' => 1]) : __('video_count_plural', [':count' => $total]) ?></p>

Available Translation Keys

Translation keys are grouped into categories:

CategoryExample Keys
Navigationhome, categories, tags, pornstars, studios
Authlogin, sign_up, logout, password
Videoviews, show_more, related_videos, add_to_favorites
Commentscomments_count, add_a_comment, reply_to_comment
Timetime_seconds_ago, time_minutes_ago, time_hours_ago
Empty Statesno_videos, no_categories, no_results_for

There are 130+ translation keys covering every frontend string. Check lang/en.php for the complete list.

Language Switcher

The active language is detected automatically from: ?_lang= parameter → cookie → browser Accept-Language → DB default. Use the Translator API to build a language switcher:

<?php foreach (Translator::available() as $lang): ?>
    <a href="?_lang=<?= $lang['code'] ?>"
       class="<?= Translator::locale() === $lang['code'] ? 'active' : '' ?>">
        <?= htmlspecialchars($lang['native_name']) ?>
    </a>
<?php endforeach; ?>

Custom Overrides

Admins can customize any translation string directly from the admin panel at Languages → Edit Translations. Overrides are stored in the database and take priority over file-based translations:

Priority chain: DB override → language file value → English fallback → raw key.

Overrides survive CMS updates. The original translation files are never modified. Click "Reset" in the admin to remove an override and fall back to the file default.

How It Works

  • Table: translation_overrides with columns lang_code, translation_key, value
  • Loading: Overrides are loaded once at boot via a single indexed query per request
  • Save: Only changed values are stored. Values matching the file default are automatically deleted
  • Reset: Deleting a row instantly reverts to the file default

Translator API

The Translator class provides static methods for working with translations:

MethodDescription
Translator::get($key, $replacements)Get translated string with optional placeholder replacements
Translator::locale()Current locale code (e.g. 'fr')
Translator::setLocale($code)Switch locale and reload strings + overrides
Translator::available()Array of enabled languages from DB
Translator::isRtl()Whether current locale is RTL (Arabic, Persian, Hebrew)
Translator::direction()Returns 'rtl' or 'ltr'
Translator::jsTranslations()JSON-encoded subset of keys for frontend JS
Translator::getFileStrings($code)Raw file strings for a language (no overrides)
Translator::getOverrides($code)DB overrides only for a language

RTL Support

Set the dir attribute on your <html> tag for RTL languages:

<html lang="<?= Translator::locale() ?>" dir="<?= Translator::direction() ?>">

Captcha (Google reCAPTCHA)

TubePress includes built-in support for Google reCAPTCHA v2 (checkbox) and v3 (invisible). The admin configures it under Settings → Advanced → Google reCAPTCHA.

Captcha API

All methods are static. Call them with Captcha::methodName().

MethodReturnDescription
Captcha::provider()stringActive provider: 'none', 'v2', or 'v3'
Captcha::siteKey()stringThe reCAPTCHA site key (empty if not configured)
Captcha::theme()stringWidget theme: 'light' or 'dark'
Captcha::isEnabled($action)boolCheck if captcha is active for an action ('register' or 'comment'). Returns false if provider is none or keys are missing.
Captcha::verify($action)boolServer-side verification. Returns true if captcha is not enabled for this action. For v3, checks score ≥ 0.5.
Captcha::renderWidget($action)stringRenders the captcha HTML for a form. For v2: a <div class="g-recaptcha"> with data-theme. For v3: a hidden input + auto-execute script.
Captcha::scriptTag()stringReturns the <script> tag to load the Google reCAPTCHA API. Include once in the page head.

Usage in Templates

To add a captcha widget to any form:

<!-- In your <head> or layout.php -->
<?php if ($_captchaProvider !== 'none' && $_captchaSiteKey !== ''): ?>
    <?= Captcha::scriptTag() ?>
<?php endif; ?>

<!-- In a form (e.g. comment form) -->
<form method="POST" action="/video/<?= $video['id'] ?>/comment">
    <textarea name="body"></textarea>
    <?= Captcha::renderWidget('comment') ?>
    <button type="submit">Submit</button>
</form>

Server-side Verification

In your controller, call Captcha::verify() before processing the form. It automatically skips verification when captcha is disabled for the given action:

if (!Captcha::verify('comment')) {
    // Show error: captcha failed
    return;
}
// Process the comment...

Guest Comments

When registration is disabled in admin settings, visitors can post comments without logging in. The comment form should include an optional guest_name field. If registration is enabled, login is required to comment.

Template variables to check:

  • $_registrationEnabled — if false, show guest comment form
  • $_commentsEnabled — if false, hide comments entirely
  • $_user — if not null, user is logged in (show authenticated form)

Tip: The global variables $_captchaProvider, $_captchaSiteKey, $_captchaOnRegister, and $_captchaOnComment are available in all templates for conditional rendering.

SpamGuard (Anti-Spam)

TubePress includes a built-in SpamGuard system that protects comment forms from spam and bots. It works independently of reCAPTCHA. All layers are individually configurable in Settings → Advanced → Comment Anti-Spam.

Protection Layers

LayerSetting keyHow it worksDefault
Honeypotspam_honeypotHidden website field off-screen. Bots auto-fill it; humans never see it.Enabled
Time gatespam_timegateHMAC-signed timestamp. Submissions faster than spam_timegate_seconds are rejected.Enabled, 3 sec
Rate limitingspam_ratelimitMax spam_ratelimit_hour per hour and spam_ratelimit_day per day per IP.Enabled, 5/h, 30/day
Duplicate detectionspam_duplicateSHA-256 hash of body + IP check within spam_duplicate_window minutes.Enabled, 60 min
Link filterspam_linksBlock comments exceeding spam_max_links URLs. Set to 0 to reject all links.Enabled, 0 links
Length checkspam_lengthEnforce spam_min_lengthspam_max_length characters.Enabled, 2–5000

SpamGuard API

MethodReturnDescription
SpamGuard::formFields()stringReturns honeypot + time gate hidden HTML fields. Include inside every comment <form>.
SpamGuard::validate($body, $ip, $videoId, $context)string|nullRuns all checks. $context defaults to 'comment'; pass 'report' for report form submissions. Returns null if clean, or an error translation key.
SpamGuard::ip()stringReturns the client IP address.
SpamGuard::bodyHash($body)stringSHA-256 hash of the comment body (used for duplicate detection).

Usage in Themes

Include SpamGuard::formFields() inside your comment form, right after the CSRF field:

<form method="POST" action="/video/<?= $video['id'] ?>/comment">
    <?= Router::csrfField() ?>
    <?= SpamGuard::formFields() ?>
    <textarea name="body"></textarea>
    <button type="submit">Submit</button>
</form>

Important: SpamGuard validation is performed automatically by the core comment controller. Themes only need to include SpamGuard::formFields() in comment forms. No additional configuration or server-side code is needed in the theme.

Error Translation Keys

When SpamGuard rejects a comment, the error message is shown via the translation system. All keys are available in every language:

KeyShown when
spam_detectedHoneypot triggered or time gate signature tampered
comment_too_fastSubmitted faster than configured minimum seconds
comment_too_shortBody below configured minimum length
comment_too_longBody exceeds configured maximum length
comment_too_many_linksURLs exceed configured max links (default 0 = no links)
comment_rate_limitedIP exceeded configured rate limit
comment_duplicateIdentical comment from same IP within configured window

Admin Settings

All settings are in Settings → Advanced → Comment Anti-Spam. Each layer has a checkbox to enable/disable it, plus numeric inputs for thresholds. Changes take effect immediately.

SettingTypeDefaultDescription
spam_honeypotbool1Enable honeypot hidden field
spam_timegatebool1Enable time gate check
spam_timegate_secondsint3Minimum seconds before submission (1–30)
spam_ratelimitbool1Enable IP rate limiting
spam_ratelimit_hourint5Max comments per hour per IP (1–100)
spam_ratelimit_dayint30Max comments per day per IP (1–500)
spam_duplicatebool1Enable duplicate detection
spam_duplicate_windowint60Duplicate check window in minutes (1–1440)
spam_linksbool1Enable link filter
spam_max_linksint0Max allowed URLs per comment (0 = none)
spam_lengthbool1Enable length validation
spam_min_lengthint2Minimum comment length (1–500)
spam_max_lengthint5000Maximum comment length (10–50000)

Report System

TubePress includes a video reporting system that lets visitors flag content for review. Reports are managed through the admin inbox with full AJAX support.

Front-End Report Form

The report modal is displayed on the video watch page and includes:

  • Reason dropdown<select> with predefined reasons (illegal, copyright, spam, broken, underage, other)
  • Email field — optional <input type="email"> for reporter contact
  • Details textarea — 4-row textarea for additional details (max 1000 chars)
  • SpamGuard fields — honeypot + time gate hidden inputs via SpamGuard::formFields()

Report API Endpoint

MethodURLBody (JSON)Response
POST /video/{id}/report reason, details, reporter_email, _csrf_token, _sg_ts, _sg_h, website JSON with success or error + message

SpamGuard Integration

Reports use the same SpamGuard system as comments, with a dedicated 'report' context:

// In the report controller:
$spamError = SpamGuard::validate($details ?: $reason, $ip, $videoId, 'report');

// Duplicate detection checks the reports table (same IP + same video)
// Rate limiting uses separate 'report' keys from 'comment' keys

Admin Report Management

Reports are managed in Inbox → Reports. The admin interface supports:

  • Modal view — click “View” to open a modal with full report details, reporter email, status controls
  • AJAX status update — change status (pending/reviewed/resolved/dismissed) + admin note without page reload
  • Delete video — red button in modal deletes the video and resolves all reports for it

Admin Report API

MethodURLDescription
GET/admin/reports/{id}/jsonReturns report data as JSON (for modal)
POST/admin/reports/{id}/statusUpdate status + admin note. Send _ajax=1 for JSON response.
POST/admin/reports/{id}/delete-videoDeletes the reported video and resolves all reports for it.

Server Health Check

TubePress includes a comprehensive server health check in Settings → Maintenance → Server Health. It provides a WordPress-style diagnostics page that checks your server configuration.

Check Categories

CategoryChecks
PHPVersion (≥ 8.1), required extensions (pdo, pdo_mysql, mbstring, json, fileinfo, curl, openssl, zip, xml), image library (GD/Imagick)
PHP Configmemory_limit (≥ 128M), max_execution_time (≥ 30), upload_max_filesize (≥ 50M), post_max_size (≥ 50M), max_input_vars (≥ 1000), allow_url_fopen, file_uploads
PermissionsWritable directories: config/, storage/, public/uploads/, storage/imports/
ServerWeb server detection (Nginx/Apache)
DatabaseConnection status, MySQL version (≥ 5.7), charset (utf8mb4)
SecurityHTTPS enabled, display_errors off, expose_php off
PerformanceOPcache enabled, realpath_cache_size (≥ 4K)

Status Indicators

  • ✓ Pass — Check meets or exceeds the recommended value
  • ⚠ Warning — Check is below recommended but not critical
  • ✗ Fail — Critical issue that should be fixed

The overall status banner shows green (all pass), yellow (warnings), or red (failures) with counts for each status.

Note: Each failed or warning check includes a recommended fix command. Common fixes involve editing php.ini or adjusting file permissions.

Hooks & Filters

TubePress uses a WordPress-like hook system. Themes and plugins can register actions (side effects) and filters (data transformations).

Actions

Actions run code at specific points without modifying data.

// Register an action (priority 10 = default)
HookSystem::addAction('head.meta', function () {
    echo '<meta property="og:site_name" content="My Site">';
}, 10);

// Fire an action in your template
HookSystem::doAction('my_custom_hook');

// Check if an action exists
if (HookSystem::hasAction('head.meta')) { ... }

// Remove all callbacks for an action
HookSystem::removeAction('head.meta');

Built-in action hooks

HookFires
head.metaIn <head>, after CSS — inject meta tags, fonts, custom styles
footer.scriptsBefore </body>, after JS — inject analytics, custom scripts
video.card.beforeBefore each video card renders — plugins can track impressions
render.completeAfter page render completes — post-render tasks
routes.registeredAfter all routes are defined — register custom routes

Filters

Filters transform data and must return the modified value.

// Modify template data before rendering
HookSystem::addFilter('theme.template_data', function (array $data, string $tpl) {
    $data['myCustomVar'] = 'Hello';
    return $data;
});

// Override a video card HTML
HookSystem::addFilter('video.card.html', function (string $html, array $video) {
    return '<div class="my-card">'.htmlspecialchars($video['title']).'</div>';
});

// Check if a filter exists
if (HookSystem::hasFilter('video.card.html')) { ... }

// Remove all callbacks for a filter
HookSystem::removeFilter('video.card.html');

Built-in filter hooks

HookArgsDescription
theme.template_data(array $data, string $template)Modify variables before a template renders. Must return $data.
head.css(string $html)Modify the CSS <link> tags HTML output
video.card.html(string $html, array $video)Override the HTML of a video card
video.sort_options(array $allowedSorts)Register additional whitelisted sort options. Return associative array ['key' => 'SQL clause'].
video.similar(null, int $id, int $limit)Override similar videos query. Return array to replace default.
video.recommended(null, int $excludeId, array $excludeIds, int $limit)Override recommended videos query
video.trending(null, int $limit)Override trending videos query
category.listing(null, int $limit, int $offset)Override category listing query
performer.listing(null, string $search, int $limit, int $offset)Override performer listing query
studio.listing(null, string $search, int $limit, int $offset)Override studio listing query
front.default_sort(string $default, string $context)Change default sort order on entity pages
video.should_increment_views(bool $should, array $video)Control whether a view should be counted

Plugin System

TubePress supports WordPress-style plugins that extend the CMS without modifying core files. Plugins live in the plugins/ directory and are managed from Admin → Plugins.

How Plugins Work

  • Activate: go to Admin → Plugins → click Activate. The plugin's boot() method registers its hooks.
  • Deactivate: click Deactivate. All hooks are removed, the CMS falls back to its default behavior.
  • No core modification: plugins use HookSystem filters and actions to override queries, templates, and behavior.

Plugin File Structure

plugins/ my-plugin/ plugin.json # Required — name, description, version, author plugin.php # Required — returns PluginInterface instance src/ # Optional — additional PHP classes

plugin.json

{
    "name": "My Plugin",
    "description": "What this plugin does.",
    "version": "1.0.0",
    "author": "Your Name",
    "requires": "1.0.0"
}

plugin.php

Must return an instance of PluginInterface:

<?php
declare(strict_types=1);

return new class implements PluginInterface {
    public function boot(): void {
        // Register hooks — runs when plugin is active
        HookSystem::addFilter('video.sort_options', function ($sorts) {
            $sorts['trending'] = 'v.my_score DESC';
            return $sorts;
        });

        // Register a cron task (every 30 minutes)
        CronManager::register('my_task', 1800, function () {
            Database::query('UPDATE videos SET my_score = ...');
        });
    }

    public function activate(): void {
        // Run on activation — create DB columns, tables, etc.
    }

    public function deactivate(): void {
        // Run on deactivation — cleanup if needed
    }

    public function install(): void { $this->activate(); }
    public function uninstall(): void { /* drop columns/tables */ }
    public function info(): array {
        return json_decode(file_get_contents(__DIR__ . '/plugin.json'), true) ?? [];
    }
};

Available Hook Points

Plugins can use all the filters and actions listed in the Hooks & Filters section to override video queries, listing behavior, sort order, template data, and more. Key hooks for content plugins:

HookTypeUse Case
video.sort_optionsFilterCustom sort algorithms (whitelist-based)
video.similar / video.recommended / video.trendingFilterOverride video queries
category.listing / performer.listing / studio.listingFilterOverride entity listings
front.default_sortFilterChange default sort on entity pages
video.should_increment_viewsFilterBot filtering, view control
video.card.beforeActionTrack impressions per video card
theme.template_dataFilterInject custom template variables
footer.scriptsFilterInject scripts or run end-of-page logic

Example: CTR Ranking Plugin

The official CTR Ranking plugin demonstrates the full plugin pattern. When activated, it:

  • Adds impressions and ctr_score columns to the videos table
  • Tracks thumbnail impressions via video.card.before
  • Overrides sort/trending/similar/recommended queries via filters
  • Sorts performers, studios, and categories by total CTR with best thumbnails (deduplicated — no two categories share the same thumbnail)
  • Registers a cron task to recalculate scores every 10 minutes
  • Filters bot traffic from views and impressions

When deactivated, everything falls back to the default behavior (views, name sort, random recommendations). No CTR code runs in core — it is entirely plugin-driven.

Safe fallbacks: Plugins may add extra fields to entity arrays (total_ctr, best_video_folder). Always use the ?? operator in templates:
$img = !empty($performer['best_video_folder']) ? VideoAsset::thumbUrlFromFolder($performer['best_video_folder']) : ($performer['photo'] ?? '');

Thumbnail Deduplication System

On listing pages (/categories, /pornstars, /studios), each entity displays a thumbnail from its best-performing video. Since multiple entities can share the same top video, a deduplication algorithm ensures no two entities display the same thumbnail.

Priority Chain

  1. Manual upload always wins. If an admin has uploaded a custom thumbnail (thumbnail for categories, photo for performers, logo for studios), that image is always used — the deduplication system skips these entities entirely.
  2. Best video by ranking. For entities without a manual upload, the system picks the video with the highest CTR score (when the CTR plugin is active) or the most views (core fallback). If that video's thumbnail is already claimed by a higher-priority entity, it moves to the next-best video, and so on.

How It Works

The method VideoAsset::assignUniqueThumbFolders() runs one single SQL query to fetch all candidate video folders for the entities on the current page, ranked by score. It then iterates through the entities in display order (highest CTR / sort order first) and greedily assigns the best available (unclaimed) folder to each.

// Parameters:
VideoAsset::assignUniqueThumbFolders(
    &$items,       // Array of entities (modified in-place, adds 'best_video_folder' key)
    $joinTable,    // Junction table: 'video_categories', 'video_performers', or 'video_studios'
    $fkColumn,     // FK column: 'category_id', 'performer_id', or 'studio_id'
    $manualField,  // Manual upload field: 'thumbnail', 'photo', or 'logo'
    $orderBy       // Optional: 'v.ctr_score DESC, v.views DESC' (CTR) or 'v.views DESC' (core)
);

Where It Runs

ContextLocationOrder By
Core (CTR inactive)Category::all(), Performer::all(), Studio::all()v.views DESC
CTR plugin (active)category.listing, performer.listing, studio.listing hooksv.ctr_score DESC, v.views DESC

Thumbnail sizes: Category, performer, and studio cards use the full-size thumbnail (thumb.jpg) for visual quality. Video cards in grids use thumb_small.jpg for faster loading. The video player poster always uses the full-size thumbnail.

CronManager (Pseudo-Cron)

TubePress includes a built-in WordPress-style pseudo-cron system that runs scheduled tasks automatically — no system crontab required. It works on any hosting (Apache, Nginx, shared hosting) without any configuration.

How It Works

  1. At the end of every page render (ThemeRenderer::render()), CronManager::run() is called automatically
  2. The manager checks each registered task: has enough time passed since the last run?
  3. If a task is due, it acquires an atomic lock via a SQL UPDATE ... WHERE value = ? query — this prevents concurrent execution even under heavy traffic
  4. The task callback runs, and the timestamp is stored in the settings table

Registering Tasks

The CronManager has no built-in tasks — plugins register their own. Use CronManager::register() in your plugin's boot() method:

// Register a task that runs every 10 minutes (600 seconds)
CronManager::register('my_recalc', 600, function () {
    Database::query('UPDATE videos SET score = ...');
});

// Register a task that runs every hour
CronManager::register('cleanup', 3600, function () {
    Database::query('DELETE FROM sessions WHERE expires_at < NOW()');
});
MethodArgsDescription
CronManager::register()string $name, int $interval, callable $callbackRegister a recurring task. $name must be unique. $interval is in seconds.
CronManager::run()(none)Execute all due tasks. Called automatically by ThemeRenderer — no manual call needed.

Resilience

  • Server crash / restart: Timestamps are stored in the database (not in memory). After restart, the next page load picks up where it left off.
  • No visitors: Tasks only run when someone visits the site. If no one visits for an hour, tasks run on the next visit.
  • Concurrent requests: The atomic lock prevents two requests from running the same task simultaneously.
  • Task failure: Errors are caught and logged to storage/logs/error.log. The task will retry on the next interval.

No configuration needed: The pseudo-cron works out of the box. Theme developers don't need to do anything — CronManager::run() is called automatically after every ThemeRenderer::render() call. Plugins simply call CronManager::register() during boot() and the rest is handled by the core.

AJAX Endpoints

These POST-only routes return JSON or redirect. Use them with JavaScript fetch calls from your theme:

EndpointMethodDescriptionResponse
/video/{id}/likePOSTLike a video (authenticated)JSON: { likes, dislikes, userVote }
/video/{id}/dislikePOSTDislike a video (authenticated)JSON: { likes, dislikes, userVote }
/video/{id}/commentPOSTPost a comment. Body: body, optional parent_id, optional guest_name. Requires login if registration is enabled; otherwise allows guest comments.Redirect to video page
/favorite/{id}/togglePOSTToggle favorite status (authenticated)JSON: { favorited: true/false }
/history/clearPOSTClear watch history (authenticated)Redirect to history page

Example: Call the like endpoint from JavaScript:

fetch(`/video/${videoId}/like`, { method: 'POST' })
    .then(r => r.json())
    .then(data => {
        // data.likes, data.dislikes, data.userVote
    });

CSS Variables

The default Simply theme defines CSS custom properties on :root. Override them in your own stylesheet:

:root {
    --primary: #2563eb;         --primary-dark: #1d4ed8;
    --primary-light: #dbeafe;   --secondary: #7c3aed;
    --bg: #ffffff;              --bg-surface: #f8fafc;
    --bg-card: #ffffff;         --bg-dark: #0f172a;
    --text: #1e293b;            --text-muted: #64748b;
    --text-light: #94a3b8;      --border: #e2e8f0;
    --success: #059669;         --danger: #dc2626;
    --radius: 8px;              --radius-lg: 12px;
    --max-width: 1320px;        --header-height: 60px;
    --grid-cols: 4;             /* controlled by body.cols-N */
    --card-gap: 4px;            /* admin: Video Cards → gap */
    --card-radius: 8px;         /* admin: Video Cards → border radius */
}

Responsive Grid System

The video grid respects the admin “Videos per row” setting (4, 5, or 6) via a CSS class on <body>.

Your layout must include this class:

<body class="cols-<?= (int)($_videosPerRow ?? 4) ?> <?= ThemeRenderer::bodyClass() ?>">

Then define grid columns and responsive breakpoints in your CSS:

.video-grid {
    display: grid;
    grid-template-columns: repeat(var(--grid-cols), 1fr);
    gap: 10px;
}

body.cols-4 { --grid-cols: 4; }
body.cols-5 { --grid-cols: 5; }
body.cols-6 { --grid-cols: 6; }

/* Responsive — only reduce, never increase */
@media (max-width: 1400px) { body.cols-6 { --grid-cols: 5; } }
@media (max-width: 1200px) { body.cols-5, body.cols-6 { --grid-cols: 4; } }
@media (max-width: 1024px) { body.cols-4, body.cols-5, body.cols-6 { --grid-cols: 3; } }
@media (max-width: 768px)  { body.cols-4, body.cols-5, body.cols-6 { --grid-cols: 2; } }

How it works: When the admin selects 5 columns, <body class="cols-5"> sets --grid-cols: 5. On screens narrower than 1200px, the media query targets body.cols-5 specifically to reduce it to 4. This approach never increases columns beyond the admin's choice.

Listing Grids

All grid column settings are core CMS settings — they are stored in the settings table, managed by SettingController, injected by ThemeRenderer, and persist across theme changes. They are configured in Admin > Settings > Appearance > Layout.

Setting KeyLabelCSS VariableDefaultRange
videos_per_rowVideos per row--grid-cols54 – 6
categories_per_rowCategories per row--cat-cols53 – 6
performers_per_rowPornstars per row--perf-cols54 – 7
studios_per_rowStudios per row--studio-cols54 – 7

How it works

  1. Save: SettingController::update() validates and stores each value in the settings table.
  2. Inject: ThemeRenderer::render() reads them via Setting::get() and passes $_videosPerRow, $_categoriesPerRow, $_performersPerRow, $_studiosPerRow to every template.
  3. Render: Each listing template outputs an inline <style> that sets the CSS variable, overriding the theme default.
/* Theme CSS grid definitions */
.video-grid     { grid-template-columns: repeat(var(--grid-cols), 1fr); }
.category-grid  { grid-template-columns: repeat(var(--cat-cols), 1fr); }
.performer-grid { grid-template-columns: repeat(var(--perf-cols), 1fr); }
.studio-grid    { grid-template-columns: repeat(var(--studio-cols), 1fr); }

/* Responsive overrides reduce columns on smaller screens */
@media (max-width: 1024px) {
    .category-grid  { --cat-cols: 3; }
    .performer-grid { --perf-cols: 3; }
    .studio-grid    { --studio-cols: 3; }
}
@media (max-width: 768px) {
    .category-grid  { --cat-cols: 2; }
    .performer-grid { --perf-cols: 2; }
    .studio-grid    { --studio-cols: 2; }
}

Core, not theme: These settings belong to the CMS core. Switching or deactivating a theme does not affect them. The theme only provides the CSS classes and responsive breakpoints that consume the variables.

Partials

Partials are reusable template fragments stored in templates/partials/:

<!-- Include a partial from any template -->
<?php ThemeRenderer::partial('video-card', ['video' => $video]); ?>

<!-- templates/partials/video-card.php -->
<article class="video-card">
    <a href="/video/<?= htmlspecialchars($video['slug']) ?>">
        <img src="<?= htmlspecialchars(VideoAsset::thumbUrl($video)) ?>"
             alt="<?= htmlspecialchars($video['title']) ?>" loading="lazy">
    </a>
    <h3><?= htmlspecialchars($video['title']) ?></h3>
</article>

How partials resolve: ThemeRenderer::partial('video-card', $data) looks for templates/partials/video-card.php in the active theme first, then falls back to the Simply theme. The $data array is extracted into local variables inside the partial.

Timezone Setting

TubePress includes a global timezone setting in Admin > Settings > General. When configured, all dates and times throughout the site use this timezone.

The timezone is applied at boot time in App::boot() via date_default_timezone_set(). The default is UTC.

// How it works internally:
$tz = Setting::get('timezone', 'UTC');
if (in_array($tz, DateTimeZone::listIdentifiers(), true)) {
    date_default_timezone_set($tz);
}

Theme developers don’t need to do anything special — standard PHP date functions like date() and strtotime() will automatically use the configured timezone.

Email System (Mailer)

TubePress includes a built-in Mailer helper class (src/Helpers/Mailer.php) that provides centralized email sending with a modern HTML template.

Configuration

Email settings are configured in Admin > Settings > Advanced:

  • PHP mail() — Default method, works on most hosting without configuration
  • SMTP — For reliable delivery via external mail servers (Gmail, SendGrid, etc.)

SMTP settings: smtp_host, smtp_port, smtp_encryption (none/tls/ssl), smtp_user, smtp_pass, smtp_from_email, smtp_from_name.

A “Send Test Email” button in the admin panel lets you verify your configuration instantly.

API

// Send an email (wraps body in HTML template automatically)
Mailer::send(string $to, string $subject, string $htmlBody, ?string $replyTo = null): bool

// Send a test email and return result
Mailer::test(string $to): array  // ['success' => bool, 'error' => ?string]

HTML Template

All emails are wrapped in a responsive HTML template with:

  • Light gray background (#f4f4f7) with white 600px card
  • Header with site name and primary color accent
  • Clean body area — pass any HTML as $htmlBody
  • Footer with “Sent by {site_name}” and year
  • All inline CSS for maximum email client compatibility

SMTP Implementation

The SMTP client uses raw PHP sockets (stream_socket_client) with STARTTLS/SSL support and AUTH LOGIN. No external dependencies (no PHPMailer, no SwiftMailer).

Settings keys

mail_method       — 'php' (default) or 'smtp'
smtp_host         — SMTP server hostname
smtp_port         — Port number (default 587)
smtp_encryption   — 'none', 'tls', or 'ssl'
smtp_user         — SMTP username
smtp_pass         — SMTP password
smtp_from_email   — Sender email address
smtp_from_name    — Sender display name (defaults to site_name)

Contact Form System

Pages in TubePress can optionally include a built-in contact form. This is controlled by the has_contact_form column on the pages table (toggle in the page editor sidebar).

How it works

  1. Admin: Edit any page → check “Show contact form” in the Publish card
  2. Front-end: The theme’s page.php renders a two-column layout: form on the left, page body on the right (stacks on mobile). If the page has no body content, the form is centered.
  3. Submission: POST /contact-submit is handled by FrontContactController
  4. Storage: Messages are saved in the contact_messages table
  5. Email notification: An HTML email is sent via Mailer::send() to the contact_email setting (non-fatal if it fails)

Flow

User submits form
  → CSRF check
  → Honeypot check (hidden field)
  → Rate limit check (3/hour, 10/day per IP)
  → Input validation & sanitization
  → CAPTCHA verification (if enabled)
  → Save to contact_messages table
  → Send HTML notification via Mailer::send()
  → Flash success message & redirect back

Security

  • CSRF: Standard Router::verifyCsrf() protection
  • Honeypot: Hidden website_url field — if filled, silently swallows the submission
  • Rate limiting: 3 messages/hour, 10/day per IP via RateLimiter::throttle()
  • CAPTCHA: Optional reCAPTCHA v2/v3 — enable “Contact form” in Admin > Settings > Security
  • Sanitization: All inputs sanitized via Sanitizer::text(), length limits enforced

contact_messages table

id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
page_id      INT UNSIGNED DEFAULT NULL
name         VARCHAR(255) NOT NULL
email        VARCHAR(255) NOT NULL
subject      VARCHAR(500) NOT NULL
message      TEXT NOT NULL
ip_address   VARCHAR(45) DEFAULT NULL
is_read      TINYINT(1) NOT NULL DEFAULT 0
created_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP

ContactMessage model

ContactMessage::all(int $limit, int $offset): array
ContactMessage::find(int $id): ?array
ContactMessage::count(): int
ContactMessage::countUnread(): int
ContactMessage::create(array $data): int
ContactMessage::markRead(int $id): void
ContactMessage::delete(int $id): void

Settings

  • contact_email — Email address that receives contact form submissions (General settings)
  • captcha_on_contact — Enable CAPTCHA on contact form (Security settings)

Theme integration

In your page.php template, check $page['has_contact_form'] and render different layouts:

<?php
$hasContact = !empty($page['has_contact_form']);
$hasBody = trim($page['body'] ?? '') !== '';
?>

<?php if ($hasContact && $hasBody): ?>
<!-- Two-column: form left, body right -->
<div class="page-contact-layout">
    <div class="page-contact-layout__form">
        <form method="POST" action="/contact-submit">
            <?= Router::csrfField() ?>
            <div style="display:none"><input type="text" name="website_url" tabindex="-1"></div>
            <!-- name, email, subject, message inputs -->
            <?= Captcha::renderWidget('contact') ?>
            <button type="submit">Send</button>
        </form>
    </div>
    <div class="page-contact-layout__body rich-content">
        <?= $page['body'] ?>
    </div>
</div>

<?php elseif ($hasContact): ?>
<!-- Centered form (no body) -->
<div class="page-contact-centered">
    <!-- same form -->
</div>
<?php endif; ?>

Required CSS classes

.page-contact-layout         — Two-column grid (1fr 1fr)
.page-contact-layout__form   — Sticky form column
.page-contact-layout__body   — Body column with left border
.page-contact-centered       — Centered form (max-width 640px)
.contact-form                — Flex column form container
.contact-form__grid          — 2-col grid for name/email row
.contact-form__group         — Individual field wrapper
.contact-form__label         — Field label
.contact-form__input         — Text/email inputs
.contact-form__textarea      — Message textarea
.contact-form__submit        — Submit button
.contact-flash               — Flash message container
.contact-flash--success      — Green success variant
.contact-flash--error        — Red error variant

Admin Messages Panel

Contact form submissions are viewable in the admin panel under Messages (sidebar, after Comments). Features:

  • Paginated list of all messages, newest first
  • Unread/read status with visual indicators and sidebar badge count
  • Click to view full message — automatically marked as read
  • “Reply by Email” button opens the user’s mail client
  • Delete individual messages

Admin routes:

GET  /admin/messages          — Paginated message list
GET  /admin/messages/{id}     — View message (marks as read)
POST /admin/messages/{id}/delete — Delete message

Password Reset System

TubePress includes a complete token-based password reset flow for front-end users.

How it works

  1. User clicks “Forgot your password?” in the login modal
  2. POST /forgot-passwordFrontAuthController::forgotPassword()
  3. If the email exists, a 64-char random token is generated and stored with a 1-hour expiry
  4. A reset email is sent via Mailer::send() with a link to /reset-password?token=xxx
  5. Always responds with a generic success message (prevents email enumeration)
  6. User clicks the link → GET /reset-password validates the token and shows a form
  7. User submits new password → POST /reset-password updates the password and clears the token

Database columns (users table)

reset_token    VARCHAR(64) DEFAULT NULL  -- Random token (bin2hex(random_bytes(32)))
reset_expires  DATETIME DEFAULT NULL     -- Expiry timestamp (1 hour from creation)

Routes

POST /forgot-password    — Generate token + send email (AJAX-compatible)
GET  /reset-password     — Show reset form (validates token from query string)
POST /reset-password     — Validate token + update password

Theme template

The reset form template is at themes/{theme}/templates/auth/reset_password.php. It receives:

$token   — string  (the reset token)
$valid   — bool    (whether the token is valid and not expired)
$error   — ?string (error message)
$success — ?string (success message after password is reset)

Security

  • Token is 64 hex characters (256 bits of entropy via random_bytes(32))
  • Token expires after 1 hour
  • Token is cleared immediately after successful password reset
  • Generic success message prevents email enumeration
  • CSRF protection on all POST endpoints
  • Password must be at least 8 characters, hashed with Argon2id

Best Practices

Security

  • Always escape output with htmlspecialchars() to prevent XSS attacks.
  • Never trust $_GET or $_POST data — sanitize before displaying.
  • Use loading="lazy" on images for performance.

Performance

  • Keep CSS and JS files minimal. Use functions.php only for asset registration and helpers.
  • Use the priority parameter in enqueueCSS() to control loading order.
  • Avoid inline <style> and <script> when possible — use the asset system.

Compatibility

  • Include cols-N and ThemeRenderer::bodyClass() on <body> to support the admin grid setting and plugins.
  • Always call renderCSS(), renderJS(), and both hook actions (head.meta, footer.scripts) so plugins work correctly.
  • Use $_template for nav highlighting — don't hardcode URL checks.
  • Use $_footerColumns to render the footer dynamically (see Footer Builder).
  • Prefix all custom function names with your theme name to avoid collisions.

Video Player

Always use the core Player class to render video players. It handles embed detection, FluidPlayer initialization, and responsive sizing automatically:

<!-- In your video.php template -->
<?= Player::render($video) ?>

Do not manually write <video> or <iframe> tags for the main player. See the Player section for full details.

Security note: The embed_code field contains raw HTML (typically iframes from trusted sources). It is set by the admin, not by users. Never allow user input into this field.