Neu: Spotify Stats
This commit is contained in:
@@ -185,6 +185,10 @@
|
||||
<h2 class="tool-title">MyAnimeList Visualisierung</h2>
|
||||
<p class="tool-description">Visualisiere deine MyAnimeList.net-Anime- und Manga-Listen als Grafiken und Statistiken.</p>
|
||||
</a>
|
||||
<a href="https://tools.ponywave.de/spotify_stats" class="tool-bubble">
|
||||
<h2 class="tool-title">Spotify Stats</h2>
|
||||
<p class="tool-description">Analysiere deine Spotify Streaming History und sehe deine Top-Tracks, Künstler und Alben.</p>
|
||||
</a>
|
||||
<a href="https://tools.ponywave.de/sys_info" class="tool-bubble">
|
||||
<h2 class="tool-title">Systeminformationen</h2>
|
||||
<p class="tool-description">Zeigt Infos zu Browser, Gerät, Betriebssystem, User-Agent und IPs an.</p>
|
||||
|
||||
@@ -23,6 +23,7 @@ https://tools.ponywave.de/pinkie_timer
|
||||
https://tools.ponywave.de/pokemon_quiz
|
||||
https://tools.ponywave.de/shape_shifter
|
||||
https://tools.ponywave.de/solitaire
|
||||
https://tools.ponywave.de/spotify_stats
|
||||
https://tools.ponywave.de/sys_info
|
||||
https://tools.ponywave.de/text_cleaner
|
||||
https://tools.ponywave.de/text_decoder
|
||||
|
||||
BIN
spotify_stats/icon.png
Normal file
BIN
spotify_stats/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
539
spotify_stats/index.html
Normal file
539
spotify_stats/index.html
Normal file
@@ -0,0 +1,539 @@
|
||||
<!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> |
|
||||
© <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>
|
||||
Reference in New Issue
Block a user