5 Commits
Author SHA1 Message Date
apt-get 163fa6b910 enable/disable tag intensity for now 2026-11-02 16:18:31 +01:00
apt-get 3d67b58b03 scroll to currently active stream on sidebar mount 2026-09-27 16:26:26 +02:00
apt-get 3aae8a0130 yay accessibility!
aria tags + better touchscreen support
2026-09-25 21:50:00 +02:00
apt-get 83cc73acff add varying intensity to stream tags
plus improving the tag select dropdown in general
2026-09-25 21:26:31 +02:00
apt-get c49fb9bb54 upgrade svelte-select to v6 2026-09-25 14:25:00 +02:00
16 changed files with 457 additions and 107 deletions
+2 -2
View File
@@ -20,7 +20,7 @@
"sqids": "0.3.0", "sqids": "0.3.0",
"svelte": "^5.53.12", "svelte": "^5.53.12",
"svelte-check": "^4.4.5", "svelte-check": "^4.4.5",
"svelte-select": "^5.8.3", "svelte-select": "^6",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^8.0.0", "vite": "^8.0.0",
@@ -487,7 +487,7 @@
"svelte-floating-ui": ["svelte-floating-ui@1.5.8", "", { "dependencies": { "@floating-ui/core": "^1.5.0", "@floating-ui/dom": "^1.5.3" } }, "sha512-dVvJhZ2bT+kQDHlE4Lep8t+sgEc0XD96fXLzAi2DDI2bsaegBbClxXVNMma0C2WsG+n9GJSYx292dTvA8CYRtw=="], "svelte-floating-ui": ["svelte-floating-ui@1.5.8", "", { "dependencies": { "@floating-ui/core": "^1.5.0", "@floating-ui/dom": "^1.5.3" } }, "sha512-dVvJhZ2bT+kQDHlE4Lep8t+sgEc0XD96fXLzAi2DDI2bsaegBbClxXVNMma0C2WsG+n9GJSYx292dTvA8CYRtw=="],
"svelte-select": ["svelte-select@5.8.3", "", { "dependencies": { "svelte-floating-ui": "1.5.8" } }, "sha512-nQsvflWmTCOZjssdrNptzfD1Ok45hHVMTL5IHay5DINk7dfu5Er+8KsVJnZMJdSircqtR0YlT4YkCFlxOUhVPA=="], "svelte-select": ["svelte-select@6.0.0", "", { "dependencies": { "svelte-floating-ui": "1.5.8" }, "peerDependencies": { "svelte": ">=5" } }, "sha512-WJ1UDw0a1Z3I7oVFgaCS7G9D2rCSmYAPpufNEj/DNuk5Oyb2a5dju6F36o0urBVw9bTE3WkM9cZkI0x+Sfo8Bg=="],
"svelte2tsx": ["svelte2tsx@0.7.52", "", { "dependencies": { "dedent-js": "^1.0.1", "scule": "^1.3.0" }, "peerDependencies": { "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", "typescript": "^4.9.4 || ^5.0.0" } }, "sha512-svdT1FTrCLpvlU62evO5YdJt/kQ7nxgQxII/9BpQUvKr+GJRVdAXNVw8UWOt0fhoe5uWKyU0WsUTMRVAtRbMQg=="], "svelte2tsx": ["svelte2tsx@0.7.52", "", { "dependencies": { "dedent-js": "^1.0.1", "scule": "^1.3.0" }, "peerDependencies": { "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", "typescript": "^4.9.4 || ^5.0.0" } }, "sha512-svdT1FTrCLpvlU62evO5YdJt/kQ7nxgQxII/9BpQUvKr+GJRVdAXNVw8UWOt0fhoe5uWKyU0WsUTMRVAtRbMQg=="],
+5 -2
View File
@@ -52,8 +52,11 @@ for (const file of files) {
// Prepare attributes for insertion // Prepare attributes for insertion
const fields = Object.entries(jsonData) const fields = Object.entries(jsonData)
.filter(([key]) => key in lookup) .filter(([key]) => key in lookup)
// Serialize value if array // Serialize value if array or object
.map(([key, value]) => [lookup[key], Array.isArray(value) ? JSON.stringify(value) : value]); .map(([key, value]) => [
lookup[key],
typeof value === 'object' && value !== null ? JSON.stringify(value) : value
]);
const row = existingRow.get(jsonData.id); const row = existingRow.get(jsonData.id);
if (row) { if (row) {
+1
View File
@@ -1,2 +1,3 @@
STREAM_JSON_LOCATION=/data/streams/json STREAM_JSON_LOCATION=/data/streams/json
STREAM_MEDIA_LOCATION=/data/streams/media STREAM_MEDIA_LOCATION=/data/streams/media
PUBLIC_TAG_INTENSITY=true
+1 -1
View File
@@ -30,7 +30,7 @@
"sqids": "0.3.0", "sqids": "0.3.0",
"svelte": "^5.53.12", "svelte": "^5.53.12",
"svelte-check": "^4.4.5", "svelte-check": "^4.4.5",
"svelte-select": "^5.8.3", "svelte-select": "^6",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^8.0.0" "vite": "^8.0.0"
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { TAG_WEIGHT_NAMES, TAG_INTENSITY_ENABLED } from '$lib/stores.svelte.ts';
import type { TagWeights } from '$lib/types';
let { tags, highlighted = {} }: { tags: TagWeights; highlighted?: Record<string, number> } =
$props();
let sorted = $derived(
TAG_INTENSITY_ENABLED
? Object.entries(tags).sort((a, b) => b[1] - a[1])
: Object.entries(tags)
);
</script>
{#each sorted as [tag, weight], i}{i ? ', ' : ''}<span
class="weight-{TAG_INTENSITY_ENABLED ? weight : 1}"
class:highlighted={tag in highlighted}
>{tag}{#if TAG_INTENSITY_ENABLED}<span class="screen-reader-only">
({TAG_WEIGHT_NAMES[weight]})</span
>{/if}</span
>{/each}
<style>
.weight-1 {
font-weight: normal;
}
.weight-2 {
font-weight: bold;
}
.weight-3 {
font-weight: bold;
text-decoration: underline;
}
/* translucent to take on the hover/selected bg colour tint */
.highlighted {
background: rgba(255, 220, 0, 0.18);
}
.screen-reader-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
</style>
+2 -2
View File
@@ -18,7 +18,7 @@ export function getStreams(): StreamSummary[] {
stream_date: Date.parse(stream.stream_date as string), stream_date: Date.parse(stream.stream_date as string),
filename: stream.filename as string, filename: stream.filename as string,
title: stream.title as string | null, title: stream.title as string | null,
tags: (JSON.parse(stream.tags as string) as string[]) ?? [], tags: stream.tags ? JSON.parse(stream.tags as string) : {},
length_seconds: stream.length_seconds as number length_seconds: stream.length_seconds as number
})); }));
} }
@@ -40,7 +40,7 @@ export function getStreamInfo(streamId: string): Stream | null {
format: streamData.format as string, format: streamData.format as string,
title: streamData.title as string | null, title: streamData.title as string | null,
description: streamData.description as string | null, description: streamData.description as string | null,
tags: (JSON.parse(streamData.tags as string) as string[]) ?? [], tags: streamData.tags ? JSON.parse(streamData.tags as string) : {},
length_seconds: streamData.length_seconds as number, length_seconds: streamData.length_seconds as number,
tracks: JSON.parse(streamData.tracks as string) tracks: JSON.parse(streamData.tracks as string)
}; };
+5
View File
@@ -1,4 +1,5 @@
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { env } from '$env/dynamic/public';
import { readStored, writeStored } from './utils.ts'; import { readStored, writeStored } from './utils.ts';
export const favoritedStreams = new SvelteSet<string>(readStored('favoritedStreams', [])); export const favoritedStreams = new SvelteSet<string>(readStored('favoritedStreams', []));
@@ -8,6 +9,10 @@ $effect.root(() => {
}); });
}); });
export const MAX_TAG_WEIGHT = 3;
export const TAG_WEIGHT_NAMES = ['off', 'light', 'medium', 'heavy'];
export const TAG_INTENSITY_ENABLED = env.PUBLIC_TAG_INTENSITY !== 'false';
export const tagList = [ export const tagList = [
'acoustic', 'acoustic',
'electronic', 'electronic',
+4 -1
View File
@@ -1,13 +1,16 @@
/** A track entry: [timestamp_seconds, artist, title] */ /** A track entry: [timestamp_seconds, artist, title] */
export type Track = [number, string, string]; export type Track = [number, string, string];
// tag -> weight, 1 (light) to 3 (heavy)
export type TagWeights = Record<string, number>;
/** The summary shape used in sidebar listings. */ /** The summary shape used in sidebar listings. */
export interface StreamSummary { export interface StreamSummary {
id: string; id: string;
stream_date: number; stream_date: number;
filename: string; filename: string;
title: string | null; title: string | null;
tags: string[]; tags: TagWeights;
length_seconds: number; length_seconds: number;
} }
+4
View File
@@ -8,6 +8,10 @@
<svelte:head> <svelte:head>
<style> <style>
:root {
--accent: #add8e6;
}
body, body,
html { html {
margin: 0; margin: 0;
-1
View File
@@ -38,7 +38,6 @@
a, a,
a:visited { a:visited {
outline: none;
color: black; color: black;
} }
</style> </style>
+136 -31
View File
@@ -1,47 +1,102 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { favoritedStreams } from '$lib/stores.svelte.ts'; import {
favoritedStreams,
tagList,
MAX_TAG_WEIGHT,
TAG_INTENSITY_ENABLED
} from '$lib/stores.svelte.ts';
import * as streamCache from '$lib/streamCache.svelte.ts'; import * as streamCache from '$lib/streamCache.svelte.ts';
import { getStreamContext } from '$lib/streamContext.svelte.ts'; import { getStreamContext } from '$lib/streamContext.svelte.ts';
import type { StreamSummary } from '$lib/types'; import type { StreamSummary } from '$lib/types';
import { hashColor, shorthandCode, formatSecondsToHms, formatDate } from '$lib/utils.ts'; import { shorthandCode, formatSecondsToHms, formatDate } from '$lib/utils.ts';
import TagSelect from './TagSelect.svelte'; import TagSelect from './TagSelect.svelte';
import WeightedTags from '$lib/components/WeightedTags.svelte';
const ctx = getStreamContext(); const ctx = getStreamContext();
let filteredTags = $state([]); // map of tag -> minimum weight
// a stream shows if it has every tag with at least the minimum weight
let filteredTags: Record<string, number> = $state({});
let favoritesOnly = $state(false); let favoritesOnly = $state(false);
let cachedOnly = $state(false); let cachedOnly = $state(false);
let listOpen = $state(); let listOpen = $state(false);
let displayedStreams = $derived.by(streamsToDisplay); let displayedStreams = $derived.by(streamsToDisplay);
let remainingTags = $derived.by(getRemainingTags);
// how many displayed streams have each unselected tag, at each weight?
// used in the tag select dropdown
let dropdownCounts = $derived.by(getDropdownCounts);
// how many displayed streams would be shown
// if filtered per each of this tag's weights,
// all other filters unchanged?
// used for the selected tag pills
let pillCounts = $derived.by(getPillCounts);
// which tags can still alter the displayed streams if selected?
// we eliminate either those which aren't present in the displayed streams
// or those present (at the same weight) in every displayed stream
// decides which tags are visible in the selection dropdown
let remainingTags = $derived(
Object.keys(dropdownCounts).filter((tag) =>
dropdownCounts[tag].some((count) => count > 0 && count < displayedStreams.size)
)
);
let cacheUsage = $derived(streamCache.usage.bytes); let cacheUsage = $derived(streamCache.usage.bytes);
// autoscroll to center currently active stream on page load
onMount(() => {
if (!ctx.current) return;
document
.getElementById(`stream-${ctx.current.id}`)
?.scrollIntoView({ block: 'center', behavior: 'instant' });
});
$effect(() => { $effect(() => {
if (displayedStreams.length == 1) { if (displayedStreams.size == 1) {
listOpen = untrack(() => !listOpen); listOpen = false;
} }
}); });
function matchesFilter(stream: StreamSummary, filters: Record<string, number>) {
return Object.entries(filters).every(([tag, min]) => (stream.tags[tag] ?? 0) >= min);
}
function streamsToDisplay() { function streamsToDisplay() {
return ctx.streams.filter( return new Set(ctx.streams.filter((stream) => matchesFilter(stream, filteredTags)));
(stream) => }
!('tags' in stream) || filteredTags.every((tag) => stream.tags.includes(tag))
// count streams at each enabled minimum weight
// e.g. dark at weights [1, 3, 3, None] on four streams gives [3, 2, 2]
function weightCounts(streams: Iterable<StreamSummary>, tag: string) {
const counts = new Array(TAG_INTENSITY_ENABLED ? MAX_TAG_WEIGHT : 1).fill(0);
for (const stream of streams) {
const weight = stream.tags[tag] ?? 0;
for (let i = 0; i < Math.min(weight, counts.length); i++) counts[i]++;
}
return counts;
}
function getDropdownCounts() {
const unselected = tagList.filter((tag) => !(tag in filteredTags));
return Object.fromEntries(
unselected.map((tag) => [tag, weightCounts(displayedStreams, tag)])
); );
} }
function getRemainingTags() { function getPillCounts() {
let tagCounts = displayedStreams.reduce((tags: Record<string, number>, stream) => { return Object.fromEntries(
stream.tags.forEach((tag) => (tags[tag] ? (tags[tag] += 1) : (tags[tag] = 1))); Object.keys(filteredTags).map((tag) => {
return tags; const otherFilters = { ...filteredTags };
}, {}); delete otherFilters[tag];
return Object.entries(tagCounts)
.filter((el) => el[1] != displayedStreams.length)
.map((el) => el[0]);
}
function getDisplayedTagsInList(streamTags: string[], remainingTags: string[]) { const candidates = ctx.streams.filter((stream) =>
return streamTags.filter((tag) => remainingTags.includes(tag)); matchesFilter(stream, otherFilters)
);
return [tag, weightCounts(candidates, tag)];
})
);
} }
function updateFavorites(stream: StreamSummary) { function updateFavorites(stream: StreamSummary) {
@@ -52,15 +107,25 @@
</script> </script>
<div class="tag-select"> <div class="tag-select">
<TagSelect bind:listOpen bind:checked={filteredTags} {remainingTags} /> <TagSelect
bind:listOpen
bind:checked={filteredTags}
{remainingTags}
{dropdownCounts}
{pillCounts}
/>
<button <button
onclick={() => (cachedOnly = !cachedOnly)} onclick={() => (cachedOnly = !cachedOnly)}
aria-label="Downloaded only"
aria-pressed={cachedOnly}
class="material-icons filter-download {cachedOnly class="material-icons filter-download {cachedOnly
? 'select-faves-star' ? 'select-faves-star'
: 'unselect-faves-star'}">file_download</button : 'unselect-faves-star'}">file_download</button
> >
<button <button
onclick={() => (favoritesOnly = !favoritesOnly)} onclick={() => (favoritesOnly = !favoritesOnly)}
aria-label="Favorites only"
aria-pressed={favoritesOnly}
class="material-icons {favoritesOnly ? '' : 'un'}select-faves-star">star</button class="material-icons {favoritesOnly ? '' : 'un'}select-faves-star">star</button
> >
</div> </div>
@@ -72,13 +137,17 @@
{@const isPartial = streamCache.partials.has(stream.id)} {@const isPartial = streamCache.partials.has(stream.id)}
{@const current = ctx.current?.id === stream.id} {@const current = ctx.current?.id === stream.id}
<li <li
hidden={!displayedStreams.includes(stream) || hidden={!displayedStreams.has(stream) ||
(favoritesOnly && !favorited) || (favoritesOnly && !favorited) ||
(cachedOnly && !(isCached || isPartial))} (cachedOnly && !(isCached || isPartial))}
class="stream-item {current ? 'current-stream' : ''}" class="stream-item {current ? 'current-stream' : ''}"
id="stream-{stream.id}" id="stream-{stream.id}"
> >
<button class="stream-item-button" onclick={() => goto('/streams/' + stream.id)}> <button
class="stream-item-button"
aria-current={current ? 'page' : undefined}
onclick={() => goto('/streams/' + stream.id)}
>
<span class="stream-item-id"> <span class="stream-item-id">
ID: ID:
{shorthandCode(stream.id)}</span {shorthandCode(stream.id)}</span
@@ -88,12 +157,12 @@
<span class="stream-item-length">{formatSecondsToHms(stream.length_seconds)}</span> <span class="stream-item-length">{formatSecondsToHms(stream.length_seconds)}</span>
<p class="stream-item-tags" hidden={!remainingTags.length}> <span class="stream-item-tags" hidden={!Object.keys(stream.tags).length}>
Tags: <span hidden={!filteredTags.length}>[...] </span>{getDisplayedTagsInList( <span class="stream-item-tags-label">Tags:</span>
stream.tags, <span class="stream-item-tags-list"
remainingTags ><WeightedTags tags={stream.tags} highlighted={filteredTags} /></span
).join(', ')} >
</p> </span>
</button> </button>
<button <button
@@ -101,6 +170,8 @@
e.stopPropagation(); e.stopPropagation();
updateFavorites(stream); updateFavorites(stream);
}} }}
aria-label="Favorite"
aria-pressed={favorited}
class="material-icons stream-item-star {favorited ? 'stream-item-star-faved' : ''}" class="material-icons stream-item-star {favorited ? 'stream-item-star-faved' : ''}"
>{favorited ? 'star' : 'star_border'}</button >{favorited ? 'star' : 'star_border'}</button
> >
@@ -112,6 +183,7 @@
e.stopPropagation(); e.stopPropagation();
streamCache.remove(stream.id); streamCache.remove(stream.id);
}} }}
aria-label="Delete download"
class="material-icons stream-item-cached stream-item-cached-btn">delete</button class="material-icons stream-item-cached stream-item-cached-btn">delete</button
> >
{:else} {:else}
@@ -121,6 +193,7 @@
e.stopPropagation(); e.stopPropagation();
streamCache.download(stream); streamCache.download(stream);
}} }}
aria-label={partial ? 'Resume download' : 'Download'}
title={partial title={partial
? `Partially downloaded (${Math.round((partial.received / partial.total) * 100)}%). Click to resume.` ? `Partially downloaded (${Math.round((partial.received / partial.total) * 100)}%). Click to resume.`
: undefined} : undefined}
@@ -156,11 +229,27 @@
} }
.stream-item-tags { .stream-item-tags {
display: flex;
clear: both;
gap: 0.3em;
margin: 0; margin: 0;
font-family: Tahoma; font-family: Tahoma;
font-size: smaller; font-size: smaller;
} }
.stream-item-tags[hidden] {
display: none;
}
.stream-item-tags-label {
flex-shrink: 0;
}
.stream-item-tags-list {
flex-grow: 1;
text-align: center;
}
.stream-item-id { .stream-item-id {
float: left; float: left;
font-family: monospace; font-family: monospace;
@@ -287,6 +376,22 @@
border: 0; border: 0;
} }
.stream-item .stream-item-star:focus-visible,
.stream-item .stream-item-cached-btn:focus-visible {
opacity: 1;
}
/* touch screens can't hover, so always display */
@media (hover: none) {
.stream-item-star {
opacity: 0.5;
}
.stream-item-cached {
opacity: 0.4;
}
}
@media (max-width: 575px) { @media (max-width: 575px) {
.stream-item-star { .stream-item-star {
opacity: 0.5; opacity: 0.5;
+146 -32
View File
@@ -1,42 +1,73 @@
<script lang="ts"> <script lang="ts">
import Select from 'svelte-select'; import Select from 'svelte-select';
import { tagList } from '$lib/stores.svelte.ts'; import {
tagList,
MAX_TAG_WEIGHT,
TAG_WEIGHT_NAMES,
TAG_INTENSITY_ENABLED
} from '$lib/stores.svelte.ts';
let { checked = $bindable([]), remainingTags = [], listOpen = $bindable() } = $props(); // type not exported in the lib itself
type SelectItem = Record<string, unknown>;
type Props = {
// tag -> minimum weight
checked: Record<string, number>;
// unselected tags that can still narrow the list
remainingTags: string[];
// unselected tag -> [displayed streams with it at weight 1+, 2+, 3+]
dropdownCounts: Record<string, number[]>;
// selected tag -> [streams shown if its minimum weight were 1, 2, 3]
pillCounts: Record<string, number[]>;
listOpen: boolean;
};
let {
checked = $bindable({}),
remainingTags = [],
dropdownCounts = {},
pillCounts = {},
listOpen = $bindable()
}: Props = $props();
let items = $state(tagList.map((x) => ({ value: x, label: x }))); let items = $state(tagList.map((x) => ({ value: x, label: x })));
let isChecked = $derived.by(computeIsChecked);
let value = $derived.by(computeValue); let value = $derived.by(computeValue);
function computeIsChecked() {
return checked.reduce((acc, c) => {
acc[c] = true;
return acc;
}, {});
}
function computeValue() { function computeValue() {
return checked.map((c) => items.find((i) => i.value === c)); return Object.keys(checked).map((c) => items.find((i) => i.value === c));
} }
function handleSelectable() { function handleSelectable() {
items = items.map((item) => { items = items.map((item) => {
return { ...item, selectable: !checked.includes(item.value) }; return { ...item, selectable: !(item.value in checked) };
}); });
} }
function handleChange(e: CustomEvent) { // item selected or deleted => passes every selected item as a list
if (e.type === 'clear' && Array.isArray(e.detail)) checked = []; // removing last pill or clearing all => passes undefined
else function handleInput(selected: unknown) {
checked.includes(e.detail.value) const tags = ((selected ?? []) as SelectItem[]).map((item) => item.value as string);
? (checked = checked.filter((i) => i != e.detail.value)) checked = Object.fromEntries(tags.map((tag) => [tag, checked[tag] ?? 1]));
: (checked = [...checked, e.detail.value]);
handleSelectable(); handleSelectable();
} }
let itemFilter = function (label: string, filterText: string, option: { value: string }) { // maximum weight that matches at least one stream for this tag with the current filters
function ceilingOf(tag: string) {
return pillCounts[tag].filter((count) => count > 0).length;
}
// increment the weight threshold for this tag
// and cycles back to 1 once we hit ceilingOf
function cycle(tag: string, back: boolean) {
const min = checked[tag];
const ceiling = ceilingOf(tag);
const next = back ? (min > 1 ? min - 1 : ceiling) : min < ceiling ? min + 1 : 1;
checked = { ...checked, [tag]: next };
}
let itemFilter = function (label: unknown, filterText: string, option: SelectItem) {
return ( return (
remainingTags.includes(option['value']) && remainingTags.includes(option.value as string) &&
label.toLowerCase().includes(filterText.toLowerCase()) String(label).toLowerCase().includes(filterText.toLowerCase())
); );
}; };
</script> </script>
@@ -49,27 +80,110 @@
placeholder="Tag Filter..." placeholder="Tag Filter..."
filterSelectedItems={true} filterSelectedItems={true}
closeListOnChange={false} closeListOnChange={false}
on:select={handleChange} oninput={handleInput}
on:clear={handleChange}
{itemFilter} {itemFilter}
--max-height="42px" --max-height="42px"
--background="rgba(255, 255, 255, 0.1)"
--border="1px solid rgba(255, 255, 255, 0.3)"
--border-hover="1px solid rgba(255, 255, 255, 0.3)"
--border-focused="1px solid var(--accent)"
--item-color="white"
--placeholder-color="rgba(255, 255, 255, 0.5)"
--icons-color="rgba(255, 255, 255, 0.6)"
--list-background="#3a3a3a"
--list-border="1px solid rgba(255, 255, 255, 0.3)"
--list-shadow="none"
--item-hover-bg="rgba(255, 255, 255, 0.15)"
--item-hover-color="white"
--item-transition="none"
--item-height="30px"
--item-line-height="30px"
--item-active-background="rgba(173, 216, 230, 0.3)"
--multi-item-bg="rgba(255, 255, 255, 0.15)"
--multi-item-outline="none"
--multi-item-clear-icon-color="white"
listOffset={0} listOffset={0}
> >
<!-- svelte-select 5.8.3 exposes this as a slot, not a snippet prop --> {#snippet item({ item }: { item: SelectItem })}
<div class="item" slot="item" let:item> {item.label}<span class="count">{dropdownCounts[item.value as string][0]}</span>
<label for={item.value}> {/snippet}
<input type="checkbox" id={item.value} checked={isChecked[item.value]} />
{item.label} {#snippet empty()}
</label> <div class="empty">(´・ω・`)</div>
</div> {/snippet}
{#snippet selection({ selection }: { selection: SelectItem })}
{@const tag = selection.value as string}
{#if tag in checked && TAG_INTENSITY_ENABLED}
{@const min = checked[tag]}
{@const ceiling = ceilingOf(tag)}
<button
type="button"
class="weight"
aria-label="{selection.label}, at least {TAG_WEIGHT_NAMES[min]}, press to change"
onpointerup={(e) => e.stopPropagation()}
onclick={(e) => cycle(tag, e.shiftKey)}
>
{selection.label}<span class="dots"
>{#each { length: MAX_TAG_WEIGHT }, i}<span
class:headroom={i >= min}
class:spacer={i >= ceiling}>·</span
>{/each}</span
>
</button>
{:else}
{selection.label}
{/if}
{/snippet}
</Select> </Select>
<style> <style>
.item { .count {
pointer-events: none; float: right;
opacity: 0.4;
}
.empty {
text-align: center;
padding: 6px 0;
color: rgba(255, 255, 255, 0.5);
}
.weight {
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
}
.weight:hover {
color: var(--accent);
}
.dots {
margin-left: 3px;
color: var(--accent);
font-weight: bold;
letter-spacing: 1px;
}
/* to avoid pills' size shifting as tags are selected */
.headroom {
opacity: 0.3;
}
.spacer {
visibility: hidden;
} }
:global(.svelte-select:hover) { :global(.svelte-select:hover) {
max-height: unset !important; max-height: unset !important;
} }
:global(.svelte-select-list) {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
</style> </style>
@@ -5,6 +5,7 @@ import { getStreamInfo } from '$lib/database.ts';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { STREAM_JSON_LOCATION } from '$env/static/private'; import { STREAM_JSON_LOCATION } from '$env/static/private';
import type { Actions } from './$types'; import type { Actions } from './$types';
import type { TagWeights } from '$lib/types';
type SideloadJson = { title: unknown; description: unknown; tags: unknown }; type SideloadJson = { title: unknown; description: unknown; tags: unknown };
@@ -22,7 +23,7 @@ if (dev) {
const jsonString = fs.readFileSync(filePath, 'utf8'); const jsonString = fs.readFileSync(filePath, 'utf8');
return jsonString; return jsonString;
} else { } else {
return JSON.stringify({ title: '', description: '', tags: [] }); return JSON.stringify({ title: '', description: '', tags: {} });
} }
}; };
@@ -34,10 +35,12 @@ if (dev) {
actions = { actions = {
default: async ({ params, request }) => { default: async ({ params, request }) => {
const data = await request.formData(); const data = await request.formData();
const weights = JSON.parse(String(data.get('tags'))) as TagWeights;
const newJson: SideloadJson = { const newJson: SideloadJson = {
title: data.get('title'), title: data.get('title'),
description: data.get('description'), description: data.get('description'),
tags: data.getAll('tags') // weight 0 means the tag is off
tags: Object.fromEntries(Object.entries(weights).filter(([, weight]) => weight > 0))
}; };
writeSideloadJson(params.stream_id, newJson); writeSideloadJson(params.stream_id, newJson);
} }
@@ -1,24 +1,20 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/state';
import { tagList } from '$lib/stores.svelte.ts'; import { tagList, MAX_TAG_WEIGHT, TAG_WEIGHT_NAMES } from '$lib/stores.svelte.ts';
import type { TagWeights } from '$lib/types';
let { original = $bindable() } = $props(); let { original = $bindable() } = $props();
let tagMap = new Map<string, boolean>(); let weights: TagWeights = $derived({ ...original.tags });
// Create a mapping of tags and their checked status // off -> light -> medium -> heavy -> off, backwards with shift
let reloadTags = (original: { tags: string[] }) => { function cycle(tag: string, back: boolean) {
tagList.forEach((tag) => { const step = back ? MAX_TAG_WEIGHT : 1;
tagMap.set(tag, original.tags.includes(tag)); weights = { ...weights, [tag]: ((weights[tag] ?? 0) + step) % (MAX_TAG_WEIGHT + 1) };
}); }
};
$effect.pre(() => {
reloadTags(original);
});
</script> </script>
{#key $page.url.pathname} {#key page.url.pathname}
<form method="POST"> <form method="POST">
<br /> <br />
<label> <label>
@@ -36,17 +32,24 @@
></textarea> ></textarea>
</label> </label>
<br /> <br />
<label> <div class="tags">
<p>Tags:</p> <p>Tags:</p>
<div class="checkboxContainer"> <input type="hidden" name="tags" value={JSON.stringify(weights)} />
<div class="tag-pills">
{#each tagList as tag} {#each tagList as tag}
<label> {@const weight = weights[tag] ?? 0}
<input type="checkbox" name="tags" value={tag} checked={tagMap.get(tag)} /> <button
{tag} type="button"
</label> class="tag-pill"
class:off={!weight}
aria-label="{tag}, {TAG_WEIGHT_NAMES[weight]}"
onclick={(e) => cycle(tag, e.shiftKey)}
>
{tag}<span class="dots">{'·'.repeat(weight)}</span>
</button>
{/each} {/each}
</div> </div>
</label> </div>
<br /> <br />
<button type="submit">Submit</button> <button type="submit">Submit</button>
@@ -58,19 +61,54 @@
margin-left: 10px; margin-left: 10px;
} }
label { label,
.tags {
display: flex; display: flex;
align-items: center; align-items: center;
} }
label > p { label > p,
.tags > p {
margin-right: 5px; margin-right: 5px;
} }
.checkboxContainer { .tag-pills {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 1px; gap: 1px;
max-width: 50%; max-width: 50%;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.tag-pill {
background: none;
border: none;
padding: 2px 0;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
user-select: none;
}
.tag-pill.off {
opacity: 0.35;
}
.tag-pill:hover {
color: var(--accent);
}
.tag-pill.off:hover {
opacity: 0.65;
color: inherit;
}
.dots {
margin-left: 2px;
color: var(--accent);
font-weight: bold;
} }
</style> </style>
+16 -7
View File
@@ -246,7 +246,7 @@
seekText = formatSeconds(seekValue); seekText = formatSeconds(seekValue);
} }
function trackMouse(event: MouseEvent) { function trackPointer(event: MouseEvent) {
if (seeking) updateSeekVisual(event); if (seeking) updateSeekVisual(event);
if (showTooltip && !disableTooltip) seekTooltip(event); if (showTooltip && !disableTooltip) seekTooltip(event);
if (volumeSeeking) seekVolume(event); if (volumeSeeking) seekVolume(event);
@@ -265,7 +265,7 @@
<svelte:window <svelte:window
bind:innerWidth bind:innerWidth
bind:innerHeight bind:innerHeight
onmouseup={() => { onpointerup={() => {
if (seeking && pendingSeekTime != null) { if (seeking && pendingSeekTime != null) {
audio.currentTime = pendingSeekTime; audio.currentTime = pendingSeekTime;
currentTime = pendingSeekTime; currentTime = pendingSeekTime;
@@ -274,7 +274,11 @@
} }
seeking = volumeSeeking = false; seeking = volumeSeeking = false;
}} }}
onmousemove={trackMouse} onpointercancel={() => {
pendingSeekTime = null;
seeking = volumeSeeking = false;
}}
onpointermove={trackPointer}
/> />
{#if display} {#if display}
@@ -282,6 +286,7 @@
<button <button
class="material-icons" class="material-icons"
style="--icon-color:{iconColor}" style="--icon-color:{iconColor}"
aria-label={paused ? 'Play' : 'Pause'}
onclick={() => (audio.paused ? audio.play() : audio.pause())} onclick={() => (audio.paused ? audio.play() : audio.pause())}
> >
{#if paused} {#if paused}
@@ -294,12 +299,12 @@
bind:this={songBar} bind:this={songBar}
value={seeking && pendingSeekTime != null ? pendingSeekTime : currentTime || 0} value={seeking && pendingSeekTime != null ? pendingSeekTime : currentTime || 0}
max={duration} max={duration}
onmousedown={(e) => { onpointerdown={(e) => {
seeking = true; seeking = true;
updateSeekVisual(e); updateSeekVisual(e);
}} }}
onmouseenter={() => (showTooltip = true)} onpointerenter={() => (showTooltip = true)}
onmouseleave={() => (showTooltip = false)} onpointerleave={() => (showTooltip = false)}
style="--primary-color:{barPrimaryColor}; --secondary-color:{barSecondaryColor}" style="--primary-color:{barPrimaryColor}; --secondary-color:{barSecondaryColor}"
class="song-progress" class="song-progress"
></progress> ></progress>
@@ -309,6 +314,8 @@
<button <button
style="--icon-color:{iconColor}" style="--icon-color:{iconColor}"
class="material-icons" class="material-icons"
aria-label="Mute"
aria-pressed={muted}
onclick={() => (muted = !muted)} onclick={() => (muted = !muted)}
> >
{#if muted} {#if muted}
@@ -324,7 +331,7 @@
<progress <progress
bind:this={volumeBar} bind:this={volumeBar}
value={volume} value={volume}
onmousedown={() => (volumeSeeking = true)} onpointerdown={() => (volumeSeeking = true)}
onclick={seekVolume} onclick={seekVolume}
style="--primary-color:{barPrimaryColor}; --secondary-color:{barSecondaryColor}" style="--primary-color:{barPrimaryColor}; --secondary-color:{barSecondaryColor}"
class="volume-progress" class="volume-progress"
@@ -448,6 +455,8 @@
margin: auto; margin: auto;
margin-left: 5px; margin-left: 5px;
margin-right: 5px; margin-right: 5px;
/* make finger drags seek */
touch-action: none;
} }
progress::-webkit-progress-bar { progress::-webkit-progress-bar {
@@ -2,6 +2,7 @@
import { getStreamContext } from '$lib/streamContext.svelte.ts'; import { getStreamContext } from '$lib/streamContext.svelte.ts';
import { shorthandCode, formatTrackTime, formatDate } from '$lib/utils.ts'; import { shorthandCode, formatTrackTime, formatDate } from '$lib/utils.ts';
import { jumpToTrack } from './Player.svelte'; import { jumpToTrack } from './Player.svelte';
import WeightedTags from '$lib/components/WeightedTags.svelte';
const ctx = getStreamContext(); const ctx = getStreamContext();
@@ -18,7 +19,7 @@
<h3 class="stream-id">ID: {shorthandCode(ctx.current.id)}</h3> <h3 class="stream-id">ID: {shorthandCode(ctx.current.id)}</h3>
<h1 class="stream-date">{formattedStreamDate}</h1> <h1 class="stream-date">{formattedStreamDate}</h1>
</div> </div>
<h5 class="stream-tags"><u>Tags</u>: {ctx.current.tags.join(', ')}</h5> <h5 class="stream-tags"><u>Tags</u>: <WeightedTags tags={ctx.current.tags} /></h5>
</div> </div>
<div class="description-bubble"> <div class="description-bubble">
@@ -30,12 +31,16 @@
<tbody> <tbody>
<tr><th>Timestamp</th><th>Artist</th><th>Title</th></tr> <tr><th>Timestamp</th><th>Artist</th><th>Title</th></tr>
{#each ctx.current.tracks as track, i} {#each ctx.current.tracks as track, i}
<tr class:current={i == ctx.songIndex}> <tr
class:current={i == ctx.songIndex}
aria-current={i == ctx.songIndex ? 'true' : undefined}
>
<td class="timestamp-field" <td class="timestamp-field"
><div class="timestamp-field-flex"> ><div class="timestamp-field-flex">
{formatTrackTime(track[0])} {formatTrackTime(track[0])}
<button <button
onclick={() => jumpToTrack(track[0])} onclick={() => jumpToTrack(track[0])}
aria-label="Play from {formatTrackTime(track[0])}"
class="material-icons" class="material-icons"
style="padding: 4px 6px; margin-top: -4px; margin-bottom: -4px; margin-right: -6px" style="padding: 4px 6px; margin-top: -4px; margin-bottom: -4px; margin-right: -6px"
>fast_forward</button >fast_forward</button
@@ -179,6 +184,17 @@
border: 0; border: 0;
} }
tr .material-icons:focus-visible {
opacity: 1;
}
/* touch screens can't hover, so always display */
@media (hover: none) {
.material-icons {
opacity: 0.5;
}
}
@media (max-width: 575px) { @media (max-width: 575px) {
.material-icons { .material-icons {
opacity: 0.5; opacity: 0.5;