= {
9 | condition: boolean;
10 | children: React.ReactNode;
11 | as: ComponentType
;
12 | } & P;
13 |
14 | /**
15 | * Wraps the children with a specified component if a condition is met, otherwise returns the children as is.
16 | *
17 | * @template P - The props type of the component.
18 | * @param {Object} props - The component props.
19 | * @param {boolean} props.condition - The condition to check.
20 | * @param {React.ReactNode} props.children - The children to wrap.
21 | * @param {React.ComponentType
} props.as - The component to wrap the children with.
22 | * @param {P} props.rest - The rest of the props to pass to the wrapped component.
23 | * @returns {JSX.Element} - The wrapped or unwrapped children.
24 | *
25 | * @example
26 | * // Wraps the children with a
component if the condition is true.
27 | *
28 | * Hello, world!
29 | *
30 | *
31 | * @example
32 | * // Returns the children as is if the condition is false.
33 | *
34 | * Hello, world!
35 | *
36 | */
37 | function ConditionalWrapper
({ condition, children, as: Component, ...rest }: ConditionWrapperProps
): JSX.Element {
38 | if (condition) {
39 | return {children}
40 | } else {
41 | return <>{children}>
42 | }
43 | }
44 |
45 |
46 | export default ConditionalWrapper
--------------------------------------------------------------------------------
/src/components/ShadowRoot.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react"
2 | import ReactShadowRoot from "react-shadow-root"
3 | import ShadowRootContext from "~contexts/ShadowRootContext"
4 |
5 | /**
6 | * Props for the ShadowRoot component.
7 | */
8 | export type ShadowRootProps = {
9 | /**
10 | * The children of the ShadowRoot component.
11 | */
12 | children: React.ReactNode
13 | /**
14 | * An array of styles to be applied to the ShadowRoot component.
15 | */
16 | styles: string[]
17 | /**
18 | * Specifies whether the ShadowRoot component should have a line wrap or not.
19 | */
20 | noWrap?: boolean
21 | }
22 |
23 | /**
24 | * Renders a component that creates a shadow root and provides it as a context value.
25 | *
26 | * @component
27 | * @example
28 | * // Example usage of ShadowRoot component
29 | * function App() {
30 | * const styles = ['body { background-color: lightgray }']
31 | * return (
32 | *
33 | *
App
34 | *
35 | * ShadowRoot Content
36 | *
37 | *
38 | * )
39 | * }
40 | *
41 | * @param {Object} props - The component props.
42 | * @param {ReactNode} props.children - The content to be rendered inside the shadow root.
43 | * @param {string[]} props.styles - An array of CSS styles to be applied to the shadow root.
44 | * @returns {JSX.Element} The rendered ShadowRoot component.
45 | */
46 | function ShadowRoot({ children, styles, noWrap = false }: ShadowRootProps): JSX.Element {
47 | const reactShadowRoot = useRef
(null)
48 | const [shadowRoot, setShadowRoot] = useState(null)
49 |
50 | useEffect(() => {
51 | if (reactShadowRoot.current) {
52 | setShadowRoot(reactShadowRoot.current.shadowRoot)
53 | console.debug("ShadowRoot created")
54 | }
55 | }, [])
56 |
57 | const child = shadowRoot && (
58 |
59 | {children}
60 |
61 | )
62 |
63 | return (
64 |
65 | {styles?.map((style, i) => (
66 |
67 | ))}
68 | {noWrap ? child : (
69 | {child}
70 | )}
71 |
72 | )
73 | }
74 |
75 | export default ShadowRoot
76 |
--------------------------------------------------------------------------------
/src/components/ShadowStyle.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect, useRef, useState, type RefObject } from "react";
2 | import { createPortal } from "react-dom";
3 | import ShadowRootContext from "~contexts/ShadowRootContext";
4 |
5 |
6 |
7 | /**
8 | * promote a style element to the root level of ShadowRoot.
9 | * @param {Object} props - The component props.
10 | * @param {React.ReactNode} props.children - The children to be promoted inside the style element.
11 | * @returns {JSX.Element} - The rendered style element.
12 | *
13 | * @example
14 | * import { ShadowStyle } from "~components/ShadowStyle";
15 | *
16 | * function MyComponent() {
17 | * return (
18 | * // The style will be promoted to the root level of the ShadowRoot.
19 | *
20 | * {`
21 | * .my-class {
22 | * color: red;
23 | * }
24 | * `}
25 | *
26 | * )
27 | * }
28 | */
29 | function ShadowStyle({ children }: { children: React.ReactNode }): JSX.Element {
30 |
31 | const host = useContext(ShadowRootContext)
32 |
33 | if (!host) {
34 | console.warn('No ShadowRoot found: ShadowStyle must be used inside a ShadowRoot')
35 | }
36 |
37 | return host ? createPortal(, host) : <>>
38 |
39 | }
40 |
41 |
42 | export default ShadowStyle
--------------------------------------------------------------------------------
/src/components/TailwindScope.tsx:
--------------------------------------------------------------------------------
1 | import styleText from 'data-text:~style.css';
2 | import ShadowRoot from '~components/ShadowRoot';
3 |
4 | /**
5 | * Renders a component that applies a Tailwind CSS scope to its children.
6 | *
7 | * @param {Object} props - The component props.
8 | * @param {React.ReactNode} props.children - The children to be rendered within the Tailwind scope.
9 | * @param {boolean} [props.dark] - Optional. Specifies whether the dark mode should be applied.
10 | * @returns {JSX.Element} The rendered TailwindScope component.
11 | */
12 | function TailwindScope({ children, dark, noWrap = false }: { children: React.ReactNode, dark?: boolean, noWrap?: boolean }): JSX.Element {
13 | return (
14 |
15 |
16 | {children}
17 |
18 |
19 | )
20 | }
21 |
22 | export default TailwindScope
--------------------------------------------------------------------------------
/src/contents/index/components/ButtonList.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@material-tailwind/react"
2 | import { useCallback, useContext } from "react"
3 | import { sendForward } from "~background/forwards"
4 | import ContentContext from "~contexts/ContentContexts"
5 | import { usePopupWindow } from "~hooks/window"
6 | import { sendMessager } from "~utils/messaging"
7 |
8 |
9 | function ButtonList(): JSX.Element {
10 |
11 | const streamInfo = useContext(ContentContext)
12 |
13 | const { settings, info } = streamInfo
14 | const { "settings.display": displaySettings, "settings.features": { common: { enabledPip, monitorWindow }} } = settings
15 |
16 | const { createPopupWindow } = usePopupWindow(enabledPip, {
17 | width: 700,
18 | height: 450
19 | })
20 |
21 | const restart = useCallback(() => sendForward('background', 'redirect', { target: 'content-script', command: 'command', body: { command: 'restart' }, queryInfo: { url: '*://live.bilibili.com/*' } }), [])
22 | const addBlackList = () => confirm(`确定添加房间 ${info.room}${info.room === info.shortRoom ? '' : `(${info.shortRoom})`} 到黑名单?`) && sendMessager('add-black-list', { roomId: info.room })
23 | const openSettings = useCallback(() => sendMessager('open-options'), [])
24 | const openMonitor = createPopupWindow(`stream.html`, {
25 | roomId: info.room,
26 | title: info.title,
27 | owner: info.username,
28 | muted: enabledPip.toString() //in iframe, only muted video can autoplay
29 | })
30 |
31 | return (
32 |
33 | {displaySettings.blackListButton &&
34 | 添加到黑名单 }
35 | {displaySettings.settingsButton &&
36 | 进入设置 }
37 | {displaySettings.restartButton &&
38 | 重新启动 }
39 | {monitorWindow &&
40 | 弹出直播视窗 }
41 | {(info.isTheme && displaySettings.themeToNormalButton) &&
42 | window.open(`https://live.bilibili.com/blanc/${info.room}`)}>返回非海报界面
43 | }
44 |
45 | )
46 | }
47 |
48 | export default ButtonList
--------------------------------------------------------------------------------
/src/contents/index/components/FloatingMenuButtion.tsx:
--------------------------------------------------------------------------------
1 | import extIcon from 'raw:assets/icon.png';
2 |
3 |
4 | function FloatingMenuButton({ toggle }: { toggle: VoidFunction }) {
5 | return (
6 |
7 |
8 |
9 | 功能菜单
10 |
11 |
12 | )
13 | }
14 |
15 |
16 | export default FloatingMenuButton
--------------------------------------------------------------------------------
/src/contents/index/components/FooterButton.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton, Tooltip } from "@material-tailwind/react";
2 |
3 | function FooterButton({ children, title, onClick }: { children: React.ReactNode, title: string, onClick?: VoidFunction }): JSX.Element {
4 | return (
5 |
6 |
7 | {children}
8 |
9 |
10 | )
11 | }
12 |
13 | export default FooterButton
--------------------------------------------------------------------------------
/src/contents/index/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { Typography, IconButton } from "@material-tailwind/react"
2 | import { useContext } from "react"
3 | import ContentContext from "~contexts/ContentContexts"
4 |
5 |
6 | function Header({ closeDrawer }: { closeDrawer: VoidFunction }): JSX.Element {
7 |
8 | const { info } = useContext(ContentContext)
9 |
10 | return (
11 |
12 |
13 |
14 | {info.title}
15 |
16 |
17 | {info.username} 的直播间
18 |
19 |
20 |
21 |
29 |
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | export default Header
--------------------------------------------------------------------------------
/src/contexts/BLiveThemeDarkContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import type { UseState } from "~types/common";
3 |
4 | const BJFThemeDarkContext = createContext>(null)
5 |
6 | export default BJFThemeDarkContext
--------------------------------------------------------------------------------
/src/contexts/ContentContexts.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import type { StreamInfo } from "~api/bilibili";
3 | import type { Settings } from "~options/fragments";
4 |
5 | export type ContentContextProps = {
6 | info: StreamInfo
7 | settings: Settings
8 | }
9 |
10 | const ContentContext = createContext(null)
11 |
12 | export default ContentContext
--------------------------------------------------------------------------------
/src/contexts/GenericContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 |
3 | const GenericContext = createContext(null)
4 |
5 | export default GenericContext
--------------------------------------------------------------------------------
/src/contexts/JimakuFeatureContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import { type FeatureSettingSchema as FeatureJimakuSchema } from "~options/features/jimaku";
3 |
4 | const JimakuFeatureContext = createContext(null)
5 |
6 | export default JimakuFeatureContext
--------------------------------------------------------------------------------
/src/contexts/RecorderFeatureContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import type { FeatureSettingSchema as RecorderFeatureSchema } from "~options/features/recorder";
3 |
4 | const RecorderFeatureContext = createContext(null)
5 |
6 | export default RecorderFeatureContext
--------------------------------------------------------------------------------
/src/contexts/ShadowRootContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 |
3 |
4 | const ShadowRootContext = createContext(null)
5 |
6 | export default ShadowRootContext
--------------------------------------------------------------------------------
/src/contexts/SuperChatFeatureContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import type { FeatureSettingSchema as SuperChatFeatureSchema } from "~options/features/superchat";
3 |
4 | const SuperChatFeatureContext = createContext(null)
5 |
6 | export default SuperChatFeatureContext
--------------------------------------------------------------------------------
/src/database/index.ts:
--------------------------------------------------------------------------------
1 | import Dexie, { type Table } from 'dexie'
2 |
3 | import migrate from './migrations'
4 |
5 | export interface CommonSchema {
6 | id?: number
7 | date: string
8 | room: string
9 | }
10 |
11 | export const commonSchema = "++id, date, room, "
12 |
13 | export type TableType = { [K in keyof IndexedDatabase]: IndexedDatabase[K] extends Table ? K : never }[keyof IndexedDatabase]
14 |
15 | export type RecordType = IndexedDatabase[T] extends Table ? R : never
16 |
17 | export class IndexedDatabase extends Dexie {
18 | public constructor() {
19 | super("bilibili-vup-stream-enhancer")
20 | migrate(this)
21 | }
22 | }
23 |
24 | const db = new IndexedDatabase()
25 |
26 | export default db
--------------------------------------------------------------------------------
/src/database/migrations.ts:
--------------------------------------------------------------------------------
1 | import type Dexie from "dexie"
2 | import { commonSchema } from '~database'
3 |
4 | export default function (db: Dexie) {
5 |
6 | // version 1
7 | db.version(1).stores({
8 | superchats: commonSchema + "text, scId, backgroundColor, backgroundImage, backgroundHeaderColor, userIcon, nameColor, uid, uname, price, message, hash, timestamp",
9 | jimakus: commonSchema + "text, uid, uname, hash"
10 | })
11 |
12 | // version 2
13 | db.version(2).stores({
14 | streams: commonSchema + "content, order"
15 | })
16 |
17 | }
--------------------------------------------------------------------------------
/src/database/tables/jimaku.d.ts:
--------------------------------------------------------------------------------
1 | import { Table } from 'dexie'
2 | import { CommonSchema } from '~database'
3 |
4 | declare module '~database' {
5 | interface IndexedDatabase {
6 | jimakus: Table
7 | }
8 | }
9 |
10 | interface Jimaku extends CommonSchema {
11 | text: string
12 | uid: number
13 | uname: string
14 | hash: string
15 | }
16 |
--------------------------------------------------------------------------------
/src/database/tables/stream.d.ts:
--------------------------------------------------------------------------------
1 | import { Table } from 'dexie'
2 | import { CommonSchema } from '~database'
3 |
4 | declare module '~database' {
5 | interface IndexedDatabase {
6 | streams: Table
7 | }
8 | }
9 |
10 | interface Stream extends CommonSchema {
11 | content: Blob
12 | order: number
13 | }
--------------------------------------------------------------------------------
/src/database/tables/superchat.d.ts:
--------------------------------------------------------------------------------
1 | import { Table } from 'dexie'
2 | import { CommonSchema } from '~database'
3 |
4 | declare module '~database' {
5 | interface IndexedDatabase {
6 | superchats: Table
7 | }
8 | }
9 |
10 | interface Superchat extends CommonSchema {
11 | scId: number
12 | backgroundColor: string
13 | backgroundImage: string
14 | backgroundHeaderColor: string
15 | userIcon: string
16 | nameColor: string
17 | uid: number
18 | uname: string
19 | price: number
20 | message: string
21 | hash: string
22 | timestamp: number
23 | date: string
24 | }
25 |
--------------------------------------------------------------------------------
/src/features/index.ts:
--------------------------------------------------------------------------------
1 | import * as jimaku from './jimaku'
2 | import * as superchat from './superchat'
3 | import * as recorder from './recorder'
4 |
5 | import type { StreamInfo } from '~api/bilibili'
6 | import type { Settings } from '~options/fragments'
7 |
8 | export type FeatureHookRender = (settings: Readonly, info: StreamInfo) => Promise<(React.ReactPortal | React.ReactNode)[] | string | undefined>
9 |
10 | export type FeatureAppRender = React.FC<{}>
11 |
12 | export type FeatureType = keyof typeof features
13 |
14 | export interface FeatureHandler {
15 | default: FeatureHookRender,
16 | App?: FeatureAppRender,
17 | FeatureContext?: React.Context
18 | }
19 |
20 | const features = {
21 | jimaku,
22 | superchat,
23 | recorder
24 | }
25 |
26 |
27 | export default (features as Record)
--------------------------------------------------------------------------------
/src/features/jimaku/components/ButtonSwitchList.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from "react"
2 |
3 |
4 | export type ButtonSwitchListProps = {
5 | onClick: VoidFunction
6 | switched: boolean
7 | }
8 |
9 | function ButtonSwitchList(props: ButtonSwitchListProps): JSX.Element {
10 |
11 | const { onClick, switched } = props
12 |
13 | return (
14 |
15 |
23 | 切换字幕按钮列表
24 |
25 |
26 | )
27 | }
28 |
29 |
30 | export default ButtonSwitchList
--------------------------------------------------------------------------------
/src/features/jimaku/components/JimakuAreaSkeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, useContext } from "react";
2 | import JimakuFeatureContext from "~contexts/JimakuFeatureContext";
3 |
4 | function JimakuAreaSkeleton(): JSX.Element {
5 |
6 | const { jimakuZone: jimakuSettings, buttonZone: buttonSettings } = useContext(JimakuFeatureContext)
7 | const { backgroundHeight, backgroundColor, color, firstLineSize, lineGap } = jimakuSettings
8 | const { backgroundListColor } = buttonSettings
9 |
10 | return (
11 |
12 |
13 |
字幕加载中...
14 |
15 |
16 | {...Array(3).fill(0).map((_, i) => {
17 | // make random skeleton width
18 | const width = [120, 160, 130][i]
19 | return (
20 |
21 |
22 |
23 | )
24 | })}
25 |
26 |
27 | )
28 | }
29 |
30 | export default JimakuAreaSkeleton
--------------------------------------------------------------------------------
/src/features/jimaku/components/JimakuAreaSkeletonError.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, useContext } from "react"
2 | import JimakuFeatureContext from "~contexts/JimakuFeatureContext"
3 |
4 | export type JimakuAreaSkeletonErrorProps = {
5 | error: Error | any
6 | retry: VoidFunction
7 | }
8 |
9 | function JimakuAreaSkeletonError({ error, retry }: JimakuAreaSkeletonErrorProps): JSX.Element {
10 |
11 | const { jimakuZone: jimakuSettings, buttonZone: buttonSettings } = useContext(JimakuFeatureContext)
12 | const { backgroundHeight, backgroundColor, firstLineSize, lineGap, size } = jimakuSettings
13 | const { backgroundListColor } = buttonSettings
14 |
15 | return (
16 |
17 |
18 |
加载失败
19 | {String(error)}
20 |
21 |
22 |
23 | 重试
24 |
25 |
26 |
27 | )
28 | }
29 |
30 | export default JimakuAreaSkeletonError
--------------------------------------------------------------------------------
/src/features/jimaku/components/JimakuButton.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, type MouseEventHandler } from 'react';
2 | import JimakuFeatureContext from '~contexts/JimakuFeatureContext';
3 |
4 | export type JimakuButtonProps = {
5 | onClick?: MouseEventHandler,
6 | children: React.ReactNode
7 | }
8 |
9 | function JimakuButton({ onClick, children }: JimakuButtonProps): JSX.Element {
10 |
11 | const { buttonZone: btnStyle } = useContext(JimakuFeatureContext)
12 |
13 | return (
14 |
21 | {children}
22 |
23 | )
24 | }
25 |
26 | export default JimakuButton
--------------------------------------------------------------------------------
/src/features/jimaku/components/JimakuLine.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { useRowOptimizer } from '~hooks/optimizer';
3 |
4 | // here must be a subset of database Jimaku schema
5 | export type Jimaku = {
6 | date: string
7 | text: string
8 | uid: number
9 | uname: string
10 | hash: string
11 | }
12 |
13 | export type JimakuLineProps = {
14 | item: Jimaku
15 | show: (e: React.MouseEvent) => void
16 | index: number
17 | observer: React.MutableRefObject
18 | }
19 |
20 |
21 | function JimakuLine({ item, show, index, observer }: JimakuLineProps): JSX.Element {
22 |
23 | const ref = useRowOptimizer(observer)
24 |
25 | return (
26 |
27 | {item.text}
28 |
29 | )
30 | }
31 |
32 | export default memo(JimakuLine)
33 |
--------------------------------------------------------------------------------
/src/features/recorder/components/ProgressText.tsx:
--------------------------------------------------------------------------------
1 | import type { ProgressEvent } from "@ffmpeg/ffmpeg/dist/esm/types"
2 | import { Spinner, Progress } from "@material-tailwind/react"
3 | import { useState } from "react"
4 | import TailwindScope from "~components/TailwindScope"
5 | import type { FFMpegHooks } from "~hooks/ffmpeg"
6 | import { useAsyncEffect } from "~hooks/life-cycle"
7 |
8 |
9 | function ProgressText({ ffmpeg }: { ffmpeg: Promise }) {
10 |
11 | const [progress, setProgress] = useState(null)
12 |
13 | useAsyncEffect(
14 | async () => {
15 | const ff = await ffmpeg
16 | ff.onProgress(setProgress)
17 | },
18 | async () => { },
19 | (err) => {
20 | console.error('unexpected: ', err)
21 | },
22 | [ffmpeg])
23 |
24 | if (!progress) {
25 | return `编译视频中...`
26 | }
27 |
28 | const progressValid = progress.progress > 0 && progress.progress <= 1
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | {`编译视频中... ${progressValid ? `(${Math.round(progress.progress * 10000) / 100}%)` : ''}`}
39 |
40 |
41 | {progressValid &&
}
42 |
43 |
44 | )
45 |
46 | }
47 |
48 | export default ProgressText
--------------------------------------------------------------------------------
/src/features/recorder/index.tsx:
--------------------------------------------------------------------------------
1 | import RecorderFeatureContext from "~contexts/RecorderFeatureContext";
2 | import type { FeatureHookRender } from "~features";
3 | import { sendMessager } from "~utils/messaging";
4 | import RecorderLayer from "./components/RecorderLayer";
5 |
6 | export const FeatureContext = RecorderFeatureContext
7 |
8 | const handler: FeatureHookRender = async (settings, info) => {
9 |
10 | const { error, data: urls } = await sendMessager('get-stream-urls', { roomId: info.room })
11 | if (error) {
12 | console.warn('啟用快速切片功能失敗: ', error)
13 | return '啟用快速切片功能失敗: '+ error // 返回 string 以顯示錯誤
14 | }
15 |
16 | return [
17 |
18 | ]
19 | }
20 |
21 |
22 |
23 |
24 | export default handler
--------------------------------------------------------------------------------
/src/features/recorder/recorders/buffer.ts:
--------------------------------------------------------------------------------
1 | import { recordStream, type PlayerOptions, type VideoInfo } from "~players";
2 | import type { StreamPlayer } from "~types/media";
3 | import { Recorder } from "~types/media";
4 | import { toArrayBuffer } from "~utils/binary";
5 | import { type ChunkData } from ".";
6 |
7 | class BufferRecorder extends Recorder {
8 |
9 | private player: StreamPlayer = null
10 | private info: VideoInfo = null
11 |
12 | async start(): Promise {
13 | let i = 0
14 | this.player = await recordStream(this.urls, (buffer) => this.onBufferArrived(++i, buffer), this.options)
15 | this.appendBufferChecker()
16 | this.info = this.player.videoInfo
17 | }
18 |
19 | private async onBufferArrived(order: number, buffer: ArrayBufferLike): Promise {
20 | try {
21 | const ab = toArrayBuffer(buffer)
22 | const blob = new Blob([ab], { type: 'application/octet-stream' })
23 | return this.saveChunk(blob, order)
24 | }catch(err){
25 | console.error('failed to save chunk: ', err)
26 | throw err
27 | }
28 | }
29 |
30 | async loadChunkData(flush: boolean = true): Promise {
31 | const chunks = await this.loadChunks(flush)
32 | return {
33 | chunks,
34 | info: this.info
35 | }
36 | }
37 |
38 | stop(): void {
39 | clearInterval(this.bufferAppendChecker)
40 | this.player?.stopAndDestroy()
41 | this.player = null
42 | }
43 |
44 | get recording(): boolean {
45 | return !!this.player
46 | }
47 |
48 | set onerror(handler: (error: Error) => void) {
49 | if (!this.player) return
50 | if (this.errorHandler) this.player.off('error', this.errorHandler)
51 | this.player.on('error', handler)
52 | this.errorHandler = handler
53 | }
54 |
55 | }
56 |
57 | export default BufferRecorder
--------------------------------------------------------------------------------
/src/features/recorder/recorders/index.ts:
--------------------------------------------------------------------------------
1 | import type { StreamUrls } from "~background/messages/get-stream-urls"
2 | import type { PlayerOptions, VideoInfo } from "~players"
3 | import { Recorder } from "~types/media"
4 | import buffer from "./buffer"
5 | import capture, { type CaptureOptions } from "./capture"
6 |
7 | export type ChunkData = {
8 | chunks: Blob[]
9 | info: VideoInfo
10 | }
11 |
12 | export type RecorderType = keyof typeof recorders
13 |
14 | export type RecorderPayload = {
15 | buffer: PlayerOptions
16 | capture: CaptureOptions
17 | }
18 |
19 | const recorders = {
20 | buffer,
21 | capture,
22 | }
23 |
24 | function createRecorder(room: string, urls: StreamUrls, type: T, options: RecorderPayload[T]): Recorder {
25 | const Recorder = recorders[type]
26 | if (!Recorder) {
27 | throw new Error('unsupported recorder type: ' + type)
28 | }
29 | return new Recorder(room, urls, options)
30 | }
31 |
32 | export default createRecorder
--------------------------------------------------------------------------------
/src/features/superchat/components/SuperChatArea.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useRef } from "react"
2 | import ContentContext from "~contexts/ContentContexts"
3 | import { useScrollOptimizer } from "~hooks/optimizer"
4 | import { useRecords } from "~hooks/records"
5 | import SuperChatItem, { type SuperChatCard } from "./SuperChatItem"
6 | import SuperChatFeatureContext from "~contexts/SuperChatFeatureContext"
7 | import BJFThemeDarkContext from "~contexts/BLiveThemeDarkContext"
8 |
9 |
10 | export type SuperChatAreaProps = {
11 | superchats: SuperChatCard[],
12 | clearSuperChat: VoidFunction
13 | }
14 |
15 | function SuperChatArea(props: SuperChatAreaProps): JSX.Element {
16 |
17 | const [ themeDark ] = useContext(BJFThemeDarkContext)
18 | const { settings, info } = useContext(ContentContext)
19 | const { buttonColor } = useContext(SuperChatFeatureContext)
20 | const { superchats, clearSuperChat } = props
21 | const { enabledRecording } = settings['settings.features']
22 |
23 | const listRef = useRef(null)
24 |
25 | const observer = useScrollOptimizer({ root: listRef, rootMargin: '100px', threshold: 0.13 })
26 |
27 | const { downloadRecords, deleteRecords } = useRecords(info.room, superchats, {
28 | feature: 'superchat',
29 | table: enabledRecording.includes('superchat') ? 'superchats' : undefined,
30 | description: '醒目留言',
31 | format: (superchat) => `[${superchat.date}] [¥${superchat.price}] ${superchat.uname}(${superchat.uid}): ${superchat.message}`,
32 | clearRecords: clearSuperChat,
33 | reverse: true // superchat always revsered
34 | })
35 |
36 | return (
37 |
38 |
39 |
40 | 导出醒目留言记录
41 |
42 |
43 | 刪除所有醒目留言记录
44 |
45 |
46 |
47 |
48 | {superchats.map((item) => (
49 |
50 |
51 |
52 | ))}
53 |
54 |
55 | )
56 |
57 |
58 | }
59 |
60 |
61 |
62 | export default SuperChatArea
--------------------------------------------------------------------------------
/src/features/superchat/components/SuperChatButtonSkeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Spinner } from "@material-tailwind/react"
2 |
3 | function SuperChatButtonSkeleton(): JSX.Element {
4 | return (
5 |
16 | )
17 | }
18 |
19 | export default SuperChatButtonSkeleton
--------------------------------------------------------------------------------
/src/features/superchat/components/SuperChatFloatingButton.tsx:
--------------------------------------------------------------------------------
1 | import { Item, Menu, useContextMenu } from 'react-contexify';
2 | import styleText from 'data-text:react-contexify/dist/ReactContexify.css';
3 | import { Fragment, useContext } from 'react';
4 | import DraggableFloatingButton from '~components/DraggableFloatingButton';
5 | import BJFThemeDarkContext from '~contexts/BLiveThemeDarkContext';
6 | import SuperChatFeatureContext from '~contexts/SuperChatFeatureContext';
7 |
8 | export type SuperChatFloatingButtonProps = {
9 | children: React.ReactNode
10 | }
11 |
12 | function SuperChatFloatingButton({ children }: SuperChatFloatingButtonProps): JSX.Element {
13 |
14 | const [themeDark] = useContext(BJFThemeDarkContext)
15 | const { floatingButtonColor } = useContext(SuperChatFeatureContext)
16 |
17 | const { show } = useContextMenu({
18 | id: 'superchat-menu'
19 | })
20 |
21 | return (
22 |
23 |
24 | show({ event: e })} className='hover:brightness-90 duration-150 dark:bg-gray-700 dark:hover:bg-gray-800 text-white'>
25 |
30 | 醒目留言
31 |
32 | e.preventDefault()} id="superchat-menu" style={{ backgroundColor: '#f1f1f1', overscrollBehaviorY: 'none' }}>
33 | - {''}
34 | {children}
35 |
36 |
37 | )
38 | }
39 |
40 | export default SuperChatFloatingButton
--------------------------------------------------------------------------------
/src/features/superchat/index.tsx:
--------------------------------------------------------------------------------
1 | import type { FeatureHookRender } from "..";
2 |
3 |
4 | import { getSuperChatList } from "~api/bilibili";
5 | import OfflineRecordsProvider from "~components/OfflineRecordsProvider";
6 | import SuperChatFeatureContext from "~contexts/SuperChatFeatureContext";
7 | import { randomString, toStreamingTime, toTimer } from "~utils/misc";
8 | import SuperChatButtonSkeleton from "./components/SuperChatButtonSkeleton";
9 | import SuperChatCaptureLayer from "./components/SuperChatCaptureLayer";
10 | import { type SuperChatCard } from "./components/SuperChatItem";
11 |
12 |
13 | export const FeatureContext = SuperChatFeatureContext
14 |
15 | const handler: FeatureHookRender = async (settings, info) => {
16 |
17 | const { common: { useStreamingTime }, enabledRecording } = settings['settings.features']
18 |
19 | const list = await getSuperChatList(info.room)
20 | const superchats: SuperChatCard[] = (list ?? [])
21 | .sort((a, b) => b.start_time - a.start_time)
22 | .map((item) => ({
23 | id: item.id,
24 | backgroundColor: item.background_bottom_color,
25 | backgroundImage: item.background_image,
26 | backgroundHeaderColor: item.background_color,
27 | userIcon: item.user_info.face,
28 | nameColor: '#646c7a',
29 | uid: item.uid,
30 | uname: item.user_info.uname,
31 | price: item.price,
32 | message: item.message,
33 | timestamp: item.start_time,
34 | date: useStreamingTime ? toTimer(item.start_time - info.liveTime) : toStreamingTime(item.start_time),
35 | hash: `${randomString()}${item.id}`,
36 | persist: false
37 | }))
38 |
39 | return [
40 | superchats.every(s => s.id !== superchat.scId)}
47 | sortBy="timestamp"
48 | reverse={true}
49 | loading={ }
50 | error={(err) => <>>}
51 | >
52 | {(records) => {
53 | const offlineRecords = [...superchats, ...records.map((r) => ({ ...r, id: r.scId, persist: true }))]
54 | return (info.status === 'online' || (enabledRecording.includes('superchat') && offlineRecords.length > 0)) &&
55 | }}
56 |
57 | ]
58 | }
59 |
60 |
61 | export default handler
--------------------------------------------------------------------------------
/src/ffmpeg/core.ts:
--------------------------------------------------------------------------------
1 | import type { Cleanup, FFMpegCore } from "~ffmpeg";
2 |
3 | import type { FFmpeg } from "@ffmpeg/ffmpeg";
4 | import { toBlobURL } from "@ffmpeg/util";
5 | import ffmpegWorkerJs from 'url:assets/ffmpeg/worker.js';
6 | import { randomString } from "~utils/misc";
7 |
8 | const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm"
9 |
10 | export class SingleThread implements FFMpegCore {
11 |
12 | private ffmpeg: FFmpeg = null
13 |
14 | async load(ffmpeg: FFmpeg): Promise {
15 | this.ffmpeg = ffmpeg
16 | return ffmpeg.load({
17 | coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "application/javascript"),
18 | wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
19 | classWorkerURL: await toBlobURL(ffmpegWorkerJs, 'text/javascript')
20 | })
21 | }
22 |
23 | async fix(input: string, output: string, prepareCut: boolean): Promise {
24 | if (!this.ffmpeg) throw new Error('FFmpeg not loaded')
25 |
26 | await this.ffmpeg.exec([
27 | '-fflags', '+genpts+igndts',
28 | '-i', input,
29 | '-c', 'copy',
30 | ...(prepareCut ? [] : ['-r', '60']),
31 | output
32 | ])
33 |
34 | return async () => {
35 | await this.ffmpeg.deleteFile(output)
36 | }
37 |
38 | }
39 |
40 | async cut(input: string, output: string, duration: number): Promise {
41 | if (!this.ffmpeg) throw new Error('FFmpeg not loaded')
42 |
43 | const seconds = `${duration * 60}`
44 | const temp = randomString()
45 |
46 | await this.ffmpeg.exec([
47 | '-fflags', '+genpts+igndts',
48 | '-sseof', `-${seconds}`,
49 | '-i', input,
50 | '-r', '60',
51 | '-avoid_negative_ts', 'make_zero',
52 | '-c', 'copy',
53 | temp + output
54 | ])
55 |
56 | await this.ffmpeg.exec([
57 | '-fflags', '+genpts+igndts',
58 | '-i', temp + output,
59 | '-t', seconds,
60 | '-c', 'copy',
61 | output
62 | ])
63 |
64 | return async () => {
65 | await this.ffmpeg.deleteFile(temp + output)
66 | await this.ffmpeg.deleteFile(output)
67 | }
68 | }
69 |
70 | }
71 |
72 | const singleThread = new SingleThread()
73 | export default singleThread
--------------------------------------------------------------------------------
/src/ffmpeg/index.ts:
--------------------------------------------------------------------------------
1 | import { FFmpeg } from "@ffmpeg/ffmpeg"
2 | import { isBackgroundScript } from "~utils/file"
3 | import coreSt from './core'
4 | import coreMt from './core-mt'
5 |
6 | export type Cleanup = () => Promise
7 |
8 | export interface FFMpegCore {
9 |
10 | load(ffmpeg: FFmpeg): Promise
11 |
12 | cut(input: string, output: string, duration: number): Promise
13 | fix(input: string, output: string, prepareCut: boolean): Promise
14 | }
15 |
16 | function getFFMpegCore(): FFMpegCore {
17 | return isBackgroundScript() ? coreMt : coreSt
18 | }
19 |
20 | export default getFFMpegCore
--------------------------------------------------------------------------------
/src/hooks/bilibili.ts:
--------------------------------------------------------------------------------
1 | import { useMutationObserver } from '@react-hooks-library/core'
2 | import { useState } from 'react'
3 | import { type SettingSchema as DeveloperSchema } from '~options/fragments/developer'
4 |
5 | export type WebScreenStatus = 'normal' | 'web-fullscreen' | 'fullscreen'
6 |
7 | /**
8 | * Custom hook that tracks the screen status of a web page.
9 | * @param classes - The CSS classes used to identify different screen statuses.
10 | * @returns The current screen status.
11 | * @example
12 | * // Usage
13 | * const classes = {
14 | * screenWeb: 'web-screen',
15 | * screenFull: 'full-screen'
16 | * };
17 | * const screenStatus = useWebScreenChange(classes);
18 | * console.log(screenStatus); // 'web-fullscreen', 'fullscreen', or 'normal'
19 | */
20 | export function useWebScreenChange(classes: DeveloperSchema['classes']): WebScreenStatus {
21 |
22 | const fetchScreenStatus = (bodyElement: HTMLElement) =>
23 | bodyElement.classList.contains(classes.screenWeb) ?
24 | 'web-fullscreen' :
25 | bodyElement.classList.contains(classes.screenFull) ?
26 | 'fullscreen' :
27 | 'normal'
28 |
29 | const [screenStatus, setScreenStatus] = useState(() => fetchScreenStatus(document.body))
30 |
31 | useMutationObserver(document.body, (mutations: MutationRecord[]) => {
32 | if (mutations[0].type !== 'attributes') return
33 | if (!(mutations[0].target instanceof HTMLElement)) return
34 | const bodyElement = mutations[0].target
35 |
36 | const newStatus: WebScreenStatus = fetchScreenStatus(bodyElement)
37 |
38 | setScreenStatus(newStatus)
39 |
40 | }, { attributes: true, subtree: false, childList: false })
41 |
42 | return screenStatus
43 | }
--------------------------------------------------------------------------------
/src/hooks/force-update.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | /**
4 | * Custom hook that returns a state and a function to force update the component.
5 | * This hook is useful when you want to trigger a re-render of the component
6 | * without changing any of its dependencies.
7 | *
8 | * @returns An array containing the state object and the force update function.
9 | * The state object can be used as a dependency in `useEffect`, `useCallback`,
10 | * `useMemo`, etc.
11 | *
12 | * @example
13 | * ```typescript
14 | * const [forceUpdateState, forceUpdate] = useForceUpdate()
15 | *
16 | * useEffect(() => {
17 | * // This effect will be triggered whenever `forceUpdateState` changes.
18 | * // You can use this to force a re-render of the component.
19 | * }, [forceUpdateState])
20 | *
21 | * const handleClick = useCallback(() => {
22 | * // This callback will be memoized and will only change when `forceUpdateState` changes.
23 | * // You can use this to trigger a re-render of the component.
24 | * forceUpdate()
25 | * }, [forceUpdateState])
26 | *
27 | * const memoizedValue = useMemo(() => {
28 | * // This value will be memoized and will only change when `forceUpdateState` changes.
29 | * // You can use this to trigger a re-render of the component.
30 | * return someExpensiveComputation()
31 | * }, [forceUpdateState])
32 | * ```
33 | */
34 | export function useForceUpdate(): [any, () => void] {
35 | const [deps, setDeps] = useState({})
36 | return [
37 | deps,
38 | () => setDeps({})
39 | ] as const
40 | }
41 |
42 | /**
43 | * Custom hook that returns a function to force re-rendering of a component.
44 | *
45 | * @returns {() => void} The function to force re-rendering.
46 | *
47 | * @example
48 | * // Usage
49 | * const forceRender = useForceRender()
50 | *
51 | * // Call the function to force re-rendering
52 | * forceRender()
53 | */
54 | export function useForceRender(): () => void {
55 | const [, forceUpdate] = useForceUpdate()
56 | return forceUpdate
57 | }
--------------------------------------------------------------------------------
/src/hooks/form.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef } from "react";
2 |
3 | /**
4 | * Custom hook to handle file input selection and processing.
5 | *
6 | * @param onFileChange - Callback function that processes the selected files. It should return a Promise.
7 | * @param onError - Optional callback function to handle errors during file processing.
8 | * @param deps - Dependency array for the `useCallback` hook.
9 | *
10 | * @returns An object containing:
11 | * - `inputRef`: A reference to the file input element.
12 | * - `selectFiles`: A function to trigger the file input dialog and handle file selection.
13 | *
14 | * @example
15 | * ```typescript
16 | * const { inputRef, selectFiles } = useFileInput(
17 | * async (files) => {
18 | * // Process the files
19 | * console.log(files);
20 | * },
21 | * (error) => {
22 | * // Handle the error
23 | * console.error(error);
24 | * }
25 | * );
26 | *
27 | * // To trigger file selection
28 | * selectFiles();
29 | * ```
30 | */
31 | export function useFileInput(onFileChange: (files: FileList) => Promise, onError?: (e: Error | any) => void, deps: any[] = []) {
32 |
33 | const inputRef = useRef()
34 | const selectFiles = useCallback(function (): Promise {
35 | return new Promise((resolve, reject) => {
36 | const finallize = () => {
37 | inputRef.current.removeEventListener('change', listener)
38 | inputRef.current.removeEventListener('cancel', finallize)
39 | inputRef.current.files = null
40 | resolve()
41 | }
42 | const listener = async (e: Event) => {
43 | try {
44 | const files = (e.target as HTMLInputElement).files
45 | if (files.length === 0) return
46 | await onFileChange(files)
47 | } catch (e: Error | any) {
48 | console.error(e)
49 | onError?.(e)
50 | reject(e)
51 | } finally {
52 | finallize()
53 | }
54 | }
55 | inputRef.current.addEventListener('change', listener)
56 | inputRef.current.addEventListener('cancel', finallize)
57 | inputRef.current.click()
58 | })
59 | }, deps)
60 |
61 | return {
62 | inputRef,
63 | selectFiles,
64 | }
65 | }
--------------------------------------------------------------------------------
/src/hooks/loader.ts:
--------------------------------------------------------------------------------
1 | import { stateProxy } from 'react-state-proxy'
2 |
3 | export type Loaders = Record Promise>
4 |
5 | export type LoaderBinding = [
6 | Record Promise>,
7 | Readonly>
8 | ]
9 |
10 |
11 | /**
12 | * Custom hook that creates a loader binding for a set of loaders.
13 | * @template L - The type of the loaders object.
14 | * @param loaders - An object containing loader functions.
15 | * @param onCatch - Optional error handler function. Defaults to console.error.
16 | * @returns A tuple containing the loader functions and a loading state object.
17 | * @example
18 | * const [loader, loading] = useLoader({
19 | * loadUsers: async () => {
20 | * // Load users
21 | * },
22 | * loadPosts: async () => {
23 | * // Load posts
24 | * },
25 | * }, handleError)
26 | *
27 | * // Usage
28 | * loader.loadUsers() // Start loading users
29 | *
30 | * if (loading.loadUsers) {
31 | * // Show loading indicator for users
32 | * }
33 | */
34 | export function useLoader(loaders: L, onCatch: (e: Error | any) => void = console.error): LoaderBinding {
35 |
36 | const loading = stateProxy(Object.keys(loaders)
37 | .reduce((acc, k: keyof L) =>
38 | ({
39 | ...acc,
40 | [k]: false
41 | })
42 | , {})) as { [key in keyof L]: boolean }
43 |
44 | const loader = Object.keys(loaders)
45 | .reduce((acc, k: keyof L) =>
46 | ({
47 | ...acc,
48 | [k]: async () => {
49 | try {
50 | loading[k] = true
51 | await loaders[k]()
52 | } catch (e: Error | any) {
53 | onCatch(e)
54 | } finally {
55 | loading[k] = false
56 | }
57 | }
58 | })
59 | , {}) as { [key in keyof L]: () => Promise }
60 |
61 | return [loader, loading] as const
62 | }
63 |
64 |
--------------------------------------------------------------------------------
/src/hooks/message.ts:
--------------------------------------------------------------------------------
1 | import {
2 | addBLiveMessageCommandListener,
3 | addBLiveMessageListener,
4 | addWindowMessageListener
5 | } from '~utils/messaging'
6 |
7 | import type { BLiveDataWild } from "~types/bilibili"
8 | import { addBLiveSubscriber } from '~utils/subscriber'
9 | import { useEffect } from 'react'
10 |
11 | /**
12 | * Custom hook that listens for window messages with a specific command and executes a handler function.
13 | *
14 | * @param command - The command string to listen for.
15 | * @param handler - The function to be executed when a matching window message is received.
16 | */
17 | export function useWindowMessage(command: string, handler: (data: any, event: MessageEvent) => void) {
18 | useEffect(() => {
19 | const removeListener = addWindowMessageListener(command, handler)
20 | return () => removeListener()
21 | }, [])
22 |
23 | }
24 |
25 | /**
26 | * Custom hook for handling BLive messages.
27 | *
28 | * @template K - The type of the command key.
29 | * @param {function} handler - The callback function to handle the message.
30 | * @returns {void}
31 | */
32 | export function useBLiveMessage(handler: (data: { cmd: K, command: BLiveDataWild }, event: MessageEvent) => void) {
33 | useEffect(() => {
34 | const removeListener = addBLiveMessageListener(handler)
35 | return () => removeListener()
36 | }, [])
37 | }
38 |
39 | /**
40 | * Custom hook for handling BLive message commands.
41 | *
42 | * @template K - The type of the command.
43 | * @param {K} cmd - The command to listen for.
44 | * @param {(command: BLiveDataWild, event: MessageEvent) => void} handler - The handler function to be called when the command is received.
45 | * @returns {void}
46 | */
47 | export function useBLiveMessageCommand(cmd: K, handler: (command: BLiveDataWild, event: MessageEvent) => void) {
48 | useEffect(() => {
49 | const removeListener = addBLiveMessageCommandListener(cmd, handler)
50 | return () => removeListener()
51 | }, [])
52 | }
53 |
54 | /**
55 | * Custom hook for subscribing to BLive messages.
56 | *
57 | * @template K - The type of the command.
58 | * @param {K} command - The command to subscribe to.
59 | * @param {(command: BLiveDataWild, event: MessageEvent) => void} handler - The handler function to be called when a message is received.
60 | * @returns {void}
61 | */
62 | export function useBLiveSubscriber(command: K, handler: (command: BLiveDataWild, event: MessageEvent) => void) {
63 | useEffect(() => {
64 | const removeListener = addBLiveSubscriber(command, handler)
65 | return () => removeListener()
66 | }, [])
67 | }
--------------------------------------------------------------------------------
/src/hooks/promise.ts:
--------------------------------------------------------------------------------
1 | import { type Reducer, useEffect, useReducer } from 'react'
2 |
3 | type State = {
4 | data: T | null
5 | error: Error | null
6 | loading: boolean
7 | }
8 |
9 | type Action =
10 | | { type: "LOADING" }
11 | | { type: "SUCCESS", payload: T }
12 | | { type: "ERROR", payload: Error }
13 |
14 | function reducer(state: State, action: Action): State {
15 | switch (action.type) {
16 | case "LOADING":
17 | return { ...state, loading: true }
18 | case "SUCCESS":
19 | return { data: action.payload, error: null, loading: false }
20 | case "ERROR":
21 | return { data: null, error: action.payload, loading: false }
22 | default:
23 | return state
24 | }
25 | }
26 |
27 | /**
28 | * Custom hook that handles a promise and its state.
29 | * @template T The type of data returned by the promise.
30 | * @param {Promise | (() => Promise)} promise The promise to be handled.
31 | * @param {any[]} [deps=[]] The dependencies array for the useEffect hook. (Only work if the promise is a function.)
32 | * @returns {[T, Error | any, boolean]} An array containing the data, error, and loading state.
33 | */
34 | /**
35 | * Custom hook that handles a promise and returns the result, error, and loading state.
36 | * @template T The type of the promise result.
37 | * @param {Promise | (() => Promise)} promise The promise to be executed or a function that returns the promise.
38 | * @param {any[]} [deps=[]] The dependencies array for the useEffect hook.
39 | * @returns {[T, Error | any, boolean]} An array containing the result, error, and loading state.
40 | *
41 | * @example
42 | * const [data, error, loading] = usePromise(fetchData)
43 | *
44 | * @example With dependencies
45 | * const [data, error, loading] = usePromise(() => fetchData(id), [id])
46 | */
47 | export function usePromise(promise: Promise | (() => Promise), deps: any[] = []): [T, Error | any, boolean] {
48 | const [state, dispatch] = useReducer, Action>>(reducer, {
49 | data: null,
50 | error: null,
51 | loading: true,
52 | })
53 |
54 | useEffect(() => {
55 | dispatch({ type: "LOADING" });
56 | (promise instanceof Function ? promise() : promise)
57 | .then((data) => {
58 | dispatch({ type: "SUCCESS", payload: data })
59 | })
60 | .catch((error) => {
61 | console.warn(error)
62 | dispatch({ type: "ERROR", payload: error })
63 | })
64 | }, [promise, ...deps])
65 |
66 | return [state.data, state.error, state.loading] as const
67 | }
68 |
69 |
70 |
--------------------------------------------------------------------------------
/src/hooks/storage.ts:
--------------------------------------------------------------------------------
1 | import { useStorage as useStorageApi } from '@plasmohq/storage/hook'
2 | import { useState, useEffect } from 'react'
3 | import { Storage, type StorageCallbackMap } from '@plasmohq/storage'
4 | import { storage } from '~utils/storage'
5 |
6 | type Setter = ((v?: T, isHydrated?: boolean) => T) | T
7 |
8 | export const useStorage = (key: string, onInit?: Setter) => useStorageApi({ key, instance: storage }, onInit)
9 |
10 |
11 | /**
12 | * Custom hook for watching changes in browser storage and returning the watched value.
13 | *
14 | * @template T - The type of the value stored in the storage.
15 | * @param {string} key - The key used to store the value in the storage.
16 | * @param {"sync" | "local" | "managed" | "session"} area - The storage area to watch for changes.
17 | * @param {T} [defaultValue] - The default value to be returned if the value is not found in the storage.
18 | * @returns {T} - The watched value from the storage.
19 | *
20 | * @example
21 | * // Watching changes in "sync" storage for the key "myKey" with a default value of 0
22 | * const watchedValue = useStorageWatch("myKey", "sync", 0);
23 | */
24 | export function useStorageWatch(key: string, area: "sync" | "local" | "managed" | "session", defaultValue?: T): T {
25 | const storage = new Storage({ area })
26 | const [watchedValue, setWatchedValue] = useState(defaultValue)
27 | const watchCallback: StorageCallbackMap = {
28 | [key]: (value, ar) => ar === area && setWatchedValue(value.newValue)
29 | }
30 | useEffect(() => {
31 | storage.get(key)
32 | .then(value => setWatchedValue(value))
33 | .catch(() => console.error(`Failed to get ${key} from ${area} storage`))
34 | storage.watch(watchCallback)
35 | return () => {
36 | storage.unwatch(watchCallback)
37 | }
38 | }, [])
39 | return watchedValue
40 | }
--------------------------------------------------------------------------------
/src/hooks/styles.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 |
3 |
4 | /**
5 | * Hook to get the computed style of a given DOM element.
6 | *
7 | * @param {Element} element - The DOM element to get the computed style for.
8 | * @returns {CSSStyleDeclaration} The computed style of the given element.
9 | *
10 | * @example
11 | * ```typescript
12 | * const element = document.getElementById('myElement');
13 | * const computedStyle = useComputedStyle(element);
14 | * console.log(computedStyle.color); // Outputs the color style of the element
15 | * ```
16 | */
17 | export function useComputedStyle(element: Element): CSSStyleDeclaration {
18 | return useMemo(() => element ? window.getComputedStyle(element) : {} as CSSStyleDeclaration, [element]);
19 | }
20 |
21 | /**
22 | * Calculates the contrast of a given background element and returns an object
23 | * containing the brightness, appropriate text color, and a boolean indicating
24 | * if the background is dark.
25 | *
26 | * @param {Element} background - The background element to compute the contrast for.
27 | * @returns {Object} An object containing:
28 | * - `brightness` {number} - The brightness value of the background color.
29 | * - `color` {string} - The text color that contrasts with the background ('black' or 'white').
30 | * - `dark` {boolean} - A boolean indicating if the background is dark (true if brightness > 125).
31 | *
32 | * @example
33 | * const backgroundElement = document.getElementById('myElement');
34 | * const contrast = useContrast(backgroundElement);
35 | * console.log(contrast.brightness); // e.g., 150
36 | * console.log(contrast.color); // 'black'
37 | * console.log(contrast.dark); // true
38 | */
39 | export function useContrast(background: Element) {
40 | const { backgroundColor: rgb } = useComputedStyle(background);
41 | return useMemo(() => {
42 | const r = parseInt(rgb.slice(4, rgb.indexOf(',')));
43 | const g = parseInt(rgb.slice(rgb.indexOf(',', rgb.indexOf(',') + 1)));
44 | const b = parseInt(rgb.slice(rgb.lastIndexOf(',') + 1, -1));
45 | const brightness = (r * 299 + g * 587 + b * 114) / 1000;
46 | return {
47 | brightness,
48 | color: brightness > 125 ? 'black' : 'white',
49 | dark: brightness > 125
50 | };
51 | }, [rgb]);
52 | }
--------------------------------------------------------------------------------
/src/hooks/teleport.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo } from 'react'
2 | import { createPortal } from 'react-dom'
3 |
4 | export type TeleportSettings = {
5 | parentQuerySelector: string
6 | id: string
7 | placement: (parent: Element, child: Element) => void
8 | shouldPlace: (data: T) => boolean
9 | }
10 |
11 |
12 | /**
13 | * Custom hook for teleporting a component to a different location in the DOM.
14 | * @template T - The type of the state object.
15 | * @param {T} state - The state object.
16 | * @param {TeleportSettings} settings - The settings for the teleportation.
17 | * @returns {{ Teleport: JSX.Element, rootContainer: HTMLElement | null }} - The Teleport component and the root container element.
18 | *
19 | * @example
20 | * const state = { isVisible: true };
21 | * const settings = {
22 | * id: 'teleport-container',
23 | * parentQuerySelector: '#app',
24 | * shouldPlace: (state) => state.isVisible,
25 | * placement: (parent, child) => parent.appendChild(child)
26 | * };
27 | * const { Teleport, rootContainer } = useTeleport(state, settings);
28 | *
29 | * // Usage of the Teleport component
30 | *
31 | * Teleported content
32 | *
33 | */
34 | export function useTeleport(state: T, settings: TeleportSettings) {
35 |
36 | // dont forget to remove the root container when unmount
37 | useEffect(() => {
38 | return () => document.getElementById(settings.id)?.remove()
39 | }, [])
40 |
41 | const rootContainer = useMemo(() => {
42 | const parentElement = document.querySelector(settings.parentQuerySelector)
43 | if (!parentElement) {
44 | console.warn(`找不到父元素,請檢查 parentQuerySelector: ${settings.parentQuerySelector}`)
45 | }
46 | let childElement = document.getElementById(settings.id)
47 | if (!settings.shouldPlace(state)) {
48 | childElement?.remove()
49 | childElement = null
50 | } else if (parentElement && childElement === null) {
51 | childElement = document.createElement('div')
52 | childElement.id = settings.id
53 | settings.placement(parentElement, childElement)
54 | }
55 | return childElement
56 | }, [state])
57 |
58 | if (settings.shouldPlace(state) && rootContainer === null) {
59 | console.warn(`找不到子元素,請檢查 id: ${settings.id}`)
60 | }
61 |
62 | return { Teleport, rootContainer }
63 |
64 | }
65 |
66 | const Teleport = ({ children, container }: { children: JSX.Element, container?: Element }): React.ReactNode => {
67 | return container ? createPortal(children, container) : children
68 | }
69 |
--------------------------------------------------------------------------------
/src/hooks/window.ts:
--------------------------------------------------------------------------------
1 | import { sendMessager } from "~utils/messaging"
2 | import { useCallback } from "react"
3 |
4 | export type PopupCreateInfo = Omit
5 |
6 | /**
7 | * Custom hook for creating a popup window or opening a new tab/window.
8 | * @param enabledPip - Flag indicating whether picture-in-picture mode is enabled.
9 | * @param options - chrome window create configuration options.
10 | * @returns An object containing the createPopupWindow function and the pipSupported flag.
11 | * @example
12 | * const { createPopupWindow, pipSupported } = usePopupWindow(true, { width: 800, height: 600 });
13 | * createPopupWindow('https://example.com', { param1: 'value1', param2: 'value2' });
14 | */
15 | export function usePopupWindow(enabledPip: boolean, options: PopupCreateInfo) {
16 | const pipSupported = window.documentPictureInPicture !== undefined
17 | const createPopupWindow = useCallback((tabUrl: string, params: Record = {}) => {
18 | const url = chrome.runtime.getURL(`/tabs/${tabUrl}?${new URLSearchParams(params).toString()}`)
19 | return async function (e: React.MouseEvent) {
20 | e.preventDefault()
21 | if (enabledPip && e.ctrlKey) {
22 | if (!pipSupported) {
23 | alert('你的浏览器不支持自定义元素的画中画')
24 | return
25 | }
26 | const size: RequestPipOptions = options.width || options.height ? { width: options.width ?? 500, height: options.height ?? 800 } : undefined
27 | const pip = await window.documentPictureInPicture.requestWindow(size)
28 | const iframe = document.createElement('iframe')
29 | iframe.src = url
30 | iframe.style.width = '100%'
31 | iframe.style.height = '100%'
32 | iframe.height = options.height?.toString()
33 | iframe.width = options.width?.toString()
34 | iframe.allow = 'autoplay; fullscreen'
35 | iframe.frameBorder = '0'
36 | iframe.allowFullscreen = true
37 | iframe.mozallowfullscreen = true
38 | iframe.msallowfullscreen = true
39 | iframe.oallowfullscreen = true
40 | iframe.webkitallowfullscreen = true
41 | pip.document.body.style.margin = '0' // I dunno why but default is 8px
42 | pip.document.body.style.overflow = 'hidden'
43 | pip.document.body.appendChild(iframe)
44 | return
45 | } else {
46 | await sendMessager('open-window', {
47 | url,
48 | ...options
49 | })
50 | }
51 |
52 | }
53 |
54 | }, [enabledPip, options])
55 |
56 | return { createPopupWindow, pipSupported }
57 | }
--------------------------------------------------------------------------------
/src/llms/cloudflare-ai.ts:
--------------------------------------------------------------------------------
1 | import { runAI, runAIStream, validateAIToken } from "~api/cloudflare";
2 | import type { LLMEvent, LLMProviders, Session } from "~llms";
3 | import type { SettingSchema } from "~options/fragments/llm";
4 |
5 | export default class CloudFlareAI implements LLMProviders {
6 |
7 | private static readonly DEFAULT_MODEL: string = '@cf/qwen/qwen1.5-14b-chat-awq'
8 |
9 | private readonly accountId: string
10 | private readonly apiToken: string
11 |
12 | private readonly model: string
13 |
14 | constructor(settings: SettingSchema) {
15 | this.accountId = settings.accountId
16 | this.apiToken = settings.apiToken
17 |
18 | // only text generation model for now
19 | this.model = settings.model || CloudFlareAI.DEFAULT_MODEL
20 | }
21 |
22 | // mot support progress
23 | on(event: E, listener: LLMEvent[E]): void {}
24 |
25 | cumulative: boolean = true
26 |
27 | async validate(): Promise {
28 | const success = await validateAIToken(this.accountId, this.apiToken, this.model)
29 | if (typeof success === 'boolean' && !success) throw new Error('Cloudflare API 验证失败')
30 | if (typeof success === 'string') throw new Error(success)
31 | }
32 |
33 | async prompt(chat: string): Promise {
34 | const res = await runAI(this.wrap(chat), { token: this.apiToken, account: this.accountId, model: this.model })
35 | if (!res.result) throw new Error(res.errors.join(', '))
36 | return res.result.response
37 | }
38 |
39 | async *promptStream(chat: string): AsyncGenerator {
40 | return runAIStream(this.wrap(chat), { token: this.apiToken, account: this.accountId, model: this.model })
41 | }
42 |
43 | async asSession(): Promise> {
44 | console.warn('Cloudflare AI session is not supported')
45 | return {
46 | ...this,
47 | [Symbol.asyncDispose]: async () => { }
48 | }
49 | }
50 |
51 | // text generation model input schema
52 | // so only text generation model for now
53 | private wrap(chat: string): any {
54 | return {
55 | max_tokens: 512,
56 | prompt: chat,
57 | temperature: 0.2
58 | }
59 | }
60 |
61 |
62 | }
--------------------------------------------------------------------------------
/src/llms/index.ts:
--------------------------------------------------------------------------------
1 | import type { SettingSchema as LLMSchema } from '~options/fragments/llm'
2 |
3 | import cloudflare from './cloudflare-ai'
4 | import nano from './gemini-nano'
5 | import worker from './remote-worker'
6 | import webllm from './web-llm'
7 |
8 | export type LLMEvent = {
9 | progress: (p: number, t: string) => void
10 | }
11 |
12 | export interface LLMProviders {
13 | cumulative: boolean
14 | on(event: E, listener: LLMEvent[E]): void
15 | validate(): Promise
16 | prompt(chat: string): Promise
17 | promptStream(chat: string): AsyncGenerator
18 | asSession(): Promise>
19 | }
20 |
21 | export type Session = AsyncDisposable & Pick
22 |
23 | const llms = {
24 | cloudflare,
25 | nano,
26 | worker,
27 | webllm
28 | }
29 |
30 | export type LLMs = typeof llms
31 |
32 | export type LLMTypes = keyof LLMs
33 |
34 | function createLLMProvider(settings: LLMSchema): LLMProviders {
35 | const type = settings.provider
36 | const LLM = llms[type]
37 | return new LLM(settings)
38 | }
39 |
40 | export default createLLMProvider
--------------------------------------------------------------------------------
/src/llms/models.ts:
--------------------------------------------------------------------------------
1 | import { prebuiltAppConfig } from "@mlc-ai/web-llm"
2 | import type { LLMTypes } from "~llms"
3 |
4 | export type ModelList = {
5 | providers: LLMTypes[]
6 | models: string[]
7 | }
8 |
9 | const models: ModelList[] = [
10 | {
11 | providers: ['worker', 'cloudflare'],
12 | models: [
13 | '@cf/qwen/qwen1.5-14b-chat-awq',
14 | '@cf/qwen/qwen1.5-7b-chat-awq',
15 | '@cf/qwen/qwen1.5-1.8b-chat',
16 | '@hf/google/gemma-7b-it',
17 | '@hf/nousresearch/hermes-2-pro-mistral-7b'
18 | ]
19 | },
20 | {
21 | providers: [ 'webllm' ],
22 | models: prebuiltAppConfig.model_list.map(m => m.model_id)
23 | }
24 | ]
25 |
26 |
27 | export default models
--------------------------------------------------------------------------------
/src/llms/remote-worker.ts:
--------------------------------------------------------------------------------
1 | import type { LLMEvent, LLMProviders, Session } from "~llms";
2 | import type { SettingSchema } from "~options/fragments/llm";
3 | import { parseSSEResponses } from "~utils/binary";
4 |
5 | // for my worker, so limited usage
6 | export default class RemoteWorker implements LLMProviders {
7 |
8 | private readonly model?: string
9 |
10 | constructor(settings: SettingSchema) {
11 | this.model = settings.model || undefined
12 | }
13 |
14 | cumulative: boolean = true
15 |
16 | on(event: E, listener: LLMEvent[E]): void {}
17 |
18 | async validate(): Promise {
19 | const res = await fetch('https://llm.ericlamm.xyz/status')
20 | const json = await res.json()
21 | if (json.status !== 'working') {
22 | throw new Error('Remote worker is not working')
23 | }
24 | }
25 |
26 | async prompt(chat: string): Promise {
27 | const res = await fetch('https://llm.ericlamm.xyz/', {
28 | method: 'POST',
29 | headers: {
30 | 'Content-Type': 'application/json'
31 | },
32 | body: JSON.stringify({ prompt: chat, model: this.model })
33 | })
34 | if (!res.ok) throw new Error(await res.text())
35 | const json = await res.json()
36 | return json.response
37 | }
38 |
39 | async *promptStream(chat: string): AsyncGenerator {
40 | const res = await fetch('https://llm.ericlamm.xyz/', {
41 | method: 'POST',
42 | headers: {
43 | 'Content-Type': 'application/json'
44 | },
45 | body: JSON.stringify({ prompt: chat, stream: true, model: this.model })
46 | })
47 | if (!res.ok) throw new Error(await res.text())
48 | if (!res.body) throw new Error('Remote worker response body is not readable')
49 | const reader = res.body.getReader()
50 | for await (const response of parseSSEResponses(reader, '[DONE]')) {
51 | yield response
52 | }
53 | }
54 |
55 | async asSession(): Promise> {
56 | console.warn('Remote worker session is not supported')
57 | return {
58 | ...this,
59 | [Symbol.asyncDispose]: async () => { }
60 | }
61 | }
62 |
63 | }
--------------------------------------------------------------------------------
/src/logger.ts:
--------------------------------------------------------------------------------
1 | const debug = process.env.DEBUG || process.env.NODE_ENV !== 'production'
2 |
3 |
4 | console.info = console.info.bind(console, '[bilibili-vup-stream-enhancer]')
5 | console.warn = console.warn.bind(console, '[bilibili-vup-stream-enhancer]')
6 | console.error = console.error.bind(console, '[bilibili-vup-stream-enhancer]')
7 | console.log = console.log.bind(console, '[bilibili-vup-stream-enhancer]')
8 | console.debug = debug ? console.debug.bind(console, '[bilibili-vup-stream-enhancer]') : () => { }
9 | console.trace = debug ? console.trace.bind(console, '[bilibili-vup-stream-enhancer]') : () => { }
--------------------------------------------------------------------------------
/src/options/components/AffixInput.tsx:
--------------------------------------------------------------------------------
1 | import { Input, type InputProps, Typography } from '@material-tailwind/react';
2 | import type { RefAttributes } from "react";
3 |
4 | export type AffixInputProps = {
5 | suffix?: string
6 | prefix?: string
7 | } & RefAttributes & InputProps
8 |
9 | function AffixInput(props: AffixInputProps): JSX.Element {
10 |
11 | const { suffix, prefix, disabled, ...attrs } = props
12 |
13 | return (
14 |
15 | {prefix &&
16 |
17 | {prefix}
18 |
19 | }
20 |
29 | {suffix &&
30 |
31 | {suffix}
32 |
33 | }
34 |
35 | )
36 | }
37 |
38 | export default AffixInput
--------------------------------------------------------------------------------
/src/options/components/CheckBoxListItem.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Checkbox, ListItem, ListItemPrefix, ListItemSuffix, Tooltip, Typography
3 | } from '@material-tailwind/react';
4 | import type { ChangeEventHandler } from "react";
5 |
6 |
7 | export type CheckboxListItemProps = {
8 | onChange?: ChangeEventHandler
9 | suffix?: React.ReactNode
10 | value: boolean
11 | label: string
12 | hint?: string
13 | }
14 |
15 | function CheckBoxListItem(props: CheckboxListItemProps): JSX.Element {
16 | return (
17 |
18 |
19 |
20 |
31 |
32 |
33 | {props.label}
34 |
35 | {props.hint &&
36 |
37 |
38 |
39 | }
40 | {props.suffix && (
41 |
42 | {props.suffix}
43 |
44 | )}
45 |
46 |
47 | )
48 | }
49 |
50 |
51 |
52 | export default CheckBoxListItem
--------------------------------------------------------------------------------
/src/options/components/ColorInput.tsx:
--------------------------------------------------------------------------------
1 | import type { RefAttributes } from 'react';
2 | import { Input, type InputProps } from '@material-tailwind/react';
3 | import type { HexColor } from "~types/common";
4 |
5 |
6 | export type ColorInputProps = {
7 | value: HexColor
8 | optional?: boolean
9 | } & RefAttributes & Omit
10 |
11 |
12 | function ColorInput(props: ColorInputProps): JSX.Element {
13 |
14 | const { optional: opt, value = '', ...attrs } = props
15 | const optional = opt ?? false
16 |
17 | return (
18 |
38 | )
39 | }
40 |
41 |
42 | export default ColorInput
--------------------------------------------------------------------------------
/src/options/components/DeleteIcon.tsx:
--------------------------------------------------------------------------------
1 |
2 | const DeleteIcon: React.FC<{}> = () => (
3 |
4 |
5 |
6 | )
7 |
8 |
9 | export default DeleteIcon
--------------------------------------------------------------------------------
/src/options/components/Expander.tsx:
--------------------------------------------------------------------------------
1 | import { Collapse } from "@material-tailwind/react"
2 | import { useToggle } from "@react-hooks-library/core"
3 | import { Fragment } from "react"
4 |
5 | export type ExpanderProps = {
6 | title: string
7 | expanded?: boolean
8 | toggle?: VoidFunction
9 | prefix?: React.ReactNode
10 | colorClass?: string
11 | hoverColorClass?: string
12 | children: React.ReactNode
13 | }
14 |
15 | function Expander(props: ExpanderProps): JSX.Element {
16 |
17 | const color = props.colorClass ?? 'bg-gray-300 dark:bg-gray-800'
18 | const hoverColor = props.hoverColorClass ?? 'hover:bg-gray-400 dark:hover:bg-gray-900'
19 | const { toggle: internalToggle, bool: internalExpanded } = useToggle()
20 | const { title, expanded, toggle, children, prefix } = props
21 |
22 | return (
23 |
24 |
27 |
28 | {prefix}
29 | {title}
30 |
31 |
32 |
33 | {children}
34 |
35 |
36 | )
37 | }
38 |
39 | export default Expander
--------------------------------------------------------------------------------
/src/options/components/ExperientmentFeatureIcon.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from "@material-tailwind/react";
2 |
3 | function ExperienmentFeatureIcon(): JSX.Element {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | )
11 | }
12 |
13 | export default ExperienmentFeatureIcon
--------------------------------------------------------------------------------
/src/options/components/FeatureRoomTable.tsx:
--------------------------------------------------------------------------------
1 | import { Switch, Typography } from "@material-tailwind/react";
2 | import type { TableAction, TableHeader } from "./DataTable";
3 |
4 | import { toast } from "sonner/dist";
5 | import type { FeatureType } from "~features";
6 | import type { RoomList } from "~types/common";
7 | import { removeArr } from "~utils/misc";
8 | import DataTable from "./DataTable";
9 | import DeleteIcon from "./DeleteIcon";
10 |
11 | const roomListHeaders: TableHeader<{ room: string, date: string }>[] = [
12 | {
13 | name: '房间号',
14 | value: 'room'
15 | },
16 | {
17 | name: '添加时间',
18 | value: 'date',
19 | align: 'center'
20 | }
21 | ]
22 |
23 |
24 |
25 | export type FeatureRoomTableProps = {
26 | title?: string
27 | feature: FeatureType,
28 | roomList: Record,
29 | actions?: TableAction<{ room: string, date: string }>[]
30 | }
31 |
32 | function FeatureRoomTable(props: FeatureRoomTableProps): JSX.Element {
33 |
34 | const { roomList, feature, title, actions } = props
35 |
36 | return (
37 | {
43 | if (roomList[feature].list.some(e => e.room === room)) {
44 | toast.error(`房间 ${room} 已经在列表中`)
45 | return
46 | }
47 | roomList[feature].list.push({ room, date: new Date().toLocaleDateString() })
48 | }}
49 | headerSlot={
50 | roomList[feature].asBlackList = e.target.checked}
54 | crossOrigin={'annoymous'}
55 | label={
56 |
57 | 使用为黑名单
58 |
59 | }
60 | />
61 | }
62 | actions={[
63 | {
64 | label: '删除',
65 | icon: ,
66 | onClick: (e) => {
67 | const result = removeArr(roomList[feature].list, e)
68 | if (!result) {
69 | toast.error('删除失败')
70 | }
71 | }
72 | },
73 | ...(actions ?? [])
74 | ]}
75 | />
76 | )
77 | }
78 |
79 |
80 |
81 |
82 | export default FeatureRoomTable
--------------------------------------------------------------------------------
/src/options/components/Hints.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react';
2 |
3 | import { Typography } from '@material-tailwind/react';
4 |
5 | export type HintsProps = {
6 | values: (string | React.ReactNode)[]
7 | }
8 |
9 |
10 | function Hints(props: HintsProps): JSX.Element {
11 | const [first, ...rest] = props.values
12 |
13 | if (!first) return <>>
14 |
15 | return (
16 |
17 |
21 |
27 |
32 |
33 | {first}
34 |
35 | {rest.map((hint, i) => ({hint} ))}
36 |
37 | )
38 | }
39 |
40 |
41 | export default Hints
--------------------------------------------------------------------------------
/src/options/components/SwitchListItem.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ListItem,
3 | ListItemPrefix,
4 | ListItemSuffix,
5 | Switch,
6 | Typography
7 | } from '@material-tailwind/react';
8 |
9 | import type { ChangeEventHandler } from "react";
10 | import type { colors } from "@material-tailwind/react/types/generic";
11 |
12 | export type SwitchListItemProps = {
13 | onChange?: ChangeEventHandler
14 | prefix?: React.ReactNode
15 | affix?: React.ReactNode
16 | suffix?: React.ReactNode
17 | marker?: React.ReactNode
18 | value: boolean
19 | label: string | ((b: boolean) => string)
20 | hint?: string
21 | color?: colors
22 | disabled?: boolean
23 | }
24 |
25 |
26 |
27 |
28 | function SwitchListItem(props: SwitchListItemProps): JSX.Element {
29 | return (
30 |
31 |
32 | {props.prefix && (
33 |
34 | {props.prefix}
35 |
36 | )}
37 |
47 |
48 |
49 | {props.label instanceof Function ? props.label(props.value) : props.label}
50 | {props.marker}
51 |
52 | {props.hint &&
53 | {props.hint}
54 | }
55 |
56 | {props.affix ? (
57 |
58 | {props.affix}
59 |
60 | ) : <>>}
61 |
62 | }
63 | containerProps={{
64 | className: "flex items-center p-0",
65 | }}
66 | />
67 | {props.suffix && (
68 |