1
0
Files
PonyWave-Tools/spotify_stats/index.html
2025-10-27 16:10:32 +01:00

540 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spotify Stats - PonyWave Tools</title>
<meta property="og:title" content="Spotify Stats | PonyWave Tools">
<meta property="og:description" content="Spotify Streaming History lokal analysieren und Statistiken anzeigen">
<meta property="og:type" content="website">
<meta property="og:url" content="https://tools.ponywave.de/spotify_stats">
<meta property="og:image" content="https://tools.ponywave.de/spotify_stats/icon.png">
<link rel="icon" href="icon.png">
<!-- Umami Tracking -->
<script defer src="https://stats.ponywave.de/script" data-website-id="9ef713d2-adb9-4906-9df5-708d8a8b9131" data-tag="spotify_stats"></script>
<style>
:root {
--primary-color: #7F006E;
--secondary-color: #FF7FED;
--spotify-green: #1DB954;
--bg-gradient: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
--text-color: #e0e0e0;
--tool-bg: #2a2a2a;
--shadow-color: rgba(0, 0, 0, 0.5);
--footer-color: rgba(0, 0, 0, 0.8);
--border-color: #444;
--card-bg: #1e1e1e;
--card-header-bg: #252525;
--label-color: #888;
--button-hover: #CC65B5;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-image: var(--bg-gradient);
background-attachment: fixed;
color: var(--text-color);
min-height: 100vh;
display: flex;
flex-direction: column;
line-height: 1.6;
margin: 0;
padding: 20px 20px 70px 20px;
}
.container {
flex: 1;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
width: 100%;
}
.title {
text-align: center;
color: var(--secondary-color);
margin-bottom: 30px;
font-size: 2.5em;
text-shadow: 2px 2px 6px rgba(255, 127, 237, 0.66);
}
.upload-section {
background: var(--tool-bg);
border-radius: 15px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 4px 6px var(--shadow-color);
text-align: center;
}
.file-input-wrapper {
position: relative;
display: inline-block;
margin: 20px 0;
}
.file-input-label {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: white;
padding: 15px 30px;
border-radius: 8px;
cursor: pointer;
font-size: 1.1em;
font-weight: 600;
transition: all 0.3s ease;
display: inline-block;
box-shadow: 0 2px 4px var(--shadow-color);
}
.file-input-label:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px var(--shadow-color);
}
#fileInput {
display: none;
}
.file-name {
margin-top: 15px;
color: var(--secondary-color);
font-size: 1em;
font-weight: 500;
}
.stats-container {
display: none;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--card-bg);
border-radius: 15px;
padding: 25px;
box-shadow: 0 4px 6px var(--shadow-color);
border: 1px solid var(--border-color);
}
.stat-card h3 {
color: var(--spotify-green);
margin-bottom: 15px;
font-size: 1.3em;
border-bottom: 2px solid var(--spotify-green);
padding-bottom: 10px;
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: var(--secondary-color);
margin: 15px 0;
}
.stat-label {
color: var(--label-color);
font-size: 0.9em;
margin-bottom: 10px;
}
.top-list {
list-style: none;
padding: 0;
}
.top-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
margin-bottom: 10px;
background: var(--card-header-bg);
border-radius: 8px;
border-left: 3px solid var(--spotify-green);
}
.top-list-item .rank {
font-weight: bold;
color: var(--spotify-green);
min-width: 30px;
}
.top-list-item .name {
flex: 1;
margin: 0 15px;
word-break: break-word;
}
.top-list-item .count {
color: var(--secondary-color);
font-weight: 600;
white-space: nowrap;
}
.error-message {
background: #3d1e1e;
border: 1px solid #8b0000;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
color: #ff6b6b;
display: none;
}
.info-message {
background: rgba(127, 0, 110, 0.2);
border: 1px solid var(--primary-color);
border-radius: 8px;
padding: 15px;
margin: 15px 0;
color: var(--text-color);
}
.loading {
display: none;
text-align: center;
padding: 20px;
color: var(--spotify-green);
font-size: 1.2em;
}
.loading::after {
content: '...';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 15px 0;
background-color: var(--footer-color);
border-top: 1px solid var(--border-color);
text-align: center;
font-size: 14px;
color: var(--text-color);
z-index: 100;
}
footer a {
color: var(--secondary-color);
text-decoration: none;
font-weight: bold;
}
footer a:hover {
text-decoration: underline;
}
.heart {
color: #ff0000;
animation: heartbeat 1.5s infinite;
}
@keyframes heartbeat {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
@media (max-width: 768px) {
.title {
font-size: 2em;
}
.stats-grid {
grid-template-columns: 1fr;
}
.upload-section {
padding: 20px;
}
.top-list-item {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.top-list-item .name {
margin: 5px 0;
}
}
</style>
</head>
<body>
<div class="container">
<h1 class="title">🎵 Spotify Stats</h1>
<div class="upload-section">
<h2 style="margin-bottom: 15px;">Streaming History hochladen</h2>
<p style="color: var(--label-color); margin-bottom: 10px;">
Lade deine Spotify Streaming History JSON-Datei hoch, um deine Statistiken zu sehen.
</p>
<div class="info-message">
Alle Daten werden nur lokal in deinem Browser verarbeitet. Es werden keine Daten hochgeladen.
</div>
<div class="file-input-wrapper">
<label for="fileInput" class="file-input-label">
JSON-Datei auswählen
</label>
<input type="file" id="fileInput" accept=".json" />
</div>
<div class="file-name" id="fileName"></div>
</div>
<div class="loading" id="loading">Analysiere deine Daten</div>
<div class="error-message" id="errorMessage"></div>
<div class="stats-container" id="statsContainer">
<div class="stats-grid">
<div class="stat-card">
<h3>📊 Übersicht</h3>
<div class="stat-label">Anzahl Streams</div>
<div class="stat-value" id="totalStreams">0</div>
<div class="stat-label">Gesamte Hörzeit</div>
<div class="stat-value" style="font-size: 1.5em;" id="totalTime">0h</div>
<div class="stat-label">Zeitraum</div>
<div style="color: var(--text-color); margin-top: 10px;" id="dateRange">-</div>
</div>
<div class="stat-card">
<h3>🎵 Top 10 Tracks</h3>
<ul class="top-list" id="topTracks"></ul>
</div>
<div class="stat-card">
<h3>🎤 Top 10 Künstler</h3>
<ul class="top-list" id="topArtists"></ul>
</div>
<div class="stat-card">
<h3>💿 Top 10 Alben</h3>
<ul class="top-list" id="topAlbums"></ul>
</div>
<div class="stat-card">
<h3>📻 Weitere Statistiken</h3>
<div class="stat-label">Geskippte Tracks</div>
<div class="stat-value" style="font-size: 1.5em;" id="skippedCount">0</div>
<div class="stat-label">Shuffle genutzt</div>
<div class="stat-value" style="font-size: 1.5em;" id="shuffleCount">0</div>
<div class="stat-label">Offline gehört</div>
<div class="stat-value" style="font-size: 1.5em;" id="offlineCount">0</div>
</div>
<div class="stat-card" id="podcastCard" style="display: none;">
<h3>🎙️ Top 10 Podcasts</h3>
<ul class="top-list" id="topPodcasts"></ul>
</div>
</div>
</div>
</div>
<footer>
<p>
<a href="https://tools.ponywave.de/">Zurück zur Startseite</a> |
&copy; <span id="currentYear"></span> Akamaru | Made with <span class="heart">❤️</span> by Claude
</p>
</footer>
<script>
// Jahr aktualisieren
document.getElementById('currentYear').textContent = new Date().getFullYear();
// File Input Handler
document.getElementById('fileInput').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
if (!file.name.toLowerCase().endsWith('.json')) {
showError('Bitte wähle eine JSON-Datei aus.');
return;
}
document.getElementById('fileName').textContent = `Ausgewählt: ${file.name}`;
readJSONFile(file);
}
});
function showError(message) {
const errorDiv = document.getElementById('errorMessage');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
document.getElementById('statsContainer').style.display = 'none';
document.getElementById('loading').style.display = 'none';
}
function hideError() {
document.getElementById('errorMessage').style.display = 'none';
}
function readJSONFile(file) {
document.getElementById('loading').style.display = 'block';
document.getElementById('statsContainer').style.display = 'none';
hideError();
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
if (!Array.isArray(data)) {
throw new Error('Die JSON-Datei muss ein Array enthalten.');
}
analyzeData(data);
document.getElementById('loading').style.display = 'none';
} catch (error) {
showError('Fehler beim Lesen der JSON-Datei: ' + error.message);
}
};
reader.onerror = function() {
showError('Fehler beim Lesen der Datei.');
};
reader.readAsText(file);
}
function analyzeData(data) {
if (data.length === 0) {
showError('Die JSON-Datei enthält keine Daten.');
return;
}
// Grundstatistiken
const totalStreams = data.length;
const totalMs = data.reduce((sum, item) => sum + (item.ms_played || 0), 0);
const totalHours = Math.round(totalMs / 1000 / 60 / 60);
const totalDays = Math.floor(totalHours / 24);
const remainingHours = totalHours % 24;
// Zeitraum
const dates = data.map(item => new Date(item.ts)).sort((a, b) => a - b);
const firstDate = dates[0].toLocaleDateString('de-DE');
const lastDate = dates[dates.length - 1].toLocaleDateString('de-DE');
// Top Tracks
const trackCounts = {};
const artistCounts = {};
const albumCounts = {};
const podcastCounts = {};
let skippedCount = 0;
let shuffleCount = 0;
let offlineCount = 0;
data.forEach(item => {
// Tracks
const trackName = item.master_metadata_track_name;
const artistName = item.master_metadata_album_artist_name;
const albumName = item.master_metadata_album_album_name;
if (trackName && artistName) {
const trackKey = `${trackName} - ${artistName}`;
trackCounts[trackKey] = (trackCounts[trackKey] || 0) + 1;
}
// Artists
if (artistName) {
artistCounts[artistName] = (artistCounts[artistName] || 0) + 1;
}
// Albums
if (albumName && artistName) {
const albumKey = `${albumName} - ${artistName}`;
albumCounts[albumKey] = (albumCounts[albumKey] || 0) + 1;
}
// Podcasts
const podcastName = item.episode_show_name;
if (podcastName) {
podcastCounts[podcastName] = (podcastCounts[podcastName] || 0) + 1;
}
// Weitere Stats
if (item.skipped) skippedCount++;
if (item.shuffle) shuffleCount++;
if (item.offline) offlineCount++;
});
// Display
document.getElementById('totalStreams').textContent = totalStreams.toLocaleString('de-DE');
let timeText = '';
if (totalDays > 0) {
timeText = `${totalDays}d ${remainingHours}h`;
} else {
timeText = `${totalHours}h`;
}
document.getElementById('totalTime').textContent = timeText;
document.getElementById('dateRange').textContent = `${firstDate} - ${lastDate}`;
// Top Lists
displayTopList('topTracks', trackCounts, 10);
displayTopList('topArtists', artistCounts, 10);
displayTopList('topAlbums', albumCounts, 10);
// Podcasts (nur anzeigen wenn vorhanden)
if (Object.keys(podcastCounts).length > 0) {
document.getElementById('podcastCard').style.display = 'block';
displayTopList('topPodcasts', podcastCounts, 10);
}
// Weitere Stats
document.getElementById('skippedCount').textContent = `${skippedCount} (${Math.round(skippedCount / totalStreams * 100)}%)`;
document.getElementById('shuffleCount').textContent = `${shuffleCount} (${Math.round(shuffleCount / totalStreams * 100)}%)`;
document.getElementById('offlineCount').textContent = `${offlineCount} (${Math.round(offlineCount / totalStreams * 100)}%)`;
// Show stats
document.getElementById('statsContainer').style.display = 'block';
}
function displayTopList(elementId, counts, limit) {
const sorted = Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, limit);
const listElement = document.getElementById(elementId);
listElement.innerHTML = '';
if (sorted.length === 0) {
listElement.innerHTML = '<li style="padding: 12px; color: var(--label-color);">Keine Daten vorhanden</li>';
return;
}
sorted.forEach(([name, count], index) => {
const li = document.createElement('li');
li.className = 'top-list-item';
li.innerHTML = `
<span class="rank">${index + 1}.</span>
<span class="name">${escapeHtml(name)}</span>
<span class="count">${count}×</span>
`;
listElement.appendChild(li);
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>