music player + upd index

This commit is contained in:
Lain Iwakura 2025-08-24 16:15:42 +03:00
parent 4715acaaf3
commit 96257a0188
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
7 changed files with 926 additions and 1 deletions

View File

@ -12,5 +12,9 @@
"@sveltejs/vite-plugin-svelte": "^6.1.1",
"svelte": "^5.38.1",
"vite": "^7.1.2"
},
"dependencies": {
"lucide-svelte": "^0.541.0",
"music-metadata": "^11.8.3"
}
}

120
pnpm-lock.yaml generated
View File

@ -7,6 +7,13 @@ settings:
importers:
.:
dependencies:
lucide-svelte:
specifier: ^0.541.0
version: 0.541.0(svelte@5.38.3)
music-metadata:
specifier: ^11.8.3
version: 11.8.3
devDependencies:
'@sveltejs/vite-plugin-svelte':
specifier: ^6.1.1
@ -20,6 +27,12 @@ importers:
packages:
'@borewit/text-codec@0.1.1':
resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
'@borewit/text-codec@0.2.0':
resolution: {integrity: sha512-X999CKBxGwX8wW+4gFibsbiNdwqmdQEXmUejIWaIqdrHBgS5ARIOOeyiQbHjP9G58xVEPcuvP6VwwH3A0OFTOA==}
'@esbuild/aix-ppc64@0.25.9':
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
engines: {node: '>=18'}
@ -312,6 +325,13 @@ packages:
svelte: ^5.0.0
vite: ^6.3.0 || ^7.0.0
'@tokenizer/inflate@0.2.7':
resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
engines: {node: '>=18'}
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -332,6 +352,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
@ -365,11 +389,21 @@ packages:
picomatch:
optional: true
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
file-type@21.0.0:
resolution: {integrity: sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg==}
engines: {node: '>=20'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
is-reference@3.0.3:
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
@ -380,12 +414,25 @@ packages:
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
lucide-svelte@0.541.0:
resolution: {integrity: sha512-Jk+LiOYDl62R/0nWkG1s5XL2k6LHmPq3wUfiJ6qtBhb8jGefB4PU10x5HJrAihwaKqVc2vH5wjKMELGjHJenEQ==}
peerDependencies:
svelte: ^3 || ^4 || ^5.0.0-next.42
magic-string@0.30.18:
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
music-metadata@11.8.3:
resolution: {integrity: sha512-Tgiv4MlCgDb6XzelziB1mmL2xeoHls0KTpCm3Z3qr+LfF4mBEpkuc5vNrc927IT5+S5fv+vzStfI+HYC0igDpA==}
engines: {node: '>=18'}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -411,6 +458,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
strtok3@10.3.4:
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
engines: {node: '>=18'}
svelte@5.38.3:
resolution: {integrity: sha512-ldbPzKdjUy7IALMBn15jzBM/TNxdXMxKeQZ538zzdABUjLg7e7/OIwnlaMQ+OR6s91W7DbDmJYjxHThHH7r9xA==}
engines: {node: '>=18'}
@ -419,6 +470,14 @@ packages:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'}
token-types@6.1.1:
resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==}
engines: {node: '>=14.16'}
uint8array-extras@1.5.0:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
vite@7.1.3:
resolution: {integrity: sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -472,6 +531,10 @@ packages:
snapshots:
'@borewit/text-codec@0.1.1': {}
'@borewit/text-codec@0.2.0': {}
'@esbuild/aix-ppc64@0.25.9':
optional: true
@ -655,6 +718,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tokenizer/inflate@0.2.7':
dependencies:
debug: 4.4.1
fflate: 0.8.2
token-types: 6.1.1
transitivePeerDependencies:
- supports-color
'@tokenizer/token@0.3.0': {}
'@types/estree@1.0.8': {}
acorn@8.15.0: {}
@ -665,6 +738,8 @@ snapshots:
clsx@2.1.1: {}
content-type@1.0.5: {}
debug@4.4.1:
dependencies:
ms: 2.1.3
@ -710,9 +785,22 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fflate@0.8.2: {}
file-type@21.0.0:
dependencies:
'@tokenizer/inflate': 0.2.7
strtok3: 10.3.4
token-types: 6.1.1
uint8array-extras: 1.5.0
transitivePeerDependencies:
- supports-color
fsevents@2.3.3:
optional: true
ieee754@1.2.1: {}
is-reference@3.0.3:
dependencies:
'@types/estree': 1.0.8
@ -721,12 +809,32 @@ snapshots:
locate-character@3.0.0: {}
lucide-svelte@0.541.0(svelte@5.38.3):
dependencies:
svelte: 5.38.3
magic-string@0.30.18:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
media-typer@1.1.0: {}
ms@2.1.3: {}
music-metadata@11.8.3:
dependencies:
'@borewit/text-codec': 0.2.0
'@tokenizer/token': 0.3.0
content-type: 1.0.5
debug: 4.4.1
file-type: 21.0.0
media-typer: 1.1.0
strtok3: 10.3.4
token-types: 6.1.1
uint8array-extras: 1.5.0
transitivePeerDependencies:
- supports-color
nanoid@3.3.11: {}
picocolors@1.1.1: {}
@ -767,6 +875,10 @@ snapshots:
source-map-js@1.2.1: {}
strtok3@10.3.4:
dependencies:
'@tokenizer/token': 0.3.0
svelte@5.38.3:
dependencies:
'@jridgewell/remapping': 2.3.5
@ -789,6 +901,14 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
token-types@6.1.1:
dependencies:
'@borewit/text-codec': 0.1.1
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
uint8array-extras@1.5.0: {}
vite@7.1.3:
dependencies:
esbuild: 0.25.9

Binary file not shown.

BIN
public/music/mgxleepe.flac Normal file

Binary file not shown.

View File

@ -1,4 +1,23 @@
<script>
import AudioPlayer from './lib/AudioPlayer.svelte';
import { onMount } from 'svelte';
let dayCounter = '0.00000000';
function updateCounter() {
const startDate = new Date('2011-09-19T00:00:00');
const now = new Date();
const diffMs = now.getTime() - startDate.getTime();
const years = diffMs / (1000 * 60 * 60 * 24 * 365.25);
dayCounter = years.toFixed(8);
}
onMount(() => {
updateCounter();
const interval = setInterval(updateCounter, 10);
return () => clearInterval(interval);
});
const contacts = [
{ label: 'email', href: 'mailto:lain@iwakurahome.ru', text: 'lain@iwakurahome.ru' },
{ label: 'telegram', href: 'https://t.me/systemxplore', text: '@systemxplore' },
@ -48,7 +67,9 @@
<div class="aperture"></div>
<header class="site-header">
<div class="head-wrap">
<div class="brand">Lain <span class="handle">@systemxplore</span></div>
<div class="brand">
Lain <span class="handle">@systemxplore</span> {dayCounter}
</div>
<nav class="nav">
<a class="{location.pathname === '/' ? 'current' : ''}" href="/">whoami</a>
</nav>
@ -109,6 +130,9 @@
</tbody>
</table>
</div>
<AudioPlayer />
<div class="divider"></div>
<div class="badges">

446
src/lib/AudioPlayer.css Normal file
View File

@ -0,0 +1,446 @@
.audio-player {
background: var(--bg-1);
border: 1px solid var(--green);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
position: relative;
overflow: hidden;
font-family: Fantasque, VT323, ui-monospace, monospace;
}
.loading-state, .error-state {
text-align: center;
padding: 2rem;
color: var(--green-2);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--bg-1);
border-top: 3px solid var(--green);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-bar {
width: 100%;
height: 4px;
background: var(--bg-1);
border-radius: 2px;
overflow: hidden;
margin-top: 1rem;
}
.loading-fill {
height: 100%;
background: var(--green);
transition: width 0.3s ease;
}
.error-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
color: #ff6b6b;
}
.metadata {
display: flex;
align-items: flex-start;
gap: 1.5rem;
margin-bottom: 1.5rem;
position: relative;
z-index: 1;
}
.track-info {
flex: 1;
}
.title-row {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 0.5rem;
}
.title {
font-size: 1.2rem;
font-weight: bold;
color: var(--green);
}
.format-badge {
background: var(--green);
color: var(--bg-0);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: bold;
letter-spacing: 0.5px;
}
.track-counter {
background: var(--bg-2);
color: var(--green-2);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
border: 1px solid var(--green-2);
}
.artist {
color: var(--green-2);
font-size: 1.1rem;
margin-bottom: 0.3rem;
}
.album, .year {
color: #aaa;
font-size: 0.9rem;
margin-bottom: 0.2rem;
}
.tech-info {
color: #666;
font-size: 0.8rem;
font-family: Fantasque, monospace;
margin-top: 0.5rem;
padding: 0.3rem 0.5rem;
background: var(--bg-1);
border-radius: 4px;
border: 1px solid #333;
}
.artwork {
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--green);
position: relative;
}
.artwork-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artwork-glow {
display: none;
}
.artwork-placeholder {
width: 80px;
height: 80px;
border-radius: 8px;
border: 1px dashed var(--green);
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-1);
}
.artwork-placeholder :global(svg) {
opacity: 0.7;
color: var(--green-2);
}
.controls {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 1rem;
position: relative;
z-index: 1;
}
.control-btn, .playlist-btn {
background: none;
border: 1px solid var(--green);
border-radius: 4px;
padding: 0.5rem;
color: var(--green);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.control-btn:hover, .playlist-btn:hover {
background: var(--green);
color: var(--bg-0);
}
.control-btn:disabled, .playlist-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.control-btn:disabled:hover, .playlist-btn:disabled:hover {
background: none;
color: var(--green);
}
.play-btn {
background: var(--green);
color: var(--bg-0);
border: none;
border-radius: 4px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.2rem;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.play-btn:hover {
background: var(--green-2);
}
.play-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-content {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.time-control {
flex: 1;
text-align: center;
}
.time-display {
color: var(--green-2);
font-family: Fantasque, monospace;
font-size: 1.1rem;
margin-bottom: 0.2rem;
}
.current-time {
color: var(--green);
font-weight: bold;
}
.separator {
color: #666;
margin: 0 0.3rem;
}
.total-time {
color: var(--green-2);
}
.time-remaining {
font-size: 0.8rem;
color: #888;
font-family: Fantasque, monospace;
}
.volume-control {
display: flex;
align-items: center;
gap: 0.8rem;
}
.volume-btn {
background: none;
border: 1px solid var(--green);
border-radius: 4px;
padding: 0.3rem 0.5rem;
color: var(--green);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.8rem;
}
.volume-btn:hover {
background: var(--green);
color: var(--bg-0);
}
.volume-slider-container {
display: flex;
flex-direction: column;
gap: 0.2rem;
align-items: center;
}
.volume-slider {
width: 80px;
height: 6px;
background: var(--bg-1);
border-radius: 3px;
outline: none;
cursor: pointer;
border: 1px solid var(--green);
}
.volume-slider::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
background: var(--green);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.5);
}
.volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: var(--green);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.5);
}
.volume-percentage {
font-size: 0.7rem;
color: #666;
font-family: Fantasque, monospace;
}
.progress-container {
cursor: pointer;
outline: none;
position: relative;
z-index: 1;
}
.progress-container:focus {
box-shadow: 0 0 0 2px var(--green);
border-radius: 4px;
}
.progress-container:hover .progress-bar {
height: 8px;
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--bg-1);
border-radius: 3px;
overflow: hidden;
border: 1px solid var(--green);
transition: all 0.2s ease;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--green), var(--green-2));
transition: width 0.1s ease;
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 4px;
height: 100%;
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
}
.playlist {
margin-top: 1rem;
border-top: 1px solid var(--green);
padding-top: 1rem;
}
.playlist-header {
color: var(--green);
font-weight: bold;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.playlist-items {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--green);
border-radius: 4px;
background: var(--bg-2);
}
.playlist-item {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid rgba(0, 255, 0, 0.1);
}
.playlist-item:last-child {
border-bottom: none;
}
.playlist-item:hover, .playlist-item:focus {
background: var(--bg-1);
outline: none;
}
.playlist-item:focus {
box-shadow: inset 0 0 0 1px var(--green);
}
.playlist-item.active {
background: rgba(0, 255, 0, 0.1);
border-left: 3px solid var(--green);
}
.track-number {
width: 20px;
text-align: center;
font-size: 0.8rem;
color: var(--green-2);
font-family: Fantasque, monospace;
}
.track-details {
flex: 1;
}
.track-title {
color: var(--green);
font-size: 0.9rem;
font-weight: bold;
}
.track-artist {
color: var(--green-2);
font-size: 0.8rem;
}
.track-duration {
color: #888;
font-size: 0.8rem;
font-family: Fantasque, monospace;
}

331
src/lib/AudioPlayer.svelte Normal file
View File

@ -0,0 +1,331 @@
<script>
import { parseBuffer } from 'music-metadata';
import { onMount } from 'svelte';
import { Play, Pause, Volume2, Volume1, VolumeX, Music, SkipForward, SkipBack, List } from 'lucide-svelte';
import './AudioPlayer.css';
let audioElement;
let isPlaying = false;
let currentTime = 0;
let duration = 0;
let volume = 0.7;
let loading = true;
let loadingProgress = 0;
let error = null;
let showPlaylist = false;
let tracks = [];
let currentTrackIndex = 0;
let currentTrack = null;
onMount(async () => {
await loadTracks();
});
async function loadTracks() {
const timeoutId = setTimeout(() => {
if (loading) {
error = 'Loading timeout';
loading = false;
}
}, 15000);
try {
loadingProgress = 10;
const trackFiles = ['mgxleepe.flac', 'jifmachinecd.flac'];
tracks = [];
for (let i = 0; i < trackFiles.length; i++) {
const filename = trackFiles[i];
loadingProgress = 20 + (i * 60 / trackFiles.length);
try {
const response = await fetch(`/music/${filename}`);
if (!response.ok) continue;
const arrayBuffer = await response.arrayBuffer();
const metadata = await parseBuffer(new Uint8Array(arrayBuffer));
tracks.push({
filename,
src: `/music/${filename}`,
metadata,
title: metadata.common.title || filename.replace('.flac', ''),
artist: metadata.common.artist || 'Unknown Artist',
album: metadata.common.album,
year: metadata.common.year,
duration: metadata.format.duration
});
} catch (trackError) {
console.warn(`Failed to load ${filename}:`, trackError);
}
}
if (tracks.length === 0) {
throw new Error('No audio files found');
}
loadingProgress = 90;
currentTrack = tracks[0];
loadingProgress = 100;
setTimeout(() => {
loading = false;
clearTimeout(timeoutId);
}, 200);
} catch (err) {
console.error('Error loading tracks:', err);
error = err.message || 'Failed to load tracks';
loading = false;
clearTimeout(timeoutId);
}
}
function togglePlay() {
if (isPlaying) {
audioElement.pause();
} else {
audioElement.play();
}
isPlaying = !isPlaying;
}
function nextTrack() {
if (tracks.length === 0) return;
currentTrackIndex = (currentTrackIndex + 1) % tracks.length;
switchToTrack(currentTrackIndex);
}
function prevTrack() {
if (tracks.length === 0) return;
currentTrackIndex = currentTrackIndex === 0 ? tracks.length - 1 : currentTrackIndex - 1;
switchToTrack(currentTrackIndex);
}
function switchToTrack(index) {
if (index < 0 || index >= tracks.length) return;
const wasPlaying = isPlaying;
if (isPlaying) {
audioElement.pause();
isPlaying = false;
}
currentTrackIndex = index;
currentTrack = tracks[index];
currentTime = 0;
if (wasPlaying) {
setTimeout(() => {
audioElement.play();
isPlaying = true;
}, 100);
}
}
function updateTime() {
currentTime = audioElement.currentTime;
duration = audioElement.duration || 0;
}
function seek(event) {
const rect = event.target.getBoundingClientRect();
const percent = (event.clientX - rect.left) / rect.width;
audioElement.currentTime = percent * duration;
}
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function onVolumeChange() {
audioElement.volume = volume;
}
function arrayBufferToBase64(buffer) {
try {
const uint8Array = new Uint8Array(buffer);
const binaryString = uint8Array.reduce((str, byte) => str + String.fromCharCode(byte), '');
return btoa(binaryString);
} catch (error) {
console.error('Error converting image to base64:', error);
return null;
}
}
</script>
<div class="audio-player">
<audio
bind:this={audioElement}
src={currentTrack?.src}
on:timeupdate={updateTime}
on:loadedmetadata={updateTime}
on:ended={nextTrack}
></audio>
{#if loading}
<div class="loading-state">
<div class="loading-spinner"></div>
<div class="loading-text">Loading tracks... {loadingProgress}%</div>
<div class="loading-bar">
<div class="loading-fill" style="width: {loadingProgress}%"></div>
</div>
</div>
{:else if error}
<div class="error-state">
<div class="error-icon"></div>
<div class="error-text">{error}</div>
</div>
{:else if currentTrack}
<div class="metadata">
<div class="track-info">
<div class="title-row">
<div class="title">{currentTrack.title}</div>
<div class="format-badge">FLAC</div>
<div class="track-counter">{currentTrackIndex + 1}/{tracks.length}</div>
</div>
<div class="artist">{currentTrack.artist}</div>
{#if currentTrack.album}
<div class="album">[Album] {currentTrack.album}</div>
{/if}
{#if currentTrack.year}
<div class="year">[Year] {currentTrack.year}</div>
{/if}
{#if currentTrack.metadata.format}
<div class="tech-info">
{Math.round(currentTrack.metadata.format.sampleRate / 1000)}kHz • {currentTrack.metadata.format.bitsPerSample}bit • {currentTrack.metadata.format.bitrate ? Math.round(currentTrack.metadata.format.bitrate / 1000) + 'kbps' : 'Lossless'}
</div>
{/if}
</div>
{#if currentTrack.metadata.common.picture && currentTrack.metadata.common.picture[0]}
{@const base64Image = arrayBufferToBase64(currentTrack.metadata.common.picture[0].data)}
{#if base64Image}
<div class="artwork">
<img
src={`data:${currentTrack.metadata.common.picture[0].format};base64,${base64Image}`}
alt="Album artwork"
class="artwork-img"
/>
<div class="artwork-glow"></div>
</div>
{:else}
<div class="artwork-placeholder">
<Music size={24} />
</div>
{/if}
{:else}
<div class="artwork-placeholder">
<Music size={24} />
</div>
{/if}
</div>
{/if}
<div class="controls">
<button class="control-btn" on:click={prevTrack} disabled={loading || error || tracks.length <= 1}>
<SkipBack size={14} />
</button>
<button class="play-btn {isPlaying ? 'playing' : ''}" on:click={togglePlay} disabled={loading || error}>
<div class="btn-content">
{#if isPlaying}
<Pause size={16} />
{:else}
<Play size={16} />
{/if}
</div>
</button>
<button class="control-btn" on:click={nextTrack} disabled={loading || error || tracks.length <= 1}>
<SkipForward size={14} />
</button>
<div class="time-control">
<div class="time-display">
<span class="current-time">{formatTime(currentTime)}</span>
<span class="separator">/</span>
<span class="total-time">{formatTime(duration)}</span>
</div>
{#if duration > 0}
<div class="time-remaining">-{formatTime(duration - currentTime)}</div>
{/if}
</div>
<div class="volume-control">
<button class="volume-btn" on:click={() => volume = volume > 0 ? 0 : 0.7}>
{#if volume === 0}
<VolumeX size={14} />
{:else if volume < 0.5}
<Volume1 size={14} />
{:else}
<Volume2 size={14} />
{/if}
</button>
<div class="volume-slider-container">
<input
type="range"
bind:value={volume}
on:input={onVolumeChange}
min="0"
max="1"
step="0.05"
class="volume-slider"
/>
<div class="volume-percentage">{Math.round(volume * 100)}%</div>
</div>
</div>
<button class="playlist-btn" on:click={() => showPlaylist = !showPlaylist} disabled={loading || error || tracks.length <= 1}>
<List size={14} />
</button>
</div>
<div
class="progress-container"
on:click={seek}
on:keydown={(e) => e.key === 'Enter' && seek(e)}
role="slider"
tabindex="0"
aria-label="Seek audio position"
aria-valuenow={duration ? Math.round((currentTime / duration) * 100) : 0}
aria-valuemin="0"
aria-valuemax="100"
>
<div class="progress-bar">
<div
class="progress-fill"
style="width: {duration ? (currentTime / duration) * 100 : 0}%"
></div>
</div>
</div>
{#if showPlaylist && tracks.length > 1}
<div class="playlist">
<div class="playlist-header">Playlist ({tracks.length} tracks)</div>
<div class="playlist-items">
{#each tracks as track, index}
<div
class="playlist-item {index === currentTrackIndex ? 'active' : ''}"
on:click={() => switchToTrack(index)}
on:keydown={(e) => e.key === 'Enter' && switchToTrack(index)}
role="button"
tabindex="0"
aria-label="Switch to track {track.title}"
>
<div class="track-number">{index + 1}</div>
<div class="track-details">
<div class="track-title">{track.title}</div>
<div class="track-artist">{track.artist}</div>
</div>
<div class="track-duration">{formatTime(track.duration || 0)}</div>
</div>
{/each}
</div>
</div>
{/if}
</div>