feat: support danmaku

This commit is contained in:
molvqingtai 2024-09-30 21:39:47 +08:00
parent 52cd203a53
commit 999a55c65f
31 changed files with 1116 additions and 595 deletions

View file

@ -48,6 +48,7 @@
"@lottiefiles/dotlottie-react": "^0.9.0", "@lottiefiles/dotlottie-react": "^0.9.0",
"@perfsee/jsonr": "^1.13.0", "@perfsee/jsonr": "^1.13.0",
"@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1", "@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
@ -64,8 +65,9 @@
"@webext-core/proxy-service": "^1.2.0", "@webext-core/proxy-service": "^1.2.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"danmaku": "^2.0.7",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^11.7.0", "framer-motion": "^11.9.0",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-react": "^0.446.0", "lucide-react": "^0.446.0",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
@ -86,14 +88,13 @@
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"trystero": "^0.20.0", "trystero": "^0.20.0",
"type-fest": "^4.26.1", "type-fest": "^4.26.1",
"unstorage": "^1.12.0", "unstorage": "1.11.0",
"valibot": "^0.42.1", "valibot": "^0.42.1"
"webextension-polyfill": "^0.12.0"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.5.0", "@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0", "@commitlint/config-conventional": "^19.5.0",
"@eslint-react/eslint-plugin": "^1.14.2", "@eslint-react/eslint-plugin": "^1.14.3",
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3", "@semantic-release/exec": "^6.0.3",
@ -101,12 +102,11 @@
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/eslint__js": "^8.42.3", "@types/eslint__js": "^8.42.3",
"@types/node": "^22.7.2", "@types/node": "^22.7.4",
"@types/react": "^18.3.9", "@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/webextension-polyfill": "^0.12.1",
"@typescript-eslint/parser": "^8.7.0", "@typescript-eslint/parser": "^8.7.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^9.11.1", "eslint": "^9.11.1",
@ -122,7 +122,7 @@
"postcss-rem-to-responsive-pixel": "^6.0.2", "postcss-rem-to-responsive-pixel": "^6.0.2",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"rimraf": "^5.0.10", "rimraf": "^5.0.10",
"semantic-release": "^24.1.1", "semantic-release": "^24.1.2",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2", "typescript": "^5.6.2",

File diff suppressed because it is too large Load diff

View file

@ -8,9 +8,9 @@ import RoomDomain from '@/domain/Room'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import Setup from '@/app/content/views/Setup' import Setup from '@/app/content/views/Setup'
import MessageListDomain from '@/domain/MessageList' import MessageListDomain from '@/domain/MessageList'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { indexDBStorage } from '@/domain/impls/Storage' import { browserSyncStorage, indexDBStorage } from '@/domain/impls/Storage'
import { APP_OPEN_STATUS_STORAGE_KEY } from '@/constants/config' import { APP_OPEN_STATUS_STORAGE_KEY } from '@/constants/config'
import LogoIcon0 from '@/assets/images/logo-0.svg' import LogoIcon0 from '@/assets/images/logo-0.svg'
import LogoIcon1 from '@/assets/images/logo-1.svg' import LogoIcon1 from '@/assets/images/logo-1.svg'
@ -21,16 +21,21 @@ import LogoIcon5 from '@/assets/images/logo-5.svg'
import LogoIcon6 from '@/assets/images/logo-6.svg' import LogoIcon6 from '@/assets/images/logo-6.svg'
import { getDay } from 'date-fns' import { getDay } from 'date-fns'
import DanmakuContainer from './components/DanmakuContainer'
import DanmakuDomain from '@/domain/Danmaku'
import { browser } from 'wxt/browser'
export default function App() { export default function App() {
const send = useRemeshSend() const send = useRemeshSend()
const roomDomain = useRemeshDomain(RoomDomain()) const roomDomain = useRemeshDomain(RoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain()) const userInfoDomain = useRemeshDomain(UserInfoDomain())
const messageListDomain = useRemeshDomain(MessageListDomain()) const messageListDomain = useRemeshDomain(MessageListDomain())
const danmakuDomain = useRemeshDomain(DanmakuDomain())
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery()) const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery()) const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.MessageListLoadIsFinishedQuery()) const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery())
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())] const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
@ -63,6 +68,15 @@ export default function App() {
getAppOpenStatus() getAppOpenStatus()
}, []) }, [])
const danmakuContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
danmakuIsEnabled && send(danmakuDomain.command.MountCommand(danmakuContainerRef.current!))
return () => {
danmakuIsEnabled && send(danmakuDomain.command.DestroyCommand())
}
}, [danmakuIsEnabled])
return ( return (
<> <>
<AppContainer open={appOpen}> <AppContainer open={appOpen}>
@ -75,6 +89,7 @@ export default function App() {
<AppButton onClick={handleToggleApp}> <AppButton onClick={handleToggleApp}>
<DayLogo className="max-h-full max-w-full"></DayLogo> <DayLogo className="max-h-full max-w-full"></DayLogo>
</AppButton> </AppButton>
<DanmakuContainer ref={danmakuContainerRef} />
</> </>
) )
} }

View file

@ -0,0 +1,19 @@
import { cn } from '@/utils'
import { forwardRef } from 'react'
export interface DanmakuContainerProps {
className?: string
}
const DanmakuContainer = forwardRef<HTMLDivElement, DanmakuContainerProps>(({ className }, ref) => {
return (
<div
className={cn('fixed left-0 top-20 z-infinity w-full h-full invisible pointer-events-none shadow-md', className)}
ref={ref}
></div>
)
})
DanmakuContainer.displayName = 'DanmakuContainer'
export default DanmakuContainer

View file

@ -0,0 +1,32 @@
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { Button } from '@/components/ui/Button'
import { TextMessage } from '@/domain/Room'
import { cn } from '@/utils'
import { AvatarImage } from '@radix-ui/react-avatar'
import { FC } from 'react'
export interface PromptItemProps {
data: TextMessage
className?: string
}
const DanmakuMessage: FC<PromptItemProps> = ({ data, className }) => {
return (
<Button
className={cn(
'flex justify-center pointer-events-auto visible gap-x-2 border px-2.5 py-0.5 rounded-full bg-primary/30 text-base font-medium text-white backdrop-blur-md',
className
)}
>
<Avatar className="size-5">
<AvatarImage src={data.userAvatar} alt="avatar" />
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
</Avatar>
<div className="max-w-40 overflow-hidden text-ellipsis">{data.body}</div>
</Button>
)
}
DanmakuMessage.displayName = 'DanmakuMessage'
export default DanmakuMessage

View file

@ -1,6 +1,5 @@
import { type ChangeEvent, type KeyboardEvent } from 'react' import { forwardRef, type ChangeEvent, type KeyboardEvent } from 'react'
import React from 'react'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { Markdown } from '@/components/ui/Markdown' import { Markdown } from '@/components/ui/Markdown'
import { cn } from '@/utils' import { cn } from '@/utils'
@ -17,7 +16,7 @@ export interface MessageInputProps {
onEnter?: (value: string) => void onEnter?: (value: string) => void
} }
const MessageInput = React.forwardRef<HTMLTextAreaElement, MessageInputProps>( const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus, disabled }, ref) => { ({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus, disabled }, ref) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) { if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {

View file

@ -7,8 +7,9 @@ import { defineContentScript } from 'wxt/sandbox'
import { createShadowRootUi } from 'wxt/client' import { createShadowRootUi } from 'wxt/client'
import App from './App' import App from './App'
import { IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/domain/impls/Storage' import { IndexDBStorageImpl, BrowserSyncStorageImpl, indexDBStorage } from '@/domain/impls/Storage'
import { PeerRoomImpl } from '@/domain/impls/PeerRoom' import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
import { DanmakuImpl } from '@/domain/impls/Danmaku'
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom2' // import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
import '@/assets/styles/tailwind.css' import '@/assets/styles/tailwind.css'
import '@/assets/styles/sonner.css' import '@/assets/styles/sonner.css'
@ -22,7 +23,7 @@ export default defineContentScript({
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*'], excludeMatches: ['*://localhost/*', '*://127.0.0.1/*'],
async main(ctx) { async main(ctx) {
const store = Remesh.store({ const store = Remesh.store({
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl] externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl, DanmakuImpl]
// inspectors: __DEV__ ? [RemeshLogger()] : [] // inspectors: __DEV__ ? [RemeshLogger()] : []
}) })

View file

@ -1,4 +1,4 @@
import { type ReactNode, type FC, useState, type MouseEvent, useRef, useEffect } from 'react' import { type ReactNode, type FC, useState, type MouseEvent, useRef } from 'react'
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react' import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'

View file

@ -7,7 +7,7 @@ import PromptItem from '../../components/PromptItem'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import RoomDomain, { MessageType } from '@/domain/Room' import RoomDomain, { MessageType } from '@/domain/Room'
import MessageListDomain from '@/domain/MessageList' import MessageListDomain from '@/domain/MessageList'
import BlurFade from '@/components/magicui/blur-fade' import BlurFade from '@/components/magicui/BlurFade'
const Main: FC = () => { const Main: FC = () => {
const send = useRemeshSend() const send = useRemeshSend()

View file

@ -9,9 +9,9 @@ import { FC, useEffect, useState } from 'react'
import { useRemeshDomain, useRemeshSend } from 'remesh-react' import { useRemeshDomain, useRemeshSend } from 'remesh-react'
import Timer from '@resreq/timer' import Timer from '@resreq/timer'
import ExampleImage from '@/assets/images/example.jpg' import ExampleImage from '@/assets/images/example.jpg'
import PulsatingButton from '@/components/magicui/pulsating-button' import PulsatingButton from '@/components/magicui/PulsatingButton'
import BlurFade from '@/components/magicui/blur-fade' import BlurFade from '@/components/magicui/BlurFade'
import WordPullUp from '@/components/magicui/word-pull-up' import WordPullUp from '@/components/magicui/WordPullUp'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
const mockTextList = [ const mockTextList = [
@ -41,7 +41,8 @@ const generateUserInfo = async (): Promise<UserInfo> => {
name: generateRandomName(), name: generateRandomName(),
avatar: await generateRandomAvatar(MAX_AVATAR_SIZE), avatar: await generateRandomAvatar(MAX_AVATAR_SIZE),
createTime: Date.now(), createTime: Date.now(),
themeMode: checkSystemDarkMode() ? 'dark' : 'system' themeMode: checkSystemDarkMode() ? 'dark' : 'system',
danmakuEnabled: true
} }
} }

View file

@ -1,4 +1,4 @@
import Meteors from '@/components/magicui/meteors' import Meteors from '@/components/magicui/Meteors'
import { FC, ReactNode } from 'react' import { FC, ReactNode } from 'react'
export interface LayoutProps { export interface LayoutProps {

View file

@ -15,15 +15,16 @@ import { Label } from '@/components/ui/Label'
import { RefreshCcwIcon } from 'lucide-react' import { RefreshCcwIcon } from 'lucide-react'
import { MAX_AVATAR_SIZE } from '@/constants/config' import { MAX_AVATAR_SIZE } from '@/constants/config'
import ToastDomain from '@/domain/Toast' import ToastDomain from '@/domain/Toast'
import BlurFade from '@/components/magicui/blur-fade' import BlurFade from '@/components/magicui/BlurFade'
import debounce from './../../../utils/debounce' import { Checkbox } from '@/components/ui/checkbox'
const defaultUserInfo: UserInfo = { const defaultUserInfo: UserInfo = {
id: nanoid(), id: nanoid(),
name: '', name: '',
avatar: '', avatar: '',
createTime: Date.now(), createTime: Date.now(),
themeMode: checkSystemDarkMode() ? 'dark' : 'system' themeMode: checkSystemDarkMode() ? 'dark' : 'system',
danmakuEnabled: true
} }
const formSchema = v.object({ const formSchema = v.object({
@ -49,7 +50,8 @@ const formSchema = v.object({
themeMode: v.pipe( themeMode: v.pipe(
v.string(), v.string(),
v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.') v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.')
) ),
danmakuEnabled: v.boolean()
}) })
const ProfileForm = () => { const ProfileForm = () => {
@ -89,7 +91,7 @@ const ProfileForm = () => {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} autoComplete="off" className="relative w-96 space-y-8 p-10"> <form onSubmit={form.handleSubmit(handleSubmit)} autoComplete="off" className="relative w-[450px] space-y-8 p-14">
<FormField <FormField
control={form.control} control={form.control}
name="avatar" name="avatar"
@ -128,6 +130,30 @@ const ProfileForm = () => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="danmakuEnabled"
render={({ field }) => (
<FormItem>
{/* <FormLabel>Username</FormLabel> */}
<FormControl>
<div className="flex items-center space-x-2">
<Checkbox
defaultChecked={false}
id="enable-danmaku"
onCheckedChange={field.onChange}
checked={field.value}
/>
<FormLabel className="cursor-pointer" htmlFor="enable-danmaku">
Enable Danmaku
</FormLabel>
</div>
</FormControl>
<FormDescription>Danmaku messages will scroll across the screen.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="themeMode" name="themeMode"

View file

@ -2,7 +2,6 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { Remesh } from 'remesh' import { Remesh } from 'remesh'
import { RemeshRoot } from 'remesh-react' import { RemeshRoot } from 'remesh-react'
import { RemeshLogger } from 'remesh-logger'
import App from './App' import App from './App'
import { BrowserSyncStorageImpl } from '@/domain/impls/Storage' import { BrowserSyncStorageImpl } from '@/domain/impls/Storage'
import '@/assets/styles/tailwind.css' import '@/assets/styles/tailwind.css'
@ -10,8 +9,7 @@ import '@/assets/styles/tailwind.css'
import { ToastImpl } from '@/domain/impls/Toast' import { ToastImpl } from '@/domain/impls/Toast'
const store = Remesh.store({ const store = Remesh.store({
externs: [BrowserSyncStorageImpl, ToastImpl], externs: [BrowserSyncStorageImpl, ToastImpl]
inspectors: [RemeshLogger()]
}) })
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(

View file

@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "@radix-ui/react-icons"
import { cn } from "@/utils/index"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

145
src/domain/Danmaku.ts Normal file
View file

@ -0,0 +1,145 @@
import { Remesh } from 'remesh'
import { DanmakuExtern } from './externs/Danmaku'
import { TextMessage } from './Room'
import UserInfoDomain from './UserInfo'
import { map } from 'rxjs'
const DanmakuDomain = Remesh.domain({
name: 'DanmakuDomain',
impl: (domain) => {
const danmaku = domain.getExtern(DanmakuExtern)
const userInfoDomain = domain.getDomain(UserInfoDomain())
const MountState = domain.state({
name: 'Danmaku.MountState',
default: false
})
const DanmakuEnabledState = domain.state<boolean>({
name: 'Danmaku.EnabledState',
default: false
})
const IsEnabledQuery = domain.query({
name: 'Danmaku.IsOpenQuery',
impl: ({ get }) => {
return get(DanmakuEnabledState())
}
})
const EnableCommand = domain.command({
name: 'Danmaku.EnableCommand',
impl: () => {
return DanmakuEnabledState().new(true)
}
})
const DisableCommand = domain.command({
name: 'Danmaku.DisableCommand',
impl: () => {
return DanmakuEnabledState().new(false)
}
})
const IsMountedQuery = domain.query({
name: 'Danmaku.IsMountedQuery',
impl: ({ get }) => get(MountState())
})
const PushCommand = domain.command({
name: 'Danmaku.PushCommand',
impl: (_, message: TextMessage) => {
danmaku.push(message)
return [PushEvent(message)]
}
})
const UnshiftCommand = domain.command({
name: 'Danmaku.UnshiftCommand',
impl: (_, message: TextMessage) => {
danmaku.unshift(message)
return [UnshiftEvent(message)]
}
})
const ClearCommand = domain.command({
name: 'Danmaku.ClearCommand',
impl: () => {
danmaku.clear()
return [ClearEvent()]
}
})
const MountCommand = domain.command({
name: 'Danmaku.ClearCommand',
impl: (_, container: HTMLElement) => {
danmaku.mount(container)
return [MountEvent(container)]
}
})
const DestroyCommand = domain.command({
name: 'Danmaku.DestroyCommand',
impl: () => {
danmaku.destroy()
return [DestroyEvent()]
}
})
const PushEvent = domain.event<TextMessage>({
name: 'Danmaku.PushEvent'
})
const UnshiftEvent = domain.event<TextMessage>({
name: 'Danmaku.UnshiftEvent'
})
const ClearEvent = domain.event({
name: 'Danmaku.ClearEvent'
})
const MountEvent = domain.event<HTMLElement>({
name: 'Danmaku.MountEvent'
})
const DestroyEvent = domain.event({
name: 'Danmaku.DestroyEvent'
})
domain.effect({
name: 'Danmaku.OnUserInfoEffect',
impl: ({ fromEvent }) => {
const onUserInfo$ = fromEvent(userInfoDomain.event.UpdateUserInfoEvent)
return onUserInfo$.pipe(
map((userInfo) => {
return userInfo?.danmakuEnabled ? EnableCommand() : DisableCommand()
})
)
}
})
return {
query: {
IsMountedQuery,
IsEnabledQuery
},
command: {
EnableCommand,
DisableCommand,
PushCommand,
UnshiftCommand,
ClearCommand,
MountCommand,
DestroyCommand
},
event: {
PushEvent,
UnshiftEvent,
ClearEvent,
MountEvent,
DestroyEvent
}
}
}
})
export default DanmakuDomain

View file

@ -48,8 +48,8 @@ const MessageListDomain = Remesh.domain({
key: (message) => message.id key: (message) => message.id
}) })
const MessageListLoadStatusModule = StatusModule(domain, { const LoadStatusModule = StatusModule(domain, {
name: 'MessageListLoadStatusModule' name: 'Message.ListLoadStatusModule'
}) })
const ListQuery = MessageListModule.query.ItemListQuery const ListQuery = MessageListModule.query.ItemListQuery
@ -58,6 +58,8 @@ const MessageListDomain = Remesh.domain({
const HasItemQuery = MessageListModule.query.HasItemByKeyQuery const HasItemQuery = MessageListModule.query.HasItemByKeyQuery
const LoadIsFinishedQuery = LoadStatusModule.query.IsFinishedQuery
const ChangeListEvent = domain.event({ const ChangeListEvent = domain.event({
name: 'MessageList.ChangeListEvent', name: 'MessageList.ChangeListEvent',
impl: ({ get }) => { impl: ({ get }) => {
@ -144,16 +146,14 @@ const MessageListDomain = Remesh.domain({
storageEffect storageEffect
.set(SyncToStorageEvent) .set(SyncToStorageEvent)
.get< .get<Message[]>((value) => [SyncToStateCommand(value ?? []), LoadStatusModule.command.SetFinishedCommand()])
Message[]
>((value) => [SyncToStateCommand(value ?? []), MessageListLoadStatusModule.command.SetFinishedCommand()])
return { return {
query: { query: {
HasItemQuery, HasItemQuery,
ItemQuery, ItemQuery,
ListQuery, ListQuery,
MessageListLoadIsFinishedQuery: MessageListLoadStatusModule.query.IsFinishedQuery LoadIsFinishedQuery
}, },
command: { command: {
CreateItemCommand, CreateItemCommand,

View file

@ -1,5 +1,5 @@
import { Remesh } from 'remesh' import { Remesh } from 'remesh'
import { map, merge, of, EMPTY, mergeMap, fromEvent, Observable, tap, fromEventPattern } from 'rxjs' import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
import { NormalMessage, type MessageUser } from './MessageList' import { NormalMessage, type MessageUser } from './MessageList'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom' import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import MessageListDomain, { MessageType } from '@/domain/MessageList' import MessageListDomain, { MessageType } from '@/domain/MessageList'
@ -7,7 +7,8 @@ import UserInfoDomain from '@/domain/UserInfo'
import { desert, upsert } from '@/utils' import { desert, upsert } from '@/utils'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import StatusModule from '@/domain/modules/Status' import StatusModule from '@/domain/modules/Status'
import { ToastExtern } from './externs/Toast' import { ToastExtern } from '@/domain/externs/Toast'
import DanmakuDomain from '@/domain/Danmaku'
export { MessageType } export { MessageType }
@ -50,6 +51,7 @@ const RoomDomain = Remesh.domain({
impl: (domain) => { impl: (domain) => {
const messageListDomain = domain.getDomain(MessageListDomain()) const messageListDomain = domain.getDomain(MessageListDomain())
const userInfoDomain = domain.getDomain(UserInfoDomain()) const userInfoDomain = domain.getDomain(UserInfoDomain())
const danmakuDomain = domain.getDomain(DanmakuDomain())
const peerRoom = domain.getExtern(PeerRoomExtern) const peerRoom = domain.getExtern(PeerRoomExtern)
const toast = domain.getExtern(ToastExtern) const toast = domain.getExtern(ToastExtern)
@ -65,12 +67,12 @@ const RoomDomain = Remesh.domain({
} }
}) })
const RoomJoinStatusModule = StatusModule(domain, { const JoinStatusModule = StatusModule(domain, {
name: 'RoomJoinStatusModule' name: 'Room.JoinStatusModule'
}) })
const UserListState = domain.state<RoomUser[]>({ const UserListState = domain.state<RoomUser[]>({
name: 'RoomUserListState', name: 'Room.UserListState',
default: [] default: []
}) })
@ -81,8 +83,10 @@ const RoomDomain = Remesh.domain({
} }
}) })
const JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery
const JoinRoomCommand = domain.command({ const JoinRoomCommand = domain.command({
name: 'RoomJoinRoomCommand', name: 'Room.JoinRoomCommand',
impl: ({ get }) => { impl: ({ get }) => {
peerRoom.joinRoom() peerRoom.joinRoom()
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
@ -101,14 +105,14 @@ const RoomDomain = Remesh.domain({
type: MessageType.Prompt, type: MessageType.Prompt,
date: Date.now() date: Date.now()
}), }),
RoomJoinStatusModule.command.SetFinishedCommand(), JoinStatusModule.command.SetFinishedCommand(),
JoinRoomEvent(peerRoom.roomId) JoinRoomEvent(peerRoom.roomId)
] ]
} }
}) })
const LeaveRoomCommand = domain.command({ const LeaveRoomCommand = domain.command({
name: 'RoomLeaveRoomCommand', name: 'Room.LeaveRoomCommand',
impl: ({ get }) => { impl: ({ get }) => {
peerRoom.leaveRoom() peerRoom.leaveRoom()
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
@ -126,16 +130,21 @@ const RoomDomain = Remesh.domain({
type: 'delete', type: 'delete',
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar } user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
}), }),
RoomJoinStatusModule.command.SetInitialCommand(), JoinStatusModule.command.SetInitialCommand(),
LeaveRoomEvent(peerRoom.roomId) LeaveRoomEvent(peerRoom.roomId)
] ]
} }
}) })
const SendTextMessageCommand = domain.command({ const SendTextMessageCommand = domain.command({
name: 'RoomSendTextMessageCommand', name: 'Room.SendTextMessageCommand',
impl: ({ get }, message: string) => { impl: ({ get }, message: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! const {
id: userId,
name: username,
avatar: userAvatar,
danmakuEnabled
} = get(userInfoDomain.query.UserInfoQuery())!
const textMessage: TextMessage = { const textMessage: TextMessage = {
id: nanoid(), id: nanoid(),
@ -154,13 +163,17 @@ const RoomDomain = Remesh.domain({
hateUsers: [] hateUsers: []
} }
peerRoom.sendMessage<RoomMessage>(textMessage) peerRoom.sendMessage(textMessage)
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)] return [
messageListDomain.command.CreateItemCommand(listMessage),
danmakuEnabled ? PushDanmakuCommand(textMessage) : null,
SendTextMessageEvent(textMessage)
]
} }
}) })
const SendLikeMessageCommand = domain.command({ const SendLikeMessageCommand = domain.command({
name: 'RoomSendLikeMessageCommand', name: 'Room.SendLikeMessageCommand',
impl: ({ get }, messageId: string) => { impl: ({ get }, messageId: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
@ -176,13 +189,13 @@ const RoomDomain = Remesh.domain({
...localMessage, ...localMessage,
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId') likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
} }
peerRoom.sendMessage<RoomMessage>(likeMessage) peerRoom.sendMessage(likeMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)] return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
} }
}) })
const SendHateMessageCommand = domain.command({ const SendHateMessageCommand = domain.command({
name: 'RoomSendHateMessageCommand', name: 'Room.SendHateMessageCommand',
impl: ({ get }, messageId: string) => { impl: ({ get }, messageId: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
@ -198,13 +211,13 @@ const RoomDomain = Remesh.domain({
...localMessage, ...localMessage,
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId') hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
} }
peerRoom.sendMessage<RoomMessage>(hateMessage) peerRoom.sendMessage(hateMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)] return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
} }
}) })
const SendJoinMessageCommand = domain.command({ const SendJoinMessageCommand = domain.command({
name: 'RoomSendJoinMessageCommand', name: 'Room.SendJoinMessageCommand',
impl: ({ get }, targetPeerId: string) => { impl: ({ get }, targetPeerId: string) => {
const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)! const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)!
@ -214,13 +227,13 @@ const RoomDomain = Remesh.domain({
type: SendType.Join type: SendType.Join
} }
peerRoom.sendMessage<RoomMessage>(syncUserMessage, targetPeerId) peerRoom.sendMessage(syncUserMessage, targetPeerId)
return [SendJoinMessageEvent(syncUserMessage)] return [SendJoinMessageEvent(syncUserMessage)]
} }
}) })
const UpdateUserListCommand = domain.command({ const UpdateUserListCommand = domain.command({
name: 'RoomUpdateUserListCommand', name: 'Room.UpdateUserListCommand',
impl: ({ get }, action: { type: 'create' | 'delete'; user: RoomUser }) => { impl: ({ get }, action: { type: 'create' | 'delete'; user: RoomUser }) => {
const userList = get(UserListState()) const userList = get(UserListState())
if (action.type === 'create') { if (action.type === 'create') {
@ -231,44 +244,58 @@ const RoomDomain = Remesh.domain({
} }
}) })
const PushDanmakuCommand = domain.command({
name: 'Room.PushDanmakuCommand',
impl: (_, message: TextMessage) => {
return [danmakuDomain.command.PushCommand(message)]
}
})
// const UnshiftDanmakuCommand = domain.command({
// name: 'Room.PushDanmakuCommand',
// impl: (_, message: TextMessage) => {
// return [danmakuDomain.command.UnshiftCommand(message)]
// }
// })
const SendJoinMessageEvent = domain.event<SyncUserMessage>({ const SendJoinMessageEvent = domain.event<SyncUserMessage>({
name: 'RoomSendJoinMessageEvent' name: 'Room.SendJoinMessageEvent'
}) })
const SendTextMessageEvent = domain.event<TextMessage>({ const SendTextMessageEvent = domain.event<TextMessage>({
name: 'RoomSendTextMessageEvent' name: 'Room.SendTextMessageEvent'
}) })
const SendLikeMessageEvent = domain.event<LikeMessage>({ const SendLikeMessageEvent = domain.event<LikeMessage>({
name: 'RoomSendLikeMessageEvent' name: 'Room.SendLikeMessageEvent'
}) })
const SendHateMessageEvent = domain.event<HateMessage>({ const SendHateMessageEvent = domain.event<HateMessage>({
name: 'RoomSendHateMessageEvent' name: 'Room.SendHateMessageEvent'
}) })
const JoinRoomEvent = domain.event<string>({ const JoinRoomEvent = domain.event<string>({
name: 'RoomJoinRoomEvent' name: 'Room.JoinRoomEvent'
}) })
const LeaveRoomEvent = domain.event<string>({ const LeaveRoomEvent = domain.event<string>({
name: 'RoomLeaveRoomEvent' name: 'Room.LeaveRoomEvent'
}) })
const OnMessageEvent = domain.event<RoomMessage>({ const OnMessageEvent = domain.event<RoomMessage>({
name: 'RoomOnMessageEvent' name: 'Room.OnMessageEvent'
}) })
const OnJoinRoomEvent = domain.event<string>({ const OnJoinRoomEvent = domain.event<string>({
name: 'RoomOnJoinRoomEvent' name: 'Room.OnJoinRoomEvent'
}) })
const OnLeaveRoomEvent = domain.event<string>({ const OnLeaveRoomEvent = domain.event<string>({
name: 'RoomOnLeaveRoomEvent' name: 'Room.OnLeaveRoomEvent'
}) })
domain.effect({ domain.effect({
name: 'RoomOnJoinRoomEffect', name: 'Room.OnJoinRoomEffect',
impl: () => { impl: () => {
const onJoinRoom$ = fromEventPattern<string>(peerRoom.onJoinRoom).pipe( const onJoinRoom$ = fromEventPattern<string>(peerRoom.onJoinRoom).pipe(
mergeMap((peerId) => { mergeMap((peerId) => {
@ -285,14 +312,16 @@ const RoomDomain = Remesh.domain({
}) })
domain.effect({ domain.effect({
name: 'RoomOnMessageEffect', name: 'Room.OnMessageEffect',
impl: ({ get }) => { impl: ({ get }) => {
const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.onMessage).pipe( const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.onMessage).pipe(
mergeMap((message) => { mergeMap((message) => {
// console.log('onMessage', message) // console.log('onMessage', message)
const { danmakuEnabled } = get(userInfoDomain.query.UserInfoQuery())!
const messageEvent$ = of(OnMessageEvent(message)) const messageEvent$ = of(OnMessageEvent(message))
const commandEvent$ = (() => { const messageCommand$ = (() => {
switch (message.type) { switch (message.type) {
case SendType.Join: { case SendType.Join: {
const userList = get(UserListQuery()) const userList = get(UserListQuery())
@ -326,7 +355,8 @@ const RoomDomain = Remesh.domain({
date: Date.now(), date: Date.now(),
likeUsers: [], likeUsers: [],
hateUsers: [] hateUsers: []
}) }),
danmakuEnabled ? PushDanmakuCommand(message) : null
) )
case SendType.Like: case SendType.Like:
case SendType.Hate: { case SendType.Hate: {
@ -355,7 +385,8 @@ const RoomDomain = Remesh.domain({
return EMPTY return EMPTY
} }
})() })()
return merge(messageEvent$, commandEvent$)
return merge(messageEvent$, messageCommand$)
}) })
) )
return onMessage$ return onMessage$
@ -363,7 +394,7 @@ const RoomDomain = Remesh.domain({
}) })
domain.effect({ domain.effect({
name: 'RoomOnLeaveRoomEffect', name: 'Room.OnLeaveRoomEffect',
impl: ({ get }) => { impl: ({ get }) => {
const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe( const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe(
map((peerId) => { map((peerId) => {
@ -392,7 +423,7 @@ const RoomDomain = Remesh.domain({
}) })
domain.effect({ domain.effect({
name: 'RoomOnErrorEffect', name: 'Room.OnErrorEffect',
impl: () => { impl: () => {
const onRoomError$ = fromEventPattern<Error>(peerRoom.onError).pipe( const onRoomError$ = fromEventPattern<Error>(peerRoom.onError).pipe(
map((error) => { map((error) => {
@ -407,11 +438,11 @@ const RoomDomain = Remesh.domain({
// TODO: Move this to a service worker in the future, so we don't need to send a leave room message every time the page refreshes // TODO: Move this to a service worker in the future, so we don't need to send a leave room message every time the page refreshes
domain.effect({ domain.effect({
name: 'RoomOnUnloadEffect', name: 'Room.OnUnloadEffect',
impl: ({ get }) => { impl: ({ get }) => {
const beforeUnload$ = fromEvent(window, 'beforeunload').pipe( const beforeUnload$ = fromEvent(window, 'beforedestroy').pipe(
map(() => { map(() => {
return get(RoomJoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null return get(JoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null
}) })
) )
return beforeUnload$ return beforeUnload$
@ -422,7 +453,7 @@ const RoomDomain = Remesh.domain({
query: { query: {
PeerIdQuery, PeerIdQuery,
UserListQuery, UserListQuery,
RoomJoinIsFinishedQuery: RoomJoinStatusModule.query.IsFinishedQuery JoinIsFinishedQuery
}, },
command: { command: {
JoinRoomCommand, JoinRoomCommand,

View file

@ -10,6 +10,7 @@ export interface UserInfo {
avatar: string avatar: string
createTime: number createTime: number
themeMode: 'system' | 'light' | 'dark' themeMode: 'system' | 'light' | 'dark'
danmakuEnabled: boolean
} }
const UserInfoDomain = Remesh.domain({ const UserInfoDomain = Remesh.domain({
@ -27,10 +28,10 @@ const UserInfoDomain = Remesh.domain({
}) })
const UserInfoLoadStatusModule = StatusModule(domain, { const UserInfoLoadStatusModule = StatusModule(domain, {
name: 'UserInfoLoadStatusModule' name: 'UserInfo.LoadStatusModule'
}) })
const UserInfoSetStatusModule = StatusModule(domain, { const UserInfoSetStatusModule = StatusModule(domain, {
name: 'UserInfoSetStatusModule' name: 'UserInfo.SetStatusModule'
}) })
const UserInfoQuery = domain.query({ const UserInfoQuery = domain.query({
@ -43,8 +44,6 @@ const UserInfoDomain = Remesh.domain({
const UpdateUserInfoCommand = domain.command({ const UpdateUserInfoCommand = domain.command({
name: 'UserInfo.UpdateUserInfoCommand', name: 'UserInfo.UpdateUserInfoCommand',
impl: (_, userInfo: UserInfo | null) => { impl: (_, userInfo: UserInfo | null) => {
console.log('111', userInfo)
return [ return [
UserInfoState().new(userInfo), UserInfoState().new(userInfo),
UpdateUserInfoEvent(), UpdateUserInfoEvent(),
@ -81,16 +80,16 @@ const UserInfoDomain = Remesh.domain({
UserInfoState().new(userInfo), UserInfoState().new(userInfo),
UpdateUserInfoEvent(), UpdateUserInfoEvent(),
SyncToStateEvent(userInfo), SyncToStateEvent(userInfo),
userInfo && UserInfoSetStatusModule.command.SetFinishedCommand() userInfo
? UserInfoSetStatusModule.command.SetFinishedCommand()
: UserInfoSetStatusModule.command.SetInitialCommand()
] ]
} }
}) })
storageEffect storageEffect
.set(SyncToStorageEvent) .set(SyncToStorageEvent)
.get<UserInfo>((value) => { .get<UserInfo>((value) => [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()])
return [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()]
})
.watch<UserInfo>((value) => [SyncToStateCommand(value)]) .watch<UserInfo>((value) => [SyncToStateCommand(value)])
return { return {

View file

@ -0,0 +1,30 @@
import { Remesh } from 'remesh'
import { TextMessage } from '../Room'
export interface Danmaku {
push: (message: TextMessage) => void
unshift: (message: TextMessage) => void
clear: () => void
mount: (root: HTMLElement) => void
destroy: () => void
}
export const DanmakuExtern = Remesh.extern<Danmaku>({
default: {
mount: () => {
throw new Error('"mount" not implemented.')
},
destroy() {
throw new Error('"destroy" not implemented.')
},
clear: () => {
throw new Error('"clear" not implemented.')
},
push: () => {
throw new Error('"push" not implemented.')
},
unshift: () => {
throw new Error('"unshift" not implemented.')
}
}
})

View file

@ -1,13 +1,12 @@
import { Remesh } from 'remesh' import { Remesh } from 'remesh'
import { RoomMessage } from '../Room'
export type PeerMessage = object | Blob | ArrayBuffer | ArrayBufferView
export interface PeerRoom { export interface PeerRoom {
readonly peerId: string readonly peerId: string
readonly roomId: string readonly roomId: string
joinRoom: () => PeerRoom joinRoom: () => PeerRoom
sendMessage: <T extends PeerMessage>(message: T, id?: string) => PeerRoom sendMessage: (message: RoomMessage, id?: string) => PeerRoom
onMessage: <T extends PeerMessage>(callback: (message: T) => void) => PeerRoom onMessage: (callback: (message: RoomMessage) => void) => PeerRoom
leaveRoom: () => PeerRoom leaveRoom: () => PeerRoom
onJoinRoom: (callback: (id: string) => void) => PeerRoom onJoinRoom: (callback: (id: string) => void) => PeerRoom
onLeaveRoom: (callback: (id: string) => void) => PeerRoom onLeaveRoom: (callback: (id: string) => void) => PeerRoom

View file

@ -1,10 +1,9 @@
import { Remesh } from 'remesh' import { Remesh } from 'remesh'
import { type Promisable } from 'type-fest'
export type StorageValue = null | string | number | boolean | object export type StorageValue = null | string | number | boolean | object
export type WatchEvent = 'update' | 'remove' export type WatchEvent = 'update' | 'remove'
export type WatchCallback = (event: WatchEvent, key: string) => any export type WatchCallback = (event: WatchEvent, key: string) => any
export type Unwatch = () => Promisable<void> export type Unwatch = () => Promise<void>
export interface Storage { export interface Storage {
name: string name: string
@ -31,10 +30,10 @@ export const IndexDBStorageExtern = Remesh.extern<Storage>({
clear: async () => { clear: async () => {
throw new Error('"clear" not implemented.') throw new Error('"clear" not implemented.')
}, },
watch: () => { watch: async () => {
throw new Error('"watch" not implemented.') throw new Error('"watch" not implemented.')
}, },
unwatch: () => { unwatch: async () => {
throw new Error('"unwatch" not implemented.') throw new Error('"unwatch" not implemented.')
} }
} }
@ -55,10 +54,10 @@ export const BrowserSyncStorageExtern = Remesh.extern<Storage>({
clear: async () => { clear: async () => {
throw new Error('"clear" not implemented.') throw new Error('"clear" not implemented.')
}, },
watch: () => { watch: async () => {
throw new Error('"watch" not implemented.') throw new Error('"watch" not implemented.')
}, },
unwatch: () => { unwatch: async () => {
throw new Error('"unwatch" not implemented.') throw new Error('"unwatch" not implemented.')
} }
} }

View file

@ -0,0 +1,80 @@
import { DanmakuExtern } from '@/domain/externs/Danmaku'
import { TextMessage } from '@/domain/Room'
import { createElement } from 'react'
import _Danmaku from 'danmaku'
import DanmakuMessage from '@/app/content/components/DanmakuMessage'
import { createRoot } from 'react-dom/client'
// import { create } from 'danmaku'
// const manager = create<TextMessage>({
// trackHeight: '20%',
// plugin: {
// init(manager) {
// 'shadow shadow-slate-200 bg-slate-100'.split(' ').forEach((c) => {
// manager.container.node.classList.add(c)
// })
// },
// $createNode(dm) {
// if (!dm.node) return
// createRoot(dm.node).render(createElement(DanmakuMessage, { data: dm.data }))
// }
// }
// })
// manager.mount(document.body)
// manager.startPlaying()
export class Danmaku {
private container?: Element
private _danmaku?: _Danmaku
mount(container: HTMLElement) {
this.container = container
this._danmaku = new _Danmaku({
container
})
}
destroy() {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
this._danmaku!.destroy()
}
push(message: TextMessage) {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
const root = document.createElement('div')
createRoot(root).render(createElement(DanmakuMessage, { data: message }))
// Wait for React render to complete
requestIdleCallback(() => {
this._danmaku!.emit({
render() {
return root.firstElementChild! as HTMLElement
}
})
})
}
unshift(message: TextMessage) {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
// console.log(message)
}
clear() {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
this._danmaku!.clear()
}
}
export const DanmakuImpl = DanmakuExtern.impl(new Danmaku())

View file

@ -2,9 +2,10 @@ import { type DataPayload, type Room, joinRoom, selfId } from 'trystero'
// import { joinRoom } from 'trystero/firebase' // import { joinRoom } from 'trystero/firebase'
import { PeerRoomExtern, type PeerMessage } from '@/domain/externs/PeerRoom' import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import { stringToHex } from '@/utils' import { stringToHex } from '@/utils'
import EventHub from '@resreq/event-hub' import EventHub from '@resreq/event-hub'
import { RoomMessage } from '../Room'
export interface Config { export interface Config {
peerId?: string peerId?: string
@ -44,37 +45,37 @@ class PeerRoom extends EventHub {
return this return this
} }
sendMessage<T extends PeerMessage>(message: T, id?: string) { sendMessage(message: RoomMessage, id?: string) {
if (!this.room) { if (!this.room) {
this.once('action', () => { this.once('action', () => {
if (!this.room) { if (!this.room) {
this.emit('error', new Error('Room not joined')) this.emit('error', new Error('Room not joined'))
} else { } else {
const [send] = this.room.makeAction('MESSAGE') const [send] = this.room.makeAction('MESSAGE')
send(message as DataPayload, id) send(message as any as DataPayload, id)
} }
}) })
} else { } else {
const [send] = this.room.makeAction('MESSAGE') const [send] = this.room.makeAction('MESSAGE')
send(message as DataPayload, id) send(message as any as DataPayload, id)
} }
return this return this
} }
onMessage<T extends PeerMessage>(callback: (message: T) => void) { onMessage(callback: (message: RoomMessage) => void) {
if (!this.room) { if (!this.room) {
this.once('action', () => { this.once('action', () => {
if (!this.room) { if (!this.room) {
this.emit('error', new Error('Room not joined')) this.emit('error', new Error('Room not joined'))
} else { } else {
const [, on] = this.room.makeAction('MESSAGE') const [, on] = this.room.makeAction('MESSAGE')
on((message) => callback(message as T)) on((message) => callback(message as any as RoomMessage))
} }
}) })
} else { } else {
const [, on] = this.room.makeAction('MESSAGE') const [, on] = this.room.makeAction('MESSAGE')
on((message) => callback(message as T)) on((message) => callback(message as any as RoomMessage))
} }
return this return this
} }

View file

@ -1,9 +1,10 @@
import { Artico, Room } from '@rtco/client' import { Artico, Room } from '@rtco/client'
import { PeerRoomExtern, type PeerMessage } from '@/domain/externs/PeerRoom' import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import { stringToHex } from '@/utils' import { stringToHex } from '@/utils'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import EventHub from '@resreq/event-hub' import EventHub from '@resreq/event-hub'
import { RoomMessage } from '../Room'
export interface Config { export interface Config {
peerId?: string peerId?: string
roomId: string roomId: string
@ -43,7 +44,7 @@ class PeerRoom extends EventHub {
return this return this
} }
sendMessage<T extends PeerMessage>(message: T, id?: string) { sendMessage(message: RoomMessage, id?: string) {
if (!this.room) { if (!this.room) {
this.once('action', () => { this.once('action', () => {
if (!this.room) { if (!this.room) {
@ -58,17 +59,17 @@ class PeerRoom extends EventHub {
return this return this
} }
onMessage<T extends PeerMessage>(callback: (message: T) => void) { onMessage(callback: (message: RoomMessage) => void) {
if (!this.room) { if (!this.room) {
this.once('action', () => { this.once('action', () => {
if (!this.room) { if (!this.room) {
this.emit('error', new Error('Room not joined')) this.emit('error', new Error('Room not joined'))
} else { } else {
this.room.on('message', (message) => callback(JSON.parse(message) as T)) this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage))
} }
}) })
} else { } else {
this.room.on('message', (message) => callback(JSON.parse(message) as T)) this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage))
} }
return this return this
} }

View file

@ -3,6 +3,8 @@ import indexedDbDriver from 'unstorage/drivers/indexedb'
import { IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage' import { IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
import { STORAGE_NAME } from '@/constants/config' import { STORAGE_NAME } from '@/constants/config'
import { webExtensionDriver } from '@/utils/webExtensionDriver' import { webExtensionDriver } from '@/utils/webExtensionDriver'
import { browser } from 'wxt/browser'
import { Storage } from '@/domain/externs/Storage'
export const indexDBStorage = createStorage({ export const indexDBStorage = createStorage({
driver: indexedDbDriver({ base: `${STORAGE_NAME}:` }) driver: indexedDbDriver({ base: `${STORAGE_NAME}:` })
@ -18,7 +20,7 @@ export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
set: indexDBStorage.setItem, set: indexDBStorage.setItem,
remove: indexDBStorage.removeItem, remove: indexDBStorage.removeItem,
clear: indexDBStorage.clear, clear: indexDBStorage.clear,
watch: indexDBStorage.watch, watch: indexDBStorage.watch as Storage['watch'],
unwatch: indexDBStorage.unwatch unwatch: indexDBStorage.unwatch
}) })
@ -28,6 +30,26 @@ export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
set: browserSyncStorage.setItem, set: browserSyncStorage.setItem,
remove: browserSyncStorage.removeItem, remove: browserSyncStorage.removeItem,
clear: browserSyncStorage.clear, clear: browserSyncStorage.clear,
watch: browserSyncStorage.watch, watch: browserSyncStorage.watch as Storage['watch'],
unwatch: browserSyncStorage.unwatch unwatch: browserSyncStorage.unwatch
}) })
// export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
// name: STORAGE_NAME,
// get: async (key: string) => {
// const res = await browser.storage.sync.get(key)
// return res[key] ?? null
// },
// set: async (key, value) => {
// await browser.storage.sync.set({ [key]: value ?? null })
// },
// remove: browserSyncStorage.removeItem,
// clear: browserSyncStorage.clear,
// watch: async (callback) => {
// browser.storage.sync.onChanged.addListener(callback)
// return async () => {
// return browser.storage.sync.onChanged.removeListener(callback)
// }
// },
// unwatch: browserSyncStorage.unwatch
// })

View file

@ -1,18 +1,7 @@
import { type RemeshEvent, type RemeshAction, type RemeshDomainContext, type RemeshExtern } from 'remesh' import { type RemeshEvent, type RemeshAction, type RemeshDomainContext, type RemeshExtern } from 'remesh'
import { defer, from, fromEventPattern, map, Observable, switchMap } from 'rxjs' import { defer, from, map, Observable, switchMap } from 'rxjs'
import { type Promisable } from 'type-fest'
export type StorageValue = null | string | number | boolean | object import { Storage, StorageValue } from '@/domain/externs/Storage'
export type WatchEvent = 'update' | 'remove'
export type WatchCallback = (event: WatchEvent, key: string) => any
export type Unwatch = () => Promisable<void>
export interface Storage {
get: <T extends StorageValue>(key: string) => Promise<T | null>
set: <T extends StorageValue>(key: string, value: T) => Promise<void>
watch: (callback: WatchCallback) => Promise<Unwatch>
unwatch?: Unwatch
}
export interface Options { export interface Options {
domain: RemeshDomainContext domain: RemeshDomainContext
@ -60,20 +49,14 @@ export default class StorageEffect {
this.domain.effect({ this.domain.effect({
name: 'WatchStorageToStateEffect', name: 'WatchStorageToStateEffect',
impl: () => { impl: () => {
return defer(() => { // TODO: Report the bug to https://github.com/unjs/unstorage
let unwatch: Unwatch return new Observable((observer) => {
return new Observable<void>((observer) => { const unwatchPromise = this.storage.watch(() => observer.next())
this.storage return () => unwatchPromise.then((unwatch) => unwatch())
.watch(() => observer.next()) }).pipe(
.then((_unwatch) => { switchMap(() => from(this.storage.get<T | null>(this.key))),
unwatch = _unwatch map(callback)
}) )
return () => unwatch?.()
}).pipe(
switchMap(() => from(this.storage.get<T | null>(this.key))),
map(callback)
)
})
} }
}) })
return this return this

View file

@ -1,5 +1,5 @@
import { type Driver, type WatchCallback, defineDriver } from 'unstorage' import { type Driver, type WatchCallback, defineDriver } from 'unstorage'
import browser, { type Storage as BrowserStorage } from 'webextension-polyfill' import { browser, type Storage as BrowserStorage } from 'wxt/browser'
export interface WebExtensionDriverOptions { export interface WebExtensionDriverOptions {
storageArea: 'sync' | 'local' | 'managed' | 'session' storageArea: 'sync' | 'local' | 'managed' | 'session'
@ -67,6 +67,7 @@ export const webExtensionDriver: (opts: WebExtensionDriverOptions) => Driver = d
watch(callback) { watch(callback) {
checkPermission() checkPermission()
_listeners.add(callback) _listeners.add(callback)
if (_listeners.size === 1) { if (_listeners.size === 1) {
browser.storage[opts.storageArea].onChanged.addListener(_storageListener) browser.storage[opts.storageArea].onChanged.addListener(_storageListener)
} }

View file

@ -11,7 +11,7 @@ export default defineConfig({
runner: { runner: {
startUrls: ['https://www.example.com/'] startUrls: ['https://www.example.com/']
}, },
manifest: ({ browser, manifestVersion }) => { manifest: ({ browser }) => {
const common = { const common = {
name: displayName, name: displayName,
permissions: ['storage'], permissions: ['storage'],