Compare commits
6 Commits
707ba4cdf2
...
37cfb375b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
37cfb375b8
|
|||
|
ea3d92974b
|
|||
|
0cb2a7739a
|
|||
|
5d82e92461
|
|||
|
151c719901
|
|||
|
f7636f61a7
|
13
bun.lock
13
bun.lock
@@ -4,6 +4,9 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "space-stream",
|
||||
"dependencies": {
|
||||
"jassub": "^2.4.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-cloudflare": "^7.2.8",
|
||||
@@ -272,6 +275,8 @@
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"abslink": ["abslink@1.1.6", "", {}, "sha512-8fQgnUoVSgc1IhOrYzdDY+wTDPktbuYjds2LKf9kWYWKwDnHgXU168gdV3sPei7vevFf5m2fPZ6IQSKZkSjHVg=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||
@@ -314,10 +319,14 @@
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"jassub": ["jassub@2.4.2", "", { "dependencies": { "abslink": "^1.1.6", "lfa-ponyfill": "^1.1.0", "rvfc-polyfill": "^1.0.8", "throughput": "^1.0.2" } }, "sha512-qWECKZWADUYAlXrzTEKPw+zY06XNy4tuZhTTWyk/ML+axRSMuOcbI0tAxL6tdXg+wdROd+EZU83SQFh/frugCg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"lfa-ponyfill": ["lfa-ponyfill@1.1.0", "", {}, "sha512-YS3/DmyDdywWwoEu1ZacAudqkJ4q7WtKE9+bWlaSuEoVrXva7ChIJHMJYs19zyVc1H198pzqAreQU0r/+YNeew=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -382,6 +391,8 @@
|
||||
|
||||
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
|
||||
"rvfc-polyfill": ["rvfc-polyfill@1.0.8", "", {}, "sha512-uA+0wwTkZ4OT8v45pfDfH+7Yq8mY6MvNngiF5Sq6VBgjJsvsfgt7Q18cyZqZjfAhW9rhkgXPX0cW0R9uw7yElA=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
@@ -410,6 +421,8 @@
|
||||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"throughput": ["throughput@1.0.2", "", {}, "sha512-jvK1ZXuhsggjb3qYQjMiU/AVYYiTeqT5thWvYR2yuy2LGM84P5MSSyAinwHahGsdBYKR9m9HncVR/3f3nFKkxg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
@@ -36,5 +36,8 @@
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
"wrangler": "^4.81.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"jassub": "^2.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const spacetimedb = schema({
|
||||
{
|
||||
id: t.u64().primaryKey(),
|
||||
url: t.string(),
|
||||
subtitleUrl: t.string(),
|
||||
timePosition: t.f64(),
|
||||
isPlaying: t.bool(),
|
||||
lastUpdatedAt: t.timestamp(),
|
||||
@@ -19,6 +20,7 @@ export const init = spacetimedb.init((ctx) => {
|
||||
ctx.db.videoState.insert({
|
||||
id: 1n,
|
||||
url: "https://cdn.cazzzer.com/LycoReco08.mkv",
|
||||
subtitleUrl: "",
|
||||
timePosition: 0.0,
|
||||
isPlaying: false,
|
||||
lastUpdatedAt: ctx.timestamp,
|
||||
@@ -34,6 +36,7 @@ export const set_url = spacetimedb.reducer({ url: t.string() }, (ctx, { url }) =
|
||||
ctx.db.videoState.id.update({
|
||||
...row,
|
||||
url,
|
||||
subtitleUrl: "", // Clear subtitle on new video
|
||||
timePosition: 0.0,
|
||||
isPlaying: false,
|
||||
lastUpdatedAt: ctx.timestamp,
|
||||
@@ -71,3 +74,13 @@ export const seek = spacetimedb.reducer({ time_position: t.f64() }, (ctx, { time
|
||||
lastUpdatedAt: ctx.timestamp,
|
||||
});
|
||||
});
|
||||
|
||||
export const set_subtitle_url = spacetimedb.reducer({ url: t.string() }, (ctx, { url }) => {
|
||||
const row = ctx.db.videoState.id.find(1n);
|
||||
if (!row) return;
|
||||
ctx.db.videoState.id.update({
|
||||
...row,
|
||||
subtitleUrl: url,
|
||||
lastUpdatedAt: ctx.timestamp,
|
||||
});
|
||||
});
|
||||
|
||||
BIN
src/lib/assets/fonts/AdobeArabic-Bold.otf
Normal file
BIN
src/lib/assets/fonts/AdobeArabic-Bold.otf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/AdobeArabic-BoldItalic.otf
Normal file
BIN
src/lib/assets/fonts/AdobeArabic-BoldItalic.otf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/Tahoma_0.ttf
Normal file
BIN
src/lib/assets/fonts/Tahoma_0.ttf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/arialbd_3.ttf
Normal file
BIN
src/lib/assets/fonts/arialbd_3.ttf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/arialbi_2.ttf
Normal file
BIN
src/lib/assets/fonts/arialbi_2.ttf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/ariblk_1.TTF
Normal file
BIN
src/lib/assets/fonts/ariblk_1.TTF
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/georgia_0.ttf
Normal file
BIN
src/lib/assets/fonts/georgia_0.ttf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/georgiab_0.ttf
Normal file
BIN
src/lib/assets/fonts/georgiab_0.ttf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/timesbd_3.ttf
Normal file
BIN
src/lib/assets/fonts/timesbd_3.ttf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/trebuc_0.ttf
Normal file
BIN
src/lib/assets/fonts/trebuc_0.ttf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/trebucbd_0.ttf
Normal file
BIN
src/lib/assets/fonts/trebucbd_0.ttf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/trebucbi_0.ttf
Normal file
BIN
src/lib/assets/fonts/trebucbi_0.ttf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/trebucit_0.ttf
Normal file
BIN
src/lib/assets/fonts/trebucit_0.ttf
Normal file
Binary file not shown.
BIN
src/lib/assets/fonts/verdanab_0.ttf
Normal file
BIN
src/lib/assets/fonts/verdanab_0.ttf
Normal file
Binary file not shown.
@@ -37,6 +37,7 @@ import {
|
||||
import PauseReducer from "./pause_reducer";
|
||||
import PlayReducer from "./play_reducer";
|
||||
import SeekReducer from "./seek_reducer";
|
||||
import SetSubtitleUrlReducer from "./set_subtitle_url_reducer";
|
||||
import SetUrlReducer from "./set_url_reducer";
|
||||
|
||||
// Import all procedure arg schemas
|
||||
@@ -66,6 +67,7 @@ const reducersSchema = __reducers(
|
||||
__reducerSchema("pause", PauseReducer),
|
||||
__reducerSchema("play", PlayReducer),
|
||||
__reducerSchema("seek", SeekReducer),
|
||||
__reducerSchema("set_subtitle_url", SetSubtitleUrlReducer),
|
||||
__reducerSchema("set_url", SetUrlReducer),
|
||||
);
|
||||
|
||||
|
||||
15
src/lib/st-bindings/set_subtitle_url_reducer.ts
Normal file
15
src/lib/st-bindings/set_subtitle_url_reducer.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default {
|
||||
url: __t.string(),
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
export const VideoState = __t.object("VideoState", {
|
||||
id: __t.u64(),
|
||||
url: __t.string(),
|
||||
subtitleUrl: __t.string(),
|
||||
timePosition: __t.f64(),
|
||||
isPlaying: __t.bool(),
|
||||
lastUpdatedAt: __t.timestamp(),
|
||||
|
||||
@@ -9,10 +9,12 @@ import { type Infer as __Infer } from "spacetimedb";
|
||||
import PauseReducer from "../pause_reducer";
|
||||
import PlayReducer from "../play_reducer";
|
||||
import SeekReducer from "../seek_reducer";
|
||||
import SetSubtitleUrlReducer from "../set_subtitle_url_reducer";
|
||||
import SetUrlReducer from "../set_url_reducer";
|
||||
|
||||
export type PauseParams = __Infer<typeof PauseReducer>;
|
||||
export type PlayParams = __Infer<typeof PlayReducer>;
|
||||
export type SeekParams = __Infer<typeof SeekReducer>;
|
||||
export type SetSubtitleUrlParams = __Infer<typeof SetSubtitleUrlReducer>;
|
||||
export type SetUrlParams = __Infer<typeof SetUrlReducer>;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
export default __t.row({
|
||||
id: __t.u64().primaryKey(),
|
||||
url: __t.string(),
|
||||
subtitleUrl: __t.string().name("subtitle_url"),
|
||||
timePosition: __t.f64().name("time_position"),
|
||||
isPlaying: __t.bool().name("is_playing"),
|
||||
lastUpdatedAt: __t.timestamp().name("last_updated_at"),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { useSpacetimeDB, useTable, useReducer } from "spacetimedb/svelte";
|
||||
import { tables, reducers } from "$lib/st-bindings";
|
||||
import JASSUB from "jassub";
|
||||
import font1 from "$lib/assets/fonts/trebuc_0.ttf";
|
||||
|
||||
const conn = useSpacetimeDB();
|
||||
|
||||
@@ -8,15 +10,36 @@
|
||||
const videoState = $derived($videoStates.find((state) => state.id === 1n));
|
||||
|
||||
const setUrlReducer = useReducer(reducers.setUrl);
|
||||
const setSubtitleUrlReducer = useReducer(reducers.setSubtitleUrl);
|
||||
const playReducer = useReducer(reducers.play);
|
||||
const pauseReducer = useReducer(reducers.pause);
|
||||
const seekReducer = useReducer(reducers.seek);
|
||||
|
||||
let videoElement: HTMLVideoElement | undefined = $state();
|
||||
let containerElement: HTMLDivElement | undefined = $state();
|
||||
let newUrl = $state("");
|
||||
let newSubtitleUrl = $state("");
|
||||
|
||||
let isSyncing = false;
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let jassubInstance: any | undefined;
|
||||
let currentSubtitleUrl = $derived(videoState?.subtitleUrl);
|
||||
|
||||
$effect(() => {
|
||||
console.log("effect-1");
|
||||
if (videoElement && currentSubtitleUrl && currentSubtitleUrl.endsWith(".ass")) {
|
||||
jassubInstance = new JASSUB({
|
||||
video: videoElement,
|
||||
subUrl: currentSubtitleUrl,
|
||||
fonts: [font1],
|
||||
});
|
||||
|
||||
return () => {
|
||||
jassubInstance?.destroy();
|
||||
jassubInstance = undefined;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function syncAction(fn: () => void) {
|
||||
isSyncing = true;
|
||||
@@ -28,6 +51,7 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
console.log("effect-2");
|
||||
if (!videoElement || !videoState) return;
|
||||
const el = videoElement;
|
||||
const state = videoState;
|
||||
@@ -111,6 +135,21 @@
|
||||
setUrlReducer({ url: newUrl });
|
||||
newUrl = "";
|
||||
}
|
||||
|
||||
function handleSetSubtitle(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!newSubtitleUrl.trim() || !$conn.isActive) return;
|
||||
setSubtitleUrlReducer({ url: newSubtitleUrl });
|
||||
newSubtitleUrl = "";
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
containerElement?.requestFullscreen().catch(console.error);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4">
|
||||
@@ -134,19 +173,71 @@
|
||||
<button type="submit" class="p-2" disabled={!$conn.isActive}>Set URL</button>
|
||||
</form>
|
||||
|
||||
<form onsubmit={handleSetSubtitle} class="mb-2">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Enter subtitle track URL (.vtt/.ass)"
|
||||
bind:value={newSubtitleUrl}
|
||||
class="mr-2 w-96 p-2"
|
||||
/>
|
||||
<button type="submit" class="p-2">Set Subtitles</button>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<div
|
||||
bind:this={containerElement}
|
||||
class="fullscreen-container relative w-full max-w-2xl bg-black"
|
||||
>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
muted
|
||||
controls
|
||||
controlslist="nofullscreen"
|
||||
crossorigin="anonymous"
|
||||
onplay={handlePlay}
|
||||
onpause={handlePause}
|
||||
onseeked={handleSeeked}
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
class="w-full max-w-2xl bg-black"
|
||||
ondblclick={toggleFullscreen}
|
||||
class="h-full w-full"
|
||||
>
|
||||
{#if videoState?.subtitleUrl && !videoState.subtitleUrl.endsWith(".ass")}
|
||||
<track
|
||||
src={videoState.subtitleUrl}
|
||||
kind="subtitles"
|
||||
srclang="en"
|
||||
label="English"
|
||||
default
|
||||
/>
|
||||
{/if}
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<button
|
||||
onclick={toggleFullscreen}
|
||||
class="absolute top-2 right-2 z-50 rounded bg-black/70 px-2 py-1 text-xs text-white opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
Fullscreen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.JASSUB) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
:global(.fullscreen-container:fullscreen) {
|
||||
max-width: none !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
:global(.fullscreen-container:fullscreen video) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,4 +3,12 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({ plugins: [tailwindcss(), sveltekit(), devtoolsJson()] });
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
||||
optimizeDeps: {
|
||||
// for some reason this specific combination of
|
||||
// includes/excludes is required for jassub to work
|
||||
exclude: ["jassub"],
|
||||
include: ["throughput"],
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user