+128
frontend/src/lib/components/EndOfList.svelte
+128
frontend/src/lib/components/EndOfList.svelte
···
1
+
<script lang="ts">
2
+
import { fade } from "svelte/transition";
3
+
4
+
interface Props {
5
+
message?: string;
6
+
}
7
+
8
+
let { message = "you've reached the end!" }: Props = $props();
9
+
10
+
const count = 16;
11
+
const ghostCount = 12; // trailing ghosts per dot
12
+
</script>
13
+
14
+
<div class="end-of-list" in:fade={{ duration: 400 }}>
15
+
<div class="canvas">
16
+
{#each Array(count) as _, i}
17
+
{#each Array(ghostCount) as _, g}
18
+
<i
19
+
style="--i:{i}; --g:{g}"
20
+
class:ghost={g > 0}
21
+
></i>
22
+
{/each}
23
+
{/each}
24
+
<!-- second wave: 1/3 phase offset -->
25
+
{#each Array(count) as _, i}
26
+
{#each Array(ghostCount) as _, g}
27
+
<i
28
+
style="--i:{i}; --g:{g}"
29
+
class:ghost={g > 0}
30
+
class="phase2"
31
+
></i>
32
+
{/each}
33
+
{/each}
34
+
<!-- third wave: 2/3 phase offset -->
35
+
{#each Array(count) as _, i}
36
+
{#each Array(ghostCount) as _, g}
37
+
<i
38
+
style="--i:{i}; --g:{g}"
39
+
class:ghost={g > 0}
40
+
class="phase3"
41
+
></i>
42
+
{/each}
43
+
{/each}
44
+
</div>
45
+
<p class="message">{message}</p>
46
+
</div>
47
+
48
+
<style>
49
+
.end-of-list {
50
+
display: flex;
51
+
flex-direction: column;
52
+
align-items: center;
53
+
gap: 1rem;
54
+
padding: 2rem 1rem;
55
+
margin-top: 1rem;
56
+
}
57
+
58
+
.canvas {
59
+
position: relative;
60
+
width: 100px;
61
+
height: 32px;
62
+
}
63
+
64
+
i {
65
+
--wave-color: var(--accent);
66
+
position: absolute;
67
+
left: calc(var(--i) * 6px);
68
+
top: 50%;
69
+
width: 2px;
70
+
height: 2px;
71
+
background: var(--wave-color);
72
+
border-radius: 50%;
73
+
opacity: 0.6;
74
+
box-shadow: 0 0 2px var(--wave-color);
75
+
animation: drift 2s ease-in-out infinite;
76
+
/* base delay for wave + ghost offset for trail */
77
+
animation-delay: calc((var(--i) * -0.15s) + (var(--g) * 0.07s));
78
+
}
79
+
80
+
i.ghost {
81
+
opacity: calc(0.35 - (var(--g) * 0.025));
82
+
width: 1px;
83
+
height: 1px;
84
+
border-radius: 50%;
85
+
filter: blur(calc(var(--g) * 0.2px));
86
+
box-shadow: none;
87
+
}
88
+
89
+
@keyframes drift {
90
+
0%,
91
+
100% {
92
+
transform: translateY(8px);
93
+
}
94
+
50% {
95
+
transform: translateY(-8px);
96
+
}
97
+
}
98
+
99
+
/* 2s duration, so 0.667s = 1/3 phase, 1.333s = 2/3 phase */
100
+
/* each wave gets a color variation mixed from the accent */
101
+
i.phase2 {
102
+
--wave-color: color-mix(in oklch, var(--accent) 70%, #fff);
103
+
animation-delay: calc(
104
+
(var(--i) * -0.15s) + (var(--g) * 0.07s) - 0.667s
105
+
);
106
+
}
107
+
108
+
i.phase3 {
109
+
--wave-color: color-mix(in oklch, var(--accent) 60%, #000);
110
+
animation-delay: calc(
111
+
(var(--i) * -0.15s) + (var(--g) * 0.07s) - 1.333s
112
+
);
113
+
}
114
+
115
+
.message {
116
+
margin: 0;
117
+
color: var(--text-tertiary);
118
+
font-size: 0.8rem;
119
+
letter-spacing: 0.02em;
120
+
}
121
+
122
+
@media (prefers-reduced-motion: reduce) {
123
+
i {
124
+
animation: none;
125
+
opacity: 0.5;
126
+
}
127
+
}
128
+
</style>
+2
frontend/src/routes/+page.svelte
+2
frontend/src/routes/+page.svelte
···
4
4
import TrackItem from '$lib/components/TrackItem.svelte';
5
5
import Header from '$lib/components/Header.svelte';
6
6
import WaveLoading from '$lib/components/WaveLoading.svelte';
7
+
import EndOfList from '$lib/components/EndOfList.svelte';
7
8
import { player } from '$lib/player.svelte';
8
9
import { queue } from '$lib/queue.svelte';
9
10
import { tracksCache } from '$lib/tracks.svelte';
···
106
107
/>
107
108
{/each}
108
109
</div>
110
+
<EndOfList />
109
111
{/if}
110
112
</section>
111
113
</main>