content = $content; $this->cache = $cache; add_action( 'admin_menu', array( $this, 'add_settings_page' ) ); add_filter( 'plugin_row_meta', array( $this, 'add_plugin_row_meta' ), 10, 2 ); add_action( 'admin_bar_menu', array( $this, 'add_admin_bar_menu' ), 90 ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_settings_assets' ) ); add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) ); add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ) ); add_action( 'save_post', array( $this, 'save_post_override' ) ); add_action( 'admin_post_ai_narration_run_queue_now', array( $this, 'run_queue_now' ) ); add_action( 'admin_post_ai_narration_retry_failed', array( $this, 'retry_failed' ) ); add_action( 'admin_post_ai_narration_confirm_public_player', array( $this, 'confirm_public_player' ) ); add_action( 'wp_ajax_ai_narration_free_status', array( $this, 'free_status' ) ); } /** @return array */ public static function default_settings(): array { return array( 'api_key' => '', 'narration_provider' => 'openai', 'model' => 'gpt-4o-mini-tts', 'voice' => 'cedar', 'instructions' => '', 'read_h2_headings' => false, 'auto_generate' => true, 'post_types' => array(), 'categories' => array(), 'player_preset' => 'light', ); } /** Preserve every unknown saved key so moving between editions is reversible. @return array */ public static function get_settings(): array { $saved = get_option( 'ai_narration_settings', array() ); $saved = is_array( $saved ) ? $saved : array(); $settings = wp_parse_args( $saved, self::default_settings() ); // The WordPress.org edition supports OpenAI. Unknown saved keys remain in // the shared option so changing editions does not destroy existing data. $settings['narration_provider'] = 'openai'; $settings['model'] = array_key_exists( (string) $settings['model'], self::models() ) ? (string) $settings['model'] : 'gpt-4o-mini-tts'; $settings['voice'] = array_key_exists( (string) $settings['voice'], self::voices() ) ? (string) $settings['voice'] : 'cedar'; $settings['read_h2_headings'] = ! empty( $settings['read_h2_headings'] ); $settings['auto_generate'] = ! empty( $settings['auto_generate'] ); $settings['player_preset'] = in_array( (string) $settings['player_preset'], array( 'light', 'dark' ), true ) ? (string) $settings['player_preset'] : 'light'; return $settings; } public static function get_api_key(): string { if ( defined( 'AI_NARRATION_OPENAI_API_KEY' ) && is_string( AI_NARRATION_OPENAI_API_KEY ) ) { return trim( AI_NARRATION_OPENAI_API_KEY ); } return trim( (string) ( self::get_settings()['api_key'] ?? '' ) ); } public static function has_api_key(): bool { return '' !== self::get_api_key(); } /** Record a successful Free narration voice preview for the current administrator. */ public static function mark_voice_previewed(): void { $user_id = get_current_user_id(); if ( $user_id > 0 ) { update_user_meta( $user_id, self::GETTING_STARTED_PREVIEW_META_KEY, '1' ); } } public static function narration_provider(): string { return 'openai'; } public static function has_narration_credentials(): bool { return self::has_api_key(); } public static function narration_provider_label(): string { return 'OpenAI'; } public static function automatic_cache_generation(): bool { return ! empty( self::get_settings()['auto_generate'] ); } public static function player_heading(): string { return __( 'Listen', 'ai-narration' ); } public static function show_idle_duration(): bool { return true; } public static function player_disclosure(): string { return __( 'AI Narration', 'ai-narration' ); } /** @param array $query_args */ public static function settings_url( array $query_args = array() ): string { $url = admin_url( 'admin.php?page=' . self::PAGE_SLUG ); return empty( $query_args ) ? $url : add_query_arg( $query_args, $url ); } public static function player_style_attribute(): string { $preset = (string) self::get_settings()['player_preset']; $styles = array( '--ai-narration-radius:16px' ); if ( 'light' === $preset ) { $styles[] = '--ai-narration-background:#f6f7f7'; $styles[] = '--ai-narration-color:#3c434a'; $styles[] = '--ai-narration-border-color:#c3c4c7'; } elseif ( 'dark' === $preset ) { $styles[] = '--ai-narration-background:#202125'; $styles[] = '--ai-narration-color:#c3c4c7'; $styles[] = '--ai-narration-border-color:#3c434a'; } return implode( ';', $styles ); } public function add_settings_page(): void { add_menu_page( __( 'AI Narration', 'ai-narration' ), __( 'AI Narration', 'ai-narration' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render_settings_page' ), 'dashicons-controls-volumeon', 81 ); } /** @param array $plugin_meta @return array */ public function add_plugin_row_meta( array $plugin_meta, string $plugin_file ): array { if ( plugin_basename( AI_NARRATION_FILE ) !== $plugin_file ) { return $plugin_meta; } if ( current_user_can( 'manage_options' ) ) { array_unshift( $plugin_meta, '' . esc_html__( 'Settings', 'ai-narration' ) . '' ); } return $plugin_meta; } public function add_admin_bar_menu( WP_Admin_Bar $admin_bar ): void { if ( current_user_can( 'manage_options' ) ) { $admin_bar->add_node( array( 'id' => 'ai-narration-settings', 'title' => __( 'AI Narration', 'ai-narration' ), 'href' => self::settings_url() ) ); } } public function enqueue_settings_assets( string $hook_suffix ): void { if ( self::SCREEN_ID !== $hook_suffix ) { return; } wp_enqueue_style( 'ai-narration-player-preview', AI_NARRATION_URL . 'assets/player-v4.0.0.css', array(), self::ADMIN_ASSET_VERSION ); wp_enqueue_style( 'ai-narration-admin', AI_NARRATION_URL . 'assets/admin.css', array( 'ai-narration-player-preview' ), self::ADMIN_ASSET_VERSION ); wp_enqueue_script( 'ai-narration-admin', AI_NARRATION_URL . 'assets/admin.js', array(), self::ADMIN_ASSET_VERSION, true ); wp_localize_script( 'ai-narration-admin', 'AINarrationAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'voicePreviewNonce' => wp_create_nonce( 'ai_narration_voice_preview' ), 'statusNonce' => wp_create_nonce( 'ai_narration_free_status' ), 'statusPollInterval' => 1500, 'strings' => array( 'play' => __( 'Play', 'ai-narration' ), 'pause' => __( 'Pause', 'ai-narration' ), 'preview' => __( 'Preview selected voice', 'ai-narration' ), 'preparing' => __( 'Preparing preview…', 'ai-narration' ), 'stop' => __( 'Stop preview', 'ai-narration' ), 'error' => __( 'The voice preview could not be prepared.', 'ai-narration' ), 'copied' => __( 'Copied', 'ai-narration' ), ), ) ); } public function admin_body_class( string $classes ): string { $screen = get_current_screen(); return $screen && self::SCREEN_ID === $screen->id ? $classes . ' ai-narration-admin-settings-page ai-narration-admin-theme-light' : $classes; } public function register_settings(): void { register_setting( 'ai_narration_group', 'ai_narration_settings', array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize_settings' ), 'default' => self::default_settings() ) ); } /** @param mixed $input @return array */ public function sanitize_settings( $input ): array { $input = is_array( $input ) ? $input : array(); $raw = get_option( 'ai_narration_settings', array() ); $output = is_array( $raw ) ? $raw : array(); $existing = self::get_settings(); $this->save_key( $output, $input, 'api_key', 'clear_api_key', defined( 'AI_NARRATION_OPENAI_API_KEY' ) ); $model = sanitize_key( (string) ( $input['model'] ?? '' ) ); $output['model'] = array_key_exists( $model, self::models() ) ? $model : (string) $existing['model']; $voice = sanitize_key( (string) ( $input['voice'] ?? '' ) ); $output['voice'] = array_key_exists( $voice, self::voices() ) ? $voice : (string) $existing['voice']; $output['instructions'] = $this->limit_string( sanitize_textarea_field( (string) ( $input['instructions'] ?? $existing['instructions'] ) ), 4096 ); $output['read_h2_headings'] = ! empty( $input['read_h2_headings'] ); $output['auto_generate'] = ! empty( $input['auto_generate'] ); $public_types = get_post_types( array( 'public' => true ), 'names' ); unset( $public_types['attachment'] ); $output['post_types'] = array_values( array_intersect( array_map( 'sanitize_key', (array) ( $input['post_types'] ?? array() ) ), array_values( $public_types ) ) ); $output['categories'] = array_values( array_unique( array_filter( array_map( 'absint', (array) ( $input['categories'] ?? array() ) ) ) ) ); $preset = sanitize_key( (string) ( $input['player_preset'] ?? '' ) ); $output['player_preset'] = in_array( $preset, array( 'light', 'dark' ), true ) ? $preset : (string) $existing['player_preset']; return $output; } /** @param array $output @param array $input */ private function save_key( array &$output, array $input, string $key, string $clear_key, bool $defined ): void { if ( $defined || ! empty( $input[ $clear_key ] ) ) { $output[ $key ] = ''; } elseif ( isset( $input[ $key ] ) && '' !== trim( (string) $input[ $key ] ) ) { $output[ $key ] = sanitize_text_field( trim( (string) $input[ $key ] ) ); } } private static function normalise_admin_tab( ?string $requested_tab ): string { $active_tab = sanitize_key( (string) $requested_tab ); $active_tab = 'cache' === $active_tab ? 'audio' : ( 'appearance' === $active_tab ? 'design' : $active_tab ); return in_array( $active_tab, array( 'dashboard', 'narration', 'design', 'audio' ), true ) ? $active_tab : 'dashboard'; } public function render_settings_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $settings = self::get_settings(); $requested_tab = filter_input( INPUT_GET, 'tab', FILTER_UNSAFE_RAW ); $active_tab = self::normalise_admin_tab( is_string( $requested_tab ) ? $requested_tab : null ); ?>

>render_dashboard(); ?>
>
> render_narration( $settings ); ?>
> render_appearance( $settings ); ?>
>render_cache(); ?>
cache->articles(); $summary = $this->cache->summary( $articles ); $settings = self::get_settings(); ?>
render_dashboard_status_card( $articles, $summary ); ?>

> $articles Eligible marked narration items. * @param array $settings Current narration settings. */ private function render_getting_started( array $articles, array $settings ): void { $user_id = get_current_user_id(); $credentials_complete = self::has_api_key(); $selection_complete = $this->narration_selection_ready( $settings ); $preview_complete = '1' === (string) get_user_meta( $user_id, self::GETTING_STARTED_PREVIEW_META_KEY, true ); $marked_post_id = $this->first_marked_post_id( $articles ); $generated_post_ids = array_values( array_map( 'absint', array_column( array_filter( $articles, static fn( array $article ): bool => ! empty( $article['has_active'] ) && ! empty( $article['audio_available'] ) ), 'post_id' ) ) ); $generated_post_id = (int) ( $generated_post_ids[0] ?? 0 ); $confirmed_post_id = absint( get_user_meta( $user_id, self::GETTING_STARTED_PLAYER_META_KEY, true ) ); $player_complete = $confirmed_post_id > 0 && in_array( $confirmed_post_id, $generated_post_ids, true ); $player_post_id = $player_complete ? $confirmed_post_id : $generated_post_id; $narration_url = self::settings_url( array( 'tab' => 'narration' ) ); $audio_url = self::settings_url( array( 'tab' => 'audio' ) ); $edit_url = $marked_post_id > 0 ? get_edit_post_link( $marked_post_id, 'raw' ) : false; $steps = array( array( 'label' => __( 'Add an OpenAI API key', 'ai-narration' ), 'description' => __( 'Connect the OpenAI credential used for narration.', 'ai-narration' ), 'complete' => $credentials_complete, 'url' => $narration_url . '#ai-narration-provider-credentials', 'action' => $credentials_complete ? __( 'Review', 'ai-narration' ) : __( 'Add key', 'ai-narration' ) ), array( 'label' => __( 'Select a model and voice', 'ai-narration' ), 'description' => __( 'Choose the settings used for page narration.', 'ai-narration' ), 'complete' => $selection_complete, 'url' => $narration_url . '#ai-narration-provider', 'action' => $selection_complete ? __( 'Review', 'ai-narration' ) : __( 'Choose settings', 'ai-narration' ) ), array( 'label' => __( 'Preview the selected voice', 'ai-narration' ), 'description' => __( 'Hear a short sample before generating narration.', 'ai-narration' ), 'complete' => $preview_complete, 'url' => $narration_url . '#ai-narration-preview-voice', 'action' => $preview_complete ? __( 'Preview again', 'ai-narration' ) : __( 'Preview voice', 'ai-narration' ) ), array( 'label' => __( 'Add a placement shortcode or HTML marker', 'ai-narration' ), 'description' => __( 'Choose where the public player appears in your content.', 'ai-narration' ), 'complete' => $marked_post_id > 0, 'url' => $narration_url . '#ai-narration-marker-guide', 'action' => $marked_post_id > 0 ? __( 'Review', 'ai-narration' ) : __( 'View placement', 'ai-narration' ) ), array( 'label' => __( 'Enable narration and generate the first item', 'ai-narration' ), 'description' => __( 'Create the first complete stored narration.', 'ai-narration' ), 'complete' => $generated_post_id > 0, 'url' => $generated_post_id > 0 ? $audio_url : ( $edit_url ?: $audio_url ), 'action' => $generated_post_id > 0 ? __( 'Open Audio', 'ai-narration' ) : ( $edit_url ? __( 'Open content', 'ai-narration' ) : __( 'Open Audio', 'ai-narration' ) ) ), array( 'label' => __( 'Confirm the public player', 'ai-narration' ), 'description' => __( 'Open the generated page and check playback as a visitor.', 'ai-narration' ), 'complete' => $player_complete, 'url' => $player_complete && $player_post_id > 0 ? get_permalink( $player_post_id ) : '', 'action' => $player_complete ? __( 'View player', 'ai-narration' ) : __( 'Check player', 'ai-narration' ), 'confirm_id' => ! $player_complete ? $player_post_id : 0 ), ); ?>

    $step ) : ?>
  1. >
$settings Current narration settings. */ private function narration_selection_ready( array $settings ): bool { return self::has_narration_credentials() && array_key_exists( (string) ( $settings['model'] ?? '' ), self::models() ) && array_key_exists( (string) ( $settings['voice'] ?? '' ), self::voices() ); } /** @param array> $articles Eligible marked narration items. */ private function first_marked_post_id( array $articles ): int { $known_post_id = absint( $articles[0]['post_id'] ?? 0 ); if ( $known_post_id > 0 ) { return $known_post_id; } $post_types = get_post_types( array( 'public' => true ), 'names' ); unset( $post_types['attachment'] ); $post_ids = get_posts( array( 'post_type' => array_values( $post_types ), 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'orderby' => 'modified', 'order' => 'DESC', 'no_found_rows' => true ) ); foreach ( $post_ids as $post_id ) { $post_id = absint( $post_id ); if ( $post_id > 0 && $this->content->has_narration_marker( $post_id ) ) { return $post_id; } } return 0; } /** Mark the generated public player as checked, then open that content. */ public function confirm_public_player(): void { if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'ai_narration_confirm_public_player' ) ) { wp_die( esc_html__( 'You cannot confirm this player.', 'ai-narration' ) ); } $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; $permalink = $post_id > 0 ? get_permalink( $post_id ) : false; if ( $post_id < 1 || ! $permalink || ! $this->content->has_narration_marker( $post_id ) || null === $this->cache->get_playable_manifest( $post_id ) ) { wp_safe_redirect( self::settings_url() ); exit; } update_user_meta( get_current_user_id(), self::GETTING_STARTED_PLAYER_META_KEY, $post_id ); wp_safe_redirect( $permalink ); exit; } /** * @param array> $articles Eligible narration rows. * @param array $summary Cache summary. */ private function render_dashboard_status_card( array $articles, array $summary ): void { $processing = (int) $summary['queued'] > 0; $failed = (int) $summary['failed']; $queue_delayed = $this->dashboard_queue_delayed( $articles ); $label = $failed > 0 ? __( 'Needs attention', 'ai-narration' ) : ( $processing ? __( 'Processing', 'ai-narration' ) : __( 'Ready', 'ai-narration' ) ); $modifier = $failed > 0 ? 'failed' : ( $processing ? 'queued' : 'ready' ); $health = AI_Narration_Background::health(); $heartbeat = absint( $health['last_heartbeat'] ?? 0 ); ?>

0 ? __( 'AI Narration needs attention.', 'ai-narration' ) : ( $processing ? __( 'AI Narration is processing audio.', 'ai-narration' ) : __( 'Narration is ready.', 'ai-narration' ) ) ); ?>

0 ) : ?>
0 ) : ?>
> $articles Eligible narration rows. */ private function dashboard_queue_delayed( array $articles ): bool { $now = time(); $delayed_candidate = false; foreach ( $articles as $article ) { $status = sanitize_key( (string) ( $article['status'] ?? '' ) ); if ( ! in_array( $status, array( 'queued', 'generating', 'regenerating', 'retrying' ), true ) ) { continue; } $updated = absint( $article['updated'] ?? 0 ); $retry_due = 'retrying' !== $status || absint( $article['next_attempt'] ?? 0 ) <= $now; if ( $updated > 0 && $updated <= $now - ( 3 * MINUTE_IN_SECONDS ) && $retry_due ) { $delayed_candidate = true; break; } } if ( ! $delayed_candidate ) { return false; } $health = AI_Narration_Background::health(); $worker_activity = absint( $health['last_worker_activity'] ?? 0 ); return ! $this->has_active_worker_lock() && ( 0 === $worker_activity || $worker_activity <= $now - ( 3 * MINUTE_IN_SECONDS ) ); } private function has_active_worker_lock(): bool { global $wpdb; // A wildcard query is required to enumerate the plugin's expiring lock options; each option value is then read through get_option(). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $lock_names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'ai_narration_worker_lock_' ) . '%' ) ); $now = time(); foreach ( $lock_names as $lock_name ) { $lease = get_option( (string) $lock_name, false ); if ( is_array( $lease ) && absint( $lease['expires'] ?? 0 ) > $now ) { return true; } } return false; } /** @param array $settings */ private function render_narration( array $settings ): void { $post_types = get_post_types( array( 'public' => true, 'show_ui' => true ), 'objects' ); unset( $post_types['attachment'] ); $categories = get_categories( array( 'hide_empty' => false ) ); ?>

render_pro_context( __( 'Free uses OpenAI with one narration profile. Gemini, ElevenLabs and additional narration profiles are available with', 'ai-narration' ) ); ?>

render_key_row( 'api_key', 'clear_api_key', __( 'OpenAI API key', 'ai-narration' ), self::has_api_key(), defined( 'AI_NARRATION_OPENAI_API_KEY' ) ); ?>

render_provider_rows( $settings, 'openai' ); ?>

render_shortcode( 'ai-narration-start', __( 'Start shortcode', 'ai-narration' ), '[ai_narration]' ); ?>render_shortcode( 'ai-narration-stop', __( 'Recommended stop shortcode', 'ai-narration' ), '[ai_narration_stop]' ); ?>

<div data-narration-start></div> <div data-narration-stop></div>

placeholder="">

$settings */ private function render_provider_rows( array $settings, string $provider ): void { unset( $provider ); $models = self::models(); $voices = self::voices(); ?>
$settings */ private function render_appearance( array $settings ): void { $presets = array( 'light' => array( __( 'Light', 'ai-narration' ), __( 'Uses a restrained light player surface.', 'ai-narration' ) ), 'dark' => array( __( 'Dark', 'ai-narration' ), __( 'Uses the AI Narration dark player surface.', 'ai-narration' ) ), ); ?>

render_pro_context( __( 'Free includes fixed Light and Dark presets. Custom colours, corner radius, plus additional options and controls are available with', 'ai-narration' ) ); ?>

$details ) : ?> >

cache_view( $search, absint( $_GET['paged'] ?? 1 ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $summary = $view['summary']; $filtered = $view['filtered']; $page = $view['page']; $pages = $view['pages']; $rows = $view['rows']; ?>

render_pro_context( __( 'Free provides individual search, playback and regeneration. Bulk management, advanced filters and reusable Custom Audio are available with', 'ai-narration' ) ); ?>
render_cache_summary_items( $summary ); ?>
render_cache_rows( $rows, $search ); ?>
0 ) : ?> ,filtered:array>,page:int,pages:int,rows:array>} */ private function cache_view( string $search, int $requested_page ): array { $articles = $this->cache->articles(); $summary = $this->cache->summary( $articles ); $filtered = array_values( array_filter( $articles, static function ( array $item ) use ( $search ): bool { return '' === $search || false !== stripos( (string) $item['title'] . ' ' . (string) $item['post_id'], $search ); } ) ); $pages = max( 1, (int) ceil( count( $filtered ) / self::PAGE_SIZE ) ); $page = min( max( 1, $requested_page ), $pages ); return array( 'summary' => $summary, 'filtered' => $filtered, 'page' => $page, 'pages' => $pages, 'rows' => array_slice( $filtered, ( $page - 1 ) * self::PAGE_SIZE, self::PAGE_SIZE ) ); } /** @param array $summary */ private function render_cache_summary_items( array $summary ): void { ?> ready previous audio queued failed stored> $rows */ private function render_cache_rows( array $rows, string $search ): void { if ( empty( $rows ) ) { ?> $row ) { $this->render_cache_row( $row, (int) $index ); } } public function free_status(): void { $this->require_admin( __( 'You are not allowed to view AI Narration status.', 'ai-narration' ) ); check_ajax_referer( 'ai_narration_free_status', 'nonce' ); $search = sanitize_text_field( (string) ( isset( $_POST['search'] ) ? wp_unslash( $_POST['search'] ) : '' ) ); $view = $this->cache_view( $search, absint( $_POST['page'] ?? 1 ) ); if ( (int) $view['summary']['queued'] > 0 ) { // Status polling also reasserts a prompt wake-up. This is a safe // self-heal when a host accepts but drops a non-blocking loopback. $this->cache->run_due_queue_now(); } ob_start(); $this->render_dashboard_status_card( $this->cache->articles(), $view['summary'] ); $dashboard_html = (string) ob_get_clean(); ob_start(); $this->render_cache_summary_items( $view['summary'] ); $summary_html = (string) ob_get_clean(); ob_start(); $this->render_cache_rows( $view['rows'], $search ); $rows_html = (string) ob_get_clean(); wp_send_json_success( array( 'dashboardHtml' => $dashboard_html, 'summaryHtml' => $summary_html, 'rowsHtml' => $rows_html, 'hasPending' => (int) $view['summary']['queued'] > 0 ) ); } private function render_pro_context( string $text ): void { ?>

.

$row */ private function cache_row_signature( array $row ): string { $fields = array( 'status', 'audio_available', 'created', 'bytes', 'chunks', 'voice', 'model', 'provider', 'urls', 'error', 'updated', 'completed', 'total', 'next_attempt' ); $signature = array(); foreach ( $fields as $field ) { $signature[ $field ] = $row[ $field ] ?? null; } return hash( 'sha256', (string) wp_json_encode( $signature ) ); } /** @param array $row */ private function render_cache_row( array $row, int $row_index ): void { $id = absint( $row['post_id'] ); $title = (string) $row['title']; $state = sanitize_key( (string) $row['status'] ); $row_class = 0 === $row_index % 2 ? ' ai-narration-cache-row--alternate' : ''; $labels = array( 'ready' => 'Ready', 'previous' => 'Previous audio', 'queued' => 'Queued', 'generating' => 'Generating', 'regenerating' => 'Regenerating', 'retrying' => 'Retrying', 'failed' => ! empty( $row['audio_available'] ) ? 'Replacement failed' : 'Failed', 'missing' => 'Not generated' ); /* translators: 1: number of audio chunks, 2: formatted audio file size. */ $generated = ! empty( $row['created'] ) ? wp_date( 'j F Y, ' . get_option( 'time_format' ), (int) $row['created'] ) : '—'; $audio = ! empty( $row['audio_available'] ) ? sprintf( _n( '%1$d chunk · %2$s', '%1$d chunks · %2$s', (int) $row['chunks'], 'ai-narration' ), $row['chunks'], size_format( $row['bytes'], 1 ) ) : '—'; $provider = sanitize_key( (string) $row['provider'] ); $provider_label = 'openai' === $provider ? 'OpenAI' : __( 'Existing audio', 'ai-narration' ); $urls = ! empty( $row['urls'] ) ? array_values( array_map( 'esc_url_raw', (array) $row['urls'] ) ) : array(); $generate_label = 'failed' === $state ? __( 'Retry', 'ai-narration' ) : ( ! empty( $row['audio_available'] ) ? __( 'Regenerate', 'ai-narration' ) : __( 'Generate', 'ai-narration' ) ); /* translators: %s: content title. */ ?># ·
require_admin( __( 'You are not allowed to run the AI Narration queue.', 'ai-narration' ) ); check_admin_referer( 'ai_narration_run_queue_now' ); $this->cache->run_due_queue_now(); wp_safe_redirect( self::settings_url() ); exit; } public function retry_failed(): void { $this->require_admin( __( 'You are not allowed to retry failed AI Narration jobs.', 'ai-narration' ) ); check_admin_referer( 'ai_narration_retry_failed' ); $this->cache->retry_failed_jobs(); wp_safe_redirect( self::settings_url() ); exit; } private function require_admin( string $message ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html( $message ) ); } } public function add_meta_boxes(): void { $types = get_post_types( array( 'public' => true, 'show_ui' => true ), 'names' ); unset( $types['attachment'] ); foreach ( $types as $type ) { add_meta_box( 'ai-narration-eligibility', __( 'AI Narration', 'ai-narration' ), array( $this, 'render_meta_box' ), $type, 'side', 'default' ); } } public function render_meta_box( WP_Post $post ): void { $value = (string) get_post_meta( $post->ID, '_ai_narration_override', true ); $value = in_array( $value, array( 'enabled', 'disabled' ), true ) ? $value : 'inherit'; wp_nonce_field( 'ai_narration_save_override', 'ai_narration_override_nonce' ); ?>

[ai_narration]
[ai_narration_stop]

*/ public static function models(): array { return array( 'gpt-4o-mini-tts' => 'GPT-4o mini TTS (current alias)', 'gpt-4o-mini-tts-2025-12-15' => 'GPT-4o mini TTS (2025-12-15 snapshot)', 'gpt-4o-mini-tts-2025-03-20' => 'GPT-4o mini TTS (2025-03-20 snapshot)' ); } /** @return array */ public static function voices(): array { return array( 'cedar' => 'Cedar', 'marin' => 'Marin', 'alloy' => 'Alloy', 'ash' => 'Ash', 'ballad' => 'Ballad', 'coral' => 'Coral', 'echo' => 'Echo', 'fable' => 'Fable', 'nova' => 'Nova', 'onyx' => 'Onyx', 'sage' => 'Sage', 'shimmer' => 'Shimmer', 'verse' => 'Verse' ); } private function limit_string( string $text, int $limit ): string { return function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $limit, 'UTF-8' ) : substr( $text, 0, $limit ); } private function limit_utf8_bytes( string $text, int $limit ): string { if ( strlen( $text ) <= $limit ) { return $text; } $candidate = substr( $text, 0, $limit ); while ( '' !== $candidate && 1 !== preg_match( '//u', $candidate ) ) { $candidate = substr( $candidate, 0, -1 ); } return $candidate; } }