Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
},
"dependencies": {
"@better-fetch/fetch": "^1.1.18",
"@dnd-kit-svelte/svelte": "^0.1.5",
"@dnd-kit/abstract": "^0.1.21",
"@dnd-kit/collision": "^0.1.21",
"@dnd-kit/helpers": "^0.1.21",
"@tailwindcss/vite": "^4.1.17",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
Expand Down
83 changes: 83 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

120 changes: 59 additions & 61 deletions src/lib/components/ChannelList.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
<script lang="ts">
import { DragDropProvider } from "@dnd-kit-svelte/svelte";
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers";
import { move } from "@dnd-kit/helpers";
import { onDestroy } from "svelte";
import { flip } from "svelte/animate";
import Users from "~icons/ph/users-bold";
import { app } from "$lib/app.svelte";
import type { Channel } from "$lib/models/channel.svelte";
import { settings } from "$lib/settings";
import Draggable from "./Draggable.svelte";
import Droppable from "./Droppable.svelte";
import { getSidebarContext } from "./Sidebar.svelte";
import StreamTooltip from "./StreamTooltip.svelte";
import { Separator } from "./ui/separator";
const { collapsed = false } = $props();
const sidebar = getSidebarContext();
const sorted = $derived(
app.channels
Expand All @@ -26,22 +32,35 @@
);
const groups = $derived.by(() => {
const ephemeral = { type: "Ephemeral", channels: sorted.filter((c) => c.ephemeral) };
const pinnedChannels = sorted
.filter((c) => c.pinned)
.sort((a, b) => {
const indexA = settings.state.pinned.indexOf(a.id);
const indexB = settings.state.pinned.indexOf(b.id);
return indexA - indexB;
});
const pinned = { type: "Pinned", channels: pinnedChannels };
const ephemeral = { type: "Ephemeral", channels: [] as Channel[] };
const online = { type: "Online", channels: [] as Channel[] };
const offline = { type: "Offline", channels: [] as Channel[] };
for (const channel of sorted) {
if (channel.id === app.user?.id || channel.ephemeral) continue;
if (channel.id === app.user?.id || channel.pinned) {
continue;
}
if (channel.stream) {
if (channel.ephemeral) {
ephemeral.channels.push(channel);
} else if (channel.stream) {
online.channels.push(channel);
} else {
offline.channels.push(channel);
}
}
return [ephemeral, online, offline].filter((g) => g.channels.length);
return [pinned, ephemeral, online, offline].filter((g) => g.channels.length);
});
const interval = setInterval(
Expand All @@ -58,61 +77,40 @@
);
onDestroy(() => clearInterval(interval));
function formatViewers(viewers: number) {
if (viewers >= 1000) {
return `${(viewers / 1000).toFixed(1)}K`;
}
return viewers.toString();
}
</script>

{#each groups as group}
{#if collapsed}
<Separator />
{:else}
<span class="text-muted-foreground mt-2 inline-block px-2 text-xs font-semibold uppercase">
{group.type}
</span>
{/if}

{#each group.channels as channel (channel.user.id)}
<div class="px-1.5" animate:flip={{ duration: 500 }}>
<StreamTooltip {channel} {collapsed}>
{#if !collapsed}
{#if channel.stream}
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between">
<div
class="text-sidebar-foreground flex items-center gap-x-1 truncate text-sm font-medium"
>
{channel.user.displayName}

{#if channel.stream.guests.size}
<span class="text-xs">+{channel.stream.guests.size}</span>
{/if}
</div>

<div
class="flex items-center gap-1 text-xs font-medium text-red-400"
>
<Users />
{formatViewers(channel.stream.viewers)}
</div>
</div>

<p class="text-muted-foreground truncate text-xs">
{channel.stream.game}
</p>
</div>
{:else}
<span class="text-muted-foreground truncate text-sm font-medium">
{channel.user.displayName}
</span>
{/if}
{/if}
</StreamTooltip>
</div>
<DragDropProvider
modifiers={[
// @ts-expect-error - type mismatch
RestrictToVerticalAxis,
]}
onDragOver={(event) => {
settings.state.pinned = move(settings.state.pinned, event);
}}
>
{#each groups as group}
{#if sidebar.collapsed}
<Separator />
{:else}
<span
class="text-muted-foreground mt-2 inline-block px-2 text-xs font-semibold uppercase"
>
{group.type}
</span>
{/if}

{#if group.type === "Pinned"}
<Droppable id="pinned-channels" class="space-y-1.5">
{#each group.channels as channel, i (channel.user.id)}
<Draggable id={channel.id} index={i} {channel} />
{/each}
</Droppable>
{:else}
{#each group.channels as channel (channel.user.id)}
<div class="px-1.5" animate:flip={{ duration: 500 }}>
<StreamTooltip {channel} />
</div>
{/each}
{/if}
{/each}
{/each}
</DragDropProvider>
72 changes: 72 additions & 0 deletions src/lib/components/ChannelListItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<script lang="ts" module>
export interface ChannelListItemProps {
channel: Channel;
}
</script>

<script lang="ts">
import DotsThreeCircle from "~icons/ph/dots-three-circle";
import Users from "~icons/ph/users-bold";
import type { Channel } from "$lib/models/channel.svelte";
import { getSidebarContext } from "./Sidebar.svelte";

const sidebar = getSidebarContext();

const { channel }: ChannelListItemProps = $props();

function formatViewers(viewers: number) {
if (viewers >= 1000) {
return `${(viewers / 1000).toFixed(1)}K`;
}

return viewers.toString();
}
</script>

<img
class={["size-8 rounded-full object-cover", !channel.stream && "grayscale"]}
src={channel.user.avatarUrl}
alt={channel.user.displayName}
width="150"
height="150"
draggable="false"
/>

{#if sidebar.collapsed && channel.stream?.guests.size}
<div
class="bg-muted/70 absolute right-1 bottom-1 flex items-center justify-center rounded-full"
>
<DotsThreeCircle class="size-5" />
</div>
{/if}

{#if !sidebar.collapsed}
{#if channel.stream}
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between">
<div
class="text-sidebar-foreground flex items-center gap-x-1 truncate text-sm font-medium"
>
{channel.user.displayName}

{#if channel.stream.guests.size}
<span class="text-xs">+{channel.stream.guests.size}</span>
{/if}
</div>

<div class="flex items-center gap-1 text-xs font-medium text-red-400">
<Users />
{formatViewers(channel.stream.viewers)}
</div>
</div>

<p class="text-muted-foreground truncate text-xs">
{channel.stream.game}
</p>
</div>
{:else}
<span class="text-muted-foreground truncate text-sm font-medium">
{channel.user.displayName}
</span>
{/if}
{/if}
23 changes: 23 additions & 0 deletions src/lib/components/Draggable.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script lang="ts">
import { useSortable } from "@dnd-kit-svelte/svelte/sortable";
import type { UseSortableInput } from "@dnd-kit-svelte/svelte/sortable";
import ChannelListItem from "./ChannelListItem.svelte";
import StreamTooltip from "./StreamTooltip.svelte";
import type { ChannelListItemProps } from "./ChannelListItem.svelte";

const { channel, ...rest }: UseSortableInput & ChannelListItemProps = $props();

const { ref, isDragging } = useSortable(rest);
</script>

<div class="relative px-1.5" {@attach ref}>
<div class={[isDragging.current && "invisible"]}>
<StreamTooltip {channel} />
</div>

{#if isDragging.current}
<div class="absolute inset-1.5 flex items-center gap-2 px-1.5 opacity-70">
<ChannelListItem {channel} />
</div>
{/if}
</div>
Loading