Skip to content
Open
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
7 changes: 7 additions & 0 deletions apps/ferret/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,17 @@
"dependencies": {
"@craig/db": "workspace:*",
"@craig/logger": "workspace:*",
"@henrygd/queue": "^1.0.7",
"@libav.js/variant-webcodecs": "^6.8.8",
"@wasm-audio-decoders/flac": "^0.2.8",
"destr": "^2.0.5",
"emittery": "^1.2.0",
"eventemitter3": "^5.0.1",
"ioredis": "^5.9.2",
"just-clone": "^6.2.0",
"mediabunny": "^1.21.1",
"nanoid": "^5.1.6",
"opus-decoder": "^0.7.10",
"zod": "^4.3.5"
},
"devDependencies": {
Expand All @@ -44,12 +49,14 @@
"@sveltejs/enhanced-img": "^0.8.5",
"@sveltejs/kit": "^2.49.5",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/wicg-file-system-access": "^2023.10.6",
"autoprefixer": "^10.4.23",
"clsx": "^2.1.1",
"cookie-es": "^2.0.0",
"discord-api-types": "^0.38.37",
"fflate": "^0.8.2",
"just-range": "^4.2.0",
"mime": "^4.1.0",
"postcss": "^8.5.6",
"postcss-load-config": "^6.0.1",
"sass": "^1.97.2",
Expand Down
8 changes: 5 additions & 3 deletions apps/ferret/src/components/Checkbox.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
<script lang="ts">
interface Props {
import type { HTMLInputAttributes } from 'svelte/elements';

interface Props extends Omit<HTMLInputAttributes, 'type' | 'class'> {
id?: string | undefined;
checked: boolean;
disabled?: boolean;
}

let { id = undefined, checked = $bindable(), disabled = false }: Props = $props();
let { id = undefined, checked = $bindable(), disabled = false, ...rest }: Props = $props();
</script>

<input {id} type="checkbox" class="h-6 w-6 flex-none cursor-pointer" bind:checked {disabled} />
<input {id} type="checkbox" class="h-6 w-6 flex-none cursor-pointer" bind:checked {disabled} {...rest} />

<style lang="scss">
input[type='checkbox'] {
Expand Down
9 changes: 7 additions & 2 deletions apps/ferret/src/components/FormatButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@

interface Props {
ennuizel?: boolean;
minizel?: boolean;
disabled?: boolean;
icon?: IconifyIcon | null;
suffix?: string;
onclick?: (e: MouseEvent) => void;
children?: import('svelte').Snippet;
}

let { ennuizel = false, disabled = false, icon = null, suffix = '', onclick, children }: Props = $props();
let { ennuizel = false, minizel = false, disabled = false, icon = null, suffix = '', onclick, children }: Props = $props();
</script>

<button class:ennuizel {disabled} {onclick}>
<button class:ennuizel class:minizel {disabled} {onclick}>
{#if icon}
<Icon {icon} class="scale-125" />
{/if}
Expand Down Expand Up @@ -48,6 +49,10 @@
@apply border-red-500 bg-red-500 bg-opacity-25 text-red-400;
}

&.minizel {
@apply border-purple-500 bg-purple-500 bg-opacity-25 text-purple-400;
}

&:hover {
@apply bg-opacity-50 text-white;
}
Expand Down
10 changes: 8 additions & 2 deletions apps/ferret/src/components/Modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { Snippet } from 'svelte';

interface Props {
title: string;
title: string | Snippet;
subtitle?: string;
children?: Snippet;
buttons?: Snippet;
Expand All @@ -13,7 +13,13 @@

<div class="z-10 flex w-full flex-col gap-4 overflow-y-auto p-6">
<div class=" self-stretch leading-tight">
<h2 class="font-display text-xl font-bold text-neutral-100 sm:text-2xl">{title}</h2>
<h2 class="font-display text-xl font-bold text-neutral-100 sm:text-2xl">
{#if typeof title === 'string'}
{title}
{:else}
{@render title?.()}
{/if}
</h2>
{#if subtitle}
<span class="text-sm sm:text-base">
{subtitle}
Expand Down
99 changes: 78 additions & 21 deletions apps/ferret/src/lib/device.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
import { browser } from '$app/environment';
import { writable } from 'svelte/store';

const device = {
interface DeviceInfo {
userAgent: string;
platform: {
windows: boolean;
mac: boolean;
unix: boolean;
iphone: boolean;
android: boolean;
mobile: boolean;
desktop: boolean;
};
prefers: {
language: string;
reducedMotion: boolean;
reducedTransparency: boolean;
};
capabilities: {
showSaveFilePicker: boolean;
showDirectoryPicker: boolean;
minizel: boolean;
};
}

const defaultDevice: DeviceInfo = {
userAgent: '(SvelteKit server render)',
platform: {
windows: false,
Expand All @@ -15,39 +39,72 @@ const device = {
language: 'en',
reducedMotion: false,
reducedTransparency: false
},
capabilities: {
showSaveFilePicker: false,
showDirectoryPicker: false,
minizel: false
}
};

export type DevicePlatform = keyof (typeof device)['platform'];
/** Reactive device store for use in Svelte components */
export const device = writable<DeviceInfo>({ ...defaultDevice });

export type DevicePlatform = keyof DeviceInfo['platform'];
export type DeviceCapability = keyof DeviceInfo['capabilities'];

export function processUserAgent(userAgent: string) {
const ua = userAgent.toLowerCase();

const iphone = ua.includes('iphone os');
const android = ua.includes('android');
device.platform = {
windows: ua.includes('windows nt'),
mac: ua.includes('mac os x') && !iphone,
unix: (ua.includes('linux') || ua.includes('bsd')) && !android,
const firefox = ua.includes('firefox');

device.update((d) => ({
...d,
userAgent,
platform: {
windows: ua.includes('windows nt'),
mac: ua.includes('mac os x') && !iphone,
unix: (ua.includes('linux') || ua.includes('bsd')) && !android,
iphone,
android,
mobile: iphone || android,
desktop: !(iphone || android)
},
capabilities: {
showSaveFilePicker: !firefox,
showDirectoryPicker: !firefox,
minizel: !firefox
}
}));
}

iphone,
android,
/** Refresh device capabilities - call once after mount to ensure reactivity */
export function refreshDeviceCapabilities() {
if (!browser) return;

mobile: iphone || android,
desktop: !(iphone || android)
};
device.update((d) => {
const hasShowSaveFilePicker = typeof window.showSaveFilePicker === 'function';
const hasShowDirectoryPicker = typeof window.showDirectoryPicker === 'function';

device.userAgent = userAgent;
return {
...d,
prefers: {
language: navigator.language.toLowerCase().slice(0, 2) || 'en',
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
reducedTransparency: window.matchMedia('(prefers-reduced-transparency: reduce)').matches
},
capabilities: {
showSaveFilePicker: hasShowSaveFilePicker,
showDirectoryPicker: hasShowDirectoryPicker,
minizel: hasShowSaveFilePicker
}
};
});
}

if (browser) {
processUserAgent(navigator.userAgent.toLowerCase());

device.prefers = {
language: navigator.language.toLowerCase().slice(0, 2) || 'en',
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
reducedTransparency: window.matchMedia('(prefers-reduced-transparency: reduce)').matches
};
processUserAgent(navigator.userAgent);
refreshDeviceCapabilities();
}

export { device };
78 changes: 78 additions & 0 deletions apps/ferret/src/lib/minizel/bitstream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
export class Bitstream {
/** Current offset in bits. */
pos = 0;

constructor(public bytes: Uint8Array) {}

seekToByte(byteOffset: number) {
this.pos = 8 * byteOffset;
}

private readBit() {
const byteIndex = Math.floor(this.pos / 8);
const byte = this.bytes[byteIndex] ?? 0;
const bitIndex = 0b111 - (this.pos & 0b111);
const bit = (byte & (1 << bitIndex)) >> bitIndex;

this.pos++;
return bit;
}

readBits(n: number) {
if (n === 1) {
return this.readBit();
}

let result = 0;

for (let i = 0; i < n; i++) {
result <<= 1;
result |= this.readBit();
}

return result;
}

writeBits(n: number, value: number) {
const end = this.pos + n;

for (let i = this.pos; i < end; i++) {
const byteIndex = Math.floor(i / 8);
let byte = this.bytes[byteIndex]!;
const bitIndex = 0b111 - (this.pos & 0b111);

byte &= ~(1 << bitIndex);
byte |= ((value & (1 << (end - i - 1))) >> (end - i - 1)) << bitIndex;
this.bytes[byteIndex] = byte;
}

this.pos = end;
}

readAlignedByte() {
// Ensure we're byte-aligned
if (this.pos % 8 !== 0) {
throw new Error('Bitstream is not byte-aligned.');
}

const byteIndex = this.pos / 8;
const byte = this.bytes[byteIndex] ?? 0;

this.pos += 8;
return byte;
}

skipBits(n: number) {
this.pos += n;
}

getBitsLeft() {
return this.bytes.length * 8 - this.pos;
}

clone() {
const clone = new Bitstream(this.bytes);
clone.pos = this.pos;
return clone;
}
}
7 changes: 7 additions & 0 deletions apps/ferret/src/lib/minizel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { Bitstream } from './bitstream';
export { LibAVFlacEncoder } from './libav-flac-encoder';
export { MixedProcessor, type MixedProcessorOptions } from './mixed-processor';
export type { PageMeta, WorkerMessage } from './oggParser.worker';
export { MinizelProcessor, type MinizelProcessorOptions, type TrackStats } from './processor';
export { createResilientStream, type ResilientFetchOptions } from './resilientFetch';
export { convertToTimeMark, formatBytes, type MinizelFormat } from './util';
Loading
Loading