forked from Keelrleek/SoulStream
Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,166 @@
|
||||
<script>
|
||||
import { player, saveStream } from "../lib/state.svelte.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
// align hint: "down" (panel) or "up" (player bar). Auto-flips if needed.
|
||||
let { align = "down" } = $props();
|
||||
|
||||
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
||||
const isStream = $derived(!!currentItem?.stream);
|
||||
const streamCount = $derived(player.queue.filter((it) => it.stream).length);
|
||||
const isAlbum = $derived(streamCount > 1);
|
||||
|
||||
let triggerEl = $state(null);
|
||||
let open = $state(false);
|
||||
let pos = $state({ top: 0, left: 0 });
|
||||
let toast = $state("");
|
||||
let toastPos = $state({ top: 0, left: 0 });
|
||||
let toastTimer;
|
||||
|
||||
const MENU_W = 190;
|
||||
|
||||
function place() {
|
||||
const r = triggerEl.getBoundingClientRect();
|
||||
const h = 92; // two items
|
||||
let left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
|
||||
let top = align === "up" ? r.top - 6 - h : r.bottom + 6;
|
||||
if (align !== "up" && top + h > window.innerHeight - 8) top = r.top - 6 - h;
|
||||
if (align === "up" && top < 8) top = r.bottom + 6;
|
||||
pos = { top, left };
|
||||
}
|
||||
|
||||
async function save(scope) {
|
||||
open = false;
|
||||
const res = await saveStream(scope);
|
||||
if (!res.msg) return;
|
||||
const r = triggerEl.getBoundingClientRect();
|
||||
toastPos = {
|
||||
top: align === "up" ? r.top - 34 : r.bottom + 6,
|
||||
left: Math.max(8, r.right - 190),
|
||||
};
|
||||
toast = res.msg;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (toast = ""), 2600);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
e.stopPropagation();
|
||||
// Single track: one destination, save straight away. Album: choose scope.
|
||||
if (!isAlbum) {
|
||||
save("track");
|
||||
return;
|
||||
}
|
||||
if (open) {
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
place();
|
||||
open = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e) => {
|
||||
if (!e.target.closest(".save-wrap") && !e.target.closest(".save-pop")) open = false;
|
||||
};
|
||||
const close = () => (open = false);
|
||||
document.addEventListener("click", onDoc, true);
|
||||
document.addEventListener("scroll", close, true);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("click", onDoc, true);
|
||||
document.removeEventListener("scroll", close, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isStream}
|
||||
<span class="save-wrap">
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
class="save-icon"
|
||||
class:on={open}
|
||||
title="Save to library"
|
||||
onclick={onClick}
|
||||
>
|
||||
<Icon name="download" size={18} />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
{#if open}
|
||||
<div class="save-pop menu" style="top:{pos.top}px; left:{pos.left}px;">
|
||||
<button class="mi" onclick={() => save("track")}>
|
||||
<Icon name="music" size={16} /> Save this track
|
||||
</button>
|
||||
<button class="mi" onclick={() => save("album")}>
|
||||
<Icon name="library" size={16} /> Save whole album
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if toast}
|
||||
<div class="save-pop toast" style="top:{toastPos.top}px; left:{toastPos.left}px;">
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.save-wrap { display: inline-flex; }
|
||||
|
||||
.save-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.save-icon:hover,
|
||||
.save-icon.on {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
/* Fixed positioning escapes the panel's overflow clipping and any
|
||||
stacking context, so the popover always sits on top. */
|
||||
.save-pop {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.menu {
|
||||
width: 190px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
box-shadow: 0 16px 38px -12px rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.mi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
.mi:hover { background: var(--bg-highlight); }
|
||||
.mi :global(.icon) { color: var(--text-secondary); flex: 0 0 auto; }
|
||||
|
||||
.toast {
|
||||
white-space: nowrap;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 12px 26px -10px rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user