Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aae8a0130 | ||
|
|
83cc73acff | ||
|
|
c49fb9bb54 |
@@ -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
@@ -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
-1
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { TAG_WEIGHT_NAMES } from '$lib/stores.svelte.ts';
|
||||||
|
import type { TagWeights } from '$lib/types';
|
||||||
|
|
||||||
|
let { tags, highlighted = {} }: { tags: TagWeights; highlighted?: Record<string, number> } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let sorted = $derived(Object.entries(tags).sort((a, b) => b[1] - a[1]));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each sorted as [tag, weight], i}{i ? ', ' : ''}<span
|
||||||
|
class="weight-{weight}"
|
||||||
|
class:highlighted={tag in highlighted}
|
||||||
|
>{tag}<span class="screen-reader-only"> ({TAG_WEIGHT_NAMES[weight]})</span></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
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ $effect.root(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const MAX_TAG_WEIGHT = 3;
|
||||||
|
export const TAG_WEIGHT_NAMES = ['off', 'light', 'medium', 'heavy'];
|
||||||
|
|
||||||
export const tagList = [
|
export const tagList = [
|
||||||
'acoustic',
|
'acoustic',
|
||||||
'electronic',
|
'electronic',
|
||||||
|
|||||||
+4
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--accent: #add8e6;
|
||||||
|
}
|
||||||
|
|
||||||
body,
|
body,
|
||||||
html {
|
html {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -38,7 +38,6 @@
|
|||||||
|
|
||||||
a,
|
a,
|
||||||
a:visited {
|
a:visited {
|
||||||
outline: none;
|
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,47 +1,88 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } 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 } 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);
|
||||||
|
|
||||||
$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))
|
|
||||||
|
// 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() {
|
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 +93,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 +123,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 +143,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 +156,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 +169,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 +179,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 +215,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 +362,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;
|
||||||
|
|||||||
@@ -1,42 +1,68 @@
|
|||||||
<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 } 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 +75,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}
|
||||||
|
{@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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user