YT Music Tracker
+300
| @import url("https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,600&family=Geist:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); | ||
| :root { | ||
| --bg: #0a0908; | ||
| --bg-elevated: #14110f; | ||
| --bg-card: #17130f; | ||
| --border: #2a2520; | ||
| --border-soft: rgba(245, 241, 234, 0.06); | ||
| --text: #f5f1ea; | ||
| --text-dim: #b8ad9a; | ||
| --text-muted: #6b6358; | ||
| --accent: #ff3d2e; | ||
| --accent-2: #ff7a3d; | ||
| --accent-soft: rgba(255, 61, 46, 0.14); | ||
| --accent-glow: rgba(255, 61, 46, 0.07); | ||
| --font-display: "Fraunces", "Times New Roman", serif; | ||
| --font-sans: "Geist", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; | ||
| --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; | ||
| } | ||
| * { box-sizing: border-box; margin: 0; padding: 0; } | ||
| body { | ||
| background: | ||
| radial-gradient(1200px circle at 110% -10%, var(--accent-glow), transparent 50%), | ||
| radial-gradient(1000px circle at -10% 110%, rgba(201, 162, 39, 0.04), transparent 50%), | ||
| var(--bg); | ||
| transition: background 0.4s ease; | ||
| color: var(--text); | ||
| font-family: var(--font-sans); | ||
| -webkit-font-smoothing: antialiased; | ||
| min-height: 100vh; | ||
| font-size: 14px; | ||
| } | ||
| ::selection { background: var(--accent); color: var(--bg); } | ||
| .page { | ||
| max-width: 860px; | ||
| margin: 0 auto; | ||
| padding: 32px 40px 80px; | ||
| } | ||
| @media (max-width: 640px) { .page { padding: 20px; } } | ||
| /* ─── header ─────────────────────────────────────── */ | ||
| .page-header { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| margin-bottom: 40px; | ||
| padding-bottom: 20px; | ||
| border-bottom: 1px solid var(--border-soft); | ||
| } | ||
| .brand-block { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 14px; | ||
| } | ||
| .brand-mark { | ||
| width: 10px; | ||
| height: 10px; | ||
| border-radius: 50%; | ||
| background: var(--accent); | ||
| box-shadow: 0 0 14px var(--accent), 0 0 28px var(--accent-soft); | ||
| flex-shrink: 0; | ||
| animation: pulse 2.4s ease-in-out infinite; | ||
| } | ||
| @keyframes pulse { | ||
| 0%, 100% { transform: scale(1); opacity: 1; } | ||
| 50% { transform: scale(0.85); opacity: 0.6; } | ||
| } | ||
| .brand-name { | ||
| font-family: var(--font-display); | ||
| font-size: 18px; | ||
| font-weight: 600; | ||
| font-style: italic; | ||
| letter-spacing: -0.02em; | ||
| } | ||
| .accent { color: var(--accent); font-style: normal; } | ||
| .brand-sub { | ||
| font-size: 11px; | ||
| color: var(--text-muted); | ||
| letter-spacing: 0.02em; | ||
| margin-top: 2px; | ||
| } | ||
| /* ─── controls ───────────────────────────────────── */ | ||
| .recap-controls { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| gap: 16px; | ||
| margin-bottom: 32px; | ||
| flex-wrap: wrap; | ||
| } | ||
| .period-switch { | ||
| display: flex; | ||
| background: var(--bg-elevated); | ||
| border: 1px solid var(--border); | ||
| border-radius: 10px; | ||
| padding: 4px; | ||
| gap: 2px; | ||
| } | ||
| .period-tab { | ||
| background: transparent; | ||
| border: none; | ||
| color: var(--text-muted); | ||
| padding: 8px 20px; | ||
| border-radius: 7px; | ||
| cursor: pointer; | ||
| font-family: var(--font-sans); | ||
| font-size: 13px; | ||
| font-weight: 500; | ||
| letter-spacing: 0.01em; | ||
| transition: all 0.15s ease; | ||
| } | ||
| .period-tab:hover { color: var(--text-dim); } | ||
| .period-tab.is-active { | ||
| background: var(--text); | ||
| color: var(--bg); | ||
| } | ||
| /* platform switch */ | ||
| .platform-switch { | ||
| display: flex; | ||
| background: var(--bg-elevated); | ||
| border: 1px solid var(--border); | ||
| border-radius: 10px; | ||
| padding: 4px; | ||
| gap: 2px; | ||
| } | ||
| .platform-tab { | ||
| background: transparent; | ||
| border: none; | ||
| color: var(--text-muted); | ||
| padding: 6px 14px; | ||
| border-radius: 7px; | ||
| cursor: pointer; | ||
| font-family: var(--font-sans); | ||
| font-size: 11px; | ||
| font-weight: 500; | ||
| letter-spacing: 0.01em; | ||
| transition: all 0.15s ease; | ||
| } | ||
| .platform-tab:hover { color: var(--text-dim); } | ||
| .platform-tab.is-active { | ||
| background: var(--text); | ||
| color: var(--bg); | ||
| } | ||
| /* ─── export button ──────────────────────────────── */ | ||
| .export-btn { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| gap: 9px; | ||
| background: var(--accent); | ||
| border: none; | ||
| color: #fff; | ||
| padding: 10px 20px; | ||
| border-radius: 8px; | ||
| font-family: var(--font-sans); | ||
| font-size: 13px; | ||
| font-weight: 500; | ||
| letter-spacing: 0.01em; | ||
| cursor: pointer; | ||
| transition: all 0.15s ease; | ||
| box-shadow: 0 4px 16px rgba(255, 61, 46, 0.30); | ||
| } | ||
| .export-btn:hover:not(:disabled) { | ||
| background: var(--accent-2); | ||
| box-shadow: 0 6px 24px rgba(255, 61, 46, 0.40); | ||
| transform: translateY(-1px); | ||
| } | ||
| .export-btn:disabled { | ||
| opacity: 0.4; | ||
| cursor: not-allowed; | ||
| transform: none; | ||
| } | ||
| .ghost-btn { | ||
| background: transparent; | ||
| border: 1px solid var(--border); | ||
| color: var(--text-dim); | ||
| padding: 8px 14px; | ||
| border-radius: 6px; | ||
| font-family: var(--font-sans); | ||
| font-size: 12px; | ||
| letter-spacing: 0.01em; | ||
| cursor: pointer; | ||
| display: inline-flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| transition: all 0.15s ease; | ||
| text-decoration: none; | ||
| } | ||
| .ghost-btn:hover { | ||
| color: var(--text); | ||
| border-color: var(--text-dim); | ||
| background: var(--bg-elevated); | ||
| } | ||
| /* ─── canvas container ───────────────────────────── */ | ||
| .canvas-wrap { | ||
| position: relative; | ||
| display: flex; | ||
| justify-content: center; | ||
| align-items: flex-start; | ||
| min-height: 300px; | ||
| } | ||
| #recap-canvas { | ||
| max-width: 100%; | ||
| max-height: calc(100vh - 180px); | ||
| width: auto; | ||
| height: auto; | ||
| display: block; | ||
| border-radius: 20px; | ||
| box-shadow: | ||
| 0 0 0 1px var(--border-soft), | ||
| 0 32px 80px rgba(0, 0, 0, 0.70), | ||
| 0 0 80px rgba(255, 61, 46, 0.06); | ||
| transition: opacity 0.3s ease; | ||
| } | ||
| #recap-canvas.is-loading { opacity: 0.25; } | ||
| #recap-canvas:not([width]) { display: none; } | ||
| /* ─── loading state ──────────────────────────────── */ | ||
| .loading-state { | ||
| position: absolute; | ||
| top: 120px; | ||
| left: 50%; | ||
| transform: translateX(-50%); | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 12px; | ||
| font-family: var(--font-display); | ||
| font-style: italic; | ||
| font-size: 16px; | ||
| color: var(--text-muted); | ||
| white-space: nowrap; | ||
| pointer-events: none; | ||
| } | ||
| .loading-dot { | ||
| width: 8px; | ||
| height: 8px; | ||
| border-radius: 50%; | ||
| background: var(--accent); | ||
| animation: pulse 1.2s ease-in-out infinite; | ||
| flex-shrink: 0; | ||
| } | ||
| /* ─── empty state ────────────────────────────────── */ | ||
| .empty-state { | ||
| position: absolute; | ||
| top: 120px; | ||
| left: 50%; | ||
| transform: translateX(-50%); | ||
| font-family: var(--font-display); | ||
| font-style: italic; | ||
| font-size: 16px; | ||
| color: var(--text-muted); | ||
| white-space: nowrap; | ||
| text-align: center; | ||
| } | ||
| /* ─── theming por plataforma ─────────────────────── */ | ||
| [data-platform="spotify"] { | ||
| --accent: #1ed760; | ||
| --accent-2: #1fdf64; | ||
| --accent-soft: rgba(30, 215, 96, 0.12); | ||
| --accent-glow: rgba(30, 215, 96, 0.07); | ||
| } | ||
| [data-platform="all"] { | ||
| --accent: #c9a227; | ||
| --accent-2: #d4a843; | ||
| --accent-soft: rgba(201, 162, 39, 0.14); | ||
| --accent-glow: rgba(201, 162, 39, 0.06); | ||
| } |
| <!doctype html> | ||
| <html lang="pt-BR"> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <title>YT Music Tracker — Recap</title> | ||
| <link rel="stylesheet" href="recap.css" /> | ||
| </head> | ||
| <body> | ||
| <div class="page"> | ||
| <!-- ─── header ────────────────────────────────── --> | ||
| <header class="page-header"> | ||
| <div class="brand-block"> | ||
| <div class="brand-mark"></div> | ||
| <div class="brand-text"> | ||
| <div class="brand-name">YTM<span class="accent">·</span>Tracker</div> | ||
| <div class="brand-sub">recap — compartilhe suas estatísticas</div> | ||
| </div> | ||
| </div> | ||
| <button class="ghost-btn" id="back-btn"> | ||
| <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> | ||
| dashboard | ||
| </button> | ||
| </header> | ||
| <!-- ─── controls ──────────────────────────────── --> | ||
| <section class="recap-controls"> | ||
| <div class="period-switch" role="tablist"> | ||
| <button class="period-tab is-active" data-period="7d">7 dias</button> | ||
| <button class="period-tab" data-period="30d">30 dias</button> | ||
| <button class="period-tab" data-period="1y">1 ano</button> | ||
| </div> | ||
| <div class="platform-switch" role="tablist" aria-label="Plataforma"> | ||
| <button class="platform-tab is-active" data-platform="all">tudo</button> | ||
| <button class="platform-tab" data-platform="ytmusic">YouTube Music</button> | ||
| <button class="platform-tab" data-platform="spotify">Spotify</button> | ||
| </div> | ||
| <button class="export-btn" id="export-btn" disabled> | ||
| <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg> | ||
| exportar imagem | ||
| </button> | ||
| </section> | ||
| <!-- ─── canvas preview ────────────────────────── --> | ||
| <div class="canvas-wrap"> | ||
| <div id="loading" class="loading-state"> | ||
| <span class="loading-dot"></span> | ||
| gerando recap… | ||
| </div> | ||
| <div id="empty-state" class="empty-state" hidden> | ||
| nenhuma escuta registrada neste período. | ||
| </div> | ||
| <canvas id="recap-canvas"></canvas> | ||
| </div> | ||
| </div> | ||
| <script type="module" src="recap.js"></script> | ||
| </body> | ||
| </html> |
+534
| import { getSessionsInRange, getDailyStats, getAllSongs } from "../lib/db.js"; | ||
| import { shiftDate, todayKey, formatDuration } from "../lib/utils.js"; | ||
| /* ── Canvas dimensions (Instagram story 9:16) ───────────────────── */ | ||
| const CW = 1080; | ||
| const CH = 1920; | ||
| const P = 64; // horizontal padding | ||
| /* ── Design tokens ──────────────────────────────────────────────── */ | ||
| const T = { | ||
| bg: "#0a0908", | ||
| card: "#17130f", | ||
| el: "#14110f", | ||
| border: "#2a2520", | ||
| bsoft: "rgba(245,241,234,0.07)", | ||
| text: "#f5f1ea", | ||
| dim: "#b8ad9a", | ||
| muted: "#6b6358", | ||
| accent: "#ff3d2e", | ||
| accent2: "#ff7a3d", | ||
| gold: "#c9a227", | ||
| }; | ||
| /* ── Period config ──────────────────────────────────────────────── */ | ||
| const PERIODS = { | ||
| "7d": { days: 7, line1: "seus 7 dias", label: "7 dias" }, | ||
| "30d": { days: 30, line1: "seu mês", label: "30 dias" }, | ||
| "1y": { days: 365, line1: "seu ano", label: "1 ano" }, | ||
| }; | ||
| const PLATFORM_LABELS = { | ||
| all: "YouTube Music + Spotify", | ||
| ytmusic: "music.youtube.com", | ||
| spotify: "open.spotify.com", | ||
| }; | ||
| /* ── Data aggregation ───────────────────────────────────────────── */ | ||
| async function computeData(period, platform = "all") { | ||
| const cfg = PERIODS[period]; | ||
| const today = todayKey(); | ||
| const startDate = shiftDate(today, -(cfg.days - 1)); | ||
| const [allSessions, allSongs, dailyStats] = await Promise.all([ | ||
| getSessionsInRange(startDate, today), | ||
| getAllSongs(), | ||
| getDailyStats(startDate, today), | ||
| ]); | ||
| // Apply platform filter | ||
| const sessions = platform === "all" | ||
| ? allSessions | ||
| : allSessions.filter(s => s.platform === platform); | ||
| if (!sessions.length) return null; | ||
| const songMap = new Map(allSongs.map(s => [s.id, s])); | ||
| // aggregate by song | ||
| const byId = new Map(); | ||
| for (const sess of sessions) { | ||
| const song = songMap.get(sess.songId); | ||
| if (!song) continue; | ||
| if (!byId.has(sess.songId)) byId.set(sess.songId, { song, plays: 0, secs: 0 }); | ||
| const r = byId.get(sess.songId); | ||
| r.plays++; | ||
| r.secs += sess.listenedSeconds; | ||
| } | ||
| // aggregate by artist | ||
| const byArtist = new Map(); | ||
| for (const { song, plays, secs } of byId.values()) { | ||
| const name = song.artist || "—"; | ||
| if (!byArtist.has(name)) byArtist.set(name, { name, plays: 0, secs: 0 }); | ||
| const a = byArtist.get(name); | ||
| a.plays += plays; | ||
| a.secs += secs; | ||
| } | ||
| const topSongs = [...byId.values()].sort((a, b) => b.secs - a.secs).slice(0, 5); | ||
| const topArtists = [...byArtist.values()].sort((a, b) => b.secs - a.secs).slice(0, 4); | ||
| // Best day: derive from sessions when platform-filtered, use dailyStats when "all" | ||
| let bestDay = null; | ||
| if (platform === "all") { | ||
| bestDay = [...dailyStats].sort((a, b) => b.totalListenSeconds - a.totalListenSeconds)[0] ?? null; | ||
| } else { | ||
| const byDate = new Map(); | ||
| for (const sess of sessions) { | ||
| byDate.set(sess.date, (byDate.get(sess.date) || 0) + sess.listenedSeconds); | ||
| } | ||
| let maxDate = null, maxSecs = 0; | ||
| for (const [date, secs] of byDate) { | ||
| if (secs > maxSecs) { maxSecs = secs; maxDate = date; } | ||
| } | ||
| if (maxDate) bestDay = { date: maxDate, totalListenSeconds: maxSecs }; | ||
| } | ||
| const totalSecs = sessions.reduce((s, r) => s + r.listenedSeconds, 0); | ||
| const totalPlays = sessions.length; | ||
| const uniqueSongs = byId.size; | ||
| const uniqueArtists = byArtist.size; | ||
| // Date range label | ||
| const s = new Date(startDate + "T12:00:00"); | ||
| const e = new Date(today + "T12:00:00"); | ||
| const fmtShort = d => d.toLocaleDateString("pt-BR", { day: "2-digit", month: "short" }); | ||
| const fmtFull = d => d.toLocaleDateString("pt-BR", { day: "2-digit", month: "short", year: "numeric" }); | ||
| const dateRange = `${fmtShort(s)} – ${fmtFull(e)}`; | ||
| const platformLabel = PLATFORM_LABELS[platform] || "music.youtube.com"; | ||
| return { cfg, dateRange, platformLabel, totalSecs, totalPlays, uniqueSongs, uniqueArtists, topSongs, topArtists, bestDay }; | ||
| } | ||
| /* ── Canvas helpers ─────────────────────────────────────────────── */ | ||
| function rrect(ctx, x, y, w, h, r) { | ||
| ctx.beginPath(); | ||
| ctx.roundRect(x, y, w, h, r); | ||
| ctx.closePath(); | ||
| } | ||
| function sep(ctx, y) { | ||
| ctx.save(); | ||
| ctx.strokeStyle = T.bsoft; | ||
| ctx.lineWidth = 1; | ||
| ctx.beginPath(); | ||
| ctx.moveTo(P, y); | ||
| ctx.lineTo(CW - P, y); | ||
| ctx.stroke(); | ||
| ctx.restore(); | ||
| } | ||
| function clip(ctx, x, y, w, h, r) { | ||
| ctx.beginPath(); | ||
| ctx.roundRect(x, y, w, h, r); | ||
| ctx.clip(); | ||
| } | ||
| function trunc(ctx, text, maxW) { | ||
| if (!text) return ""; | ||
| if (ctx.measureText(text).width <= maxW) return text; | ||
| let t = text; | ||
| while (ctx.measureText(t + "…").width > maxW && t.length) t = t.slice(0, -1); | ||
| return t + "…"; | ||
| } | ||
| async function loadImg(url) { | ||
| if (!url) return null; | ||
| return new Promise(resolve => { | ||
| const img = new Image(); | ||
| img.crossOrigin = "anonymous"; | ||
| img.onload = () => resolve(img); | ||
| img.onerror = () => resolve(null); | ||
| img.src = url; | ||
| }); | ||
| } | ||
| /* ── Canvas renderer ────────────────────────────────────────────── */ | ||
| async function drawRecap(canvas, data) { | ||
| canvas.width = CW; | ||
| canvas.height = CH; | ||
| // Pre-load thumbnails in parallel | ||
| const thumbs = await Promise.all(data.topSongs.map(r => loadImg(r.song.thumbnail))); | ||
| const ctx = canvas.getContext("2d"); | ||
| let y = 0; | ||
| /* ── Background ── */ | ||
| ctx.fillStyle = T.bg; | ||
| ctx.fillRect(0, 0, CW, CH); | ||
| let g = ctx.createRadialGradient(CW * 1.05, CH * -0.04, 0, CW * 1.05, CH * -0.04, CW); | ||
| g.addColorStop(0, "rgba(255,61,46,0.11)"); | ||
| g.addColorStop(1, "transparent"); | ||
| ctx.fillStyle = g; | ||
| ctx.fillRect(0, 0, CW, CH); | ||
| g = ctx.createRadialGradient(-CW * 0.05, CH * 1.04, 0, -CW * 0.05, CH * 1.04, CW * 0.8); | ||
| g.addColorStop(0, "rgba(201,162,39,0.06)"); | ||
| g.addColorStop(1, "transparent"); | ||
| ctx.fillStyle = g; | ||
| ctx.fillRect(0, 0, CW, CH); | ||
| /* ── Brand row ── */ | ||
| y = P; | ||
| ctx.font = `600 italic 26px "Fraunces"`; | ||
| ctx.fillStyle = T.text; | ||
| ctx.fillText("YTM", P, y + 26); | ||
| let bx = P + ctx.measureText("YTM").width; | ||
| ctx.fillStyle = T.accent; | ||
| ctx.fillText("·", bx, y + 26); | ||
| bx += ctx.measureText("·").width; | ||
| ctx.fillStyle = T.text; | ||
| ctx.fillText("Tracker", bx, y + 26); | ||
| ctx.font = `400 18px "Geist"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.textAlign = "right"; | ||
| ctx.fillText(data.dateRange, CW - P, y + 26); | ||
| ctx.textAlign = "left"; | ||
| y += 56; | ||
| /* ── Separator ── */ | ||
| sep(ctx, y); y += 44; | ||
| /* ── Headline: "seus X dias" ── */ | ||
| ctx.font = `400 italic 52px "Fraunces"`; | ||
| ctx.fillStyle = T.dim; | ||
| ctx.fillText(data.cfg.line1, P, y + 52); | ||
| y += 70; | ||
| /* ── Headline: "em música." ── */ | ||
| ctx.font = `700 76px "Fraunces"`; | ||
| const emW = ctx.measureText("em ").width; | ||
| ctx.fillStyle = T.dim; | ||
| ctx.fillText("em ", P, y + 76); | ||
| const gradH = ctx.createLinearGradient(P + emW, 0, P + emW + 480, 0); | ||
| gradH.addColorStop(0, T.accent); | ||
| gradH.addColorStop(1, T.accent2); | ||
| ctx.fillStyle = gradH; | ||
| const mW = ctx.measureText("música").width; | ||
| ctx.fillText("música", P + emW, y + 76); | ||
| ctx.fillStyle = T.accent; | ||
| ctx.fillText(".", P + emW + mW, y + 76); | ||
| y += 108; | ||
| /* ── Stats grid 2×2 ── */ | ||
| const cW = (CW - 2 * P - 14) / 2; | ||
| const cH = 140; | ||
| const stats = [ | ||
| { label: "TEMPO ESCUTADO", value: formatDuration(data.totalSecs), sub: "no período", primary: true }, | ||
| { label: "PLAYS", value: data.totalPlays.toLocaleString("pt-BR"), sub: "sessões registradas" }, | ||
| { label: "MÚSICAS ÚNICAS", value: data.uniqueSongs.toLocaleString("pt-BR"), sub: "faixas distintas" }, | ||
| { label: "ARTISTAS", value: data.uniqueArtists.toLocaleString("pt-BR"), sub: "artistas escutados" }, | ||
| ]; | ||
| for (let i = 0; i < 4; i++) { | ||
| const col = i % 2, row = Math.floor(i / 2); | ||
| const cx = P + col * (cW + 14); | ||
| const cy = y + row * (cH + 14); | ||
| const s = stats[i]; | ||
| if (s.primary) { | ||
| g = ctx.createLinearGradient(cx, cy, cx + cW, cy + cH); | ||
| g.addColorStop(0, "rgba(255,61,46,0.10)"); | ||
| g.addColorStop(1, T.card); | ||
| ctx.fillStyle = g; | ||
| } else { | ||
| ctx.fillStyle = T.card; | ||
| } | ||
| rrect(ctx, cx, cy, cW, cH, 14); ctx.fill(); | ||
| ctx.strokeStyle = s.primary ? "rgba(255,61,46,0.30)" : T.border; | ||
| ctx.lineWidth = 1; | ||
| rrect(ctx, cx, cy, cW, cH, 14); ctx.stroke(); | ||
| ctx.font = `500 13px "Geist"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.fillText(s.label, cx + 22, cy + 32); | ||
| ctx.font = `600 48px "Fraunces"`; | ||
| ctx.fillStyle = s.primary ? T.accent : T.text; | ||
| ctx.fillText(s.value, cx + 22, cy + 92); | ||
| ctx.font = `400 14px "Geist"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.fillText(s.sub, cx + 22, cy + 118); | ||
| } | ||
| y += 2 * cH + 14 + 52; | ||
| /* ── Top songs section ── */ | ||
| sep(ctx, y); y += 26; | ||
| ctx.font = `600 28px "Fraunces"`; | ||
| ctx.fillStyle = T.text; | ||
| ctx.fillText("top músicas", P, y + 28); | ||
| ctx.font = `400 16px "Geist"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.textAlign = "right"; | ||
| ctx.fillText("por tempo escutado", CW - P, y + 28); | ||
| ctx.textAlign = "left"; | ||
| y += 52; | ||
| const rankColors = [T.accent, T.gold, T.dim, T.muted, T.muted]; | ||
| const rowH = 80; | ||
| const thumbS = 58; | ||
| for (let i = 0; i < data.topSongs.length; i++) { | ||
| const { song, plays, secs } = data.topSongs[i]; | ||
| const ry = y + i * rowH; | ||
| const mid = ry + 12 + thumbS / 2; // vertical center of thumbnail | ||
| if (i > 0) { sep(ctx, ry); } | ||
| // Rank | ||
| ctx.font = `600 18px "JetBrains Mono"`; | ||
| ctx.fillStyle = rankColors[i]; | ||
| ctx.textAlign = "center"; | ||
| ctx.fillText(`${i + 1}`, P + 16, mid + 6); | ||
| ctx.textAlign = "left"; | ||
| // Thumbnail | ||
| const tx = P + 40, ty = ry + 12; | ||
| const thumb = thumbs[i]; | ||
| if (thumb) { | ||
| ctx.save(); | ||
| clip(ctx, tx, ty, thumbS, thumbS, 8); | ||
| ctx.drawImage(thumb, tx, ty, thumbS, thumbS); | ||
| ctx.restore(); | ||
| } else { | ||
| ctx.fillStyle = T.el; | ||
| rrect(ctx, tx, ty, thumbS, thumbS, 8); ctx.fill(); | ||
| ctx.font = `600 22px "Fraunces"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.textAlign = "center"; | ||
| ctx.fillText((song.title?.[0] ?? "?").toUpperCase(), tx + thumbS / 2, ty + thumbS / 2 + 8); | ||
| ctx.textAlign = "left"; | ||
| } | ||
| // Title + artist | ||
| const mx = tx + thumbS + 18; | ||
| const maxMW = CW - P - mx - 160; | ||
| ctx.font = `600 20px "Fraunces"`; | ||
| ctx.fillStyle = T.text; | ||
| ctx.fillText(trunc(ctx, song.title, maxMW), mx, mid - 5); | ||
| ctx.font = `400 15px "Geist"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.fillText(trunc(ctx, song.artist || "—", maxMW), mx, mid + 16); | ||
| // Play count + time (right) | ||
| ctx.textAlign = "right"; | ||
| ctx.font = `500 19px "JetBrains Mono"`; | ||
| ctx.fillStyle = T.text; | ||
| ctx.fillText(`${plays}×`, CW - P, mid - 5); | ||
| ctx.font = `400 14px "JetBrains Mono"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.fillText(formatDuration(secs), CW - P, mid + 16); | ||
| ctx.textAlign = "left"; | ||
| } | ||
| y += data.topSongs.length * rowH + 52; | ||
| /* ── Top artists section ── */ | ||
| sep(ctx, y); y += 26; | ||
| ctx.font = `600 28px "Fraunces"`; | ||
| ctx.fillStyle = T.text; | ||
| ctx.fillText("top artistas", P, y + 28); | ||
| y += 52; | ||
| const maxArtistSecs = data.topArtists[0]?.secs || 1; | ||
| const artRowH = 70; | ||
| for (let i = 0; i < data.topArtists.length; i++) { | ||
| const { name, plays, secs } = data.topArtists[i]; | ||
| const ay = y + i * artRowH; | ||
| ctx.font = `500 21px "Fraunces"`; | ||
| ctx.fillStyle = T.text; | ||
| ctx.fillText(trunc(ctx, name, CW - 2 * P - 220), P, ay + 22); | ||
| ctx.font = `400 15px "JetBrains Mono"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.textAlign = "right"; | ||
| ctx.fillText(`${formatDuration(secs)} · ${plays}×`, CW - P, ay + 22); | ||
| ctx.textAlign = "left"; | ||
| // Progress bar | ||
| const barY = ay + 38, barW = CW - 2 * P, barH = 6; | ||
| ctx.fillStyle = T.el; | ||
| rrect(ctx, P, barY, barW, barH, barH / 2); ctx.fill(); | ||
| const fill = Math.max(barW * (secs / maxArtistSecs), 10); | ||
| const bg = ctx.createLinearGradient(P, barY, P + fill, barY); | ||
| bg.addColorStop(0, T.accent); | ||
| bg.addColorStop(1, T.accent2); | ||
| ctx.fillStyle = bg; | ||
| rrect(ctx, P, barY, fill, barH, barH / 2); ctx.fill(); | ||
| } | ||
| y += data.topArtists.length * artRowH + 52; | ||
| /* ── Best day highlight ── */ | ||
| if (data.bestDay?.totalListenSeconds > 0) { | ||
| const bW = CW - 2 * P, bH = 90; | ||
| ctx.fillStyle = T.card; | ||
| rrect(ctx, P, y, bW, bH, 14); ctx.fill(); | ||
| ctx.strokeStyle = T.border; | ||
| ctx.lineWidth = 1; | ||
| rrect(ctx, P, y, bW, bH, 14); ctx.stroke(); | ||
| // Accent left bar | ||
| ctx.fillStyle = T.accent; | ||
| ctx.beginPath(); | ||
| ctx.roundRect(P, y, 4, bH, [14, 0, 0, 14]); | ||
| ctx.fill(); | ||
| ctx.font = `500 12px "Geist"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.fillText("MELHOR DIA", P + 24, y + 28); | ||
| const bd = new Date(data.bestDay.date + "T12:00:00"); | ||
| const bdStr = bd.toLocaleDateString("pt-BR", { weekday: "short", day: "2-digit", month: "long" }); | ||
| ctx.font = `600 26px "Fraunces"`; | ||
| ctx.fillStyle = T.text; | ||
| ctx.fillText(bdStr, P + 24, y + 66); | ||
| ctx.font = `500 24px "JetBrains Mono"`; | ||
| ctx.fillStyle = T.accent; | ||
| ctx.textAlign = "right"; | ||
| ctx.fillText(formatDuration(data.bestDay.totalListenSeconds), CW - P - 24, y + 66); | ||
| ctx.textAlign = "left"; | ||
| } | ||
| /* ── Footer (fixed at bottom) ── */ | ||
| const footerY = CH - 62; | ||
| sep(ctx, footerY - 18); | ||
| ctx.font = `600 italic 20px "Fraunces"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.fillText("YTM", P, footerY + 20); | ||
| let fx = P + ctx.measureText("YTM").width; | ||
| ctx.fillStyle = T.accent; | ||
| ctx.fillText("·", fx, footerY + 20); | ||
| fx += ctx.measureText("·").width; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.fillText("Tracker", fx, footerY + 20); | ||
| ctx.font = `400 16px "Geist"`; | ||
| ctx.fillStyle = T.muted; | ||
| ctx.textAlign = "right"; | ||
| ctx.fillText(data.platformLabel, CW - P, footerY + 20); | ||
| ctx.textAlign = "left"; | ||
| } | ||
| /* ── UI ─────────────────────────────────────────────────────────── */ | ||
| const canvas = document.getElementById("recap-canvas"); | ||
| const loadingEl = document.getElementById("loading"); | ||
| const emptyEl = document.getElementById("empty-state"); | ||
| const exportBtn = document.getElementById("export-btn"); | ||
| const tabs = document.querySelectorAll(".period-tab"); | ||
| const platformTabs = document.querySelectorAll(".platform-tab"); | ||
| const backBtn = document.getElementById("back-btn"); | ||
| let activePeriod = "7d"; | ||
| let activePlatform = "all"; | ||
| async function refresh() { | ||
| document.body.dataset.platform = activePlatform; | ||
| loadingEl.hidden = false; | ||
| emptyEl.hidden = true; | ||
| canvas.classList.add("is-loading"); | ||
| exportBtn.disabled = true; | ||
| try { | ||
| await document.fonts.ready; | ||
| const data = await computeData(activePeriod, activePlatform); | ||
| if (!data) { | ||
| canvas.removeAttribute("width"); | ||
| canvas.removeAttribute("height"); | ||
| emptyEl.hidden = false; | ||
| } else { | ||
| await drawRecap(canvas, data); | ||
| canvas.classList.remove("is-loading"); | ||
| exportBtn.disabled = false; | ||
| } | ||
| } catch (err) { | ||
| console.error("[recap]", err); | ||
| } finally { | ||
| loadingEl.hidden = true; | ||
| } | ||
| } | ||
| tabs.forEach(tab => { | ||
| tab.addEventListener("click", () => { | ||
| tabs.forEach(t => t.classList.remove("is-active")); | ||
| tab.classList.add("is-active"); | ||
| activePeriod = tab.dataset.period; | ||
| refresh(); | ||
| }); | ||
| }); | ||
| platformTabs.forEach(tab => { | ||
| tab.addEventListener("click", () => { | ||
| platformTabs.forEach(t => t.classList.remove("is-active")); | ||
| tab.classList.add("is-active"); | ||
| activePlatform = tab.dataset.platform; | ||
| refresh(); | ||
| }); | ||
| }); | ||
| exportBtn.addEventListener("click", () => { | ||
| try { | ||
| const link = document.createElement("a"); | ||
| link.download = `ytm-recap-${activePeriod}-${activePlatform}-${new Date().toISOString().slice(0, 10)}.png`; | ||
| link.href = canvas.toDataURL("image/png"); | ||
| link.click(); | ||
| } catch (e) { | ||
| // Canvas tainted by cross-origin thumbnail — retry without images | ||
| console.warn("[recap] toDataURL failed, retrying without thumbnails:", e); | ||
| exportWithoutThumbs(); | ||
| } | ||
| }); | ||
| async function exportWithoutThumbs() { | ||
| const data = await computeData(activePeriod, activePlatform); | ||
| if (!data) return; | ||
| // Patch: zero out thumbnails so loadImg returns null placeholders | ||
| data.topSongs = data.topSongs.map(r => ({ ...r, song: { ...r.song, thumbnail: null } })); | ||
| await drawRecap(canvas, data); | ||
| const link = document.createElement("a"); | ||
| link.download = `ytm-recap-${activePeriod}-${activePlatform}-${new Date().toISOString().slice(0, 10)}.png`; | ||
| link.href = canvas.toDataURL("image/png"); | ||
| link.click(); | ||
| // Re-render with original data (restoring thumbnails) | ||
| const fullData = await computeData(activePeriod, activePlatform); | ||
| if (fullData) await drawRecap(canvas, fullData); | ||
| } | ||
| backBtn.addEventListener("click", () => { | ||
| const dashUrl = chrome.runtime.getURL("dashboard/dashboard.html"); | ||
| chrome.tabs.update({ url: dashUrl }); | ||
| }); | ||
| refresh(); |
| /** | ||
| * Content script injected into open.spotify.com. | ||
| * | ||
| * Uses the Media Session API (navigator.mediaSession) for track info and | ||
| * play/pause state — Spotify does not expose an <audio> element in the | ||
| * accessible DOM. Falls back to DOM selectors for higher-resolution artwork. | ||
| */ | ||
| (() => { | ||
| "use strict"; | ||
| /* ---------- inlined utils -------------------------------------- */ | ||
| function hashSongId(platform, artist, title, album = "") { | ||
| const key = `${(platform || "").trim()}|${(artist || "").trim()}|${(title || "").trim()}|${(album || "").trim()}`.toLowerCase(); | ||
| let h = 0x811c9dc5; | ||
| for (let i = 0; i < key.length; i++) { | ||
| h ^= key.charCodeAt(i); | ||
| h = Math.imul(h, 0x01000193); | ||
| } | ||
| return ("00000000" + (h >>> 0).toString(16)).slice(-8); | ||
| } | ||
| /* ---------- config --------------------------------------------- */ | ||
| const TICK_INTERVAL_MS = 1000; | ||
| const PAUSE_FLUSH_THRESHOLD_SEC = 30; | ||
| const MIN_LISTEN_SECONDS = 5; | ||
| const STATUS_PUBLISH_INTERVAL_TICKS = 3; | ||
| const PLATFORM = "spotify"; | ||
| const STATUS_KEY = "spotify_currentStatus"; | ||
| /* ---------- state ---------------------------------------------- */ | ||
| const state = { | ||
| song: null, | ||
| startedAt: null, | ||
| listenedSeconds: 0, | ||
| pausedSeconds: 0, | ||
| tickCounter: 0, | ||
| lastFlushReason: null, | ||
| }; | ||
| let tickHandle = null; | ||
| /* ---------- playback detection --------------------------------- */ | ||
| function isPlaying() { | ||
| const ps = navigator.mediaSession.playbackState; | ||
| if (ps === "playing") return true; | ||
| if (ps === "paused") return false; | ||
| // "none" — fall back to play/pause button aria-label | ||
| const btn = document.querySelector('[data-testid="control-button-playpause"]'); | ||
| if (btn) { | ||
| const label = (btn.getAttribute("aria-label") || "").toLowerCase(); | ||
| // pt-BR: "pausar" when playing, "reproduzir" when paused | ||
| // en: "pause" when playing, "play" when paused | ||
| return label.includes("pausar") || label.includes("pause"); | ||
| } | ||
| return false; | ||
| } | ||
| /* ---------- DOM scraping --------------------------------------- */ | ||
| function getDuration() { | ||
| // Try to read total duration from the player progress bar label | ||
| const el = | ||
| document.querySelector('[data-testid="playback-duration"]') || | ||
| document.querySelector('[data-testid="progress-bar-timestamp-end"]'); | ||
| if (!el) return 0; | ||
| const parts = (el.textContent || "").trim().split(":").map(Number); | ||
| if (parts.length === 2 && !parts.some(isNaN)) return parts[0] * 60 + parts[1]; | ||
| return 0; | ||
| } | ||
| function getThumbnail(artworkList) { | ||
| // Prefer the highest-resolution artwork from Media Session metadata | ||
| if (artworkList && artworkList.length) { | ||
| const sorted = [...artworkList].sort((a, b) => { | ||
| const wa = parseInt(a.sizes?.split("x")[0] || "0", 10); | ||
| const wb = parseInt(b.sizes?.split("x")[0] || "0", 10); | ||
| return wb - wa; | ||
| }); | ||
| if (sorted[0]?.src) return sorted[0].src; | ||
| } | ||
| // Fallback to DOM cover art element | ||
| const img = | ||
| document.querySelector('[data-testid="cover-art-image"]') || | ||
| document.querySelector('[data-testid="CoverSlotExpanded--image"]'); | ||
| return img?.src || null; | ||
| } | ||
| /* ---------- song detection ------------------------------------- */ | ||
| function detectCurrentSong() { | ||
| const meta = navigator.mediaSession.metadata; | ||
| if (!meta || !meta.title || !meta.artist) return null; | ||
| const title = meta.title.trim(); | ||
| const artist = meta.artist.trim(); | ||
| if (!title || !artist) return null; | ||
| const album = meta.album?.trim() || null; | ||
| const thumbnail = getThumbnail(meta.artwork); | ||
| const duration = getDuration(); | ||
| const id = hashSongId(PLATFORM, artist, title, album || ""); | ||
| return { id, title, artist, album, thumbnail, duration, platform: PLATFORM }; | ||
| } | ||
| /* ---------- session lifecycle ---------------------------------- */ | ||
| function startSong(song) { | ||
| state.song = song; | ||
| state.startedAt = new Date().toISOString(); | ||
| state.listenedSeconds = 0; | ||
| state.pausedSeconds = 0; | ||
| } | ||
| function mergeMetadata(current, detected) { | ||
| if (!current) return; | ||
| if (!current.thumbnail && detected.thumbnail) current.thumbnail = detected.thumbnail; | ||
| if (!current.duration && detected.duration) current.duration = detected.duration; | ||
| if (!current.album && detected.album) current.album = detected.album; | ||
| } | ||
| function flushSession(reason) { | ||
| state.lastFlushReason = reason; | ||
| if (state.song && state.listenedSeconds >= MIN_LISTEN_SECONDS) { | ||
| const payload = { | ||
| song: { ...state.song }, | ||
| listenedSeconds: state.listenedSeconds, | ||
| startedAt: state.startedAt, | ||
| endedAt: new Date().toISOString(), | ||
| reason, | ||
| }; | ||
| Promise.resolve( | ||
| chrome.runtime.sendMessage({ type: "RECORD_SESSION", payload }) | ||
| ).catch((e) => console.warn("[Spotify Tracker] failed to send session", e)); | ||
| } | ||
| state.song = null; | ||
| state.startedAt = null; | ||
| state.listenedSeconds = 0; | ||
| state.pausedSeconds = 0; | ||
| } | ||
| /* ---------- tick loop ------------------------------------------ */ | ||
| function tick() { | ||
| const detected = detectCurrentSong(); | ||
| if (detected) { | ||
| if (state.song && detected.id !== state.song.id) { | ||
| flushSession("song-change"); | ||
| startSong(detected); | ||
| } else if (!state.song) { | ||
| startSong(detected); | ||
| } else { | ||
| mergeMetadata(state.song, detected); | ||
| } | ||
| } | ||
| if (!state.song) return; | ||
| if (isPlaying()) { | ||
| state.listenedSeconds += 1; | ||
| state.pausedSeconds = 0; | ||
| } else { | ||
| state.pausedSeconds += 1; | ||
| if (state.pausedSeconds >= PAUSE_FLUSH_THRESHOLD_SEC) { | ||
| flushSession("pause-timeout"); | ||
| } | ||
| } | ||
| state.tickCounter++; | ||
| if (state.tickCounter % STATUS_PUBLISH_INTERVAL_TICKS === 0) { | ||
| publishStatus(); | ||
| } | ||
| } | ||
| function publishStatus() { | ||
| const playing = state.song ? isPlaying() : false; | ||
| const status = state.song | ||
| ? { | ||
| isPlaying: playing, | ||
| song: state.song, | ||
| listenedSeconds: state.listenedSeconds, | ||
| startedAt: state.startedAt, | ||
| updatedAt: Date.now(), | ||
| } | ||
| : null; | ||
| chrome.storage.local.set({ [STATUS_KEY]: status }).catch(() => {}); | ||
| } | ||
| /* ---------- bootstrap ------------------------------------------ */ | ||
| function start() { | ||
| if (tickHandle) return; | ||
| tickHandle = setInterval(tick, TICK_INTERVAL_MS); | ||
| console.log("[Spotify Tracker] tracking started"); | ||
| } | ||
| // Media Session API is always available — no need to wait for a DOM element. | ||
| // Start immediately and let tick() handle the "no song loaded yet" state. | ||
| start(); | ||
| /* ---------- unload handlers ------------------------------------ */ | ||
| window.addEventListener("pagehide", () => flushSession("pagehide")); | ||
| window.addEventListener("beforeunload", () => flushSession("beforeunload")); | ||
| })(); |
+2
-1
@@ -38,4 +38,5 @@ /** | ||
| }); | ||
| const platform = payload.song.platform || "ytmusic"; | ||
| console.log( | ||
| `[YTM Tracker] recorded ${payload.listenedSeconds}s of "${payload.song.title}" — ${payload.song.artist} (reason: ${payload.reason})` | ||
| `[YTM Tracker] [${platform}] recorded ${payload.listenedSeconds}s of "${payload.song.title}" — ${payload.song.artist} (reason: ${payload.reason})` | ||
| ); | ||
@@ -42,0 +43,0 @@ } |
+4
-4
@@ -15,4 +15,4 @@ /** | ||
| /* ---------- inlined utils -------------------------------------- */ | ||
| function hashSongId(artist, title, album = "") { | ||
| const key = `${(artist || "").trim()}|${(title || "").trim()}|${(album || "").trim()}`.toLowerCase(); | ||
| function hashSongId(platform, artist, title, album = "") { | ||
| const key = `${(platform || "").trim()}|${(artist || "").trim()}|${(title || "").trim()}|${(album || "").trim()}`.toLowerCase(); | ||
| let h = 0x811c9dc5; | ||
@@ -105,5 +105,5 @@ for (let i = 0; i < key.length; i++) { | ||
| const id = hashSongId(artist, title, album || ""); | ||
| const id = hashSongId("ytmusic", artist, title, album || ""); | ||
| return { id, title, artist, album, thumbnail, duration }; | ||
| return { id, title, artist, album, thumbnail, duration, platform: "ytmusic" }; | ||
| } | ||
@@ -110,0 +110,0 @@ |
@@ -16,3 +16,8 @@ @import url("https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,600&family=Geist:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); | ||
| --accent-soft: rgba(255, 61, 46, 0.14); | ||
| --accent-glow: rgba(255, 61, 46, 0.07); | ||
| --gold: #c9a227; | ||
| --hm-l1: rgba(255, 61, 46, 0.20); | ||
| --hm-l2: rgba(255, 61, 46, 0.40); | ||
| --hm-l3: rgba(255, 61, 46, 0.65); | ||
| --hm-l4: rgba(255, 61, 46, 0.95); | ||
@@ -30,5 +35,6 @@ --font-display: "Fraunces", "Times New Roman", serif; | ||
| background: | ||
| radial-gradient(1200px circle at 110% -10%, rgba(255, 61, 46, 0.07), transparent 50%), | ||
| radial-gradient(1200px circle at 110% -10%, var(--accent-glow), transparent 50%), | ||
| radial-gradient(1000px circle at -10% 110%, rgba(201, 162, 39, 0.04), transparent 50%), | ||
| var(--bg); | ||
| transition: background 0.4s ease; | ||
| color: var(--text); | ||
@@ -132,2 +138,11 @@ font-family: var(--font-sans); | ||
| } | ||
| .ghost-btn-recap { | ||
| border-color: rgba(255, 61, 46, 0.30); | ||
| color: var(--accent); | ||
| } | ||
| .ghost-btn-recap:hover { | ||
| background: var(--accent-soft); | ||
| border-color: var(--accent); | ||
| color: var(--accent); | ||
| } | ||
@@ -214,2 +229,56 @@ /* ─── hero ───────────────────────────────────────── */ | ||
| /* platform switch — reutiliza o estilo do period-switch */ | ||
| .platform-switch { | ||
| display: flex; | ||
| background: var(--bg-elevated); | ||
| border: 1px solid var(--border); | ||
| border-radius: 10px; | ||
| padding: 4px; | ||
| gap: 2px; | ||
| margin-top: 12px; | ||
| } | ||
| .platform-tab { | ||
| background: transparent; | ||
| border: none; | ||
| color: var(--text-muted); | ||
| padding: 6px 14px; | ||
| border-radius: 7px; | ||
| cursor: pointer; | ||
| font-family: var(--font-sans); | ||
| font-size: 11px; | ||
| font-weight: 500; | ||
| letter-spacing: 0.01em; | ||
| transition: all 0.15s ease; | ||
| } | ||
| .platform-tab:hover { color: var(--text-dim); } | ||
| .platform-tab.is-active { | ||
| background: var(--text); | ||
| color: var(--bg); | ||
| } | ||
| /* platform badges in lists */ | ||
| .platform-badge { | ||
| display: inline-block; | ||
| font-size: 8px; | ||
| font-weight: 700; | ||
| letter-spacing: 0.06em; | ||
| padding: 1px 5px; | ||
| border-radius: 3px; | ||
| vertical-align: middle; | ||
| margin-left: 6px; | ||
| position: relative; | ||
| top: -1px; | ||
| } | ||
| .platform-badge--ytmusic { | ||
| background: rgba(255, 61, 46, 0.15); | ||
| color: var(--accent); | ||
| } | ||
| .platform-badge--spotify { | ||
| background: rgba(30, 215, 96, 0.12); | ||
| color: #1ed760; | ||
| } | ||
| /* ─── stat cards ─────────────────────────────────── */ | ||
@@ -451,6 +520,6 @@ | ||
| .heatmap-l0 { background: #1a1612; } | ||
| .heatmap-l1 { background: rgba(255, 61, 46, 0.20); } | ||
| .heatmap-l2 { background: rgba(255, 61, 46, 0.40); } | ||
| .heatmap-l3 { background: rgba(255, 61, 46, 0.65); } | ||
| .heatmap-l4 { background: rgba(255, 61, 46, 0.95); } | ||
| .heatmap-l1 { background: var(--hm-l1); } | ||
| .heatmap-l2 { background: var(--hm-l2); } | ||
| .heatmap-l3 { background: var(--hm-l3); } | ||
| .heatmap-l4 { background: var(--hm-l4); } | ||
@@ -705,1 +774,25 @@ .heatmap-legend { | ||
| .footer-sep { color: var(--accent); } | ||
| /* ─── theming por plataforma ─────────────────────── */ | ||
| [data-platform="spotify"] { | ||
| --accent: #1ed760; | ||
| --accent-2: #1fdf64; | ||
| --accent-soft: rgba(30, 215, 96, 0.12); | ||
| --accent-glow: rgba(30, 215, 96, 0.07); | ||
| --hm-l1: rgba(30, 215, 96, 0.20); | ||
| --hm-l2: rgba(30, 215, 96, 0.40); | ||
| --hm-l3: rgba(30, 215, 96, 0.65); | ||
| --hm-l4: rgba(30, 215, 96, 0.95); | ||
| } | ||
| [data-platform="all"] { | ||
| --accent: #c9a227; | ||
| --accent-2: #d4a843; | ||
| --accent-soft: rgba(201, 162, 39, 0.14); | ||
| --accent-glow: rgba(201, 162, 39, 0.06); | ||
| --hm-l1: rgba(201, 162, 39, 0.20); | ||
| --hm-l2: rgba(201, 162, 39, 0.40); | ||
| --hm-l3: rgba(201, 162, 39, 0.65); | ||
| --hm-l4: rgba(201, 162, 39, 0.95); | ||
| } |
@@ -22,2 +22,6 @@ <!doctype html> | ||
| <div class="header-actions"> | ||
| <button class="ghost-btn ghost-btn-recap" id="recap-btn"> | ||
| <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 00-1.3-3.2 4.2 4.2 0 00-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 00-6.2 0C6.5 2.8 5.4 3.1 5.4 3.1a4.2 4.2 0 00-.1 3.2A4.6 4.6 0 004 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/></svg> | ||
| recap | ||
| </button> | ||
| <button class="ghost-btn" id="export-btn"> | ||
@@ -53,2 +57,8 @@ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg> | ||
| </div> | ||
| <div class="platform-switch" role="tablist" aria-label="Plataforma"> | ||
| <button class="platform-tab is-active" data-platform="all">tudo</button> | ||
| <button class="platform-tab" data-platform="ytmusic">YouTube Music</button> | ||
| <button class="platform-tab" data-platform="spotify">Spotify</button> | ||
| </div> | ||
| </section> | ||
@@ -150,3 +160,3 @@ | ||
| <footer class="page-footer"> | ||
| <span>v0.1.0 — todos os dados estão na sua máquina (IndexedDB).</span> | ||
| <span>v0.2.0 — todos os dados estão na sua máquina (IndexedDB).</span> | ||
| <span class="footer-sep">·</span> | ||
@@ -153,0 +163,0 @@ <span id="footer-storage">— sessões registradas</span> |
+67
-13
@@ -29,2 +29,3 @@ import { | ||
| let currentPeriod = "30d"; | ||
| let currentPlatform = "all"; | ||
@@ -53,8 +54,13 @@ /* ────────────────────────────────────────────────── */ | ||
| async function loadPeriodData(periodKey) { | ||
| async function loadPeriodData(periodKey, platformKey) { | ||
| const { start, end, days } = getPeriodRange(periodKey); | ||
| const sessions = await (periodKey === "all" | ||
| const allSessions = await (periodKey === "all" | ||
| ? getSessionsInRange("0000-01-01", end) | ||
| : getSessionsInRange(start, end)); | ||
| // apply platform filter | ||
| const sessions = platformKey === "all" | ||
| ? allSessions | ||
| : allSessions.filter((s) => s.platform === platformKey); | ||
| // unique song ids in period | ||
@@ -301,3 +307,3 @@ const songIdSet = new Set(); | ||
| async function renderHeatmap() { | ||
| async function renderHeatmap(filteredSessions) { | ||
| const grid = $("#heatmap-grid"); | ||
@@ -317,7 +323,20 @@ const monthsRow = $("#heatmap-months"); | ||
| const endKey = todayKey(); | ||
| const stats = await getDailyStats(startKey, endKey); | ||
| const byDate = new Map(stats.map((s) => [s.date, s.totalListenSeconds])); | ||
| let byDate; | ||
| if (currentPlatform === "all") { | ||
| // Use pre-aggregated dailyStats for the combined view | ||
| const stats = await getDailyStats(startKey, endKey); | ||
| byDate = new Map(stats.map((s) => [s.date, s.totalListenSeconds])); | ||
| } else { | ||
| // Derive per-day totals from the already-filtered sessions | ||
| byDate = new Map(); | ||
| for (const s of filteredSessions) { | ||
| if (s.date >= startKey && s.date <= endKey) { | ||
| byDate.set(s.date, (byDate.get(s.date) || 0) + s.listenedSeconds); | ||
| } | ||
| } | ||
| } | ||
| // determine max for level scaling | ||
| const max = Math.max(0, ...stats.map((s) => s.totalListenSeconds)); | ||
| const max = byDate.size > 0 ? Math.max(0, ...[...byDate.values()]) : 0; | ||
@@ -401,7 +420,7 @@ function level(v) { | ||
| const top = [...agg.values()] | ||
| .sort((a, b) => b.plays - a.plays || b.time - a.time) | ||
| .sort((a, b) => b.time - a.time) | ||
| .slice(0, 10); | ||
| if (top.length === 0) { | ||
| list.innerHTML = `<li class="empty-state">nenhuma escuta ainda — abra o YT Music e dê o play.</li>`; | ||
| list.innerHTML = `<li class="empty-state">nenhuma escuta ainda — abra o YT Music ou Spotify e dê o play.</li>`; | ||
| return; | ||
@@ -413,2 +432,5 @@ } | ||
| if (!song) return; | ||
| const platformBadge = currentPlatform === "all" | ||
| ? `<span class="platform-badge platform-badge--${song.platform || "ytmusic"}">${song.platform === "spotify" ? "SPO" : "YTM"}</span>` | ||
| : ""; | ||
| const li = document.createElement("li"); | ||
@@ -420,3 +442,3 @@ li.className = "song-row"; | ||
| <div class="song-meta"> | ||
| <div class="song-title">${escapeHtml(song.title)}</div> | ||
| <div class="song-title">${escapeHtml(song.title)}${platformBadge}</div> | ||
| <div class="song-artist">${escapeHtml(song.artist)}${song.album ? " · " + escapeHtml(song.album) : ""}</div> | ||
@@ -487,3 +509,9 @@ </div> | ||
| const sessions = await getRecentSessions(50); | ||
| // Fetch extra sessions when filtering, to ensure we get 50 after the filter | ||
| const fetchLimit = currentPlatform === "all" ? 50 : 200; | ||
| const allSessions = await getRecentSessions(fetchLimit); | ||
| const sessions = currentPlatform === "all" | ||
| ? allSessions | ||
| : allSessions.filter((s) => s.platform === currentPlatform).slice(0, 50); | ||
| if (sessions.length === 0) { | ||
@@ -501,2 +529,5 @@ list.innerHTML = `<li class="empty-state">o histórico aparece aqui assim que você começar a tocar algo.</li>`; | ||
| if (!song) continue; | ||
| const platformBadge = currentPlatform === "all" | ||
| ? `<span class="platform-badge platform-badge--${s.platform || "ytmusic"}">${s.platform === "spotify" ? "SPO" : "YTM"}</span>` | ||
| : ""; | ||
| const li = document.createElement("li"); | ||
@@ -508,3 +539,3 @@ li.className = "timeline-row"; | ||
| <div class="timeline-info"> | ||
| <div class="timeline-title">${escapeHtml(song.title)}</div> | ||
| <div class="timeline-title">${escapeHtml(song.title)}${platformBadge}</div> | ||
| <div class="timeline-artist">${escapeHtml(song.artist)}</div> | ||
@@ -564,3 +595,3 @@ </div> | ||
| async function refresh() { | ||
| const data = await loadPeriodData(currentPeriod); | ||
| const data = await loadPeriodData(currentPeriod, currentPlatform); | ||
| renderStats(data, currentPeriod); | ||
@@ -570,3 +601,3 @@ renderBarChart(data, currentPeriod); | ||
| renderTopArtists(data); | ||
| await Promise.all([renderHeatmap(), renderTimeline(), renderFooter()]); | ||
| await Promise.all([renderHeatmap(data.sessions), renderTimeline(), renderFooter()]); | ||
| } | ||
@@ -587,5 +618,26 @@ | ||
| function applyPlatformTheme(platform) { | ||
| document.body.dataset.platform = platform; | ||
| } | ||
| function bindPlatformTabs() { | ||
| document.querySelectorAll(".platform-tab").forEach((btn) => { | ||
| btn.addEventListener("click", () => { | ||
| document | ||
| .querySelectorAll(".platform-tab") | ||
| .forEach((b) => b.classList.remove("is-active")); | ||
| btn.classList.add("is-active"); | ||
| currentPlatform = btn.dataset.platform; | ||
| applyPlatformTheme(currentPlatform); | ||
| refresh(); | ||
| }); | ||
| }); | ||
| } | ||
| function bindActions() { | ||
| $("#export-btn").addEventListener("click", exportData); | ||
| $("#reset-btn").addEventListener("click", resetData); | ||
| $("#recap-btn").addEventListener("click", () => { | ||
| chrome.tabs.create({ url: chrome.runtime.getURL("recap/recap.html") }); | ||
| }); | ||
| } | ||
@@ -595,3 +647,5 @@ | ||
| bindPeriodTabs(); | ||
| bindPlatformTabs(); | ||
| bindActions(); | ||
| applyPlatformTheme(currentPlatform); // "all" → dourado por padrão | ||
| await refresh(); | ||
@@ -598,0 +652,0 @@ } |
+98
-7
@@ -5,6 +5,6 @@ /** | ||
| * | ||
| * Schema: | ||
| * - songs keyPath: id { id, title, artist, album, thumbnail, duration, playCount, totalListenSeconds, firstPlayedAt, lastPlayedAt } | ||
| * - sessions keyPath: id (auto) { id, songId, startedAt, endedAt, listenedSeconds, date } | ||
| * indexes: songId, date, startedAt | ||
| * Schema v2: | ||
| * - songs keyPath: id { id, platform, title, artist, album, thumbnail, duration, playCount, totalListenSeconds, firstPlayedAt, lastPlayedAt } | ||
| * - sessions keyPath: id (auto) { id, songId, platform, startedAt, endedAt, listenedSeconds, date } | ||
| * indexes: songId, date, startedAt, platform | ||
| * - dailyStats keyPath: date { date, totalListenSeconds, playCount, uniqueSongIds: string[] } | ||
@@ -14,9 +14,87 @@ */ | ||
| const DB_NAME = "ytmusic-tracker"; | ||
| const DB_VERSION = 1; | ||
| const DB_VERSION = 2; | ||
| /* -------------------------------------------------------------- */ | ||
| /* FNV-1a hash — same algorithm as content scripts (inlined) */ | ||
| /* -------------------------------------------------------------- */ | ||
| function hashSongId(platform, artist, title, album = "") { | ||
| const key = `${(platform || "").trim()}|${(artist || "").trim()}|${(title || "").trim()}|${(album || "").trim()}`.toLowerCase(); | ||
| let h = 0x811c9dc5; | ||
| for (let i = 0; i < key.length; i++) { | ||
| h ^= key.charCodeAt(i); | ||
| h = Math.imul(h, 0x01000193); | ||
| } | ||
| return ("00000000" + (h >>> 0).toString(16)).slice(-8); | ||
| } | ||
| /* -------------------------------------------------------------- */ | ||
| /* Migration helpers */ | ||
| /* -------------------------------------------------------------- */ | ||
| function cursorAll(store) { | ||
| return new Promise((resolve, reject) => { | ||
| const results = []; | ||
| const req = store.openCursor(); | ||
| req.onsuccess = () => { | ||
| const cursor = req.result; | ||
| if (cursor) { | ||
| results.push(cursor.value); | ||
| cursor.continue(); | ||
| } else { | ||
| resolve(results); | ||
| } | ||
| }; | ||
| req.onerror = () => reject(req.error); | ||
| }); | ||
| } | ||
| async function migrateV1toV2(db, transaction) { | ||
| const songsStore = transaction.objectStore("songs"); | ||
| const sessionsStore = transaction.objectStore("sessions"); | ||
| const dailyStore = transaction.objectStore("dailyStats"); | ||
| const songs = await cursorAll(songsStore); | ||
| const idMap = new Map(); // oldId → newId | ||
| for (const song of songs) { | ||
| if (song.platform) continue; // already migrated (idempotency guard) | ||
| const newId = hashSongId("ytmusic", song.artist || "", song.title || "", song.album || ""); | ||
| idMap.set(song.id, newId); | ||
| const updated = { ...song, id: newId, platform: "ytmusic" }; | ||
| songsStore.put(updated); | ||
| if (newId !== song.id) songsStore.delete(song.id); | ||
| } | ||
| if (idMap.size > 0) { | ||
| const sessions = await cursorAll(sessionsStore); | ||
| for (const session of sessions) { | ||
| if (session.platform) continue; | ||
| const updatedSession = { | ||
| ...session, | ||
| platform: "ytmusic", | ||
| songId: idMap.get(session.songId) ?? session.songId, | ||
| }; | ||
| sessionsStore.put(updatedSession); | ||
| } | ||
| const days = await cursorAll(dailyStore); | ||
| for (const day of days) { | ||
| const updatedIds = day.uniqueSongIds.map((id) => idMap.get(id) ?? id); | ||
| dailyStore.put({ ...day, uniqueSongIds: updatedIds }); | ||
| } | ||
| } | ||
| } | ||
| /* -------------------------------------------------------------- */ | ||
| /* DB open */ | ||
| /* -------------------------------------------------------------- */ | ||
| function openDb() { | ||
| return new Promise((resolve, reject) => { | ||
| const req = indexedDB.open(DB_NAME, DB_VERSION); | ||
| req.onupgradeneeded = (event) => { | ||
| req.onupgradeneeded = async (event) => { | ||
| const db = req.result; | ||
| const transaction = req.transaction; | ||
| if (!db.objectStoreNames.contains("songs")) { | ||
@@ -33,2 +111,9 @@ db.createObjectStore("songs", { keyPath: "id" }); | ||
| sessions.createIndex("startedAt", "startedAt", { unique: false }); | ||
| sessions.createIndex("platform", "platform", { unique: false }); | ||
| } else if (event.oldVersion < 2) { | ||
| // Add platform index to existing sessions store | ||
| const sessionsStore = transaction.objectStore("sessions"); | ||
| if (!sessionsStore.indexNames.contains("platform")) { | ||
| sessionsStore.createIndex("platform", "platform", { unique: false }); | ||
| } | ||
| } | ||
@@ -38,2 +123,6 @@ if (!db.objectStoreNames.contains("dailyStats")) { | ||
| } | ||
| if (event.oldVersion < 2 && event.oldVersion > 0) { | ||
| await migrateV1toV2(db, transaction); | ||
| } | ||
| }; | ||
@@ -86,2 +175,3 @@ req.onsuccess = () => resolve(req.result); | ||
| duration: song.duration || existing.duration, | ||
| platform: song.platform || existing.platform, | ||
| playCount: existing.playCount + 1, | ||
@@ -103,2 +193,3 @@ totalListenSeconds: existing.totalListenSeconds + listenedSeconds, | ||
| songId: song.id, | ||
| platform: song.platform || "ytmusic", | ||
| startedAt, | ||
@@ -228,3 +319,3 @@ endedAt, | ||
| exportedAt: new Date().toISOString(), | ||
| version: 1, | ||
| version: 2, | ||
| songs, | ||
@@ -231,0 +322,0 @@ sessions, |
+3
-3
@@ -5,5 +5,5 @@ /** | ||
| /** Stable hash for song id from "artist|title". Avoids collisions on remixes. */ | ||
| export function hashSongId(artist, title, album = "") { | ||
| const key = `${(artist || "").trim()}|${(title || "").trim()}|${(album || "").trim()}`.toLowerCase(); | ||
| /** Stable hash for song id from "platform|artist|title|album". Avoids cross-platform collisions. */ | ||
| export function hashSongId(platform, artist, title, album = "") { | ||
| const key = `${(platform || "").trim()}|${(artist || "").trim()}|${(title || "").trim()}|${(album || "").trim()}`.toLowerCase(); | ||
| // FNV-1a 32-bit | ||
@@ -10,0 +10,0 @@ let h = 0x811c9dc5; |
+23
-8
| { | ||
| "manifest_version": 3, | ||
| "name": "YT Music Tracker", | ||
| "version": "0.1.1", | ||
| "description": "Acompanhe seu hist\u00f3rico do YouTube Music localmente - tempo escutado, top m\u00fasicas, artistas e estat\u00edsticas detalhadas.", | ||
| "version": "0.2.0", | ||
| "description": "Acompanhe seu hist\u00f3rico do YouTube Music localmente \u2014 tempo escutado, top m\u00fasicas, artistas e estat\u00edsticas detalhadas.", | ||
| "permissions": [ | ||
| "storage", | ||
| "alarms" | ||
| "storage" | ||
| ], | ||
| "host_permissions": [ | ||
| "*://music.youtube.com/*" | ||
| "*://music.youtube.com/*", | ||
| "*://open.spotify.com/*" | ||
| ], | ||
@@ -30,2 +30,12 @@ "background": { | ||
| "all_frames": false | ||
| }, | ||
| { | ||
| "matches": [ | ||
| "*://open.spotify.com/*" | ||
| ], | ||
| "js": [ | ||
| "spotify-content.js" | ||
| ], | ||
| "run_at": "document_idle", | ||
| "all_frames": false | ||
| } | ||
@@ -52,6 +62,8 @@ ], | ||
| "resources": [ | ||
| "dashboard/dashboard.html" | ||
| "dashboard/dashboard.html", | ||
| "recap/recap.html" | ||
| ], | ||
| "matches": [ | ||
| "*://music.youtube.com/*" | ||
| "*://music.youtube.com/*", | ||
| "*://open.spotify.com/*" | ||
| ] | ||
@@ -63,3 +75,3 @@ } | ||
| "id": "ytmusic-tracker@flugel.dev", | ||
| "strict_min_version": "121.0", | ||
| "strict_min_version": "140.0", | ||
| "data_collection_permissions": { | ||
@@ -70,4 +82,7 @@ "required": [ | ||
| } | ||
| }, | ||
| "gecko_android": { | ||
| "strict_min_version": "142.0" | ||
| } | ||
| } | ||
| } |
+40
-2
@@ -15,2 +15,3 @@ @import url("https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,400&family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); | ||
| --accent-soft: rgba(255, 61, 46, 0.14); | ||
| --accent-glow: rgba(255, 61, 46, 0.08); | ||
| --gold: #c9a227; | ||
@@ -42,6 +43,7 @@ | ||
| background: | ||
| radial-gradient(800px circle at 100% 0%, rgba(255, 61, 46, 0.08), transparent 40%), | ||
| radial-gradient(800px circle at 100% 0%, var(--accent-glow), transparent 40%), | ||
| radial-gradient(600px circle at 0% 100%, rgba(201, 162, 39, 0.04), transparent 40%), | ||
| var(--bg); | ||
| min-height: 380px; | ||
| transition: background 0.4s ease; | ||
| } | ||
@@ -162,2 +164,9 @@ | ||
| .np-label-row { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| margin-bottom: 4px; | ||
| } | ||
| .np-label { | ||
@@ -169,5 +178,20 @@ font-size: 9px; | ||
| font-weight: 500; | ||
| margin-bottom: 4px; | ||
| } | ||
| .np-platform { | ||
| font-size: 9px; | ||
| letter-spacing: 0.06em; | ||
| font-weight: 600; | ||
| padding: 2px 6px; | ||
| border-radius: 4px; | ||
| } | ||
| .np-platform--ytmusic { | ||
| background: rgba(255, 61, 46, 0.14); | ||
| color: var(--accent); | ||
| } | ||
| .np-platform--spotify { | ||
| background: rgba(30, 215, 96, 0.12); | ||
| color: #1ed760; | ||
| } | ||
| .np-title { | ||
@@ -410,1 +434,15 @@ font-family: var(--font-display); | ||
| } | ||
| /* ─── theming por plataforma ─────────────────────────── */ | ||
| [data-platform="spotify"] { | ||
| --accent: #1ed760; | ||
| --accent-soft: rgba(30, 215, 96, 0.12); | ||
| --accent-glow: rgba(30, 215, 96, 0.07); | ||
| } | ||
| [data-platform="ytmusic"] { | ||
| --accent: #ff3d2e; | ||
| --accent-soft: rgba(255, 61, 46, 0.14); | ||
| --accent-glow: rgba(255, 61, 46, 0.08); | ||
| } |
+5
-2
@@ -26,3 +26,6 @@ <!doctype html> | ||
| <div class="np-info"> | ||
| <div class="np-label">tocando agora</div> | ||
| <div class="np-label-row"> | ||
| <div class="np-label">tocando agora</div> | ||
| <div class="np-platform" id="np-platform"></div> | ||
| </div> | ||
| <div class="np-title" id="np-title"></div> | ||
@@ -45,3 +48,3 @@ <div class="np-artist" id="np-artist"></div> | ||
| </div> | ||
| <p>Abra <a href="https://music.youtube.com" target="_blank" rel="noopener">music.youtube.com</a> e comece a tocar.</p> | ||
| <p>Abra <a href="https://music.youtube.com" target="_blank" rel="noopener">YouTube Music</a> ou <a href="https://open.spotify.com" target="_blank" rel="noopener">Spotify</a> e comece a tocar.</p> | ||
| </section> | ||
@@ -48,0 +51,0 @@ |
+31
-7
@@ -15,17 +15,32 @@ import { | ||
| async function renderNowPlaying() { | ||
| const { ytm_currentStatus: status } = await chrome.storage.local.get( | ||
| "ytm_currentStatus" | ||
| ); | ||
| const { ytm_currentStatus: ytm, spotify_currentStatus: spo } = | ||
| await chrome.storage.local.get(["ytm_currentStatus", "spotify_currentStatus"]); | ||
| const npEl = $("#now-playing"); | ||
| const emptyEl = $("#now-playing-empty"); | ||
| // Stale status > 8s old means the YT Music tab is closed or ticking stopped | ||
| const isFresh = status && Date.now() - (status.updatedAt || 0) < 8000; | ||
| const now = Date.now(); | ||
| const isFresh = (s) => s && now - (s.updatedAt || 0) < 8000; | ||
| const ytmFresh = isFresh(ytm); | ||
| const spoFresh = isFresh(spo); | ||
| if (!isFresh || !status?.song) { | ||
| let status = null; | ||
| if (ytmFresh && spoFresh) { | ||
| // Both active — prefer whichever is playing; on tie, prefer YTM | ||
| status = (spo.isPlaying && !ytm.isPlaying) ? spo : ytm; | ||
| } else if (ytmFresh) { | ||
| status = ytm; | ||
| } else if (spoFresh) { | ||
| status = spo; | ||
| } | ||
| if (!status?.song) { | ||
| npEl.hidden = true; | ||
| emptyEl.hidden = false; | ||
| delete document.body.dataset.platform; | ||
| return; | ||
| } | ||
| document.body.dataset.platform = status.song.platform || "ytmusic"; | ||
| emptyEl.hidden = true; | ||
@@ -43,4 +58,13 @@ npEl.hidden = false; | ||
| : "--:--"; | ||
| $("#np-pulse").classList.toggle("is-playing", !!status.isPlaying); | ||
| $("#np-pulse").classList.toggle("is-playing", !!status.isPlaying); | ||
| const platform = status.song.platform; | ||
| const platformEl = $("#np-platform"); | ||
| if (platform === "spotify") { | ||
| platformEl.textContent = "Spotify"; | ||
| platformEl.className = "np-platform np-platform--spotify"; | ||
| } else { | ||
| platformEl.textContent = "YouTube Music"; | ||
| platformEl.className = "np-platform np-platform--ytmusic"; | ||
| } | ||
| } | ||
@@ -47,0 +71,0 @@ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet