Files
mediaplayer/index.php
T
Ty Clifford 2d52255b93 - What changed:
Added external_view_count so provider totals from YouTube/Vimeo/etc are 
added to the site’s total without double-counting local plays.
Updated public totals and per-video public view displays to use site 
views + external views.
Changed Upkeeping action to Correct external view counts with a Correct 
Views button.
Expanded provider scraping beyond YouTube with generic 
page/JSON-LD/oEmbed patterns for readable Vimeo, Dailymotion, and 
similar pages.
Improved Add Video:Title no longer has to be manually entered first.
External URL rows now have Fetch Info.
Metadata can fill title, description, duration, source label, and 
external view count.
Save also retries metadata extraction server-side.

Added authenticated metadata endpoint: 
[admin/media_info.php](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/media_info.php).
Updated docs in 
[README.md](/Users/tyemeclifford/Documents/GH/mediaplayer/README.md).
2026-06-25 04:40:59 -04:00

541 lines
24 KiB
PHP

<?php
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/layout.php';
// Determine which video to play
$slug = public_video_slug_from_request();
if (url_rewrite_enabled() && $slug !== '' && isset($_GET['v']) && ((public_route_segments()[0] ?? '') !== 'v')) {
$target = public_video_url($slug);
$redirect_page = max(1, (int)($_GET['page'] ?? 1));
if ($redirect_page > 1) $target = media_append_query($target, ['page' => (string)$redirect_page]);
header('Location: ' . $target, true, 302);
exit;
}
$video = $slug ? get_video_by_slug($slug) : null;
// Latest video as default if none specified
if (!$video) {
$db = get_db();
$latest = $db->query("SELECT slug FROM videos WHERE published=1 ORDER BY sort_order ASC, id DESC LIMIT 1")->fetch();
if ($latest) $video = get_video_by_slug($latest['slug']);
}
if ($video) {
$video['view_count'] = increment_video_view_count((int)$video['id']);
}
$show_video_views = setting_bool('public_show_video_views', true);
$show_total_views = setting_bool('public_show_total_views', true);
$show_page_stats = setting_bool('public_show_page_stats', true);
$total_public_views = total_video_views(true);
$public_stats = $show_page_stats ? public_page_stats() : [
'videos' => 0,
'sources' => 0,
'views' => $total_public_views,
];
$per_page = (int)setting('catalogue_per_page', '10');
$page = max(1, (int)($_GET['page'] ?? 1));
$paged = get_videos_paginated($page, $per_page);
$player_sources = [];
if ($video && !empty($video['sources'])) {
foreach ($video['sources'] as $source) {
$player_source = source_player_data($source, public_media_url());
if ($player_source) $player_sources[] = $player_source;
}
}
$primary_player_source = $player_sources[0] ?? null;
$native_player_sources = [];
foreach ($player_sources as $player_source) {
if (($player_source['kind'] ?? '') !== 'iframe') $native_player_sources[] = $player_source;
}
render_head('Media — TyClifford.com', '
/* ── Catalogue grid ── */
.cat-section { display:flex; flex-direction:column; gap:1rem; }
.cat-header { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.cat-search { display:flex; gap:.5rem; flex:1; min-width:240px; max-width:430px; }
.cat-search input { flex:1; min-width:0; }
@media(max-width:560px) { .cat-search { min-width:100%; max-width:none; } }
.cat-grid {
display:grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap:.85rem;
}
@media(min-width:600px) { .cat-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } }
@media(min-width:1000px) { .cat-grid { grid-template-columns: repeat(5, 1fr); } }
.cat-thumb {
background:var(--surface-2);
border:1px solid var(--border);
border-radius:calc(var(--r)+2px);
overflow:hidden;
text-decoration:none;
color:var(--text);
display:flex; flex-direction:column;
transition:border-color .2s, transform .15s, box-shadow .2s;
position:relative;
cursor:pointer;
}
.cat-thumb:hover { border-color:var(--purple); transform:translateY(-2px); box-shadow:0 6px 24px rgba(176,96,255,.18); }
.cat-thumb.active { border-color:var(--green); box-shadow:0 0 0 1px var(--green), 0 4px 20px rgba(0,232,122,.2); }
.cat-thumb-img {
position:relative; width:100%; padding-top:56.25%; background:#000;
overflow:hidden;
}
.cat-thumb-img img {
position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
transition:transform .3s;
}
.cat-thumb:hover .cat-thumb-img img { transform:scale(1.04); }
.cat-thumb-img .no-thumb {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.1em;
background:var(--surface);
}
.cat-thumb-dur {
position:absolute; bottom:4px; right:6px;
background:rgba(0,0,0,.75); backdrop-filter:blur(4px);
font-family:"Share Tech Mono",monospace; font-size:.55rem; letter-spacing:.05em;
color:#fff; padding:.15em .45em; border-radius:3px;
}
.play-overlay {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,.35); opacity:0; transition:opacity .2s;
}
.cat-thumb:hover .play-overlay { opacity:1; }
.play-overlay svg { filter:drop-shadow(0 2px 8px rgba(0,0,0,.5)); }
.cat-thumb-body { padding:.55rem .65rem; display:flex; flex-direction:column; gap:.2rem; flex:1; }
.cat-thumb-title { font-size:.78rem; font-weight:600; color:var(--text); line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.cat-thumb-meta { font-family:"Share Tech Mono",monospace; font-size:.58rem; color:var(--text-3); letter-spacing:.06em; margin-top:auto; padding-top:.35rem; }
.cat-active-indicator {
position:absolute; top:0; left:0; right:0; height:2px;
background:var(--green); opacity:0; transition:opacity .2s;
}
.cat-thumb.active .cat-active-indicator { opacity:1; }
/* ── Video.js custom skin ── */
.video-js {
width:100% !important;
height:100% !important;
position:absolute !important;
inset:0 !important;
}
.video-js .vjs-big-play-button {
background:rgba(176,96,255,.75) !important;
border:2px solid rgba(176,96,255,.9) !important;
border-radius:50% !important;
width:64px !important; height:64px !important;
top:50% !important; left:50% !important;
transform:translate(-50%,-50%) !important;
margin:0 !important;
line-height:64px !important;
transition:background .2s !important;
}
.video-js:hover .vjs-big-play-button { background:rgba(176,96,255,.95) !important; }
.video-js .vjs-control-bar {
background:linear-gradient(transparent,rgba(0,0,0,.85)) !important;
height:3.2em !important;
}
.video-js .vjs-play-progress { background:var(--green) !important; }
.video-js .vjs-volume-level { background:var(--green) !important; }
.video-js .vjs-slider { background:rgba(255,255,255,.15) !important; }
.video-js .vjs-load-progress { background:rgba(255,255,255,.1) !important; }
.video-js .vjs-current-time,
.video-js .vjs-duration,
.video-js .vjs-time-divider { color:rgba(255,255,255,.8) !important; font-size:.7em !important; }
.provider-embed-frame {
position:absolute; inset:0;
display:block; width:100%; height:100%;
border:0; background:#000;
}
/* ── Lower grid ── */
.lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
@media(min-width:640px) { .lower-grid { grid-template-columns:1fr 1fr; } }
@media(min-width:900px) { .lower-grid { grid-template-columns:2fr 1fr 1fr; } }
.card-about { background:var(--surface-2); }
.card-social { background:var(--surface-2); }
.stats-list { list-style:none; display:flex; flex-direction:column; gap:.65rem; }
.stats-list li {
display:flex; align-items:center; justify-content:space-between; gap:1rem;
border-bottom:1px solid var(--border-2); padding-bottom:.55rem;
}
.stats-list li:last-child { border-bottom:0; padding-bottom:0; }
.stats-label { font-family:"Share Tech Mono",monospace; font-size:.62rem; letter-spacing:.12em; text-transform:uppercase; color:var(--text-3); }
.stats-value { font-family:"Share Tech Mono",monospace; font-size:.88rem; color:var(--text); }
.social-list { list-style:none; display:flex; flex-direction:column; gap:.5rem; }
.social-list a {
display:flex; align-items:center; gap:.65rem;
padding:.6rem .8rem; border:1px solid var(--border-2); border-radius:var(--r);
font-family:"Share Tech Mono",monospace; font-size:.78rem; color:var(--text-2);
text-decoration:none; transition:border-color .2s, color .2s, background .2s;
}
.social-list a:hover { border-color:var(--green); color:var(--text); background:rgba(0,232,122,.04); }
.social-list a svg { flex-shrink:0; color:var(--text-3); transition:color .2s; }
.social-list a:hover svg { color:var(--green); }
/* empty state */
.empty-state {
padding:3rem 1rem; text-align:center; display:flex; flex-direction:column;
align-items:center; gap:1rem;
}
.empty-state svg { color:var(--text-3); opacity:.4; }
.empty-state p { font-family:"Share Tech Mono",monospace; font-size:.75rem; color:var(--text-3); letter-spacing:.1em; }
/* no-video placeholder */
.no-video { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:.75rem; }
.no-video p { font-family:"Share Tech Mono",monospace; font-size:.7rem; color:var(--text-3); letter-spacing:.1em; }
/* pagination row */
.cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
/* ── Embed panel ── */
.embed-panel {
border-top:1px solid var(--border);
overflow:hidden;
max-height:0;
transition:max-height .35s cubic-bezier(.4,0,.2,1), padding .35s;
padding:0 1.1rem;
}
.embed-panel.open { max-height:320px; padding:.9rem 1.1rem; }
.embed-panel-inner { display:flex; flex-direction:column; gap:.75rem; }
.embed-label { font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.14em; text-transform:uppercase; color:var(--text-3); }
.embed-code-wrap { position:relative; }
.embed-code {
display:block; width:100%;
background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r);
padding:.65rem .85rem; padding-right:5.5rem;
font-family:"Share Tech Mono",monospace; font-size:.68rem; color:var(--text-2);
white-space:nowrap; overflow-x:auto; line-height:1.6;
resize:none; cursor:text;
scrollbar-width:thin;
}
.embed-copy-btn {
position:absolute; right:.4rem; top:50%; transform:translateY(-50%);
padding:.32rem .7rem; font-size:.65rem; font-weight:600;
background:rgba(176,96,255,.15); color:var(--purple);
border:1px solid rgba(176,96,255,.35); border-radius:calc(var(--r) - 1px);
cursor:pointer; font-family:"Share Tech Mono",monospace; letter-spacing:.06em;
transition:background .2s, color .2s;
white-space:nowrap;
}
.embed-copy-btn:hover { background:rgba(176,96,255,.28); }
.embed-copy-btn.copied { background:rgba(0,232,122,.15); color:var(--green); border-color:rgba(0,232,122,.35); }
.embed-row { display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; }
.embed-direct-link {
font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3);
text-decoration:none; border-bottom:1px solid rgba(255,255,255,.1);
transition:color .15s, border-color .15s;
}
.embed-direct-link:hover { color:var(--green); border-color:rgba(0,232,122,.4); }
.embed-preview-link {
font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--purple);
text-decoration:none; border-bottom:1px solid rgba(176,96,255,.2);
transition:color .15s;
}
.embed-preview-link:hover { color:#c880ff; }
.embed-toggle-btn {
display:inline-flex; align-items:center; gap:.4rem;
font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.08em;
color:var(--text-3); background:transparent; border:1px solid var(--border-2);
border-radius:var(--r); padding:.35rem .7rem; cursor:pointer;
transition:color .2s, border-color .2s, background .2s;
}
.embed-toggle-btn:hover { color:var(--text-2); border-color:var(--purple); background:rgba(176,96,255,.07); }
.embed-toggle-btn.active { color:var(--purple); border-color:var(--purple); background:rgba(176,96,255,.1); }
');
?>
<main class="page" role="main">
<?php render_topbar('home'); ?>
<!-- ═══════════════════════════ PLAYER CARD ════════════════════════════════ -->
<section class="stream-card" aria-label="Media player">
<div class="stream-ratio">
<?php if ($primary_player_source && ($primary_player_source['kind'] ?? '') === 'iframe'): ?>
<iframe class="provider-embed-frame"
src="<?= h($primary_player_source['embed']) ?>"
title="<?= h($video['title']) ?>"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"></iframe>
<?php elseif ($video && !empty($native_player_sources)): ?>
<video id="main-player"
class="video-js vjs-default-skin vjs-big-play-centered"
controls preload="metadata"
<?php if ($video['thumbnail']): ?>
poster="<?= h(public_media_url($video['thumbnail'])) ?>"
<?php endif; ?>
data-setup='{"fluid":false,"responsive":true,"playbackRates":[0.5,1,1.25,1.5,2]}'>
<?php foreach ($native_player_sources as $src): ?>
<source src="<?= h($src['url']) ?>" type="<?= h($src['mime']) ?>" label="<?= h($src['label']) ?>">
<?php endforeach; ?>
<p class="vjs-no-js">Your browser does not support HTML video.</p>
</video>
<?php else: ?>
<div class="no-video">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".25"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<p>no video selected</p>
</div>
<?php endif; ?>
</div>
<div class="stream-footer">
<p class="eyebrow eyebrow-green"><?= $video ? h($video['title']) : 'Media Player' ?></p>
<?php if ($video && $video['description']): ?>
<p class="stream-meta" style="font-family:Inter,sans-serif;font-size:.78rem;color:var(--text-2);letter-spacing:0;max-width:60ch;line-height:1.5"><?= h($video['description']) ?></p>
<?php endif; ?>
<?php
$stream_meta = [];
if ($video && $video['duration']) $stream_meta[] = format_duration((int)$video['duration']);
if ($video && $show_video_views) $stream_meta[] = view_count_label(video_total_views($video));
?>
<?php if ($stream_meta): ?>
<p class="stream-meta"><span><?= h(implode(' · ', $stream_meta)) ?></span></p>
<?php endif; ?>
<?php if ($video): ?>
<button class="embed-toggle-btn" id="embed-toggle" aria-expanded="false" aria-controls="embed-panel">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
Embed
</button>
<?php endif; ?>
</div>
<?php if ($video):
$embed_url = public_absolute_url('embed.php?v=' . rawurlencode($video['slug']));
$iframe_code = '<iframe src="' . $embed_url . '" width="560" height="315" frameborder="0" allowfullscreen allow="fullscreen"></iframe>';
?>
<div class="embed-panel" id="embed-panel" role="region" aria-label="Embed code">
<div class="embed-panel-inner">
<div class="form-group" style="gap:.35rem;">
<span class="embed-label">iframe embed code</span>
<div class="embed-code-wrap">
<code class="embed-code" id="embed-code-iframe"><?= h($iframe_code) ?></code>
<button class="embed-copy-btn" data-target="embed-code-iframe" onclick="copyEmbed(this)">Copy</button>
</div>
</div>
<div class="embed-row">
<span class="embed-label">Direct player URL —</span>
<a class="embed-direct-link" href="<?= h($embed_url) ?>" target="_blank" rel="noopener">
<?= h($embed_url) ?>
</a>
<a class="embed-preview-link" href="<?= h($embed_url) ?>" target="_blank" rel="noopener">
↗ Preview
</a>
</div>
<div>
<span class="embed-label" style="color:var(--text-3);font-size:.58rem;">
Recommended: width="560" height="315" — any 16:9 size works. Add style="border:0;border-radius:6px" for no border.
</span>
</div>
</div>
</div>
<?php endif; ?>
</section>
<!-- ═══════════════════════════ VIDEO CATALOGUE ═══════════════════════════ -->
<section class="cat-section">
<div class="cat-header">
<p class="eyebrow eyebrow-purple">Video Catalogue</p>
<?php if ($show_total_views): ?>
<span class="cat-count"><?= h(view_count_label($total_public_views)) ?> total</span>
<?php endif; ?>
<form class="cat-search" method="get" action="<?= h(public_catalogue_url()) ?>" role="search">
<input class="form-input" type="search" name="q" placeholder="Search public videos…">
<button class="btn btn-secondary btn-sm" type="submit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Search
</button>
</form>
<a href="<?= h(public_catalogue_url()) ?>" class="btn btn-secondary btn-sm">Browse all →</a>
</div>
<?php if (empty($paged['videos'])): ?>
<div class="card">
<div class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>
<p>no videos yet</p>
</div>
</div>
<?php else: ?>
<div class="cat-grid">
<?php foreach ($paged['videos'] as $v):
$active = $video && $video['id'] == $v['id'];
$vslug = $v['slug'];
$vtitle = $v['title'];
$vthumb = $v['thumbnail'];
$vdur = (int)$v['duration'];
$created = substr($v['created_at'], 0, 10);
$meta_parts = [$created];
if ($show_video_views) $meta_parts[] = view_count_label(video_total_views($v));
?>
<a class="cat-thumb <?= $active ? 'active' : '' ?>"
href="<?= h(public_video_url($vslug)) ?>"
title="<?= h($vtitle) ?>">
<div class="cat-active-indicator"></div>
<div class="cat-thumb-img">
<?php if ($vthumb): ?>
<img src="<?= h(public_media_url($vthumb)) ?>" alt="<?= h($vtitle) ?>" loading="lazy">
<?php else: ?>
<div class="no-thumb">NO THUMB</div>
<?php endif; ?>
<?php if ($vdur): ?>
<span class="cat-thumb-dur"><?= format_duration($vdur) ?></span>
<?php endif; ?>
<div class="play-overlay">
<svg width="28" height="28" viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="12" fill="rgba(0,0,0,.4)"/><polygon points="10,8 18,12 10,16" fill="white"/></svg>
</div>
</div>
<div class="cat-thumb-body">
<span class="cat-thumb-title"><?= h($vtitle) ?></span>
<span class="cat-thumb-meta"><?= h(implode(' · ', $meta_parts)) ?></span>
</div>
</a>
<?php endforeach; ?>
</div>
<?php if ($paged['total_pages'] > 1): ?>
<div class="cat-footer">
<span class="cat-count">
<?= $paged['total'] ?> video<?= $paged['total'] != 1 ? 's' : '' ?>
· page <?= $paged['page'] ?> of <?= $paged['total_pages'] ?>
</span>
<?php
render_pagination($paged['page'], $paged['total_pages'], public_player_page_url_pattern($video['slug'] ?? ''));
?>
</div>
<?php endif; ?>
<?php endif; ?>
</section>
<!-- ═══════════════════════════ LOWER GRID ════════════════════════════════ -->
<div class="lower-grid">
<div class="card card-about">
<p class="eyebrow eyebrow-green" style="margin-bottom:.1rem">About</p>
<h2 class="card-title">Ty Clifford</h2>
<p class="card-body">
Based in <strong>Keyser, West Virginia</strong>. Nerd, tinkerer, and creator.
Hobbies are my hobby — from tech and podcasting to whatever rabbit hole I fell into this week.
</p>
</div>
<div class="card card-social">
<p class="eyebrow eyebrow-green" style="margin-bottom:.1rem">Links</p>
<h2 class="card-title">Places</h2>
<ul class="social-list" aria-label="Social media links">
<li>
<a href="mailto:ty@tyclifford.com">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
ty@tyclifford.com
</a>
</li>
<li>
<a href="https://go.tyclifford.com/patreon" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 12 14" fill="currentColor"><ellipse cx="7.5" cy="4.8" rx="4.5" ry="4.5"/><rect x="0" y="0" width="2.8" height="14" rx="1.2"/></svg>
patreon.com/tyclifford
</a>
</li>
</ul>
</div>
<?php if ($show_page_stats): ?>
<div class="card card-social">
<p class="eyebrow eyebrow-green" style="margin-bottom:.1rem">Stats</p>
<h2 class="card-title">Public Library</h2>
<ul class="stats-list" aria-label="Public library statistics">
<li>
<span class="stats-label">Videos</span>
<strong class="stats-value"><?= h(format_count((int)$public_stats['videos'])) ?></strong>
</li>
<?php if ($show_total_views): ?>
<li>
<span class="stats-label">Total Views</span>
<strong class="stats-value"><?= h(format_count((int)$public_stats['views'])) ?></strong>
</li>
<?php endif; ?>
<li>
<span class="stats-label">Sources</span>
<strong class="stats-value"><?= h(format_count((int)$public_stats['sources'])) ?></strong>
</li>
</ul>
</div>
<?php endif; ?>
</div>
<?php render_footer(); ?>
</main>
<script>
// Init Video.js if player exists
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('main-player');
if (el) {
var player = videojs('main-player', {
controls: true,
autoplay: false,
preload: 'metadata',
fluid: false,
responsive: true,
playbackRates: [0.5, 1, 1.25, 1.5, 2],
controlBar: {
children: [
'playToggle','volumePanel','currentTimeDisplay',
'timeDivider','durationDisplay','progressControl',
'playbackRateMenuButton','fullscreenToggle'
]
}
});
}
// Embed panel toggle
var toggleBtn = document.getElementById('embed-toggle');
var panel = document.getElementById('embed-panel');
if (toggleBtn && panel) {
toggleBtn.addEventListener('click', function() {
var open = panel.classList.toggle('open');
toggleBtn.classList.toggle('active', open);
toggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
});
// Copy embed code to clipboard
function copyEmbed(btn) {
var targetId = btn.getAttribute('data-target');
var el = document.getElementById(targetId);
if (!el) return;
var text = el.textContent || el.innerText;
navigator.clipboard.writeText(text).then(function() {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(function() {
btn.textContent = 'Copy';
btn.classList.remove('copied');
}, 2000);
}).catch(function() {
// Fallback for older browsers
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(function() {
btn.textContent = 'Copy';
btn.classList.remove('copied');
}, 2000);
});
}
</script>
</body>
</html>