. /** * Ajax: load tab HTML/data. * * @package mod_meetletacbfree * @copyright 2026 MeetLet * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_meetletacbfree\external; use core_external\external_api; use core_external\external_function_parameters; use core_external\external_single_structure; use core_external\external_value; use mod_meetletacbfree\api\service; use mod_meetletacbfree\local\config; use mod_meetletacbfree\local\content; use mod_meetletacbfree\local\featureaccess; use mod_meetletacbfree\local\icons; use mod_meetletacbfree\local\instance; use mod_meetletacbfree\local\recordings; use moodle_exception; defined('MOODLE_INTERNAL') || die(); /** * Return tab payload for the activity UI. */ class get_tab extends external_api { public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ 'cmid' => new external_value(PARAM_INT, 'Course module id'), 'tab' => new external_value(PARAM_ALPHA, 'Tab name'), 'groupid' => new external_value(PARAM_INT, 'Group id', VALUE_DEFAULT, 0), ]); } public static function execute(int $cmid, string $tab, int $groupid = 0): array { global $PAGE; $params = self::validate_parameters(self::execute_parameters(), [ 'cmid' => $cmid, 'tab' => $tab, 'groupid' => $groupid, ]); \mod_meetletacbfree\registration\manager::require_registered(); $inst = instance::get_from_cmid($params['cmid'], $params['groupid']); self::validate_context($inst->context); require_capability('mod/meetletacbfree:view', $inst->context); $PAGE->set_context($inst->context); if (!config::is_configured()) { return [ 'html' => \html_writer::div(get_string('notconfigured', 'meetletacbfree'), 'meetlet-alert'), 'tab' => $params['tab'], ]; } $html = ''; switch ($params['tab']) { case 'details': // Served locally in the activity page; keep endpoint defensive. $html = \html_writer::div(get_string('emptydetails', 'meetletacbfree'), 'meetlet-empty'); break; case 'info': // Legacy tab id — no Adobe call. $html = \html_writer::div(get_string('emptydetails', 'meetletacbfree'), 'meetlet-empty'); break; case 'recordings': featureaccess::require_feature(featureaccess::RECORDINGS, $inst->context, $inst->record); $html = self::render_recordings($inst); break; case 'content': featureaccess::require_feature(featureaccess::CONTENT, $inst->context, $inst->record); $html = self::render_content($inst); break; default: throw new moodle_exception('error', 'meetletacbfree'); } return ['html' => $html, 'tab' => $params['tab']]; } public static function execute_returns(): external_single_structure { return new external_single_structure([ 'html' => new external_value(PARAM_RAW, 'Tab HTML'), 'tab' => new external_value(PARAM_ALPHA, 'Tab id'), ]); } /** * Toolbar with refresh for cacheable Adobe tabs. */ protected static function wrap_refreshable(string $html, string $tab, bool $pending = false): string { $refreshlabel = get_string('refreshtab', 'meetletacbfree'); $refreshcontent = icons::svg('refresh') . \html_writer::span($refreshlabel, 'meetlet-btn-with-icon__label'); $bits = [ \html_writer::tag('button', $refreshcontent, [ 'type' => 'button', 'class' => 'meetlet-btn meetlet-btn--ghost meetlet-btn--sm meetlet-btn-with-icon meetlet-tabtoolbar__refresh', 'data-action' => 'refresh-tab', 'data-tab' => $tab, 'title' => $refreshlabel, 'aria-label' => $refreshlabel, ]), ]; if ($pending) { $bits[] = \html_writer::div( \html_writer::span(get_string('jobpollwait', 'meetletacbfree'), 'meetlet-jobpoll__label') . \html_writer::div(\html_writer::div('', 'meetlet-jobpoll__bar'), 'meetlet-jobpoll__track'), 'meetlet-jobpoll', ['aria-live' => 'polite'] ); } $bar = \html_writer::div(implode('', $bits), 'meetlet-tabtoolbar'); $attrs = ['data-meetlet-tab' => $tab]; if ($pending) { $attrs['data-meetlet-jobs-pending'] = '1'; $attrs['data-meetlet-jobs-poll-ms'] = '60000'; } return \html_writer::div($bar . $html, 'meetlet-tabbody', $attrs); } /** * Toolbar control with icon and visible label (matches refresh button styling). * * @param array $attrs Extra attributes (href makes it a link). */ protected static function labeled_action(string $icon, string $label, array $attrs = []): string { $extraclass = $attrs['class'] ?? ''; unset($attrs['class']); $classes = trim( 'meetlet-btn meetlet-btn--ghost meetlet-btn--sm meetlet-btn-with-icon ' . $extraclass ); $attrs['class'] = $classes; $attrs['title'] = $label; $attrs['aria-label'] = $label; $content = icons::svg($icon) . \html_writer::span($label, 'meetlet-btn-with-icon__label'); if (!empty($attrs['href'])) { $url = $attrs['href']; unset($attrs['href']); $attrs['target'] = $attrs['target'] ?? '_blank'; if (($attrs['target'] ?? '') === '_self') { unset($attrs['rel']); } else { $attrs['rel'] = $attrs['rel'] ?? 'noopener'; } return \html_writer::link($url, $content, $attrs); } $attrs['type'] = $attrs['type'] ?? 'button'; return \html_writer::tag('button', $content, $attrs); } /** * Icon control with hover tip. * * @param array $attrs Extra attributes (href makes it a link). */ protected static function icon_action(string $icon, string $label, array $attrs = []): string { $classes = trim('meetlet-iconbtn ' . ($attrs['class'] ?? '')); unset($attrs['class']); $attrs['class'] = $classes; $attrs['data-tip'] = $label; $attrs['aria-label'] = $label; $content = icons::svg($icon) . \html_writer::span($label, 'meetlet-sr-only'); if (!empty($attrs['href'])) { $url = $attrs['href']; unset($attrs['href']); $attrs['target'] = $attrs['target'] ?? '_blank'; if (($attrs['target'] ?? '') === '_self') { unset($attrs['rel']); } else { $attrs['rel'] = $attrs['rel'] ?? 'noopener'; } return \html_writer::link($url, $content, $attrs); } $attrs['type'] = $attrs['type'] ?? 'button'; return \html_writer::tag('button', $content, $attrs); } protected static function format_jobstatus(string $status, bool $mp4ready = false): string { return recordings::jobstatus_label($status, $mp4ready); } protected static function render_recordings(instance $inst): string { $rows = recordings::for_tab($inst); if (empty($rows)) { return self::wrap_refreshable( \html_writer::div(get_string('norecordings', 'meetletacbfree'), 'meetlet-empty'), 'recordings' ); } $canedit = featureaccess::allowed(featureaccess::EDITRECORDING, $inst->context, $inst->record); $canadobeedit = featureaccess::allowed(featureaccess::ADOBEEDITOR, $inst->context, $inst->record); $table = new \html_table(); $table->attributes['class'] = 'generaltable meetlet-table meetlet-table--recordings'; $table->head = [ get_string('recordingname', 'meetletacbfree'), get_string('recordingdate', 'meetletacbfree'), get_string('duration', 'meetletacbfree'), get_string('sessiondetails', 'meetletacbfree'), ]; foreach ($rows as $r) { $actions = []; $actions[] = self::icon_action('play', get_string('play', 'meetletacbfree'), [ 'class' => 'meetlet-iconbtn--play', 'data-action' => 'launch-recording', 'data-mode' => 'play', 'data-recording' => $r->recordingscoid, ]); if ($canadobeedit && !empty($r->urlpath)) { $actions[] = self::icon_action('adobeedit', get_string('adobeeditor', 'meetletacbfree'), [ 'class' => 'meetlet-iconbtn--adobeedit', 'data-action' => 'launch-recording', 'data-mode' => 'edit', 'data-recording' => $r->recordingscoid, ]); } $namehtml = self::render_recording_name((string)$r->name, (int)$r->recordingscoid, $canedit); $dur = $r->duration ? gmdate('H:i:s', (int)$r->duration) : '-'; $recdate = !empty($r->timestart) ? userdate((int)$r->timestart) : '—'; $table->data[] = [ $namehtml, $recdate, $dur, \html_writer::div(implode('', $actions), 'meetlet-iconactions'), ]; } return self::wrap_refreshable(\html_writer::div(\html_writer::table($table), 'meetlet-card'), 'recordings', false); } /** * Recording title with optional inline rename control. */ protected static function render_recording_name(string $name, int $recordingscoid, bool $canedit): string { $text = \html_writer::span(format_string($name), 'meetlet-recname__text'); if (!$canedit) { return \html_writer::div($text, 'meetlet-recname'); } $pencil = self::icon_action('edit', get_string('editrecording', 'meetletacbfree'), [ 'class' => 'meetlet-iconbtn--edit meetlet-iconbtn--inline', 'data-action' => 'inline-rename', ]); $input = \html_writer::empty_tag('input', [ 'type' => 'text', 'class' => 'meetlet-recname__input', 'value' => $name, 'maxlength' => 255, 'hidden' => 'hidden', ]); return \html_writer::div($text . $pencil . $input, 'meetlet-recname', [ 'data-recording' => $recordingscoid, 'data-name' => $name, ]); } protected static function render_content(instance $inst): string { global $USER; $service = new service(); $service->login_admin(); $items = $service->list_meeting_content($inst->require_meeting()); $service->logout(); if (empty($items)) { return self::wrap_refreshable( \html_writer::div(get_string('nocontent', 'meetletacbfree'), 'meetlet-empty'), 'content' ); } $table = new \html_table(); $table->attributes['class'] = 'generaltable meetlet-table meetlet-table--content'; $table->head = [ get_string('name'), get_string('contenttype', 'meetletacbfree'), get_string('contentsize', 'meetletacbfree'), get_string('contentdate', 'meetletacbfree'), get_string('sessiondetails', 'meetletacbfree'), ]; foreach ($items as $item) { $scoid = (int)($item->scoid ?? 0); $actions = [ self::icon_action('play', get_string('view'), [ 'class' => 'meetlet-iconbtn--play', 'data-action' => 'launch-content', 'data-content' => $scoid, ]), ]; if ($scoid > 0 && !empty($item->name)) { $dlurl = content::download_url((int)$USER->id, (int)$inst->cm->id, $scoid)->out(false); $actions[] = self::icon_action('download', get_string('downloadcontent', 'meetletacbfree'), [ 'href' => $dlurl, 'class' => 'meetlet-iconbtn--download', 'target' => '_self', 'download' => $item->name, 'data-action' => 'download-content', ]); } $table->data[] = [ format_string($item->name), s($item->typelabel ?? service::content_type_label( (string)($item->icon ?? ''), (string)($item->type ?? ''), (string)($item->name ?? '') )), self::format_bytes((int)($item->bytesize ?? 0)), s($item->datecreated_fmt !== '' ? $item->datecreated_fmt : '—'), \html_writer::div(implode('', $actions), 'meetlet-iconactions'), ]; } return self::wrap_refreshable(\html_writer::div(\html_writer::table($table), 'meetlet-card'), 'content'); } protected static function format_bytes(int $bytes): string { if ($bytes <= 0) { return '—'; } $units = ['B', 'KB', 'MB', 'GB']; $i = 0; $n = (float)$bytes; while ($n >= 1024 && $i < count($units) - 1) { $n /= 1024; $i++; } return format_float($n, $i === 0 ? 0 : 1) . ' ' . $units[$i]; } }