add varying intensity to stream tags

plus improving the tag select dropdown in general
This commit is contained in:
2026-09-25 21:26:31 +02:00
parent c49fb9bb54
commit 83cc73acff
11 changed files with 338 additions and 95 deletions
+91 -28
View File
@@ -1,47 +1,88 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { favoritedStreams } from '$lib/stores.svelte.ts';
import { favoritedStreams, tagList, MAX_TAG_WEIGHT } from '$lib/stores.svelte.ts';
import * as streamCache from '$lib/streamCache.svelte.ts';
import { getStreamContext } from '$lib/streamContext.svelte.ts';
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 WeightedTags from '$lib/components/WeightedTags.svelte';
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 cachedOnly = $state(false);
let listOpen = $state(false);
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);
$effect(() => {
if (displayedStreams.length == 1) {
listOpen = untrack(() => !listOpen);
if (displayedStreams.size == 1) {
listOpen = false;
}
});
function matchesFilter(stream: StreamSummary, filters: Record<string, number>) {
return Object.entries(filters).every(([tag, min]) => (stream.tags[tag] ?? 0) >= min);
}
function streamsToDisplay() {
return ctx.streams.filter(
(stream) =>
!('tags' in stream) || filteredTags.every((tag) => stream.tags.includes(tag))
return new Set(ctx.streams.filter((stream) => matchesFilter(stream, filteredTags)));
}
// how many of these streams have the tag at weight 1+, 2+ and 3+
// 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(MAX_TAG_WEIGHT).fill(0);
for (const stream of streams) {
const weight = stream.tags[tag] ?? 0;
for (let i = 0; i < weight; 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() {
let tagCounts = displayedStreams.reduce((tags: Record<string, number>, stream) => {
stream.tags.forEach((tag) => (tags[tag] ? (tags[tag] += 1) : (tags[tag] = 1)));
return tags;
}, {});
return Object.entries(tagCounts)
.filter((el) => el[1] != displayedStreams.length)
.map((el) => el[0]);
}
function getPillCounts() {
return Object.fromEntries(
Object.keys(filteredTags).map((tag) => {
const otherFilters = { ...filteredTags };
delete otherFilters[tag];
function getDisplayedTagsInList(streamTags: string[], remainingTags: string[]) {
return streamTags.filter((tag) => remainingTags.includes(tag));
const candidates = ctx.streams.filter((stream) =>
matchesFilter(stream, otherFilters)
);
return [tag, weightCounts(candidates, tag)];
})
);
}
function updateFavorites(stream: StreamSummary) {
@@ -52,7 +93,13 @@
</script>
<div class="tag-select">
<TagSelect bind:listOpen bind:checked={filteredTags} {remainingTags} />
<TagSelect
bind:listOpen
bind:checked={filteredTags}
{remainingTags}
{dropdownCounts}
{pillCounts}
/>
<button
onclick={() => (cachedOnly = !cachedOnly)}
class="material-icons filter-download {cachedOnly
@@ -72,7 +119,7 @@
{@const isPartial = streamCache.partials.has(stream.id)}
{@const current = ctx.current?.id === stream.id}
<li
hidden={!displayedStreams.includes(stream) ||
hidden={!displayedStreams.has(stream) ||
(favoritesOnly && !favorited) ||
(cachedOnly && !(isCached || isPartial))}
class="stream-item {current ? 'current-stream' : ''}"
@@ -88,11 +135,11 @@
<span class="stream-item-length">{formatSecondsToHms(stream.length_seconds)}</span>
<p class="stream-item-tags" hidden={!remainingTags.length}>
Tags: <span hidden={!filteredTags.length}>[...] </span>{getDisplayedTagsInList(
stream.tags,
remainingTags
).join(', ')}
<p class="stream-item-tags" hidden={!Object.keys(stream.tags).length}>
<span class="stream-item-tags-label">Tags:</span>
<span class="stream-item-tags-list"
><WeightedTags tags={stream.tags} highlighted={filteredTags} /></span
>
</p>
</button>
@@ -156,11 +203,27 @@
}
.stream-item-tags {
display: flex;
clear: both;
gap: 0.3em;
margin: 0;
font-family: Tahoma;
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 {
float: left;
font-family: monospace;