forked from Keelrleek/SoulStream
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.
167 lines
4.8 KiB
Svelte
167 lines
4.8 KiB
Svelte
<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>
|