import { For } from "solid-js";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-solid";
import { Button } from "./components/ui/button";
import { Card } from "./components/ui/card";
import { Stack, Box, StackProps, HStack, VStack } from "styled-system/jsx";
import { FileUpload } from "./components/ui/file-upload";
import { Text } from "./components/ui/text";
import { AtprotoDid } from "@atcute/lexicons/syntax";
import {
Account,
accounts,
selectedAccount,
setSelectedAccount,
} from "./lib/accounts";
import { Toaster } from "~/components/Toaster";
import { createListCollection, Select } from "./components/ui/select";
import { addTask, tasks, TaskState } from "./lib/task";
import Task from "./components/FileTask";
import Settings from "./components/Settings";
import MicRecorder from "./components/MicRecorder";
import { Link } from "./components/ui/link";
// Pre-compute waveform SVG paths at module load (viewBox: 200 wide × 100 tall).
// translateX(-50%) on a 200%-wide SVG scrolls exactly one screen width → seamless loop.
const makeSineStrokePath = (y: number, amp: number, λ: number): string => {
let d = `M0,${y}`;
for (let x = 0.5; x <= 200.5; x += 0.5) {
d += ` L${x.toFixed(1)},${(y + amp * Math.sin((2 * Math.PI * x) / λ)).toFixed(2)}`;
}
return d;
};
const makeSineFillPath = (y: number, amp: number, λ: number): string => {
return makeSineStrokePath(y, amp, λ) + ` L200,100 L0,100 Z`;
};
// Each track: y position (0-100), amplitude, wavelength, scroll speed, delay, opacity weight.
// λ must divide 100 (the scroll offset in viewBox units) for a seamless loop:
// sin(2π × 100/λ) = sin(0) only when 100/λ is a whole number → λ ∈ {20, 25, 50, …}
const WAVE_TRACKS = [
{ fillD: makeSineFillPath(10, 2.0, 20), strokeD: makeSineStrokePath(10, 2.0, 20), dur: '30s', delay: '0s', op: 0.90 },
{ fillD: makeSineFillPath(22, 3.2, 25), strokeD: makeSineStrokePath(22, 3.2, 25), dur: '44s', delay: '-8s', op: 0.55 },
{ fillD: makeSineFillPath(34, 1.5, 20), strokeD: makeSineStrokePath(34, 1.5, 20), dur: '24s', delay: '-14s', op: 1.00 },
{ fillD: makeSineFillPath(46, 3.5, 25), strokeD: makeSineStrokePath(46, 3.5, 25), dur: '38s', delay: '-5s', op: 0.50 },
{ fillD: makeSineFillPath(56, 2.0, 20), strokeD: makeSineStrokePath(56, 2.0, 20), dur: '33s', delay: '-20s', op: 0.80 },
{ fillD: makeSineFillPath(68, 2.8, 50), strokeD: makeSineStrokePath(68, 2.8, 50), dur: '52s', delay: '-12s', op: 0.45 },
{ fillD: makeSineFillPath(79, 1.8, 25), strokeD: makeSineStrokePath(79, 1.8, 25), dur: '28s', delay: '-18s', op: 0.70 },
{ fillD: makeSineFillPath(90, 2.2, 20), strokeD: makeSineStrokePath(90, 2.2, 20), dur: '36s', delay: '-3s', op: 0.60 },
];
const App = () => {
const collection = () =>
createListCollection({
items: accounts().map((account) => ({
label: account.handle ?? account.did,
value: account.did,
})),
});
const AccountSelect = () => (
<Select.Root
positioning={{ sameWidth: true }}
value={selectedAccount() ? [selectedAccount()!] : []}
onValueChange={(details) =>
setSelectedAccount(details.value[0] as AtprotoDid)
}
collection={collection()}
>
<Select.Control>
<Select.Trigger
border="none"
p="2"
pl="0"
minW="8rem"
maxW="xs"
boxShadow={{ _focus: "none" }}
justifyContent="end"
disabled={accounts().length === 0}
>
<Box>
{selectedAccount() ? "@" : ""}
<Select.ValueText
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
placeholder="account"
/>
</Box>
<ChevronsUpDownIcon />
</Select.Trigger>
</Select.Control>
<Select.Positioner>
<Select.Content>
<Select.ItemGroup>
<For each={collection().items}>
{(item) => (
<Select.Item item={item}>
<Select.ItemText
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
@{item.label}
</Select.ItemText>
<Select.ItemIndicator pl="2">
<CheckIcon />
</Select.ItemIndicator>
</Select.Item>
)}
</For>
</Select.ItemGroup>
</Select.Content>
</Select.Positioner>
</Select.Root>
);
return (
<>
<div class="wave-bg" aria-hidden="true">
<For each={WAVE_TRACKS}>
{(w) => (
<svg
class="wave-line"
viewBox="0 0 200 100"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
style={{
'--wave-dur': w.dur,
'--wave-delay': w.delay,
'--wave-op': w.op,
}}
>
<path d={w.fillD} fill="#ffe629" fill-opacity="0.2" />
<path d={w.strokeD} fill="none" stroke="#ffe629" stroke-width="0.22" />
</svg>
)}
</For>
</div>
<VStack
py={{ base: "8", smDown: "2" }}
minH="100vh"
w="100vw"
justifyContent="center"
alignItems="center"
boxSizing="border-box"
h={{ smDown: "100dvh" }}
overflow={{ smDown: "hidden" }}
gap={{ base: "4", smDown: "2" }}
>
<Card.Root
class="main-card"
maxW="3xl"
w="94%"
flex={{ smDown: "1" }}
minH={{ smDown: "0" }}
overflow={{ smDown: "hidden" }}
display={{ smDown: "flex" }}
flexDir={{ smDown: "column" }}
>
<Card.Header>
<Card.Title w="full">
<Stack direction="row" align="center">
<Text>trill</Text>
<div style="flex-grow: 1;"></div>
<AccountSelect />
<Settings />
</Stack>
</Card.Title>
<Card.Description>
<ol>
<li>1. upload a voice memo or record one.</li>
<li>2. it will automatically be converted to a video</li>
<li>
3. (optional) add an account to enable bluesky integration.
</li>
</ol>
</Card.Description>
</Card.Header>
<Card.Body
overflow={{ smDown: "hidden" }}
flex={{ smDown: "1" }}
minH={{ smDown: "0" }}
>
<Stack
gap="4"
direction={{ base: "row", smDown: "column" }}
h={{ smDown: "full" }}
>
<Upload
flex={{ base: "4", smDown: "0" }}
flexShrink="0"
acceptedFiles={[]}
onFileAccept={(e) =>
e.files.forEach((file) => addTask(selectedAccount(), file))
}
/>
<Tasks
flex="3"
minH={{ base: "20rem", smDown: "0" }}
maxH={{ base: "20rem", smDown: "none" }}
flexGrow={{ smDown: "1" }}
minW="0"
overflowY="auto"
currentTasks={tasks.values().toArray()}
selectedAccount={accounts().find(
(account) => account.did === selectedAccount(),
)}
/>
</Stack>
</Card.Body>
</Card.Root>
<Card.Root maxW="3xl" w="94%">
<Card.Header py="2" px="4">
<Card.Description>
<HStack justifyContent="space-between" alignItems="center">
<Text>
/made by{" "}
<Link
target="_blank"
rel="noopener noreferrer"
href="https://gaze.systems"
>
{Math.random() < 0.98 ? "dawn" : "90008"}
</Link>
/
</Text>
<Link
target="_blank"
rel="noopener noreferrer"
href="https://github.com/sponsors/90-008"
transition="all"
transitionDuration="250ms"
color={{ _hover: "red" }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
>
<path
fill="currentColor"
d="M11.8 1c-1.682 0-3.129 1.368-3.799 2.797C7.33 2.368 5.883 1 4.201 1a4.2 4.2 0 0 0-4.2 4.2c0 4.716 4.758 5.953 8 10.616c3.065-4.634 8-6.05 8-10.616c0-2.319-1.882-4.2-4.2-4.2z"
/>
</svg>
</Link>
<Text>
source on{" "}
<Link
target="_blank"
rel="noopener noreferrer"
href="https://tangled.org/did:plc:dfl62fgb7wtjj3fcbb72naae/trill"
>
tangled
</Link>
</Text>
</HStack>
</Card.Description>
</Card.Header>
</Card.Root>
</VStack>
<Toaster />
</>
);
};
export default App;
type TasksProps = StackProps & {
currentTasks: TaskState[];
selectedAccount: Account | undefined;
};
const Tasks = (props: TasksProps) => (
<Stack
border="1px solid var(--colors-border-subtle)"
borderBottomWidth="3px"
gap="1.5"
p="2"
rounded="sm"
justifyContent={props.currentTasks.length === 0 ? "center" : undefined}
alignItems={props.currentTasks.length === 0 ? "center" : undefined}
{...props}
>
<For
each={props.currentTasks}
fallback={
<Box
fontSize="sm"
display="flex"
justifyContent="center"
alignItems="center"
h="full"
>
no files processed (yet!)
</Box>
}
>
{(process) => Task(process, props.selectedAccount)}
</For>
</Stack>
);
const getAudioClipboard = async () => {
try {
const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) {
console.log(item);
const type = item.types.find((type) => type.startsWith("audio/"));
if (type) {
const blob = await item.getType(type);
const file = new File([blob], `audio.${type.split("/")[1]}`, { type });
return file;
}
}
return;
} catch (err) {
console.error(err);
return;
}
};
const Upload = (props: FileUpload.RootProps) => {
return (
<FileUpload.Root maxFiles={100} {...props}>
<FileUpload.Dropzone borderBottomWidth="3px">
<FileUpload.Label>drop your files here</FileUpload.Label>
<HStack alignItems="center">
<FileUpload.Trigger
asChild={(triggerProps) => (
<Button size="sm" {...triggerProps()}>
or pick file
</Button>
)}
/>
<MicRecorder selectedAccount={selectedAccount} />
{/*<IconButton
size="sm"
onClick={() =>
getAudioClipboard().then((file) => {
if (!file) return;
addTask(selectedAccount(), file);
})
}
variant="subtle"
>
<ClipboardIcon />
</IconButton>*/}
</HStack>
</FileUpload.Dropzone>
<FileUpload.HiddenInput />
</FileUpload.Root>
);
};