A few months back, I had mentioned that I had spun up a few YouTube channels. Since I am now a bit of a "YouTuber" I've seemed to have developed a near constant obsession on checking to see how my channels are doing. Admittedly, I am not ever going to have millions of subscribers to my channels, but I do find it fascinating to watch the ebb and flow of the channels. Since each channel has their niche area of interest, I am always interested to see which of my niches topics are gaining the most interest.
While YouTube Studio can easily give you by the minute analytics on the channels, it can be rather cumbersome since I need to physically log onto YouTube Studio, and manually flip though the channels to look at the numbers. After a while, this becomes a bit tedious, so I came up with the idea that perhaps some sort of dashboard where I could see at a glance how things are doing.
These days older Android tablets are very easy and are quite cheap to find, which makes them ideal candidates as dedicated displays and act as Internet of Things devices that could provide live information. Since these Android tablets typically have Chrome built in and are geared for internet browsing, I figured some sort of webpage solution would be ideal.
Since I am fairly comfortable in programming code, the cleanest solution is to create a self-hosted local webpage displayed on the tablet browser.
I basically see the solution looking something like this:
- A full screen browser display, with the tablet configured (through the tablet's display settings) to always keep the screen active.
- A single HTML file that calls the YouTube Data API v3 and renders everything
- The page auto-refreshes the data every 60 seconds
- Programming is to hosted locally on the tablet itself.
What is needed to make this happen
To make this all happen, I needed to get the following things set up:
- A Google API key with YouTube Data API v3 enabled (It's free and takes about 5 minutes via Google Cloud Console to set up. The free quota (10,000 units/day) is more than enough for polling 4 channels every minute).
- The Channel IDs for each of my 4 channels
What the Dashboard Should Show
Ideally I wanted to make the dashboard as visually appealing as possible, so I want to have elements like graphs and extra info in an easy to read format. The YouTube API gives me access to subscriber count, total view count, and video count per channel.
With that I fleshed out some quick requirements on what the display should do:
- Present a setup screen at first launch where the API key and 4 channel IDs can be entered and stored
- The main Dashboard should show:
- Subscriber counts per channel with color coding in large text
- Total views and video count on each channel card
- A delta indicator showing subscriber gain/loss since the last refresh
- A bar chart comparing all 4 channels
- A line chart that builds up session history for the previous 7 days
- A progress bar showing countdown to next refresh
- A live/error status indicator and real-time clock
Setting up the API
In order to get the display to work, I first needed to set up a pipeline to YouTube to pull the data.
This required a few steps:
Step 1 Create a Google Cloud Project
I went to console.cloud.google.com and signed in with my Google account.
I then clicked the project dropdown at the top, Selected New Project and created a project called "YT Dashboard" and clicked Create.
Step 2 Enable the YouTube Data API
With my project selected, I then went to APIs & Services, selected Library. I searched for "YouTube Data API v3" and click it. After that I then clicked Enable.
Step 3 Create an API Key
Next I went to APIs & Services and selected Credentials, followed by Create Credentials and then finally selected API Key. Google will generate a key of which I dutifully copied it and kept it in a safe place.
Step 4 Finding the YouTube Channel IDs
To get the ID's I simply grabbed the @handles on YouTube for each of the channels that I wanted to report on.
Building the Site
Now that I have the connection into YouTube in place, I focussed on building the Dashboard itself. The idea is to make it a self-contained YouTube analytics dashboard built as a single HTML file.
Implementation
The core idea is that with the YouTube Data API key and the four channel IDs, the site polls those channels every 60 seconds to show live subscriber counts, total views, and video counts — all in a slick, dark terminal-style interface.
On first load, if no saved config is found in localStorage, it boots with a pre-baked set of channels. however a config function will let me swap in a different API key and channels at any time, and those settings get saved to the localStorage.
The main dashboard itself would be divided into two sections:
- A top row of channel cards - one per channel, each color-coded - showing the subscriber number (in large text), total views, video count, and a little delta badge showing whether subs went up, down, or stayed the same since the last refresh.
- Below that will be two charts - a bar chart comparing current subscriber counts across all four channels side by side, and a line chart showing each channel's subscriber history over the past 7 days.
To make the history tracking work requires some clever coding. The logic saves a snapshot of subscriber counts to localStorage roughly once per hour, automatically pruning anything older than 7 days.
Visually, I wanted to have the dashboard lean hard into a retro CRT aesthetic with dark backgrounds, scanline overlays, a faint red ambient glow, monospace fonts, and a thin progress bar that counts down to the next auto-refresh.
I also added a live clock in the header and a pulsing green "LIVE" indicator when data is successfully loading to provide some feedback that the display was "live".
HTML Code
With the requirements laid out, it was finally time to write some code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YouTube Dashboard</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a0f;
--surface: #111118;
--border: #1e1e2e;
--accent: #ff0040;
--accent2: #ff6b00;
--accent3: #00d4ff;
--accent4: #7fff00;
--text: #e8e8f0;
--muted: #555570;
--card-bg: #0e0e1a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Mono', monospace;
min-height: 100vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Scanline overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.03) 2px,
rgba(0,0,0,0.03) 4px
);
pointer-events: none;
z-index: 100;
}
/* Ambient glow */
body::after {
content: '';
position: fixed;
top: -20%;
left: 50%;
transform: translateX(-50%);
width: 80%;
height: 40%;
background: radial-gradient(ellipse, rgba(255,0,64,0.06) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 28px 14px;
border-bottom: 1px solid var(--border);
position: relative;
z-index: 1;
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.yt-icon {
width: 32px;
height: 22px;
background: var(--accent);
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.yt-icon::after {
content: '';
width: 0;
height: 0;
border-left: 10px solid white;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
margin-left: 2px;
}
.logo-text {
font-family: 'Bebas Neue', cursive;
font-size: 1.6rem;
letter-spacing: 0.12em;
color: var(--text);
}
.logo-text span { color: var(--accent); }
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
#clock {
font-size: 0.75rem;
color: var(--muted);
letter-spacing: 0.08em;
}
#status {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.65rem;
letter-spacing: 0.1em;
color: var(--muted);
text-transform: uppercase;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
transition: background 0.5s;
}
.status-dot.live { background: #00ff88; box-shadow: 0 0 8px #00ff88; animation: pulse 2s infinite; }
.status-dot.error { background: var(--accent); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
#refresh-bar {
height: 2px;
background: var(--border);
flex-shrink: 0;
}
#refresh-progress {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent2));
width: 100%;
transform-origin: left;
transition: transform linear;
}
main {
flex: 1;
display: grid;
grid-template-rows: 1fr 1fr;
gap: 0;
padding: 20px;
gap: 16px;
position: relative;
z-index: 1;
}
.top-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.channel-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 20px 18px 16px;
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
overflow: hidden;
transition: border-color 0.3s;
}
.channel-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
}
.channel-card:nth-child(1)::before { background: var(--accent); }
.channel-card:nth-child(2)::before { background: var(--accent2); }
.channel-card:nth-child(3)::before { background: var(--accent3); }
.channel-card:nth-child(4)::before { background: var(--accent4); }
.channel-label {
font-size: 0.6rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-name {
font-family: 'Bebas Neue', cursive;
font-size: 1.3rem;
letter-spacing: 0.06em;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-card:nth-child(1) .channel-name { color: var(--accent); }
.channel-card:nth-child(2) .channel-name { color: var(--accent2); }
.channel-card:nth-child(3) .channel-name { color: var(--accent3); }
.channel-card:nth-child(4) .channel-name { color: var(--accent4); }
.sub-count {
font-family: 'Bebas Neue', cursive;
font-size: 2.8rem;
line-height: 1;
letter-spacing: 0.02em;
color: var(--text);
transition: all 0.4s;
}
.sub-label {
font-size: 0.58rem;
letter-spacing: 0.12em;
color: var(--muted);
text-transform: uppercase;
}
.stats-row {
display: flex;
gap: 14px;
margin-top: 4px;
}
.mini-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.mini-stat-value {
font-size: 0.8rem;
color: var(--text);
letter-spacing: 0.04em;
}
.mini-stat-label {
font-size: 0.55rem;
color: var(--muted);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.delta {
font-size: 0.65rem;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 2px;
display: inline-block;
margin-top: 4px;
}
.delta.up { background: rgba(0,255,136,0.1); color: #00ff88; }
.delta.down { background: rgba(255,0,64,0.1); color: var(--accent); }
.delta.neutral { color: var(--muted); }
.bottom-row {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 14px;
}
.chart-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.card-title {
font-size: 0.6rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
}
.chart-wrap {
flex: 1;
position: relative;
min-height: 0;
}
.loading-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
}
.spinner {
width: 28px;
height: 28px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text {
font-size: 0.6rem;
color: var(--muted);
letter-spacing: 0.12em;
text-transform: uppercase;
}
.error-msg {
font-size: 0.65rem;
color: var(--accent);
letter-spacing: 0.05em;
text-align: center;
padding: 10px;
}
/* CONFIG OVERLAY */
#config-overlay {
position: fixed;
inset: 0;
background: rgba(5,5,10,0.97);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 28px;
padding: 40px;
}
#config-overlay h2 {
font-family: 'Bebas Neue', cursive;
font-size: 2.2rem;
letter-spacing: 0.1em;
color: var(--text);
}
#config-overlay h2 span { color: var(--accent); }
.config-form {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
max-width: 520px;
}
.config-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.config-group label {
font-size: 0.58rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
}
.config-group input {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 3px;
padding: 9px 12px;
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
outline: none;
transition: border-color 0.2s;
}
.config-group input:focus { border-color: var(--accent); }
.config-group input::placeholder { color: var(--muted); }
.config-channels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.config-channel {
display: flex;
flex-direction: column;
gap: 5px;
}
.config-channel label {
font-size: 0.55rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.config-channel:nth-child(1) label { color: var(--accent); }
.config-channel:nth-child(2) label { color: var(--accent2); }
.config-channel:nth-child(3) label { color: var(--accent3); }
.config-channel:nth-child(4) label { color: var(--accent4); }
.config-channel input {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 3px;
padding: 8px 10px;
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
outline: none;
transition: border-color 0.2s;
}
.config-channel:nth-child(1) input:focus { border-color: var(--accent); }
.config-channel:nth-child(2) input:focus { border-color: var(--accent2); }
.config-channel:nth-child(3) input:focus { border-color: var(--accent3); }
.config-channel:nth-child(4) input:focus { border-color: var(--accent4); }
.config-channel input::placeholder { color: var(--muted); font-size: 0.65rem; }
.config-hint {
font-size: 0.58rem;
color: var(--muted);
letter-spacing: 0.05em;
line-height: 1.5;
}
.config-hint a { color: var(--accent3); text-decoration: none; }
.btn-save {
background: var(--accent);
color: white;
border: none;
border-radius: 3px;
padding: 12px 28px;
font-family: 'Bebas Neue', cursive;
font-size: 1.1rem;
letter-spacing: 0.1em;
cursor: pointer;
align-self: flex-start;
transition: opacity 0.2s;
}
.btn-save:hover { opacity: 0.85; }
.gear-btn {
background: none;
border: 1px solid var(--border);
border-radius: 3px;
padding: 5px 10px;
color: var(--muted);
font-family: 'DM Mono', monospace;
font-size: 0.65rem;
cursor: pointer;
letter-spacing: 0.08em;
transition: color 0.2s, border-color 0.2s;
}
.gear-btn:hover { color: var(--text); border-color: var(--text); }
@keyframes countUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.count-animate { animation: countUp 0.4s ease; }
</style>
</head>
<body>
<div id="config-overlay">
<h2>YT <span>DASHBOARD</span> SETUP</h2>
<div class="config-form">
<div class="config-group">
<label>YouTube Data API v3 Key</label>
<input type="text" id="cfg-apikey" placeholder="AIzaSy..." />
</div>
<div style="font-size:0.58rem; color: var(--muted); letter-spacing:0.05em">
Channel IDs ? find yours at <span style="color:var(--accent3)">youtube.com/account_advanced</span> or use the channel handle below each input
</div>
<div class="config-channels">
<div class="config-channel">
<label>Channel 1 ? ID or Handle</label>
<input type="text" id="cfg-ch1-id" placeholder="UCxxxxxx or @handle" />
<input type="text" id="cfg-ch1-name" placeholder="Display name" style="margin-top:4px" />
</div>
<div class="config-channel">
<label>Channel 2 ? ID or Handle</label>
<input type="text" id="cfg-ch2-id" placeholder="UCxxxxxx or @handle" />
<input type="text" id="cfg-ch2-name" placeholder="Display name" style="margin-top:4px" />
</div>
<div class="config-channel">
<label>Channel 3 ? ID or Handle</label>
<input type="text" id="cfg-ch3-id" placeholder="UCxxxxxx or @handle" />
<input type="text" id="cfg-ch3-name" placeholder="Display name" style="margin-top:4px" />
</div>
<div class="config-channel">
<label>Channel 4 ? ID or Handle</label>
<input type="text" id="cfg-ch4-id" placeholder="UCxxxxxx or @handle" />
<input type="text" id="cfg-ch4-name" placeholder="Display name" style="margin-top:4px" />
</div>
</div>
<p class="config-hint">
Get a free API key: <a href="#">console.cloud.google.com</a> ? Create project ? Enable "YouTube Data API v3" ? Credentials ? Create API Key
</p>
<button class="btn-save" onclick="saveConfig()">LAUNCH DASHBOARD</button>
</div>
</div>
<header>
<div class="logo">
<div class="yt-icon"></div>
<span class="logo-text">YT <span>LIVE</span> DASH</span>
</div>
<div class="header-right">
<div id="clock">--:--:--</div>
<div id="status">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">CONNECTING</span>
</div>
<button class="gear-btn" onclick="openConfig()">? CONFIG</button>
</div>
</header>
<div id="refresh-bar"><div id="refresh-progress"></div></div>
<main>
<div class="top-row" id="cards-row">
<!-- Cards injected by JS -->
</div>
<div class="bottom-row">
<div class="chart-card">
<div class="card-title">Subscriber Comparison</div>
<div class="chart-wrap">
<canvas id="barChart"></canvas>
</div>
</div>
<div class="chart-card">
<div class="card-title">Subscriber History (7 Days)</div>
<div class="chart-wrap">
<canvas id="lineChart"></canvas>
</div>
</div>
</div>
</main>
<script>
// ??? CONFIG ???????????????????????????????????????????????
const REFRESH_INTERVAL = 60; // seconds
let config = {};
let channels = []; // { id, name, color }
let channelData = []; // { subs, views, videos, prevSubs }
let history = []; // [{ time, timestamp, counts: [] }]
const HISTORY_KEY = 'ytdash_history';
const HISTORY_MAX_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
function loadHistory() {
try {
const saved = localStorage.getItem(HISTORY_KEY);
if (saved) {
const parsed = JSON.parse(saved);
const cutoff = Date.now() - HISTORY_MAX_MS;
history = parsed.filter(h => h.timestamp && h.timestamp > cutoff);
}
} catch(e) { history = []; }
}
function saveHistory() {
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); } catch(e) {}
}
let barChart, lineChart;
let refreshTimer, countdownTimer, countdownSecs;
const COLORS = ['#ff0040', '#ff6b00', '#00d4ff', '#7fff00'];
// ??? CLOCK ????????????????????????????????????????????????
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent =
now.toLocaleTimeString('en-US', { hour12: false });
}
setInterval(updateClock, 1000);
updateClock();
// ??? CONFIG MANAGEMENT ????????????????????????????????????
function saveConfig() {
const apiKey = document.getElementById('cfg-apikey').value.trim();
if (!apiKey) { alert('Please enter your API key'); return; }
const chs = [];
for (let i = 1; i <= 4; i++) {
const id = document.getElementById(`cfg-ch${i}-id`).value.trim();
const name = document.getElementById(`cfg-ch${i}-name`).value.trim();
if (id) chs.push({ id, name: name || id, color: COLORS[i-1] });
}
if (chs.length === 0) { alert('Please enter at least one channel ID'); return; }
config = { apiKey, channels: chs };
try { localStorage.setItem('ytdash_config', JSON.stringify(config)); } catch(e) {}
channels = chs;
channelData = chs.map(() => ({ subs: null, views: null, videos: null, prevSubs: null }));
history = [];
document.getElementById('config-overlay').style.display = 'none';
initDashboard();
}
function openConfig() {
// Pre-fill existing config
if (config.apiKey) {
document.getElementById('cfg-apikey').value = config.apiKey;
channels.forEach((ch, i) => {
document.getElementById(`cfg-ch${i+1}-id`).value = ch.id;
document.getElementById(`cfg-ch${i+1}-name`).value = ch.name;
});
}
document.getElementById('config-overlay').style.display = 'flex';
}
function loadConfig() {
try {
const saved = localStorage.getItem('ytdash_config');
if (saved) {
config = JSON.parse(saved);
channels = config.channels;
channelData = channels.map(() => ({ subs: null, views: null, videos: null, prevSubs: null }));
history = [];
document.getElementById('config-overlay').style.display = 'none';
initDashboard();
return true;
}
} catch(e) {}
return false;
}
// ??? DASHBOARD INIT ???????????????????????????????????????
function initDashboard() {
loadHistory();
renderCards();
initCharts();
fetchAll();
startRefreshLoop();
}
function renderCards() {
const row = document.getElementById('cards-row');
row.innerHTML = '';
channels.forEach((ch, i) => {
row.innerHTML += `
<div class="channel-card" id="card-${i}">
<div class="channel-label">Channel ${i+1}</div>
<div class="channel-name">${ch.name}</div>
<div class="sub-count" id="subs-${i}">?</div>
<div class="sub-label">Subscribers</div>
<div class="stats-row">
<div class="mini-stat">
<div class="mini-stat-value" id="views-${i}">?</div>
<div class="mini-stat-label">Total Views</div>
</div>
<div class="mini-stat">
<div class="mini-stat-value" id="videos-${i}">?</div>
<div class="mini-stat-label">Videos</div>
</div>
</div>
<div class="delta neutral" id="delta-${i}">? SINCE LAST</div>
</div>`;
});
}
// ??? CHARTS ???????????????????????????????????????????????
function initCharts() {
const chartDefaults = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: '#1e1e2e' }, ticks: { color: '#555570', font: { family: 'DM Mono', size: 9 } } },
y: { grid: { color: '#1e1e2e' }, ticks: { color: '#555570', font: { family: 'DM Mono', size: 9 }, callback: v => formatNum(v) } }
}
};
barChart = new Chart(document.getElementById('barChart'), {
type: 'bar',
data: {
labels: channels.map(c => c.name),
datasets: [{
data: channels.map(() => 0),
backgroundColor: channels.map(c => c.color + 'cc'),
borderColor: channels.map(c => c.color),
borderWidth: 2,
borderRadius: 2,
}]
},
options: { ...chartDefaults }
});
lineChart = new Chart(document.getElementById('lineChart'), {
type: 'line',
data: {
labels: [],
datasets: channels.map((ch, i) => ({
label: ch.name,
data: [],
borderColor: ch.color,
backgroundColor: ch.color + '11',
borderWidth: 1.5,
pointRadius: 2,
tension: 0.4,
fill: false,
}))
},
options: {
...chartDefaults,
plugins: {
legend: {
display: true,
labels: { color: '#555570', font: { family: 'DM Mono', size: 9 }, boxWidth: 10 }
}
},
scales: {
x: {
grid: { color: '#1e1e2e' },
ticks: {
color: '#555570',
font: { family: 'DM Mono', size: 9 },
maxTicksLimit: 8,
maxRotation: 30,
}
},
y: {
grid: { color: '#1e1e2e' },
ticks: { color: '#555570', font: { family: 'DM Mono', size: 9 }, callback: v => formatNum(v) }
}
}
}
});
}
// ??? API FETCH ????????????????????????????????????????????
async function resolveChannelId(idOrHandle) {
// If it looks like a UC... channel ID, use directly
if (idOrHandle.startsWith('UC')) return idOrHandle;
// Otherwise resolve via forHandle or forUsername
const handle = idOrHandle.startsWith('@') ? idOrHandle.slice(1) : idOrHandle;
const url = `https://www.googleapis.com/youtube/v3/channels?part=id&forHandle=${encodeURIComponent(handle)}&key=${config.apiKey}`;
const res = await fetch(url);
const data = await res.json();
if (data.items && data.items.length > 0) return data.items[0].id;
// fallback: forUsername
const url2 = `https://www.googleapis.com/youtube/v3/channels?part=id&forUsername=${encodeURIComponent(handle)}&key=${config.apiKey}`;
const res2 = await fetch(url2);
const data2 = await res2.json();
if (data2.items && data2.items.length > 0) return data2.items[0].id;
throw new Error(`Could not resolve channel: ${idOrHandle}`);
}
async function fetchAll() {
setStatus('loading');
try {
// Resolve all channel IDs first (only needed if handles)
const resolvedIds = await Promise.all(channels.map(ch => resolveChannelId(ch.id)));
const ids = resolvedIds.join(',');
const url = `https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet&id=${ids}&key=${config.apiKey}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) throw new Error(data.error.message);
// Map results back (API may reorder)
const itemMap = {};
(data.items || []).forEach(item => { itemMap[item.id] = item; });
// Update cards on every fetch
resolvedIds.forEach((id, i) => {
const item = itemMap[id];
if (!item) return;
const stats = item.statistics;
const subs = parseInt(stats.subscriberCount || 0);
const views = parseInt(stats.viewCount || 0);
const videos = parseInt(stats.videoCount || 0);
const prev = channelData[i].subs;
channelData[i] = { subs, views, videos, prevSubs: prev };
updateCard(i, subs, views, videos, prev);
});
const now = new Date();
const timestamp = now.getTime();
const label = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
// Only record a history point once per hour
const lastPoint = history[history.length - 1];
const oneHour = 60 * 60 * 1000;
const shouldRecord = !lastPoint || (timestamp - lastPoint.timestamp) >= oneHour;
if (shouldRecord) {
const historyPoint = { time: label, timestamp, counts: channels.map((_, i) => channelData[i].subs || 0) };
history.push(historyPoint);
const cutoff = Date.now() - HISTORY_MAX_MS;
history = history.filter(h => h.timestamp > cutoff);
saveHistory();
}
updateCharts();
setStatus('live');
} catch(e) {
console.error(e);
setStatus('error', e.message);
}
}
// ??? UI UPDATES ???????????????????????????????????????????
function updateCard(i, subs, views, videos, prevSubs) {
const subsEl = document.getElementById(`subs-${i}`);
const viewsEl = document.getElementById(`views-${i}`);
const videosEl = document.getElementById(`videos-${i}`);
const deltaEl = document.getElementById(`delta-${i}`);
subsEl.textContent = formatNum(subs);
subsEl.classList.remove('count-animate');
void subsEl.offsetWidth;
subsEl.classList.add('count-animate');
viewsEl.textContent = formatNum(views);
videosEl.textContent = formatNum(videos);
if (prevSubs === null) {
deltaEl.textContent = `? BASELINE SET`;
deltaEl.className = 'delta neutral';
} else {
const diff = subs - prevSubs;
if (diff > 0) {
deltaEl.textContent = `? +${formatNum(diff)} SINCE LAST`;
deltaEl.className = 'delta up';
} else if (diff < 0) {
deltaEl.textContent = `? ${formatNum(diff)} SINCE LAST`;
deltaEl.className = 'delta down';
} else {
deltaEl.textContent = `? NO CHANGE`;
deltaEl.className = 'delta neutral';
}
}
}
function updateCharts() {
// Bar chart
barChart.data.datasets[0].data = channels.map((_, i) => channelData[i].subs || 0);
barChart.update('none');
// Line chart
lineChart.data.labels = history.map(h => h.time);
channels.forEach((_, i) => {
lineChart.data.datasets[i].data = history.map(h => h.counts[i] || null);
});
lineChart.update('none');
}
function setStatus(state, msg) {
const dot = document.getElementById('status-dot');
const text = document.getElementById('status-text');
dot.className = 'status-dot ' + state;
if (state === 'live') text.textContent = 'LIVE';
else if (state === 'error') text.textContent = 'ERROR';
else text.textContent = 'LOADING';
}
// ??? REFRESH LOOP ?????????????????????????????????????????
function startRefreshLoop() {
clearInterval(refreshTimer);
clearInterval(countdownTimer);
countdownSecs = REFRESH_INTERVAL;
startCountdown();
refreshTimer = setInterval(() => {
fetchAll();
countdownSecs = REFRESH_INTERVAL;
}, REFRESH_INTERVAL * 1000);
}
function startCountdown() {
const bar = document.getElementById('refresh-progress');
countdownTimer = setInterval(() => {
countdownSecs--;
const pct = (countdownSecs / REFRESH_INTERVAL) * 100;
bar.style.transform = `scaleX(${pct / 100})`;
if (countdownSecs <= 0) countdownSecs = REFRESH_INTERVAL;
}, 1000);
bar.style.transitionDuration = '1s';
}
// ??? HELPERS ??????????????????????????????????????????????
function formatNum(n) {
if (n === null || isNaN(n)) return '?';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return n.toLocaleString();
}
// ??? BOOT ?????????????????????????????????????????????????
(function() {
// Pre-baked config ? edit here to change channels or API key
const presetConfig = {
apiKey: '<API Key Goes Here>',
channels: [
{ id: '@youtubechanel1', name: 'YouTube Channel 1', color: COLORS[0] },
{ id: '@youtubechanel2', name: 'YouTube Channel 2', color: COLORS[1] },
{ id: '@youtubechanel3', name: 'YouTube Channel 3', color: COLORS[2] },
{ id: '@youtubechanel4', name: 'YouTube Channel 4', color: COLORS[3] },
]
};
// Use saved config if present, otherwise use preset
let booted = false;
try {
const saved = localStorage.getItem('ytdash_config');
if (saved) {
config = JSON.parse(saved);
channels = config.channels;
channelData = channels.map(() => ({ subs: null, views: null, videos: null, prevSubs: null }));
document.getElementById('config-overlay').style.display = 'none';
initDashboard();
booted = true;
}
} catch(e) {}
if (!booted) {
config = presetConfig;
channels = config.channels;
channelData = channels.map(() => ({ subs: null, views: null, videos: null, prevSubs: null }));
try { localStorage.setItem('ytdash_config', JSON.stringify(config)); } catch(e) {}
document.getElementById('config-overlay').style.display = 'none';
initDashboard();
}
})();
</script>
</body>
</html>
To finish it off, I saved the code to a file called "YouTube-Dashboard.html" and saved it locally to the tablet that I'll be using.
On the tablet I opened up Chrome and pointed it to the html file.
Since this was the first time I opened the web page, the first thing I get is the configuration page which is where I plug in the API key that I saved earlier and filled in the channel handles that I wanted to track. Once that was done I clicked on the Launch Dashboard button and launched the dashboard.
Now my dashboard is active and seems to be working great so far. I really like how this design turned out since I can easily swap out channels by clicking on the Config button at the top right and changing the channel handles and names as needed.
There may be some additional changes that I might want to do later, such as extending tracking to 30 days as opposed to 7 days or changing the data refresh rates, all which is pretty easy to do, but for now I'll just play it by ear and enjoy finally being able to view how my channels are making out.
No comments:
Post a Comment